diff --git a/changes/17587-software-self-service-ui b/changes/17587-software-self-service-ui new file mode 100644 index 0000000000..ba4297a3be --- /dev/null +++ b/changes/17587-software-self-service-ui @@ -0,0 +1 @@ +- Updated UI to support software self-service features. diff --git a/changes/18833-filter-software-by-self-service b/changes/18833-filter-software-by-self-service new file mode 100644 index 0000000000..20381213a0 --- /dev/null +++ b/changes/18833-filter-software-by-self-service @@ -0,0 +1 @@ +* Added query parameter `self_service` to filter the list of software titles and the list of a host's software so that only those available to install via self-service are returned. diff --git a/changes/18834-add-self-service-install-endpoint b/changes/18834-add-self-service-install-endpoint new file mode 100644 index 0000000000..b69b8568f0 --- /dev/null +++ b/changes/18834-add-self-service-install-endpoint @@ -0,0 +1 @@ +* Added the device-authenticated endpoint `POST /device/{token}/software/install/{software_title_id}` to self-install software. diff --git a/changes/18834-fleetctl-add-self-service-field b/changes/18834-fleetctl-add-self-service-field new file mode 100644 index 0000000000..4a934e3493 --- /dev/null +++ b/changes/18834-fleetctl-add-self-service-field @@ -0,0 +1 @@ +* Added the `self_service` field to `fleetctl apply` and `fleetctl gitops` YAML configuration files. diff --git a/changes/18847-software-self-install-activities b/changes/18847-software-self-install-activities new file mode 100644 index 0000000000..d7c1a8e2f6 --- /dev/null +++ b/changes/18847-software-self-install-activities @@ -0,0 +1 @@ +* Added the `self_install` and `software_package` fields to the `installed_software` activity. diff --git a/changes/issue-18847-add-ui-activities-for-self-service b/changes/issue-18847-add-ui-activities-for-self-service new file mode 100644 index 0000000000..d3c82f980f --- /dev/null +++ b/changes/issue-18847-add-ui-activities-for-self-service @@ -0,0 +1 @@ +- add UI for the global and host activities for self-service software installation diff --git a/cmd/fleetctl/get_test.go b/cmd/fleetctl/get_test.go index ac520a9b1c..78e07bd1b6 100644 --- a/cmd/fleetctl/get_test.go +++ b/cmd/fleetctl/get_test.go @@ -681,6 +681,7 @@ spec: - hosts_count: 2 id: 0 name: foo + self_service: false software_package: null source: chrome_extensions versions: @@ -701,6 +702,7 @@ spec: - hosts_count: 0 id: 0 name: bar + self_service: false software_package: null source: deb_packages versions: @@ -745,6 +747,7 @@ spec: ] } ], + "self_service": false, "software_package": null }, { @@ -760,6 +763,7 @@ spec: "vulnerabilities": null } ], + "self_service": false, "software_package": null } ] diff --git a/cmd/fleetctl/gitops_test.go b/cmd/fleetctl/gitops_test.go index 0c991786c2..0f943a29a8 100644 --- a/cmd/fleetctl/gitops_test.go +++ b/cmd/fleetctl/gitops_test.go @@ -1028,6 +1028,7 @@ func TestTeamSofwareInstallersGitOps(t *testing.T) { {"testdata/gitops/team_software_installer_install_not_found.yml", "no such file or directory"}, {"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", "cannot unmarshal string into Go struct field TeamSpecSoftware.self_service of type bool"}, } for _, c := range cases { t.Run(filepath.Base(c.file), func(t *testing.T) { diff --git a/cmd/fleetctl/testdata/gitops/team_software_installer_invalid_self_service_value.yml b/cmd/fleetctl/testdata/gitops/team_software_installer_invalid_self_service_value.yml new file mode 100644 index 0000000000..b27a982703 --- /dev/null +++ b/cmd/fleetctl/testdata/gitops/team_software_installer_invalid_self_service_value.yml @@ -0,0 +1,17 @@ +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: + - url: ${SOFTWARE_INSTALLER_URL}/invalidtype.txt + self_service: "not a boolean" diff --git a/cmd/fleetctl/testdata/gitops/team_software_installer_valid.yml b/cmd/fleetctl/testdata/gitops/team_software_installer_valid.yml index 42ec2fc59c..ceeb1a7415 100644 --- a/cmd/fleetctl/testdata/gitops/team_software_installer_valid.yml +++ b/cmd/fleetctl/testdata/gitops/team_software_installer_valid.yml @@ -20,3 +20,5 @@ software: path: lib/query_ruby.yml post_install_script: path: lib/post_install_ruby.sh + - url: ${SOFTWARE_INSTALLER_URL}/other.deb + self_service: true diff --git a/cmd/fleetctl/vulnerability_data_stream_test.go b/cmd/fleetctl/vulnerability_data_stream_test.go index 279f75cc0b..0e61949eea 100644 --- a/cmd/fleetctl/vulnerability_data_stream_test.go +++ b/cmd/fleetctl/vulnerability_data_stream_test.go @@ -12,7 +12,6 @@ import ( ) func TestVulnerabilityDataStream(t *testing.T) { - t.Skip("TODO: removeme before merging the feature branch") nettest.Run(t) runAppCheckErr(t, []string{"vulnerability-data-stream"}, "No directory provided") diff --git a/docs/Using Fleet/Audit-logs.md b/docs/Using Fleet/Audit-logs.md index add0005ed7..d94e129c5b 100644 --- a/docs/Using Fleet/Audit-logs.md +++ b/docs/Using Fleet/Audit-logs.md @@ -1135,7 +1135,9 @@ This activity contains the following fields: - "host_id": ID of the host. - "host_display_name": Display name of the host. - "install_uuid": ID of the software installation. +- "self_service": Whether the installation was initiated by the end user. - "software_title": Name of the software. +- "software_package": Filename of the installer. - "status": Status of the software installation. #### Example @@ -1145,6 +1147,8 @@ This activity contains the following fields: "host_id": 1, "host_display_name": "Anna's MacBook Pro", "software_title": "Falcon.app", + "software_package": "FalconSensor-6.44.pkg", + "self_service": true, "install_uuid": "d6cffa75-b5b5-41ef-9230-15073c8a88cf", "status": "pending" } @@ -1159,6 +1163,7 @@ This activity contains the following fields: - "software_package": Filename of the installer. - "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 is available for installation by the end user. #### Example @@ -1167,9 +1172,9 @@ This activity contains the following fields: "software_title": "Falcon.app", "software_package": "FalconSensor-6.44.pkg", "team_name": "Workstations", - "team_id": 123 + "team_id": 123, + "self_service": true } - ``` ## deleted_software @@ -1181,6 +1186,7 @@ This activity contains the following fields: - "software_package": Filename of the installer. - "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. #### Example @@ -1189,9 +1195,9 @@ This activity contains the following fields: "software_title": "Falcon.app", "software_package": "FalconSensor-6.44.pkg", "team_name": "Workstations", - "team_id": 123 + "team_id": 123, + "self_service": true } - ``` diff --git a/docs/Using Fleet/Understanding-host-vitals.md b/docs/Using Fleet/Understanding-host-vitals.md index c569f119b8..5fd09052be 100644 --- a/docs/Using Fleet/Understanding-host-vitals.md +++ b/docs/Using Fleet/Understanding-host-vitals.md @@ -57,7 +57,7 @@ WITH encrypted(enabled) AS ( ## disk_space_unix -- Platforms: linux, ubuntu, debian, rhel, centos, sles, kali, gentoo, amzn, pop, arch, linuxmint, void, nixos, endeavouros, manjaro, opensuse-leap, opensuse-tumbleweed, darwin, tuxedo +- Platforms: linux, ubuntu, debian, rhel, centos, sles, kali, gentoo, amzn, pop, arch, linuxmint, void, nixos, endeavouros, manjaro, opensuse-leap, opensuse-tumbleweed, tuxedo, darwin - Query: ```sql @@ -235,7 +235,7 @@ SELECT ipv4 AS address, mac FROM network_interfaces LIMIT 1 ## network_interface_unix -- Platforms: linux, ubuntu, debian, rhel, centos, sles, kali, gentoo, amzn, pop, arch, linuxmint, void, nixos, endeavouros, manjaro, opensuse-leap, opensuse-tumbleweed, darwin, tuxedo +- Platforms: linux, ubuntu, debian, rhel, centos, sles, kali, gentoo, amzn, pop, arch, linuxmint, void, nixos, endeavouros, manjaro, opensuse-leap, opensuse-tumbleweed, tuxedo, darwin - Query: ```sql @@ -249,8 +249,8 @@ FROM -- whereas on Windows ia.interface is the IP of the interface. JOIN routes r ON r.interface = ia.interface WHERE - -- Destination 0.0.0.0/0 is the default route on route tables. - r.destination = '0.0.0.0' AND r.netmask = 0 + -- Destination 0.0.0.0/0 or ::/0 (IPv6) is the default route on route tables. + (r.destination = '0.0.0.0' OR r.destination = '::') AND r.netmask = 0 -- Type of route is "gateway" for Unix, "remote" for Windows. AND r.type = 'gateway' -- We are only interested on private IPs (some devices have their Public IP as Primary IP too). @@ -287,8 +287,8 @@ FROM -- whereas on Windows ia.interface is the IP of the interface. JOIN routes r ON r.interface = ia.address WHERE - -- Destination 0.0.0.0/0 is the default route on route tables. - r.destination = '0.0.0.0' AND r.netmask = 0 + -- Destination 0.0.0.0/0 or ::/0 (IPv6) is the default route on route tables. + (r.destination = '0.0.0.0' OR r.destination = '::') AND r.netmask = 0 -- Type of route is "gateway" for Unix, "remote" for Windows. AND r.type = 'remote' -- We are only interested on private IPs (some devices have their Public IP as Primary IP too). @@ -311,7 +311,7 @@ LIMIT 1; ## orbit_info -- Platforms: linux, ubuntu, debian, rhel, centos, sles, kali, gentoo, amzn, pop, arch, linuxmint, void, nixos, endeavouros, manjaro, opensuse-leap, opensuse-tumbleweed, darwin, windows +- Platforms: linux, ubuntu, debian, rhel, centos, sles, kali, gentoo, amzn, pop, arch, linuxmint, void, nixos, endeavouros, manjaro, opensuse-leap, opensuse-tumbleweed, tuxedo, darwin, windows - Discovery query: ```sql @@ -345,7 +345,7 @@ SELECT ## os_unix_like -- Platforms: linux, ubuntu, debian, rhel, centos, sles, kali, gentoo, amzn, pop, arch, linuxmint, void, nixos, endeavouros, manjaro, opensuse-leap, opensuse-tumbleweed, darwin, tuxedo +- Platforms: linux, ubuntu, debian, rhel, centos, sles, kali, gentoo, amzn, pop, arch, linuxmint, void, nixos, endeavouros, manjaro, opensuse-leap, opensuse-tumbleweed, tuxedo, darwin - Query: ```sql @@ -384,16 +384,23 @@ WITH display_version_table AS ( SELECT data as display_version FROM registry WHERE path = 'HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\DisplayVersion' + ), + ubr_table AS ( + SELECT data AS ubr + FROM registry + WHERE path ='HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\UBR' ) SELECT os.name, COALESCE(d.display_version, '') AS display_version, - k.version + COALESCE(CONCAT((SELECT version FROM os_version), '.', u.ubr), k.version) AS version FROM os_version os, kernel_info k LEFT JOIN display_version_table d + LEFT JOIN + ubr_table u ``` ## os_windows @@ -406,24 +413,31 @@ WITH display_version_table AS ( SELECT data as display_version FROM registry WHERE path = 'HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\DisplayVersion' + ), + ubr_table AS ( + SELECT data AS ubr + FROM registry + WHERE path ='HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\UBR' ) SELECT os.name, os.platform, os.arch, k.version as kernel_version, - os.version, + COALESCE(CONCAT((SELECT version FROM os_version), '.', u.ubr), k.version) AS version, COALESCE(d.display_version, '') AS display_version FROM os_version os, kernel_info k LEFT JOIN display_version_table d + LEFT JOIN + ubr_table u ``` ## osquery_flags -- Platforms: all +- Platforms: linux, ubuntu, debian, rhel, centos, sles, kali, gentoo, amzn, pop, arch, linuxmint, void, nixos, endeavouros, manjaro, opensuse-leap, opensuse-tumbleweed, tuxedo, darwin, windows - Query: ```sql @@ -441,7 +455,7 @@ select * from osquery_info limit 1 ## scheduled_query_stats -- Platforms: all +- Platforms: linux, ubuntu, debian, rhel, centos, sles, kali, gentoo, amzn, pop, arch, linuxmint, void, nixos, endeavouros, manjaro, opensuse-leap, opensuse-tumbleweed, tuxedo, darwin, windows - Query: ```sql @@ -662,7 +676,7 @@ FROM homebrew_packages; ## software_vscode_extensions -- Platforms: linux, ubuntu, debian, rhel, centos, sles, kali, gentoo, amzn, pop, arch, linuxmint, void, nixos, endeavouros, manjaro, opensuse-leap, opensuse-tumbleweed, darwin, windows, tuxedo +- Platforms: linux, ubuntu, debian, rhel, centos, sles, kali, gentoo, amzn, pop, arch, linuxmint, void, nixos, endeavouros, manjaro, opensuse-leap, opensuse-tumbleweed, tuxedo, darwin, windows - Discovery query: ```sql @@ -777,7 +791,7 @@ select * from system_info limit 1 ## uptime -- Platforms: all +- Platforms: linux, ubuntu, debian, rhel, centos, sles, kali, gentoo, amzn, pop, arch, linuxmint, void, nixos, endeavouros, manjaro, opensuse-leap, opensuse-tumbleweed, tuxedo, darwin, windows - Query: ```sql @@ -786,7 +800,7 @@ select * from uptime limit 1 ## users -- Platforms: linux, ubuntu, debian, rhel, centos, sles, kali, gentoo, amzn, pop, arch, linuxmint, void, nixos, endeavouros, manjaro, opensuse-leap, opensuse-tumbleweed, darwin, windows +- Platforms: linux, ubuntu, debian, rhel, centos, sles, kali, gentoo, amzn, pop, arch, linuxmint, void, nixos, endeavouros, manjaro, opensuse-leap, opensuse-tumbleweed, tuxedo, darwin, windows - Query: ```sql @@ -821,4 +835,4 @@ SELECT date, title FROM windows_update_history WHERE result_code = 'Succeeded' - + \ No newline at end of file diff --git a/ee/server/service/software_installers.go b/ee/server/service/software_installers.go index bcd834a93d..59c95de659 100644 --- a/ee/server/service/software_installers.go +++ b/ee/server/service/software_installers.go @@ -67,13 +67,13 @@ func (svc *Service) UploadSoftwareInstaller(ctx context.Context, payload *fleet. } // Create activity - if err := svc.NewActivity( - ctx, vc.User, fleet.ActivityTypeAddedSoftware{ - SoftwareTitle: payload.Title, - SoftwarePackage: payload.Filename, - TeamName: teamName, - TeamID: payload.TeamID, - }); err != nil { + if err := svc.NewActivity(ctx, vc.User, fleet.ActivityTypeAddedSoftware{ + SoftwareTitle: payload.Title, + SoftwarePackage: payload.Filename, + TeamName: teamName, + TeamID: payload.TeamID, + SelfService: payload.SelfService, + }); err != nil { return ctxerr.Wrap(ctx, err, "creating activity for added software") } @@ -112,13 +112,13 @@ func (svc *Service) DeleteSoftwareInstaller(ctx context.Context, titleID uint, t teamName = &t.Name } - if err := svc.NewActivity( - ctx, vc.User, fleet.ActivityTypeDeletedSoftware{ - SoftwareTitle: meta.SoftwareTitle, - SoftwarePackage: meta.Name, - TeamName: teamName, - TeamID: meta.TeamID, - }); err != nil { + if err := svc.NewActivity(ctx, vc.User, fleet.ActivityTypeDeletedSoftware{ + SoftwareTitle: meta.SoftwareTitle, + SoftwarePackage: meta.Name, + TeamName: teamName, + TeamID: meta.TeamID, + SelfService: meta.SelfService, + }); err != nil { return ctxerr.Wrap(ctx, err, "creating activity for deleted software") } @@ -242,15 +242,8 @@ func (svc *Service) InstallSoftwareTitle(ctx context.Context, hostID uint, softw } ext := filepath.Ext(installer.Name) - var requiredPlatform string - switch ext { - case ".msi", ".exe": - requiredPlatform = "windows" - case ".pkg": - requiredPlatform = "darwin" - case ".deb": - requiredPlatform = "linux" - default: + requiredPlatform := packageExtensionToPlatform(ext) + if requiredPlatform == "" { // this should never happen return ctxerr.Errorf(ctx, "software installer has unsupported type %s", ext) } @@ -265,7 +258,7 @@ func (svc *Service) InstallSoftwareTitle(ctx context.Context, hostID uint, softw } } - _, err = svc.ds.InsertSoftwareInstallRequest(ctx, hostID, installer.InstallerID) + _, err = svc.ds.InsertSoftwareInstallRequest(ctx, hostID, installer.InstallerID, false) return ctxerr.Wrap(ctx, err, "inserting software install request") } @@ -455,6 +448,7 @@ func (svc *Service) BatchSetSoftwareInstallers(ctx context.Context, tmName strin PreInstallQuery: p.PreInstallQuery, PostInstallScript: p.PostInstallScript, InstallerFile: bytes.NewReader(bodyBytes), + SelfService: p.SelfService, } // set the filename before adding metadata, as it is used as fallback @@ -518,3 +512,68 @@ func (svc *Service) BatchSetSoftwareInstallers(ctx context.Context, tmName strin return nil } + +func (svc *Service) SelfServiceInstallSoftwareTitle(ctx context.Context, host *fleet.Host, softwareTitleID uint) error { + installer, err := svc.ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, host.TeamID, softwareTitleID, false) + if err != nil { + if fleet.IsNotFound(err) { + return &fleet.BadRequestError{ + Message: "Software title has no package added. Please add software package to install.", + InternalErr: ctxerr.WrapWithData( + ctx, err, "couldn't find an installer for software title", + map[string]any{"host_id": host.ID, "team_id": host.TeamID, "title_id": softwareTitleID}, + ), + } + } + + return ctxerr.Wrap(ctx, err, "finding software installer for title") + } + + if !installer.SelfService { + return &fleet.BadRequestError{ + Message: "Software title is not available through self-service", + InternalErr: ctxerr.NewWithData( + ctx, "software title not available through self-service", + map[string]any{"host_id": host.ID, "team_id": host.TeamID, "title_id": softwareTitleID}, + ), + } + } + + ext := filepath.Ext(installer.Name) + requiredPlatform := packageExtensionToPlatform(ext) + if requiredPlatform == "" { + // this should never happen + return ctxerr.Errorf(ctx, "software installer has unsupported type %s", ext) + } + + if host.FleetPlatform() != requiredPlatform { + return &fleet.BadRequestError{ + Message: fmt.Sprintf("Package (%s) can be installed only on %s hosts.", ext, requiredPlatform), + InternalErr: ctxerr.WrapWithData( + ctx, err, "invalid host platform for requested installer", + map[string]any{"host_id": host.ID, "team_id": host.TeamID, "title_id": softwareTitleID}, + ), + } + } + + _, err = svc.ds.InsertSoftwareInstallRequest(ctx, host.ID, installer.InstallerID, true) + return ctxerr.Wrap(ctx, err, "inserting self-service software install request") +} + +// packageExtensionToPlatform returns the platform name based on the +// package extension. Returns an empty string if there is no match. +func packageExtensionToPlatform(ext string) string { + var requiredPlatform string + switch ext { + case ".msi", ".exe": + requiredPlatform = "windows" + case ".pkg": + requiredPlatform = "darwin" + case ".deb": + requiredPlatform = "linux" + default: + return "" + } + + return requiredPlatform +} diff --git a/ee/server/service/teams.go b/ee/server/service/teams.go index c2126e6af8..452bf80f9b 100644 --- a/ee/server/service/teams.go +++ b/ee/server/service/teams.go @@ -13,6 +13,7 @@ import ( "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/logging" "github.com/fleetdm/fleet/v4/server/contexts/viewer" "github.com/fleetdm/fleet/v4/server/fleet" @@ -586,15 +587,36 @@ func (svc *Service) DeleteTeam(ctx context.Context, teamID uint) error { } func (svc *Service) GetTeam(ctx context.Context, teamID uint) (*fleet.Team, error) { - if err := svc.authz.Authorize(ctx, &fleet.Team{ID: teamID}, fleet.ActionRead); err != nil { - return nil, err + alreadyAuthd := svc.authz.IsAuthenticatedWith(ctx, authz_ctx.AuthnDeviceToken) + if alreadyAuthd { + // device-authenticated request can only get the device's team + host, ok := hostctx.FromContext(ctx) + if !ok { + err := ctxerr.Wrap(ctx, fleet.NewAuthRequiredError("internal error: missing host from request context")) + return nil, err + } + if host.TeamID == nil || *host.TeamID != teamID { + return nil, authz.ForbiddenWithInternal("device-authenticated host does not belong to requested team", nil, "team", "read") + } + } else { + if err := svc.authz.Authorize(ctx, &fleet.Team{ID: teamID}, fleet.ActionRead); err != nil { + return nil, err + } } logging.WithExtras(ctx, "id", teamID) - vc, ok := viewer.FromContext(ctx) - if !ok { - return nil, fleet.ErrNoContext + var user *fleet.User + if alreadyAuthd { + // device-authenticated, there is no user in the context, use a global + // observer with no special permissions + user = &fleet.User{GlobalRole: ptr.String(fleet.RoleObserver)} + } else { + vc, ok := viewer.FromContext(ctx) + if !ok { + return nil, fleet.ErrNoContext + } + user = vc.User } team, err := svc.ds.Team(ctx, teamID) @@ -602,7 +624,7 @@ func (svc *Service) GetTeam(ctx context.Context, teamID uint) (*fleet.Team, erro return nil, err } - if err = obfuscateSecrets(vc.User, []*fleet.Team{team}); err != nil { + if err = obfuscateSecrets(user, []*fleet.Team{team}); err != nil { return nil, err } diff --git a/frontend/__mocks__/hostMock.ts b/frontend/__mocks__/hostMock.ts index 276c99b520..09fe9b1cba 100644 --- a/frontend/__mocks__/hostMock.ts +++ b/frontend/__mocks__/hostMock.ts @@ -139,6 +139,7 @@ const DEFAULT_HOST_SOFTWARE_MOCK: IHostSoftware = { id: 1, name: "mock software.app", package_available_for_install: "mockSoftware.app", + self_service: false, source: "apps", bundle_identifier: "com.test.mock", status: "installed", diff --git a/frontend/__mocks__/softwareMock.ts b/frontend/__mocks__/softwareMock.ts index aa808a4f8e..eb1f67f638 100644 --- a/frontend/__mocks__/softwareMock.ts +++ b/frontend/__mocks__/softwareMock.ts @@ -1,7 +1,8 @@ import { ISoftware, ISoftwareVersion, - ISoftwareTitle, + ISoftwareTitleWithPackageDetail, + ISoftwareTitleWithPackageName, ISoftwareVulnerability, ISoftwareTitleVersion, ISoftwarePackage, @@ -43,7 +44,11 @@ export const createMockSoftwareTitleVersion = ( return { ...DEFAULT_SOFTWARE_TITLE_VERSION_MOCK, ...overrides }; }; -const DEFAULT_SOFTWARE_TITLE_MOCK: ISoftwareTitle = { +type MockSoftwareTitle = + | Partial + | Partial; + +const DEFAULT_SOFTWARE_TITLE_MOCK = { id: 1, name: "mock software 1.app", software_package: null, @@ -54,16 +59,26 @@ const DEFAULT_SOFTWARE_TITLE_MOCK: ISoftwareTitle = { versions: [createMockSoftwareTitleVersion()], }; -export const createMockSoftwareTitle = ( - overrides?: Partial -): ISoftwareTitle => { - return { ...DEFAULT_SOFTWARE_TITLE_MOCK, ...overrides }; +export const createMockSoftwareTitle = < + T extends + | Partial + | Partial +>( + overrides: T +) => { + const mock = { + ...DEFAULT_SOFTWARE_TITLE_MOCK, + ...overrides, + }; + return mock; }; const DEFAULT_SOFTWARE_TITLES_RESPONSE_MOCK: ISoftwareTitlesResponse = { counts_updated_at: "2020-01-01T00:00:00.000Z", count: 1, - software_titles: [createMockSoftwareTitle()], + software_titles: [ + createMockSoftwareTitle({ software_package: null, self_service: false }), + ], meta: { has_next_results: false, has_previous_results: false, @@ -131,13 +146,16 @@ export const createMockSoftwareVersionsReponse = ( }; const DEFAULT_SOFTWARE_TITLE_RESPONSE = { - software_title: createMockSoftwareTitle(), + software_title: createMockSoftwareTitle({ + software_package: null, + } as Partial), }; export const createMockSoftwareTitleResponse = ( - overrides?: Partial + overrides: Partial = {} ): ISoftwareTitleResponse => { - return { ...DEFAULT_SOFTWARE_TITLE_RESPONSE, ...overrides }; + const mock = DEFAULT_SOFTWARE_TITLE_RESPONSE.software_title; + return { software_title: { ...mock, ...overrides } }; }; const DEFAULT_SOFTWARE_VERSION_RESPONSE = { @@ -158,6 +176,7 @@ const DEFAULT_SOFTWAREPACKAGE_MOCK: ISoftwarePackage = { pre_install_query: "SELECT 1 FROM macos_profiles WHERE uuid='abc123';", post_install_script: "sudo /Applications/Falcon.app/Contents/Resources/falconctl license abc123", + self_service: false, status: { installed: 1, pending: 2, diff --git a/frontend/components/Card/Card.tsx b/frontend/components/Card/Card.tsx index 0e394250ea..7e42f188a6 100644 --- a/frontend/components/Card/Card.tsx +++ b/frontend/components/Card/Card.tsx @@ -3,12 +3,15 @@ import classnames from "classnames"; const baseClass = "card"; -type BorderRadiusSize = "small" | "medium" | "large"; +type BorderRadiusSize = "small" | "medium" | "large" | "xlarge" | "xxlarge"; type CardColor = "white" | "gray" | "purple" | "yellow"; interface ICardProps { children?: React.ReactNode; - /** The size of the border radius. Defaults to `small`. */ + /** The size of the border radius. Defaults to `small`. + * + * These correspond to the boarder radius in the design system. Look at + * `var/_global.scss` for values */ borderRadiusSize?: BorderRadiusSize; /** Includes the card shadows. Defaults to `false` */ includeShadow?: boolean; @@ -17,9 +20,11 @@ interface ICardProps { className?: string; /** The size of the padding around the content of the card. Defaults to `large`. * - * These correspond to the padding sizes in the design system. Look at `padding.scss` for values */ + * These correspond to the padding sizes in the design system. Look at + * `padding.scss` for values */ paddingSize?: "small" | "medium" | "large" | "xlarge" | "xxlarge"; - /** NOTE: DEPRICATED. Use `paddingSize` prop instead. + /** + * @deprecated Use `paddingSize` prop instead. * * Increases to 40px padding. Defaults to `false` */ largePadding?: boolean; diff --git a/frontend/components/Card/_styles.scss b/frontend/components/Card/_styles.scss index 0fac0a084a..16bb5ad1e4 100644 --- a/frontend/components/Card/_styles.scss +++ b/frontend/components/Card/_styles.scss @@ -5,15 +5,24 @@ padding: $pad-large; // radius styles + &__radius-small { border-radius: $border-radius; } &__radius-medium { - border-radius: $border-radius-large; + border-radius: $border-radius-medium; } &__radius-large { + border-radius: $border-radius-large; + } + + &__radius-xlarge { + border-radius: $border-radius-xlarge; + } + + &__radius-xxlarge { border-radius: $border-radius-xxlarge; } diff --git a/frontend/components/InfoBanner/_styles.scss b/frontend/components/InfoBanner/_styles.scss index ac9cf359b8..7055aa4997 100644 --- a/frontend/components/InfoBanner/_styles.scss +++ b/frontend/components/InfoBanner/_styles.scss @@ -34,7 +34,7 @@ } &__border-radius-large { - border-radius: $border-radius-large; + border-radius: $border-radius-medium; } &__border-radius-xlarge { diff --git a/frontend/components/TableContainer/DataTable/SoftwareNameCell/SoftwareNameCell.tsx b/frontend/components/TableContainer/DataTable/SoftwareNameCell/SoftwareNameCell.tsx index 47c5a97a58..38fc05db82 100644 --- a/frontend/components/TableContainer/DataTable/SoftwareNameCell/SoftwareNameCell.tsx +++ b/frontend/components/TableContainer/DataTable/SoftwareNameCell/SoftwareNameCell.tsx @@ -4,15 +4,20 @@ import ReactTooltip from "react-tooltip"; import { uniqueId } from "lodash"; -import Icon from "components/Icon"; +import { ISoftwarePackage } from "interfaces/software"; +import Icon from "components/Icon"; import SoftwareIcon from "pages/SoftwarePage/components/icons/SoftwareIcon"; import LinkCell from "../LinkCell"; const baseClass = "software-name-cell"; -const InstallIconWithTooltip = () => { +const InstallIconWithTooltip = ({ + isSelfService, +}: { + isSelfService: ISoftwarePackage["self_service"]; +}) => { const tooltipId = uniqueId(); return (
@@ -21,7 +26,10 @@ const InstallIconWithTooltip = () => { data-tip data-for={tooltipId} > - +
{ data-html > - Software can be installed on Host details page. + {isSelfService ? ( + <> + End users can install from Fleet Desktop {">"} Self-service + . + + ) : ( + "Software can be installed on Host details page." + )} @@ -45,6 +60,7 @@ interface ISoftwareNameCellProps { path?: string; router?: InjectedRouter; hasPackage?: boolean; + isSelfService?: boolean; } const SoftwareNameCell = ({ @@ -53,6 +69,7 @@ const SoftwareNameCell = ({ path, router, hasPackage = false, + isSelfService = false, }: ISoftwareNameCellProps) => { // NO path or router means it's not clickable. return // a non-clickable cell early @@ -80,7 +97,9 @@ const SoftwareNameCell = ({ <> {name} - {hasPackage && } + {hasPackage && ( + + )} } /> diff --git a/frontend/components/TooltipWrapper/TooltipWrapper.tsx b/frontend/components/TooltipWrapper/TooltipWrapper.tsx index aa81f28c50..1fee1e01b1 100644 --- a/frontend/components/TooltipWrapper/TooltipWrapper.tsx +++ b/frontend/components/TooltipWrapper/TooltipWrapper.tsx @@ -21,6 +21,12 @@ interface ITooltipWrapper { // tipCustomClass?: string; clickable?: boolean; tipContent: React.ReactNode; + /** If set to `true`, will not show the tooltip. This can be used to dynamically + * disable the tooltip from the parent component. + * + * @default false + */ + disableTooltip?: boolean; } const baseClass = "component__tooltip-wrapper"; @@ -37,6 +43,7 @@ const TooltipWrapper = ({ className, tooltipClass, clickable = true, + disableTooltip = false, }: ITooltipWrapper) => { const wrapperClassNames = classnames(baseClass, className, { // [`${baseClass}__${wrapperCustomClass}`]: !!wrapperCustomClass, @@ -58,20 +65,22 @@ const TooltipWrapper = ({
{children}
- - {tipContent} - + {!disableTooltip && ( + + {tipContent} + + )} ); }; diff --git a/frontend/components/icons/InstallSelfService.tsx b/frontend/components/icons/InstallSelfService.tsx new file mode 100644 index 0000000000..05f9339480 --- /dev/null +++ b/frontend/components/icons/InstallSelfService.tsx @@ -0,0 +1,40 @@ +import React from "react"; +import { COLORS } from "styles/var/colors"; + +import { ICON_SIZES, IconSizes } from "styles/var/icon_sizes"; + +interface IInstallSelfServiceProps { + size?: IconSizes; + color?: keyof typeof COLORS; +} + +const InstallSelfService = ({ + size = "medium", + color = "ui-fleet-black-50", +}: IInstallSelfServiceProps) => { + return ( + + + + + + + + + + + ); +}; + +export default InstallSelfService; diff --git a/frontend/components/icons/index.ts b/frontend/components/icons/index.ts index 0a19e3b1dc..3b573cf9ac 100644 --- a/frontend/components/icons/index.ts +++ b/frontend/components/icons/index.ts @@ -57,6 +57,7 @@ import Download from "./Download"; import Upload from "./Upload"; import Refresh from "./Refresh"; import Install from "./Install"; +import InstallSelfService from "./InstallSelfService"; import Settings from "./Settings"; // a mapping of the usable names of icons to the icon source. @@ -119,6 +120,7 @@ export const ICON_MAP = { upload: Upload, refresh: Refresh, install: Install, + "install-self-service": InstallSelfService, settings: Settings, }; diff --git a/frontend/interfaces/activity.ts b/frontend/interfaces/activity.ts index d61b327676..06732e90ef 100644 --- a/frontend/interfaces/activity.ts +++ b/frontend/interfaces/activity.ts @@ -77,12 +77,17 @@ export enum ActivityType { } // This is a subset of ActivityType that are shown only for the host past activities -export type IHostActivityType = +export type IHostPastActivityType = | ActivityType.RanScript | ActivityType.LockedHost | ActivityType.UnlockedHost | ActivityType.InstalledSoftware; +// This is a subset of ActivityType that are shown only for the host upcoming activities +export type IHostUpcomingActivityType = + | ActivityType.RanScript + | ActivityType.InstalledSoftware; + export interface IActivity { created_at: string; id: number; @@ -94,8 +99,13 @@ export interface IActivity { details?: IActivityDetails; } -export type IHostActivity = Omit & { - type: IHostActivityType; +export type IHostPastActivity = Omit & { + type: IHostPastActivityType; + details: IActivityDetails; +}; + +export type IHostUpcomingActivity = Omit & { + type: IHostUpcomingActivityType; details: IActivityDetails; }; @@ -142,4 +152,5 @@ export interface IActivityDetails { software_package?: string; status?: string; install_uuid?: string; + self_service?: boolean; } diff --git a/frontend/interfaces/host.ts b/frontend/interfaces/host.ts index 8b9a5a2cbf..862ef00eea 100644 --- a/frontend/interfaces/host.ts +++ b/frontend/interfaces/host.ts @@ -235,6 +235,7 @@ export interface IDeviceUserResponse { host: IHostDevice; license: ILicense; org_logo_url: string; + org_contact_url: string; disk_encryption_enabled?: boolean; platform?: string; global_config: IDeviceGlobalConfig; diff --git a/frontend/interfaces/software.ts b/frontend/interfaces/software.ts index 68923d47b7..bb626b1c2a 100644 --- a/frontend/interfaces/software.ts +++ b/frontend/interfaces/software.ts @@ -57,6 +57,7 @@ export interface ISoftwarePackage { install_script: string; pre_install_query?: string; post_install_script?: string; + self_service: boolean; status: { installed: number; pending: number; @@ -64,15 +65,28 @@ export interface ISoftwarePackage { }; } -export interface ISoftwareTitle { +interface ISoftwareTitle { id: number; name: string; - software_package: ISoftwarePackage | null; + software_package: ISoftwarePackage | string | null; versions_count: number; source: string; hosts_count: number; versions: ISoftwareTitleVersion[] | null; browser: string; + self_service?: boolean; +} + +export interface ISoftwareTitleWithPackageName + extends Omit { + software_package: string | null; + self_service: boolean; +} + +export interface ISoftwareTitleWithPackageDetail + extends Omit { + software_package: ISoftwarePackage | null; + self_service?: never; } export interface ISoftwareVulnerability { @@ -210,9 +224,18 @@ export interface IHostSoftware { id: number; name: string; package_available_for_install?: string | null; + self_service: boolean; source: string; bundle_identifier?: string; status: SoftwareInstallStatus | null; last_install: ISoftwareLastInstall | null; installed_versions: ISoftwareInstallVersion[] | null; } + +export interface IDeviceSoftware extends IHostSoftware { + package_available_for_install: never; + package: { + name: string; + version: string; + }; +} diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tests.tsx b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tests.tsx index e8bff33d98..830d485425 100644 --- a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tests.tsx +++ b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tests.tsx @@ -8,20 +8,6 @@ import { ActivityType } from "interfaces/activity"; import ActivityItem from "."; -const getByTextContent = (text: string) => { - return screen.getByText((content, element) => { - if (!element) { - return false; - } - const hasText = (thisElement: Element) => thisElement.textContent === text; - const elementHasText = hasText(element); - const childrenDontHaveText = Array.from(element?.children || []).every( - (child) => !hasText(child) - ); - return elementHasText && childrenDontHaveText; - }); -}; - describe("Activity Feed", () => { it("renders avatar, actor name, timestamp", async () => { const currentDate = new Date(); @@ -1178,4 +1164,34 @@ describe("Activity Feed", () => { expect(screen.getByText("wiped", { exact: false })).toBeInTheDocument(); expect(screen.getByText("Foo Host", { exact: false })).toBeInTheDocument(); }); + + it("renders the correct actor for a installed_software activity without self_service", () => { + const activity = createMockActivity({ + type: ActivityType.InstalledSoftware, + actor_id: 1, + actor_full_name: "Test Admin", + details: { + software_title: "Foo Software", + host_display_name: "Foo Host", + }, + }); + + render(); + expect(screen.getByText("Test Admin")).toBeInTheDocument(); + }); + + it("renders the correct actor for a installed_software activity that was self_service", () => { + const activity = createMockActivity({ + type: ActivityType.InstalledSoftware, + actor_id: 1, + details: { + software_title: "Foo Software", + self_service: true, + host_display_name: "Foo Host", + }, + }); + + render(); + expect(screen.getByText("An end user")).toBeInTheDocument(); + }); }); diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx index 7378d190ee..c3a2759329 100644 --- a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx +++ b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx @@ -600,7 +600,7 @@ const TAGGED_TEMPLATES = { ); }, - enabledWindowsMdm: (activity: IActivity) => { + enabledWindowsMdm: () => { return ( <> {" "} @@ -609,7 +609,7 @@ const TAGGED_TEMPLATES = { ); }, - disabledWindowsMdm: (activity: IActivity) => { + disabledWindowsMdm: () => { return <> told Fleet to turn off Windows MDM features.; }, // TODO: Combine ranScript template with host details page templates @@ -728,7 +728,7 @@ const TAGGED_TEMPLATES = { ); }, - deletedMultipleSavedQuery: (activity: IActivity) => { + deletedMultipleSavedQuery: () => { return <> deleted multiple queries.; }, lockedHost: (activity: IActivity) => { @@ -864,8 +864,6 @@ const TAGGED_TEMPLATES = { return TAGGED_TEMPLATES.defaultActivityTemplate(activity); } - console.log("onDetailsClick", onDetailsClick); - const { host_display_name: hostName, software_title: title, @@ -1014,10 +1012,10 @@ const getDetail = ( return TAGGED_TEMPLATES.transferredHosts(activity); } case ActivityType.EnabledWindowsMdm: { - return TAGGED_TEMPLATES.enabledWindowsMdm(activity); + return TAGGED_TEMPLATES.enabledWindowsMdm(); } case ActivityType.DisabledWindowsMdm: { - return TAGGED_TEMPLATES.disabledWindowsMdm(activity); + return TAGGED_TEMPLATES.disabledWindowsMdm(); } case ActivityType.RanScript: { return TAGGED_TEMPLATES.ranScript(activity, onDetailsClick); @@ -1035,7 +1033,7 @@ const getDetail = ( return TAGGED_TEMPLATES.editedWindowsUpdates(activity); } case ActivityType.DeletedMultipleSavedQuery: { - return TAGGED_TEMPLATES.deletedMultipleSavedQuery(activity); + return TAGGED_TEMPLATES.deletedMultipleSavedQuery(); } case ActivityType.LockedHost: { return TAGGED_TEMPLATES.lockedHost(activity); @@ -1111,18 +1109,30 @@ const ActivityItem = ({ isSandboxMode && PREMIUM_ACTIVITIES.has(activity.type); const renderActivityPrefix = () => { - if (activity.type === ActivityType.UserLoggedIn) { - return {activity.actor_email} ; + const DEFAULT_ACTOR_DISPLAY = {activity.actor_full_name} ; + + switch (activity.type) { + case ActivityType.UserLoggedIn: + return {activity.actor_email} ; + case ActivityType.UserChangedGlobalRole: + case ActivityType.UserChangedTeamRole: + return activity.actor_id === activity.details?.user_id ? ( + {activity.details?.user_email} + ) : ( + DEFAULT_ACTOR_DISPLAY + ); + case ActivityType.InstalledSoftware: + return activity.details?.self_service ? ( + An end user + ) : ( + DEFAULT_ACTOR_DISPLAY + ); + + default: + return DEFAULT_ACTOR_DISPLAY; } - if ( - (activity.type === ActivityType.UserChangedGlobalRole || - activity.type === ActivityType.UserChangedTeamRole) && - activity.actor_id === activity.details?.user_id - ) { - return {activity.details?.user_email} ; - } - return {activity.actor_full_name} ; }; + return (
{ return ( - +

End user experience

After the end user continues past the Remote Management screen, diff --git a/frontend/pages/SoftwarePage/SoftwareOSDetailsPage/SoftwareOSDetailsPage.tsx b/frontend/pages/SoftwarePage/SoftwareOSDetailsPage/SoftwareOSDetailsPage.tsx index 37a299bf0b..257b09365c 100644 --- a/frontend/pages/SoftwarePage/SoftwareOSDetailsPage/SoftwareOSDetailsPage.tsx +++ b/frontend/pages/SoftwarePage/SoftwareOSDetailsPage/SoftwareOSDetailsPage.tsx @@ -183,7 +183,7 @@ const SoftwareOSDetailsPage = ({ name={osVersionDetails.platform} /> diff --git a/frontend/pages/SoftwarePage/SoftwarePage.tsx b/frontend/pages/SoftwarePage/SoftwarePage.tsx index f851e2cbf9..d52de58aa4 100644 --- a/frontend/pages/SoftwarePage/SoftwarePage.tsx +++ b/frontend/pages/SoftwarePage/SoftwarePage.tsx @@ -29,7 +29,7 @@ import TabsWrapper from "components/TabsWrapper"; import ManageAutomationsModal from "./components/ManageSoftwareAutomationsModal"; import AddSoftwareModal from "./components/AddSoftwareModal"; -import { ISoftwareDropdownFilterVal } from "./SoftwareTitles/SoftwareTable/helpers"; +import { getSoftwareFilterFromQueryParams } from "./SoftwareTitles/SoftwareTable/helpers"; interface ISoftwareSubNavItem { name: string; @@ -63,16 +63,6 @@ const getTabIndex = (path: string): number => { }); }; -const getSoftwareFilter = ( - vulnerable?: string, - installable?: string -): ISoftwareDropdownFilterVal => { - if (installable === "true") return "installableSoftware"; - return vulnerable && vulnerable === "true" - ? "vulnerableSoftware" - : "allSoftware"; -}; - // default values for query params used on this page if not provided const DEFAULT_SORT_DIRECTION = "desc"; const DEFAULT_SORT_HEADER = "hosts_count"; @@ -149,10 +139,11 @@ const SoftwarePage = ({ children, router, location }: ISoftwarePageProps) => { const query = queryParams && queryParams.query ? queryParams.query : ""; const showExploitedVulnerabilitiesOnly = queryParams !== undefined && queryParams.exploit === "true"; - const softwareFilter = getSoftwareFilter( - queryParams.vulnerable, - queryParams.available_for_install - ); + + // TODO: there should be better validation of the params depending on the route (e.g., self_service + // and available_for_install don't apply to versions, os, or vulnerabilities routes) and some + // defined redirect behavior if the params are invalid + const softwareFilter = getSoftwareFilterFromQueryParams(queryParams); const [showManageAutomationsModal, setShowManageAutomationsModal] = useState( false diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwarePackageCard/SoftwarePackageCard.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwarePackageCard/SoftwarePackageCard.tsx index cea4bfc05d..665ea5ed6d 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwarePackageCard/SoftwarePackageCard.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwarePackageCard/SoftwarePackageCard.tsx @@ -1,4 +1,9 @@ -import React, { useCallback, useContext, useState } from "react"; +import React, { + useCallback, + useContext, + useLayoutEffect, + useState, +} from "react"; import FileSaver from "file-saver"; @@ -15,18 +20,58 @@ import { buildQueryStringFromParams } from "utilities/url"; import { internationalTimeFormat } from "utilities/helpers"; import { uploadedFromNow } from "utilities/date_format"; +// @ts-ignore +import Dropdown from "components/forms/fields/Dropdown"; + import Card from "components/Card"; import Graphic from "components/Graphic"; import TooltipWrapper from "components/TooltipWrapper"; import DataSet from "components/DataSet"; import Icon from "components/Icon"; -import Button from "components/buttons/Button"; import DeleteSoftwareModal from "../DeleteSoftwareModal"; import AdvancedOptionsModal from "../AdvancedOptionsModal"; const baseClass = "software-package-card"; +/** TODO: pull this hook and SoftwareName component out. We could use this other places */ + +function useTruncatedElement(ref: any) { + const [isTruncated, setIsTruncated] = useState(false); + + useLayoutEffect(() => { + const element = ref.current; + if (element) { + const { scrollWidth, clientWidth } = element; + setIsTruncated(scrollWidth > clientWidth); + } + }, [ref]); + + return isTruncated; +} + +interface ISoftwareNameProps { + name: string; +} + +const SoftwareName = ({ name }: ISoftwareNameProps) => { + const titleRef = React.useRef(null); + const isTruncated = useTruncatedElement(titleRef); + + return ( + +

+ {name} +
+ + ); +}; + interface IStatusDisplayOption { displayName: string; iconName: "success" | "pending-outline" | "error"; @@ -96,6 +141,59 @@ const PackageStatusCount = ({ ); }; +const DROPDOWN_OPTIONS = [ + { + label: "Download", + value: "download", + }, + { + label: "Delete", + value: "delete", + }, + { + label: "Advanced options", + value: "advanced", + }, +] as const; + +const ActionsDropdown = ({ + onDownloadClick, + onDeleteClick, + onAdvancedOptionsClick, +}: { + onDownloadClick: () => void; + onDeleteClick: () => void; + onAdvancedOptionsClick: () => void; +}) => { + const onSelect = (value: string) => { + switch (value) { + case "download": + onDownloadClick(); + break; + case "delete": + onDeleteClick(); + break; + case "advanced": + onAdvancedOptionsClick(); + break; + default: + // noop + } + }; + + return ( +
+ +
+ ); +}; + interface ISoftwarePackageCardProps { softwarePackage: ISoftwarePackage; softwareId: number; @@ -115,7 +213,6 @@ const SoftwarePackageCard = ({ isTeamAdmin, isTeamMaintainer, } = useContext(AppContext); - const { renderFlash } = useContext(NotificationContext); const [showAdvancedOptionsModal, setShowAdvancedOptionsModal] = useState( @@ -171,16 +268,14 @@ const SoftwarePackageCard = ({ isGlobalAdmin || isGlobalMaintainer || isTeamAdmin || isTeamMaintainer; return ( - +
{/* TODO: main-info could be a seperate component as its reused on a couple pages already. Come back and pull this into a component */}
- - {softwarePackage.name} - + Version {softwarePackage.version} •
- {showActions && ( -
- - {/* TODO: make a component for download icons */} - - -
- )} +
+ {true && ( +
+ + Self-service +
+ )} + {showActions && ( + + )} +
{showAdvancedOptionsModal && ( .Select-menu-outer { + left: -120px; + } + .Select-placeholder { + color: $core-fleet-black; + } + } + &__download-icon { display: flex; justify-content: center; @@ -62,7 +92,7 @@ &__main-content { display: flex; flex-direction: column; - align-items: center; + align-items: flex-start; gap: $pad-large; } } diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsPage.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsPage.tsx index 772d189ece..10d5b41fd0 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsPage.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsPage.tsx @@ -12,7 +12,10 @@ import useTeamIdParam from "hooks/useTeamIdParam"; import { AppContext } from "context/app"; -import { ISoftwareTitle, formatSoftwareType } from "interfaces/software"; +import { + ISoftwareTitleWithPackageDetail, + formatSoftwareType, +} from "interfaces/software"; import { ignoreAxiosError } from "interfaces/errors"; import softwareAPI, { ISoftwareTitleResponse, @@ -80,7 +83,7 @@ const SoftwareTitleDetailsPage = ({ } = useQuery< ISoftwareTitleResponse, AxiosError, - ISoftwareTitle, + ISoftwareTitleWithPackageDetail, IGetSoftwareTitleQueryKey[] >( [{ scope: "softwareById", softwareId, teamId: teamIdForApi }], @@ -176,7 +179,7 @@ const SoftwareTitleDetailsPage = ({ /> )} diff --git a/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/SoftwareTable.tsx b/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/SoftwareTable.tsx index 7ea1bd72ce..c67000d045 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/SoftwareTable.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/SoftwareTable.tsx @@ -10,12 +10,19 @@ import { Row } from "react-table"; import PATHS from "router/paths"; import { getNextLocationPath } from "utilities/helpers"; import { GITHUB_NEW_ISSUE_LINK } from "utilities/constants"; -import { buildQueryStringFromParams } from "utilities/url"; import { + buildQueryStringFromParams, + convertParamsToSnakeCase, +} from "utilities/url"; +import { + ISoftwareApiParams, ISoftwareTitlesResponse, ISoftwareVersionsResponse, } from "services/entities/software"; -import { ISoftwareTitle, ISoftwareVersion } from "interfaces/software"; +import { + ISoftwareTitleWithPackageName, + ISoftwareVersion, +} from "interfaces/software"; // @ts-ignore import Dropdown from "components/forms/fields/Dropdown"; @@ -33,6 +40,7 @@ import { ISoftwareDropdownFilterVal, SOFTWARE_TITLES_DROPDOWN_OPTIONS, SOFTWARE_VERSIONS_DROPDOWN_OPTIONS, + getSoftwareFilterForQueryKey, } from "./helpers"; interface IRowProps extends Row { @@ -154,7 +162,10 @@ const SoftwareTable = ({ [determineQueryParamChange, generateNewQueryParams, router, currentPath] ); - let tableData: ISoftwareTitle[] | ISoftwareVersion[] | undefined; + let tableData: + | ISoftwareTitleWithPackageName[] + | ISoftwareVersion[] + | undefined; let generateTableConfig: ITableConfigGenerator; if (data === undefined) { @@ -225,28 +236,23 @@ const SoftwareTable = ({ ); }; - const handleVulnFilterDropdownChange = ( + const handleCustomFilterDropdownChange = ( value: ISoftwareDropdownFilterVal ) => { - const queryParams: Record = { + const queryParams: ISoftwareApiParams = { query, - team_id: teamId, - order_direction: orderDirection, - order_key: orderKey, + teamId, + orderDirection, + orderKey, page: 0, // resets page index + ...getSoftwareFilterForQueryKey(value), }; - if (value === "installableSoftware") { - queryParams.available_for_install = true; - } else { - queryParams.vulnerable = value === "vulnerableSoftware"; - } - router.replace( getNextLocationPath({ pathPrefix: currentPath, routeTemplate: "", - queryParams, + queryParams: convertParamsToSnakeCase(queryParams), }) ); }; @@ -304,7 +310,7 @@ const SoftwareTable = ({ className={`${baseClass}__vuln_dropdown`} options={options} searchable={false} - onChange={handleVulnFilterDropdownChange} + onChange={handleCustomFilterDropdownChange} tableFilterDropdown />
@@ -347,6 +353,9 @@ const SoftwareTable = ({ pageSize={perPage} showMarkAllPages={false} isAllPagesSelected={false} + disablePagination={ + !data?.meta.has_next_results && !data?.meta.has_previous_results + } disableNextPage={!data?.meta.has_next_results} searchable={searchable} inputPlaceHolder="Search by name or vulnerabilities (CVEs)" diff --git a/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/SoftwareTitlesTableConfig.tsx b/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/SoftwareTitlesTableConfig.tsx index 9cf8e41e24..00839a7748 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/SoftwareTitlesTableConfig.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/SoftwareTitlesTableConfig.tsx @@ -2,7 +2,10 @@ import React from "react"; import { CellProps, Column } from "react-table"; import { InjectedRouter } from "react-router"; -import { ISoftwareTitle, formatSoftwareType } from "interfaces/software"; +import { + ISoftwareTitleWithPackageName, + formatSoftwareType, +} from "interfaces/software"; import PATHS from "router/paths"; import { buildQueryStringFromParams } from "utilities/url"; @@ -19,17 +22,20 @@ import VulnerabilitiesCell from "../../components/VulnerabilitiesCell"; // NOTE: cellProps come from react-table // more info here https://react-table.tanstack.com/docs/api/useTable#cell-properties -type ISoftwareTitlesTableConfig = Column; -type ITableStringCellProps = IStringCellProps; -type IVersionsCellProps = CellProps; +type ISoftwareTitlesTableConfig = Column; +type ITableStringCellProps = IStringCellProps; +type IVersionsCellProps = CellProps< + ISoftwareTitleWithPackageName, + ISoftwareTitleWithPackageName["versions"] +>; type IVulnerabilitiesCellProps = IVersionsCellProps; type IHostCountCellProps = CellProps< - ISoftwareTitle, - ISoftwareTitle["hosts_count"] + ISoftwareTitleWithPackageName, + ISoftwareTitleWithPackageName["hosts_count"] >; -type IViewAllHostsLinkProps = CellProps; +type IViewAllHostsLinkProps = CellProps; -type ITableHeaderProps = IHeaderProps; +type ITableHeaderProps = IHeaderProps; export const getVulnerabilities = < T extends { vulnerabilities: string[] | null } @@ -63,7 +69,13 @@ const generateTableHeaders = ( disableSortBy: false, accessor: "name", Cell: (cellProps: ITableStringCellProps) => { - const { id, name, source, software_package } = cellProps.row.original; + const { + id, + name, + source, + software_package, + self_service, + } = cellProps.row.original; const teamQueryParam = buildQueryStringFromParams({ team_id: teamId }); const softwareTitleDetailsPath = `${PATHS.SOFTWARE_TITLE_DETAILS( @@ -79,6 +91,7 @@ const generateTableHeaders = ( path={softwareTitleDetailsPath} router={router} hasPackage={hasPackage} + isSelfService={self_service === true} /> ); }, diff --git a/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/helpers.ts b/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/helpers.ts index 59fa04a46c..5cdc0fcde9 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/helpers.ts +++ b/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/helpers.ts @@ -1,7 +1,10 @@ +import { QueryParams } from "utilities/url"; + export type ISoftwareDropdownFilterVal = | "allSoftware" | "vulnerableSoftware" - | "installableSoftware"; + | "installableSoftware" + | "selfServiceSoftware"; export const SOFTWARE_VERSIONS_DROPDOWN_OPTIONS = [ { @@ -27,4 +30,39 @@ export const SOFTWARE_TITLES_DROPDOWN_OPTIONS = [ value: "installableSoftware", helpText: "Software that can be installed on your hosts.", }, + { + disabled: false, + label: "Self-service", + value: "selfServiceSoftware", + helpText: "Software that end users can install from Fleet Desktop.", + }, ]; + +export const getSoftwareFilterForQueryKey = ( + val: ISoftwareDropdownFilterVal +) => { + switch (val) { + case "installableSoftware": + return { availableForInstall: true }; + case "selfServiceSoftware": + return { selfService: true }; + case "vulnerableSoftware": + return { vulnerable: true }; + default: + return {}; + } +}; + +export const getSoftwareFilterFromQueryParams = (queryParams: QueryParams) => { + const { vulnerable, available_for_install, self_service } = queryParams; + switch (true) { + case available_for_install === "true": + return "installableSoftware"; + case self_service === "true": + return "selfServiceSoftware"; + case vulnerable === "true": + return "vulnerableSoftware"; + default: + return "allSoftware"; + } +}; diff --git a/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTitles.tsx b/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTitles.tsx index c057a28d76..1a90c284f5 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTitles.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTitles.tsx @@ -5,11 +5,13 @@ import React from "react"; import { InjectedRouter } from "react-router"; import { useQuery } from "react-query"; +import { omit } from "lodash"; import PATHS from "router/paths"; import softwareAPI, { - ISoftwareApiParams, + ISoftwareTitlesQueryKey, ISoftwareTitlesResponse, + ISoftwareVersionsQueryKey, ISoftwareVersionsResponse, } from "services/entities/software"; @@ -17,7 +19,10 @@ import Spinner from "components/Spinner"; import TableDataError from "components/DataError"; import SoftwareTable from "./SoftwareTable"; -import { ISoftwareDropdownFilterVal } from "./SoftwareTable/helpers"; +import { + ISoftwareDropdownFilterVal, + getSoftwareFilterForQueryKey, +} from "./SoftwareTable/helpers"; const baseClass = "software-titles"; @@ -27,14 +32,6 @@ const QUERY_OPTIONS = { staleTime: DATA_STALE_TIME, }; -interface ISoftwareTitlesQueryKey extends ISoftwareApiParams { - scope: "software-titles"; -} - -interface ISoftwareVersionsQueryKey extends ISoftwareApiParams { - scope: "software-versions"; -} - interface ISoftwareTitlesProps { router: InjectedRouter; isSoftwareEnabled: boolean; @@ -62,25 +59,6 @@ const SoftwareTitles = ({ }: ISoftwareTitlesProps) => { const showVersions = location.pathname === PATHS.SOFTWARE_VERSIONS; - const generateSoftwareTitlesQueryKey = (): ISoftwareTitlesQueryKey => { - const queryKey: ISoftwareTitlesQueryKey = { - scope: "software-titles", - page: currentPage, - perPage, - query, - orderDirection, - orderKey, - teamId, - }; - if (softwareFilter === "installableSoftware") { - queryKey.availableForInstall = true; - } else { - queryKey.vulnerable = softwareFilter === "vulnerableSoftware"; - } - - return queryKey; - }; - // request to get software data const { data: titlesData, @@ -91,10 +69,22 @@ const SoftwareTitles = ({ ISoftwareTitlesResponse, Error, ISoftwareTitlesResponse, - ISoftwareTitlesQueryKey[] + [ISoftwareTitlesQueryKey] >( - [generateSoftwareTitlesQueryKey()], - ({ queryKey }) => softwareAPI.getSoftwareTitles(queryKey[0]), + [ + { + scope: "software-titles", + page: currentPage, + perPage, + query, + orderDirection, + orderKey, + teamId, + ...getSoftwareFilterForQueryKey(softwareFilter), + }, + ], + ({ queryKey: [queryKey] }) => + softwareAPI.getSoftwareTitles(omit(queryKey, "scope")), { ...QUERY_OPTIONS, enabled: location.pathname === PATHS.SOFTWARE_TITLES, @@ -111,7 +101,7 @@ const SoftwareTitles = ({ ISoftwareVersionsResponse, Error, ISoftwareVersionsResponse, - ISoftwareVersionsQueryKey[] + [ISoftwareVersionsQueryKey] >( [ { @@ -125,7 +115,8 @@ const SoftwareTitles = ({ vulnerable: softwareFilter === "vulnerableSoftware", }, ], - ({ queryKey }) => softwareAPI.getSoftwareVersions(queryKey[0]), + ({ queryKey: [queryKey] }) => + softwareAPI.getSoftwareVersions(omit(queryKey, "scope")), { ...QUERY_OPTIONS, enabled: location.pathname === PATHS.SOFTWARE_VERSIONS, diff --git a/frontend/pages/SoftwarePage/SoftwareVersionDetailsPage/SoftwareVersionDetailsPage.tsx b/frontend/pages/SoftwarePage/SoftwareVersionDetailsPage/SoftwareVersionDetailsPage.tsx index 861a13ea95..328713f78c 100644 --- a/frontend/pages/SoftwarePage/SoftwareVersionDetailsPage/SoftwareVersionDetailsPage.tsx +++ b/frontend/pages/SoftwarePage/SoftwareVersionDetailsPage/SoftwareVersionDetailsPage.tsx @@ -152,7 +152,7 @@ const SoftwareVersionDetailsPage = ({ source={softwareVersion.source} /> diff --git a/frontend/pages/SoftwarePage/SoftwareVulnerabilityDetailsPage/SoftwareVulnOSVersions/SoftwareVulnOSVersions.tsx b/frontend/pages/SoftwarePage/SoftwareVulnerabilityDetailsPage/SoftwareVulnOSVersions/SoftwareVulnOSVersions.tsx index af8cb9e9bd..07028e7f75 100644 --- a/frontend/pages/SoftwarePage/SoftwareVulnerabilityDetailsPage/SoftwareVulnOSVersions/SoftwareVulnOSVersions.tsx +++ b/frontend/pages/SoftwarePage/SoftwareVulnerabilityDetailsPage/SoftwareVulnOSVersions/SoftwareVulnOSVersions.tsx @@ -68,7 +68,7 @@ const SoftwareVulnOSVersions = ({ }; return ( - +

Vulnerable OS

{renderVulnerableOSTable()}
diff --git a/frontend/pages/SoftwarePage/SoftwareVulnerabilityDetailsPage/SoftwareVulnSoftwareVersions/SoftwareVulnSoftwareVersions.tsx b/frontend/pages/SoftwarePage/SoftwareVulnerabilityDetailsPage/SoftwareVulnSoftwareVersions/SoftwareVulnSoftwareVersions.tsx index a1027bfb01..29ed8561d1 100644 --- a/frontend/pages/SoftwarePage/SoftwareVulnerabilityDetailsPage/SoftwareVulnSoftwareVersions/SoftwareVulnSoftwareVersions.tsx +++ b/frontend/pages/SoftwarePage/SoftwareVulnerabilityDetailsPage/SoftwareVulnSoftwareVersions/SoftwareVulnSoftwareVersions.tsx @@ -68,7 +68,7 @@ const SoftwareVulnSoftwareVersions = ({ ); }; return ( - +

Vulnerable software

{renderVulnerableSoftwareTable()}
diff --git a/frontend/pages/SoftwarePage/SoftwareVulnerabilityDetailsPage/SoftwareVulnSummary/SoftwareVulnSummary.tsx b/frontend/pages/SoftwarePage/SoftwareVulnerabilityDetailsPage/SoftwareVulnSummary/SoftwareVulnSummary.tsx index 7b55f67494..a2ce0c221c 100644 --- a/frontend/pages/SoftwarePage/SoftwareVulnerabilityDetailsPage/SoftwareVulnSummary/SoftwareVulnSummary.tsx +++ b/frontend/pages/SoftwarePage/SoftwareVulnerabilityDetailsPage/SoftwareVulnSummary/SoftwareVulnSummary.tsx @@ -38,7 +38,7 @@ const SoftwareVulnSummary = ({ } = vuln; return ( - +

{cve}

diff --git a/frontend/pages/SoftwarePage/components/AddSoftwareForm/AddSoftwareForm.tsx b/frontend/pages/SoftwarePage/components/AddSoftwareForm/AddSoftwareForm.tsx index bb18ccf923..acad6e777d 100644 --- a/frontend/pages/SoftwarePage/components/AddSoftwareForm/AddSoftwareForm.tsx +++ b/frontend/pages/SoftwarePage/components/AddSoftwareForm/AddSoftwareForm.tsx @@ -1,15 +1,18 @@ -import React, { useState } from "react"; +import React, { useContext, useState } from "react"; +import { NotificationContext } from "context/notification"; +import { getFileDetails } from "utilities/file/fileUtils"; import getInstallScript from "utilities/software_install_scripts"; -import Spinner from "components/Spinner"; import Button from "components/buttons/Button"; +import Checkbox from "components/forms/fields/Checkbox"; import Editor from "components/Editor"; -import FileUploader, { +import { + FileUploader, FileDetails, } from "components/FileUploader/FileUploader"; - -import { getFileDetails } from "utilities/file/fileUtils"; +import Spinner from "components/Spinner"; +import TooltipWrapper from "components/TooltipWrapper"; import AddSoftwareAdvancedOptions from "../AddSoftwareAdvancedOptions"; @@ -31,6 +34,7 @@ export interface IAddSoftwareFormData { installScript: string; preInstallCondition?: string; postInstallScript?: string; + selfService: boolean; } export interface IFormValidation { @@ -38,6 +42,7 @@ export interface IFormValidation { software: { isValid: boolean }; preInstallCondition?: { isValid: boolean; message?: string }; postInstallScript?: { isValid: boolean; message?: string }; + selfService?: { isValid: boolean }; } interface IAddSoftwareFormProps { @@ -51,6 +56,8 @@ const AddSoftwareForm = ({ onCancel, onSubmit, }: IAddSoftwareFormProps) => { + const { renderFlash } = useContext(NotificationContext); + const [showPreInstallCondition, setShowPreInstallCondition] = useState(false); const [showPostInstallScript, setShowPostInstallScript] = useState(false); const [formData, setFormData] = useState({ @@ -58,6 +65,7 @@ const AddSoftwareForm = ({ installScript: "", preInstallCondition: undefined, postInstallScript: undefined, + selfService: false, }); const [formValidation, setFormValidation] = useState({ isValid: false, @@ -67,10 +75,19 @@ const AddSoftwareForm = ({ const onFileUpload = (files: FileList | null) => { if (files && files.length > 0) { const file = files[0]; + + let installScript: string; + try { + installScript = getInstallScript(file.name); + } catch (e) { + renderFlash("error", `${e}`); + return; + } + const newData = { ...formData, software: file, - installScript: getInstallScript(file.name), + installScript, }; setFormData(newData); setFormValidation( @@ -134,6 +151,18 @@ const AddSoftwareForm = ({ ); }; + const onToggleSelfServiceCheckbox = (value: boolean) => { + const newData = { ...formData, selfService: value }; + setFormData(newData); + setFormValidation( + generateFormValidation( + newData, + showPreInstallCondition, + showPostInstallScript + ) + ); + }; + const isSubmitDisabled = !formValidation.isValid; return ( @@ -175,6 +204,21 @@ const AddSoftwareForm = ({ } /> )} + + + End users can install from{" "} + Fleet Desktop {">"} Self-service. + + } + > + Self-service + + - {formData.software?.name} successfully added. Go to Host - details page to install software. + {formData.software?.name} successfully added. + {formData.selfService + ? " The end user can install from Fleet Desktop." + : ""} ); onExit(); + + const newQueryParams: QueryParams = { team_id: teamId }; + if (formData.selfService) { + newQueryParams.self_service = true; + } else { + newQueryParams.available_for_install = true; + } + router.push( - `${PATHS.SOFTWARE_TITLES}?${buildQueryStringFromParams({ - available_for_install: true, - team_id: teamId, - })}` + `${PATHS.SOFTWARE_TITLES}?${buildQueryStringFromParams(newQueryParams)}` ); } catch (e) { renderFlash("error", getErrorReason(e)); diff --git a/frontend/pages/SoftwarePage/components/DetailsNoHosts/DetailsNoHosts.tsx b/frontend/pages/SoftwarePage/components/DetailsNoHosts/DetailsNoHosts.tsx index 6c1c62a60b..94187808c7 100644 --- a/frontend/pages/SoftwarePage/components/DetailsNoHosts/DetailsNoHosts.tsx +++ b/frontend/pages/SoftwarePage/components/DetailsNoHosts/DetailsNoHosts.tsx @@ -11,7 +11,7 @@ interface IDetailsNoHosts { const DetailsNoHosts = ({ header, details }: IDetailsNoHosts) => { return ( - +

{header}

{details}

diff --git a/frontend/pages/SoftwarePage/components/SoftwareDetailsSummary/_styles.scss b/frontend/pages/SoftwarePage/components/SoftwareDetailsSummary/_styles.scss index 3a273a7046..5006d85624 100644 --- a/frontend/pages/SoftwarePage/components/SoftwareDetailsSummary/_styles.scss +++ b/frontend/pages/SoftwarePage/components/SoftwareDetailsSummary/_styles.scss @@ -10,8 +10,6 @@ .software-icon { width: 96px; height: 96px; - border: 1px solid $ui-fleet-black-10; - border-radius: 8px; } &__info { diff --git a/frontend/pages/SoftwarePage/components/icons/SoftwareIcon/_styles.scss b/frontend/pages/SoftwarePage/components/icons/SoftwareIcon/_styles.scss index 75a962198d..328a564a1e 100644 --- a/frontend/pages/SoftwarePage/components/icons/SoftwareIcon/_styles.scss +++ b/frontend/pages/SoftwarePage/components/icons/SoftwareIcon/_styles.scss @@ -1,3 +1,5 @@ .software-icon { flex-shrink: 0; + border: 1px solid $ui-fleet-black-10; + border-radius: 8px; } diff --git a/frontend/pages/SoftwarePage/components/icons/index.ts b/frontend/pages/SoftwarePage/components/icons/index.ts index 4e7e910a30..7adf14e68c 100644 --- a/frontend/pages/SoftwarePage/components/icons/index.ts +++ b/frontend/pages/SoftwarePage/components/icons/index.ts @@ -73,6 +73,7 @@ export const SOFTWARE_SOURCE_TO_ICON_MAP = { export const SOFTWARE_ICON_SIZES: Record = { medium: "24", + meduim_large: "64", // TODO: rename this to large and update large to xlarge large: "96", } as const; diff --git a/frontend/pages/hosts/ManageHostsPage/HostsPageConfig.tsx b/frontend/pages/hosts/ManageHostsPage/HostsPageConfig.tsx index 74b1723dcc..114b537d3b 100644 --- a/frontend/pages/hosts/ManageHostsPage/HostsPageConfig.tsx +++ b/frontend/pages/hosts/ManageHostsPage/HostsPageConfig.tsx @@ -10,6 +10,8 @@ export const MANAGE_HOSTS_PAGE_FILTER_KEYS = [ "policy_response", "macos_settings", "software_id", + "software_version_id", + "software_title_id", HOSTS_QUERY_PARAMS.SOFTWARE_STATUS, "status", "mdm_id", diff --git a/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx b/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx index e2f2055a56..b052371d50 100644 --- a/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx +++ b/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx @@ -15,6 +15,7 @@ import { } from "interfaces/host"; import { IHostPolicy } from "interfaces/policy"; import { IDeviceGlobalConfig } from "interfaces/config"; + import DeviceUserError from "components/DeviceUserError"; // @ts-ignore import OrgLogoIcon from "components/icons/OrgLogoIcon"; @@ -45,9 +46,22 @@ import OSSettingsModal from "../OSSettingsModal"; import ResetKeyModal from "./ResetKeyModal"; import BootstrapPackageModal from "../HostDetailsPage/modals/BootstrapPackageModal"; import { parseHostSoftwareQueryParams } from "../cards/Software/HostSoftware"; +import SelfService from "../cards/Software/SelfService"; const baseClass = "device-user"; +const PREMIUM_TABS = [ + PATHS.DEVICE_USER_DETAILS, + PATHS.DEVICE_USER_DETAILS_SELF_SERVICE, + PATHS.DEVICE_USER_DETAILS_SOFTWARE, + PATHS.DEVICE_USER_DETAILS_POLICIES, +] as const; + +const FREE_TABS = [ + PATHS.DEVICE_USER_DETAILS, + PATHS.DEVICE_USER_DETAILS_SOFTWARE, +] as const; + interface IDeviceUserPageProps { location: { pathname: string; @@ -80,6 +94,7 @@ const DeviceUserPage = ({ const [refetchStartTime, setRefetchStartTime] = useState(null); const [showRefetchSpinner, setShowRefetchSpinner] = useState(false); const [orgLogoURL, setOrgLogoURL] = useState(""); + const [orgContactURL, setOrgContactURL] = useState(""); const [selectedPolicy, setSelectedPolicy] = useState( null ); @@ -152,15 +167,19 @@ const DeviceUserPage = ({ refetchOnReconnect: false, refetchOnWindowFocus: false, retry: false, + // TODO: refactor to use non-refetch data directly in the component and remove + // unnecesary derived states for values that aren't related to the refetch status onSuccess: ({ license, org_logo_url, + org_contact_url, global_config, host: responseHost, }) => { setShowRefetchSpinner(isRefetching(responseHost)); setIsPremiumTier(license.tier === "premium"); setOrgLogoURL(org_logo_url); + setOrgContactURL(org_contact_url); setGlobalConfig(global_config); if (isRefetching(responseHost)) { // If the API reports that a Fleet refetch request is pending, we want to check back for fresh @@ -324,14 +343,17 @@ const DeviceUserPage = ({ host?.mdm.macos_settings?.disk_encryption === "action_required" && host?.mdm.macos_settings?.action_required === "rotate_key"; - const tabPaths = [ - PATHS.DEVICE_USER_DETAILS(deviceAuthToken), - PATHS.DEVICE_USER_DETAILS_SOFTWARE(deviceAuthToken), - PATHS.DEVICE_USER_DETAILS_POLICIES(deviceAuthToken), - ]; - + // TODO: We should probably have a standard way to handle this on all pages. Do we want to show + // a premium-only message in the case that a user tries direct navigation to a premium-only page + // or silently redirect as below? + const tabPaths = isPremiumTier + ? PREMIUM_TABS.map((t) => t(deviceAuthToken)) + : FREE_TABS.map((t) => t(deviceAuthToken)); const findSelectedTab = (pathname: string) => findIndex(tabPaths, (x) => x.startsWith(pathname.split("?")[0])); + if (!isLoadingHost && host && findSelectedTab(location.pathname) === -1) { + router.push(tabPaths[0]); + } // TODO: This is a temporary fix that conditionally shows the new software tab depending on // whether software items returned in the device details response (legacy endpoint). @@ -394,6 +416,7 @@ const DeviceUserPage = ({ > Details + {isPremiumTier && Self-service} {isSoftwareEnabled && Software} {isPremiumTier && ( @@ -413,6 +436,18 @@ const DeviceUserPage = ({ munki={deviceMacAdminsData?.munki} /> + {isPremiumTier && ( + + + + )} {isSoftwareEnabled && ( { - Upcoming activities will run as listed. Failure of one activity won’t - cancel other activities. + Upcoming activities will run as listed. Failure of one activity + won't cancel other activities.

Currently, only scripts are guaranteed to run in order. @@ -47,7 +47,7 @@ const UpcomingTooltip = () => { interface IActivityProps { activeTab: "past" | "upcoming"; - activities?: IHostActivitiesResponse | IUpcomingActivitiesResponse; + activities?: IHostPastActivitiesResponse | IHostUpcomingActivitiesResponse; isLoading?: boolean; isError?: boolean; upcomingCount: number; @@ -71,7 +71,7 @@ const Activity = ({ // TODO: add count to upcoming activities tab when available via API return ( | React.FC > = { @@ -34,3 +37,12 @@ export const pastActivityComponentMap: Record< [ActivityType.UnlockedHost]: UnlockedHostActivityItem, [ActivityType.InstalledSoftware]: InstalledSoftwareActivityItem, }; + +export const upcomingActivityComponentMap: Record< + IHostUpcomingActivityType, + | React.FC + | React.FC +> = { + [ActivityType.RanScript]: RanScriptActivityItem, + [ActivityType.InstalledSoftware]: InstalledSoftwareActivityItem, +}; diff --git a/frontend/pages/hosts/details/cards/Activity/ActivityItems/InstalledSoftwareActivityItem/InstalledSoftwareActivityItem.tsx b/frontend/pages/hosts/details/cards/Activity/ActivityItems/InstalledSoftwareActivityItem/InstalledSoftwareActivityItem.tsx index fd0d7d3fa6..b5edd5f4b5 100644 --- a/frontend/pages/hosts/details/cards/Activity/ActivityItems/InstalledSoftwareActivityItem/InstalledSoftwareActivityItem.tsx +++ b/frontend/pages/hosts/details/cards/Activity/ActivityItems/InstalledSoftwareActivityItem/InstalledSoftwareActivityItem.tsx @@ -31,12 +31,17 @@ const InstalledSoftwareActivityItem = ({ onShowDetails, }: IHostActivityItemComponentPropsWithShowDetails) => { const { actor_full_name: actorName, details } = activity; + const { self_service, status, software_title: title } = details; - const { status, software_title: title } = details; + const actorDisplayName = self_service ? ( + An end user + ) : ( + {actorName} + ); return ( - {actorName} {getSoftwareInstallStatusPredicate(status)}{" "} + <>{actorDisplayName} {getSoftwareInstallStatusPredicate(status)}{" "} {title} software on this host.{" "} diff --git a/frontend/pages/hosts/details/cards/Activity/ActivityItems/RanScriptActivityItem/RanScriptActivityItem.tsx b/frontend/pages/hosts/details/cards/Activity/ActivityItems/RanScriptActivityItem/RanScriptActivityItem.tsx index 94b1a8dc7c..e0f26d0a8a 100644 --- a/frontend/pages/hosts/details/cards/Activity/ActivityItems/RanScriptActivityItem/RanScriptActivityItem.tsx +++ b/frontend/pages/hosts/details/cards/Activity/ActivityItems/RanScriptActivityItem/RanScriptActivityItem.tsx @@ -9,16 +9,20 @@ import ShowDetailsButton from "../../ShowDetailsButton"; const baseClass = "ran-script-activity-item"; const RanScriptActivityItem = ({ + tab, activity, onShowDetails, }: IHostActivityItemComponentPropsWithShowDetails) => { + const ranScriptPrefix = tab === "past" ? "ran" : "told Fleet to run"; + return ( {activity.actor_full_name} <> {" "} - ran {formatScriptNameForActivityItem(activity.details?.script_name)} on - this host.{" "} + {ranScriptPrefix}{" "} + {formatScriptNameForActivityItem(activity.details?.script_name)} on this + host.{" "} diff --git a/frontend/pages/hosts/details/cards/Activity/PastActivityFeed/PastActivityFeed.tsx b/frontend/pages/hosts/details/cards/Activity/PastActivityFeed/PastActivityFeed.tsx index 97f0346141..5e58a50f4c 100644 --- a/frontend/pages/hosts/details/cards/Activity/PastActivityFeed/PastActivityFeed.tsx +++ b/frontend/pages/hosts/details/cards/Activity/PastActivityFeed/PastActivityFeed.tsx @@ -1,7 +1,7 @@ import React from "react"; -import { IHostActivity } from "interfaces/activity"; -import { IHostActivitiesResponse } from "services/entities/activities"; +import { IHostPastActivity } from "interfaces/activity"; +import { IHostPastActivitiesResponse } from "services/entities/activities"; // @ts-ignore import FleetIcon from "components/icons/FleetIcon"; @@ -16,7 +16,7 @@ import { pastActivityComponentMap } from "../ActivityConfig"; const baseClass = "past-activity-feed"; interface IPastActivityFeedProps { - activities?: IHostActivitiesResponse; + activities?: IHostPastActivitiesResponse; isError?: boolean; onDetailsClick: ShowActivityDetailsHandler; onNextPage: () => void; @@ -53,11 +53,12 @@ const PastActivityFeed = ({ return (
- {activitiesList.map((activity: IHostActivity) => { + {activitiesList.map((activity: IHostPastActivity) => { const ActivityItemComponent = pastActivityComponentMap[activity.type]; return ( diff --git a/frontend/pages/hosts/details/cards/Activity/UpcomingActivity/UpcomingActivity.tsx b/frontend/pages/hosts/details/cards/Activity/UpcomingActivity/UpcomingActivity.tsx deleted file mode 100644 index 8a202e4de9..0000000000 --- a/frontend/pages/hosts/details/cards/Activity/UpcomingActivity/UpcomingActivity.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import React from "react"; -import ReactTooltip from "react-tooltip"; -import { formatDistanceToNowStrict } from "date-fns"; - -import { ActivityType, IHostActivity } from "interfaces/activity"; -import { COLORS } from "styles/var/colors"; -import { DEFAULT_GRAVATAR_LINK } from "utilities/constants"; -import { - addGravatarUrlToResource, - formatScriptNameForActivityItem, - internationalTimeFormat, -} from "utilities/helpers"; - -import Avatar from "components/Avatar"; -import Icon from "components/Icon"; -import Button from "components/buttons/Button"; -import { ShowActivityDetailsHandler } from "../Activity"; - -const baseClass = "upcoming-activity"; - -interface IUpcomingActivityProps { - activity: IHostActivity; - onDetailsClick: ShowActivityDetailsHandler; -} - -const formatPredicate = ({ type, details }: IHostActivity) => { - switch (type) { - case ActivityType.RanScript: - return ( - <> - told Fleet to run{" "} - {formatScriptNameForActivityItem(details?.script_name)} - - ); - case ActivityType.InstalledSoftware: - return ( - <> - told Fleet to install{" "} - {details?.software_title ? ( - <> - {details.software_title}{" "} - - ) : ( - "" - )} - software - - ); - default: - // this should never happen - return <>{type}; - } -}; - -// TODO: Combine this with ./UpcomingActivity/UpcomingActivity.tsx and -// frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx -const UpcomingActivity = ({ - activity, - onDetailsClick, -}: IUpcomingActivityProps) => { - const { actor_email } = activity; - const { gravatar_url } = actor_email - ? addGravatarUrlToResource({ email: actor_email }) - : { gravatar_url: DEFAULT_GRAVATAR_LINK }; - const activityCreatedAt = new Date(activity.created_at); - - return ( -
- -
-
- - {activity.actor_full_name} {formatPredicate(activity)} on - this host.{" "} - - -
- - {formatDistanceToNowStrict(activityCreatedAt, { - addSuffix: true, - })} - - - {internationalTimeFormat(activityCreatedAt)} - -
-
-
-
- ); -}; - -export default UpcomingActivity; diff --git a/frontend/pages/hosts/details/cards/Activity/UpcomingActivity/_styles.scss b/frontend/pages/hosts/details/cards/Activity/UpcomingActivity/_styles.scss deleted file mode 100644 index afaa538a71..0000000000 --- a/frontend/pages/hosts/details/cards/Activity/UpcomingActivity/_styles.scss +++ /dev/null @@ -1,65 +0,0 @@ -.upcoming-activity { - display: grid; // Grid system is used to create variable dashed line lengths - grid-template-columns: 16px 16px 1fr; - grid-template-rows: 32px max-content; - - .avatar-wrapper { - grid-column-start: 1; - width: 32px; - height: 32px; - } - - &__dash { - border-right: 1px dashed $ui-fleet-black-10; - grid-column-start: 1; - grid-row-start: 2; - grid-row-end: 3; - } - - &__details-wrapper { - grid-column-start: 3; - grid-row-start: 1; - grid-row-end: 3; - padding-left: $pad-large; - padding-bottom: $pad-large; - - .premium-icon-tip { - position: relative; - top: 4px; - padding-right: $pad-xsmall; - } - - .activity-details { - margin: 0; - line-height: 16px; - } - } - - &__details-topline { - font-size: $x-small; - overflow-wrap: anywhere; - } - - &__details-content { - margin-right: $pad-xsmall; - } - - &__details-bottomline { - font-size: $xx-small; - color: $ui-fleet-black-25; - } - - &__show-query-icon { - margin-left: $pad-xsmall; - } - - &:last-child { - .upcoming-activity__dash { - border-right: none; - } - - .upcoming-activity__details { - padding-bottom: $pad-xxlarge; - } - } -} diff --git a/frontend/pages/hosts/details/cards/Activity/UpcomingActivity/index.ts b/frontend/pages/hosts/details/cards/Activity/UpcomingActivity/index.ts deleted file mode 100644 index 413a03e29a..0000000000 --- a/frontend/pages/hosts/details/cards/Activity/UpcomingActivity/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./UpcomingActivity"; diff --git a/frontend/pages/hosts/details/cards/Activity/UpcomingActivityFeed/UpcomingActivityFeed.tsx b/frontend/pages/hosts/details/cards/Activity/UpcomingActivityFeed/UpcomingActivityFeed.tsx index 32064a42ba..c9c12696f2 100644 --- a/frontend/pages/hosts/details/cards/Activity/UpcomingActivityFeed/UpcomingActivityFeed.tsx +++ b/frontend/pages/hosts/details/cards/Activity/UpcomingActivityFeed/UpcomingActivityFeed.tsx @@ -1,7 +1,7 @@ import React from "react"; -import { IHostActivity } from "interfaces/activity"; -import { IUpcomingActivitiesResponse } from "services/entities/activities"; +import { IHostUpcomingActivity } from "interfaces/activity"; +import { IHostUpcomingActivitiesResponse } from "services/entities/activities"; // @ts-ignore import FleetIcon from "components/icons/FleetIcon"; @@ -9,13 +9,13 @@ import DataError from "components/DataError"; import Button from "components/buttons/Button"; import EmptyFeed from "../EmptyFeed/EmptyFeed"; -import UpcomingActivity from "../UpcomingActivity/UpcomingActivity"; import { ShowActivityDetailsHandler } from "../Activity"; +import { upcomingActivityComponentMap } from "../ActivityConfig"; const baseClass = "upcoming-activity-feed"; interface IUpcomingActivityFeedProps { - activities?: IUpcomingActivitiesResponse; + activities?: IHostUpcomingActivitiesResponse; isError?: boolean; onDetailsClick: ShowActivityDetailsHandler; onNextPage: () => void; @@ -52,13 +52,18 @@ const UpcomingActivityFeed = ({ return (
- {activitiesList.map((activity: IHostActivity) => ( - - ))} + {activitiesList.map((activity: IHostUpcomingActivity) => { + const ActivityItemComponent = + upcomingActivityComponentMap[activity.type]; + return ( + + ); + })}
- {displayConfig.tooltip(packageToInstall, installedAt)} + {displayConfig.tooltip({ + softwareName, + lastInstalledAt, + })} - {displayConfig.displayText}
); }; diff --git a/frontend/pages/hosts/details/cards/Software/InstallStatusCell/_styles.scss b/frontend/pages/hosts/details/cards/Software/InstallStatusCell/_styles.scss index 7384269a1e..290e151cc0 100644 --- a/frontend/pages/hosts/details/cards/Software/InstallStatusCell/_styles.scss +++ b/frontend/pages/hosts/details/cards/Software/InstallStatusCell/_styles.scss @@ -5,6 +5,11 @@ gap: $pad-small; } + &__status-with-tooltip { + display: flex; + gap: $pad-small; + } + &__status-tooltip { text-align: center; } diff --git a/frontend/pages/hosts/details/cards/Software/SelfService/SelfService.tsx b/frontend/pages/hosts/details/cards/Software/SelfService/SelfService.tsx new file mode 100644 index 0000000000..f83f9d3b0d --- /dev/null +++ b/frontend/pages/hosts/details/cards/Software/SelfService/SelfService.tsx @@ -0,0 +1,152 @@ +import React, { useCallback } from "react"; +import { useQuery } from "react-query"; +import { InjectedRouter } from "react-router"; +import { AxiosError } from "axios"; + +import deviceApi, { + IDeviceSoftwareQueryKey, + IGetDeviceSoftwareResponse, +} from "services/entities/device_user"; + +import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants"; + +import Card from "components/Card"; +import CustomLink from "components/CustomLink"; +import DataError from "components/DataError"; +import EmptyTable from "components/EmptyTable"; +import Spinner from "components/Spinner"; + +import Pagination from "pages/ManageControlsPage/components/Pagination"; + +import { parseHostSoftwareQueryParams } from "../HostSoftware"; +import SelfServiceItem from "./SelfServiceItem"; + +const baseClass = "software-self-service"; + +// These default params are not subject to change by the user +const DEFAULT_SELF_SERVICE_QUERY_PARAMS = { + per_page: 9, + order_key: "name", + order_direction: "asc", + query: "", + self_service: true, +} as const; + +const SoftwareSelfService = ({ + contactUrl, + deviceToken, + isSoftwareEnabled, + pathname, + queryParams, + router, +}: { + contactUrl: string; // TODO: confirm this has been added to the device API response + deviceToken: string; + isSoftwareEnabled?: boolean; + pathname: string; + queryParams: ReturnType; + router: InjectedRouter; +}) => { + // TOOD: loading state for fetching? + const { data, isLoading, isError, refetch } = useQuery< + IGetDeviceSoftwareResponse, + AxiosError, + IGetDeviceSoftwareResponse, + IDeviceSoftwareQueryKey[] + >( + [ + { + scope: "device_software", + id: deviceToken, + page: queryParams.page, + ...DEFAULT_SELF_SERVICE_QUERY_PARAMS, + }, + ], + ({ queryKey }) => deviceApi.getDeviceSoftware(queryKey[0]), + { + ...DEFAULT_USE_QUERY_OPTIONS, + enabled: isSoftwareEnabled, + keepPreviousData: true, + staleTime: 7000, + } + ); + + const onNextPage = useCallback(() => { + router.push(pathname.concat(`?page=${queryParams.page + 1}`)); + }, [pathname, queryParams.page, router]); + + const onPrevPage = useCallback(() => { + router.push(pathname.concat(`?page=${queryParams.page - 1}`)); + }, [pathname, queryParams.page, router]); + + // TODO: handle empty state better, this is just a placeholder for now + // TODO: what should happen if query params are invalid (e.g., page is negative or exceeds the + // available results)? + const isEmpty = !data?.software.length && !data?.meta.has_previous_results; + + return ( + +
Self-service
+
+ Install organization-approved apps provided by your IT department.{" "} + {contactUrl && ( + + If you need help,{" "} + + + )} +
+ {isLoading ? ( + + ) : ( + <> + {isError && } + {!isError && ( +
+ {isEmpty ? ( + + ) : ( + <> +
+ {data.count} items +
+
+ {data.software.map((s) => { + const key = `${s.id}${s.last_install?.install_uuid}`; // concatenating install_uuid so item updates with fresh data on refetch + return ( + + ); + })} +
+ + + )} +
+ )} + + )} +
+ ); +}; + +export default SoftwareSelfService; diff --git a/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceItem/SelfServiceItem.tsx b/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceItem/SelfServiceItem.tsx new file mode 100644 index 0000000000..d04e235e66 --- /dev/null +++ b/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceItem/SelfServiceItem.tsx @@ -0,0 +1,221 @@ +import React, { useCallback, useContext, useEffect, useRef } from "react"; +import ReactTooltip from "react-tooltip"; + +import { + IDeviceSoftware, + IHostSoftware, + SoftwareInstallStatus, +} from "interfaces/software"; +import deviceApi from "services/entities/device_user"; +import { dateAgo } from "utilities/date_format"; +import { NotificationContext } from "context/notification"; + +import Card from "components/Card"; +import Button from "components/buttons/Button"; +import Icon from "components/Icon"; +import SoftwareIcon from "pages/SoftwarePage/components/icons/SoftwareIcon"; + +import { IStatusDisplayConfig } from "../../InstallStatusCell/InstallStatusCell"; + +const baseClass = "self-service-item"; + +const STATUS_CONFIG: Record = { + installed: { + iconName: "success", + displayText: "Installed", + tooltip: ({ lastInstalledAt }) => ( + <> + Software installed successfully ({dateAgo(lastInstalledAt as string)}). + + ), + }, + pending: { + iconName: "pending-outline", + displayText: "Install in progress...", + tooltip: () => "Software installation in progress...", + }, + failed: { + iconName: "error", + displayText: "Failed", + tooltip: ({ lastInstalledAt = "" }) => ( + <> + Software failed to install + {lastInstalledAt ? `(${dateAgo(lastInstalledAt)})` : ""}. Select{" "} + Retry to install again, or contact your IT department. + + ), + }, +}; + +interface IInstallerInfoProps { + software: IDeviceSoftware; +} + +const InstallerInfo = ({ software }: IInstallerInfoProps) => { + const { name, source, package: installerPackage } = software; + return ( +
+
+ +
+
+
+ {name || installerPackage?.name} +
+
+ {installerPackage?.version || ""} +
+
+
+ ); +}; + +type IInstallerStatusProps = Pick< + IHostSoftware, + "id" | "status" | "last_install" +>; + +const InstallerStatus = ({ + id, + status, + last_install, +}: IInstallerStatusProps) => { + const displayConfig = STATUS_CONFIG[status as keyof typeof STATUS_CONFIG]; + if (!displayConfig) { + // API should ensure this never happens, but just in case + return null; + } + + return ( +
+
+ + {displayConfig.displayText} +
+ + + {displayConfig.tooltip({ + lastInstalledAt: last_install?.installed_at, + })} + + +
+ ); +}; + +interface IInstallerStatusActionProps { + deviceToken: string; + software: IHostSoftware; + onInstall: () => void; +} + +const InstallerStatusAction = ({ + deviceToken, + software: { id, status, last_install }, + onInstall, +}: IInstallerStatusActionProps) => { + const { renderFlash } = useContext(NotificationContext); + + // localStatus is used to track the status of the any user-initiated install action + const [localStatus, setLocalStatus] = React.useState< + SoftwareInstallStatus | undefined + >(undefined); + + // displayStatus allows us to display the localStatus (if any) or the status from the list + // software reponse + const displayStatus = localStatus || status; + + // if the localStatus is "failed", we don't our tooltip to include the old installed_at date so we + // set this to null, which tells the tooltip to omit the parenthetical date + const lastInstall = localStatus === "failed" ? null : last_install; + + const isMountedRef = useRef(false); + useEffect(() => { + isMountedRef.current = true; + return () => { + isMountedRef.current = false; + }; + }, []); + + const onClick = useCallback(async () => { + setLocalStatus("pending"); + try { + await deviceApi.installSelfServiceSoftware(deviceToken, id); + if (isMountedRef.current) { + onInstall(); + } + } catch (error) { + renderFlash("error", "Couldn't install. Please try again."); + if (isMountedRef.current) { + setLocalStatus("failed"); + } + } + }, [deviceToken, id, onInstall, renderFlash]); + + return ( +
+
+ +
+
+ {(displayStatus === "failed" || displayStatus === null) && ( + + )} +
+
+ ); +}; + +interface ISelfServiceItemProps { + deviceToken: string; + software: IDeviceSoftware; + onInstall: () => void; +} + +const SelfServiceItem = ({ + deviceToken, + software, + onInstall, +}: ISelfServiceItemProps) => { + return ( + +
+ + +
+
+ ); +}; + +export default SelfServiceItem; diff --git a/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceItem/_styles.scss b/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceItem/_styles.scss new file mode 100644 index 0000000000..56d357f798 --- /dev/null +++ b/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceItem/_styles.scss @@ -0,0 +1,97 @@ +.self-service-item { + &__item { + display: flex; + flex-direction: column; + } + + &__item-content { + display: flex; + flex-direction: column; + gap: 8px; + } + + &__item-topline { + display: flex; + flex-direction: row; + height: 64px; + align-items: center; + gap: 16px; + overflow: hidden; + } + + &__item-icon { + display: flex; + height: 64px; + min-width: 64px; + } + + &__item-name-version { + display: flex; + flex-direction: column; + justify-content: center; + height: 64px; + overflow: hidden; + } + + &__item-name { + font-size: $x-small; + font-weight: $bold; + overflow: hidden; + text-overflow: ellipsis; + text-wrap: nowrap; + } + + &__item-version { + font-size: $xx-small; + color: $ui-fleet-black-75; + overflow: hidden; + text-overflow: ellipsis; + text-wrap: nowrap; + } + + &__item-status-action { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding-top: 16px; + border-top: 1px solid $ui-fleet-black-10; + } + + &__item-status { + display: flex; + align-items: center; + gap: 8px; + } + + &__item-action { + display: flex; + align-items: center; + gap: 8px; + } + + &__status-content { + display: flex; + align-items: center; + gap: $pad-small; + } + + &__status-with-tooltip { + display: flex; + flex-direction: row; + align-items: center; + gap: $pad-small; + + span { + font-size: $x-small; + } + } + + &__item-action-button { + height: auto; + + &--installing { + display: none; + } + } +} diff --git a/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceItem/index.ts b/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceItem/index.ts new file mode 100644 index 0000000000..eebc870793 --- /dev/null +++ b/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceItem/index.ts @@ -0,0 +1 @@ +export { default } from "./SelfServiceItem"; diff --git a/frontend/pages/hosts/details/cards/Software/SelfService/_styles.scss b/frontend/pages/hosts/details/cards/Software/SelfService/_styles.scss new file mode 100644 index 0000000000..484312533c --- /dev/null +++ b/frontend/pages/hosts/details/cards/Software/SelfService/_styles.scss @@ -0,0 +1,35 @@ +.software-self-service { + &__card-header { + margin: 0 0 8px 0; + } + + &__card-subheader { + margin: 0 0 24px 0; + color: $ui-fleet-black-75; + font-size: $x-small; + } + + // TODO: empty table styling differs slightly from figma (font size, color, spacing), why?g + .empty-table__container { + margin: 64px 0; + } + + &__items-count { + margin: 0 0 24px 0; + font-size: $x-small; + font-weight: $bold; + } + + &__items { + display: grid; + gap: $pad-large; + grid-template-columns: repeat(auto-fit, minmax(400px, 1fr)); + } + + &__pagination { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 16px; + } +} diff --git a/frontend/pages/hosts/details/cards/Software/SelfService/index.ts b/frontend/pages/hosts/details/cards/Software/SelfService/index.ts new file mode 100644 index 0000000000..8ee96ff078 --- /dev/null +++ b/frontend/pages/hosts/details/cards/Software/SelfService/index.ts @@ -0,0 +1 @@ +export { default } from "./SelfService"; diff --git a/frontend/pages/hosts/details/cards/Users/Users.tsx b/frontend/pages/hosts/details/cards/Users/Users.tsx index fc738949ee..ed7c01ad56 100644 --- a/frontend/pages/hosts/details/cards/Users/Users.tsx +++ b/frontend/pages/hosts/details/cards/Users/Users.tsx @@ -31,7 +31,7 @@ const Users = ({ if (!hostUsersEnabled) { return ( + diff --git a/frontend/router/paths.ts b/frontend/router/paths.ts index 697622269d..ae2b39b2fd 100644 --- a/frontend/router/paths.ts +++ b/frontend/router/paths.ts @@ -137,6 +137,9 @@ export default { DEVICE_USER_DETAILS: (deviceAuthToken: string): string => { return `${URL_PREFIX}/device/${deviceAuthToken}`; }, + DEVICE_USER_DETAILS_SELF_SERVICE: (deviceAuthToken: string): string => { + return `${URL_PREFIX}/device/${deviceAuthToken}/self-service`; + }, DEVICE_USER_DETAILS_SOFTWARE: (deviceAuthToken: string): string => { return `${URL_PREFIX}/device/${deviceAuthToken}/software`; }, diff --git a/frontend/services/entities/activities.ts b/frontend/services/entities/activities.ts index 61123bddc4..3aa2a263c5 100644 --- a/frontend/services/entities/activities.ts +++ b/frontend/services/entities/activities.ts @@ -1,5 +1,9 @@ import endpoints from "utilities/endpoints"; -import { IActivity, IHostActivity } from "interfaces/activity"; +import { + IActivity, + IHostPastActivity, + IHostUpcomingActivity, +} from "interfaces/activity"; import sendRequest from "services"; import { buildQueryStringFromParams } from "utilities/url"; @@ -16,16 +20,21 @@ export interface IActivitiesResponse { }; } -export interface IHostActivitiesResponse { - activities: IHostActivity[] | null; +export interface IHostPastActivitiesResponse { + activities: IHostPastActivity[] | null; meta: { has_next_results: boolean; has_previous_results: boolean; }; } -export interface IUpcomingActivitiesResponse extends IHostActivitiesResponse { +export interface IHostUpcomingActivitiesResponse { count: number; + activities: IHostUpcomingActivity[] | null; + meta: { + has_next_results: boolean; + has_previous_results: boolean; + }; } export default { @@ -53,7 +62,7 @@ export default { id: number, page = DEFAULT_PAGE, perPage = DEFAULT_PAGE_SIZE - ): Promise => { + ): Promise => { const { HOST_PAST_ACTIVITIES } = endpoints; const queryParams = { @@ -72,7 +81,7 @@ export default { id: number, page = DEFAULT_PAGE, perPage = DEFAULT_PAGE_SIZE - ): Promise => { + ): Promise => { const { HOST_UPCOMING_ACTIVITIES } = endpoints; const queryParams = { diff --git a/frontend/services/entities/device_user.ts b/frontend/services/entities/device_user.ts index d0de571946..33955a419d 100644 --- a/frontend/services/entities/device_user.ts +++ b/frontend/services/entities/device_user.ts @@ -1,5 +1,5 @@ import { IDeviceUserResponse } from "interfaces/host"; -import { IHostSoftware } from "interfaces/software"; +import { IDeviceSoftware } from "interfaces/software"; import sendRequest from "services"; import endpoints from "utilities/endpoints"; import { buildQueryStringFromParams } from "utilities/url"; @@ -13,7 +13,7 @@ export interface IDeviceSoftwareQueryKey extends IHostSoftwareQueryParams { } export interface IGetDeviceSoftwareResponse { - software: IHostSoftware[]; + software: IDeviceSoftware[]; count: number; meta: { has_next_results: boolean; @@ -53,4 +53,14 @@ export default { const queryString = buildQueryStringFromParams(rest); return sendRequest("GET", `${DEVICE_SOFTWARE(id)}?${queryString}`); }, + + installSelfServiceSoftware: ( + deviceToken: string, + softwareTitleId: number + ) => { + const { DEVICE_SOFTWARE_INSTALL } = endpoints; + const path = DEVICE_SOFTWARE_INSTALL(deviceToken, softwareTitleId); + + return sendRequest("POST", path); + }, }; diff --git a/frontend/services/entities/hosts.ts b/frontend/services/entities/hosts.ts index c5c8be0f44..ac1f71673f 100644 --- a/frontend/services/entities/hosts.ts +++ b/frontend/services/entities/hosts.ts @@ -12,7 +12,6 @@ import { import { SelectedPlatform } from "interfaces/platform"; import { IHostSoftware, - ISoftwareTitle, ISoftware, SoftwareInstallStatus, } from "interfaces/software"; @@ -34,7 +33,7 @@ export interface ISortOption { export interface ILoadHostsResponse { hosts: IHost[]; software: ISoftware | undefined; - software_title: ISoftwareTitle | undefined; + software_title: { name: string; version?: string } | null | undefined; // TODO: confirm type munki_issue: IMunkiIssuesAggregate; mobile_device_management_solution: IMdmSolution; } diff --git a/frontend/services/entities/software.ts b/frontend/services/entities/software.ts index 40be25ddc5..3b4067dd7d 100644 --- a/frontend/services/entities/software.ts +++ b/frontend/services/entities/software.ts @@ -1,16 +1,18 @@ import { AxiosResponse } from "axios"; -import { snakeCase, reduce } from "lodash"; - import sendRequest from "services"; import endpoints from "utilities/endpoints"; import { ISoftwareResponse, ISoftwareCountResponse, ISoftwareVersion, - ISoftwareTitle, + ISoftwareTitleWithPackageDetail, + ISoftwareTitleWithPackageName, } from "interfaces/software"; -import { buildQueryStringFromParams, QueryParams } from "utilities/url"; +import { + buildQueryStringFromParams, + convertParamsToSnakeCase, +} from "utilities/url"; import { IAddSoftwareFormData } from "pages/SoftwarePage/components/AddSoftwareForm/AddSoftwareForm"; @@ -22,13 +24,14 @@ export interface ISoftwareApiParams { query?: string; vulnerable?: boolean; availableForInstall?: boolean; + selfService?: boolean; teamId?: number; } export interface ISoftwareTitlesResponse { counts_updated_at: string | null; count: number; - software_titles: ISoftwareTitle[]; + software_titles: ISoftwareTitleWithPackageName[]; meta: { has_next_results: boolean; has_previous_results: boolean; @@ -46,13 +49,21 @@ export interface ISoftwareVersionsResponse { } export interface ISoftwareTitleResponse { - software_title: ISoftwareTitle; + software_title: ISoftwareTitleWithPackageDetail; } export interface ISoftwareVersionResponse { software: ISoftwareVersion; } +export interface ISoftwareVersionsQueryKey extends ISoftwareApiParams { + scope: "software-versions"; +} + +export interface ISoftwareTitlesQueryKey extends ISoftwareApiParams { + scope: "software-titles"; +} + export interface ISoftwareQueryKey extends ISoftwareApiParams { scope: "software"; } @@ -85,17 +96,6 @@ export interface IGetSoftwareVersionQueryKey const ORDER_KEY = "name"; const ORDER_DIRECTION = "asc"; -const convertParamsToSnakeCase = (params: ISoftwareApiParams) => { - return reduce( - params, - (result, val, key) => { - result[snakeCase(key)] = val; - return result; - }, - {} - ); -}; - export default { load: async ({ page, @@ -104,9 +104,12 @@ export default { orderDirection: orderDir = ORDER_DIRECTION, query, vulnerable, - availableForInstall, + // availableForInstall, // TODO: Is this supported for the versions endpoint? teamId, - }: ISoftwareApiParams): Promise => { + }: Omit< + ISoftwareApiParams, + "availableForInstall" | "selfService" + >): Promise => { const { SOFTWARE } = endpoints; const queryParams = { page, @@ -116,7 +119,7 @@ export default { teamId, query, vulnerable, - availableForInstall, + // availableForInstall, }; const snakeCaseParams = convertParamsToSnakeCase(queryParams); @@ -197,6 +200,7 @@ export default { const formData = new FormData(); formData.append("software", data.software); + formData.append("self_service", data.selfService.toString()); data.installScript && formData.append("install_script", data.installScript); data.preInstallCondition && formData.append("pre_install_query", data.preInstallCondition); diff --git a/frontend/styles/var/_global.scss b/frontend/styles/var/_global.scss index 1032bacc12..74e758e2db 100644 --- a/frontend/styles/var/_global.scss +++ b/frontend/styles/var/_global.scss @@ -1,6 +1,7 @@ // border radius $border-radius: 4px; -$border-radius-large: 6px; +$border-radius-medium: 6px; +$border-radius-large: 8px; $border-radius-xlarge: 10px; $border-radius-xxlarge: 16px; diff --git a/frontend/styles/var/mixins.scss b/frontend/styles/var/mixins.scss index 16fa29b45d..df8714f0ce 100644 --- a/frontend/styles/var/mixins.scss +++ b/frontend/styles/var/mixins.scss @@ -182,10 +182,11 @@ $max-width: 2560px; } } -@mixin ellipse-text { +@mixin ellipse-text($width: auto) { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + width: $width; } @mixin copy-message { diff --git a/frontend/utilities/date_format/index.ts b/frontend/utilities/date_format/index.ts index 53de0b5498..329d977dba 100644 --- a/frontend/utilities/date_format/index.ts +++ b/frontend/utilities/date_format/index.ts @@ -1,10 +1,17 @@ import { formatDistanceToNow } from "date-fns"; -// eslint-disable-next-line import/prefer-default-export +/** Utility to create a string from a date in this format: + `Uploaded .... ago` +*/ export const uploadedFromNow = (date: string) => { + // NOTE: Malformed dates will result in errors. This is expected "fail loudly" behavior. return `Uploaded ${formatDistanceToNow(new Date(date))} ago`; }; +/** Utility to create a string from a date in this format: + `.... ago` +*/ export const dateAgo = (date: string) => { + // NOTE: Malformed dates will result in errors. This is expected "fail loudly" behavior. return `${formatDistanceToNow(new Date(date))} ago`; }; diff --git a/frontend/utilities/endpoints.ts b/frontend/utilities/endpoints.ts index b2ddbfd7ae..c1dc7057d9 100644 --- a/frontend/utilities/endpoints.ts +++ b/frontend/utilities/endpoints.ts @@ -31,6 +31,8 @@ export default { DEVICE_USER_DETAILS: `/${API_VERSION}/fleet/device`, DEVICE_SOFTWARE: (token: string) => `/${API_VERSION}/fleet/device/${token}/software`, + DEVICE_SOFTWARE_INSTALL: (token: string, softwareTitleId: number) => + `/${API_VERSION}/fleet/device/${token}/software/install/${softwareTitleId}`, DEVICE_USER_RESET_ENCRYPTION_KEY: (token: string): string => { return `/${API_VERSION}/fleet/device/${token}/rotate_encryption_key`; }, diff --git a/frontend/utilities/helpers.tsx b/frontend/utilities/helpers.tsx index 0ebb1f0a97..b84f8c3fa7 100644 --- a/frontend/utilities/helpers.tsx +++ b/frontend/utilities/helpers.tsx @@ -24,7 +24,7 @@ import { } from "date-fns"; import yaml from "js-yaml"; -import { buildQueryStringFromParams } from "utilities/url"; +import { QueryParams, buildQueryStringFromParams } from "utilities/url"; import { IHost } from "interfaces/host"; import { ILabel } from "interfaces/label"; import { IPack } from "interfaces/pack"; @@ -828,7 +828,7 @@ interface ILocationParams { pathPrefix?: string; routeTemplate?: string; routeParams?: { [key: string]: string }; - queryParams?: { [key: string]: string | number | undefined }; + queryParams?: QueryParams; } type RouteParams = Record; diff --git a/frontend/utilities/url/index.ts b/frontend/utilities/url/index.ts index 5171d25ac1..b8f274ee17 100644 --- a/frontend/utilities/url/index.ts +++ b/frontend/utilities/url/index.ts @@ -1,4 +1,4 @@ -import { isEmpty, reduce, omitBy, Dictionary } from "lodash"; +import { isEmpty, reduce, omitBy, Dictionary, snakeCase } from "lodash"; import { DiskEncryptionStatus, @@ -250,3 +250,22 @@ export const getLabelParam = (selectedLabels?: string[]) => { return label.slice(7); }; + +type QueryParamish = keyof T extends string + ? { + [K in keyof T]: QueryValues; + } + : never; + +export const convertParamsToSnakeCase = >( + params: T +) => { + return reduce( + params, + (result, val, key) => { + result[snakeCase(key)] = val; + return result; + }, + {} + ); +}; diff --git a/orbit/changes/18835-add-fleet-desktop-self-service b/orbit/changes/18835-add-fleet-desktop-self-service new file mode 100644 index 0000000000..c0d54803d1 --- /dev/null +++ b/orbit/changes/18835-add-fleet-desktop-self-service @@ -0,0 +1 @@ +* Added the `Self-service` menu item to Fleet Desktop. diff --git a/orbit/cmd/desktop/desktop.go b/orbit/cmd/desktop/desktop.go index 89b8e88475..64fbdc4c49 100644 --- a/orbit/cmd/desktop/desktop.go +++ b/orbit/cmd/desktop/desktop.go @@ -130,6 +130,10 @@ func main() { transparencyItem := systray.AddMenuItem("Transparency", "") transparencyItem.Disable() + systray.AddSeparator() + + selfServiceItem := systray.AddMenuItem("Self-service", "") + selfServiceItem.Disable() tokenReader := token.Reader{Path: identifierPath} if _, err := tokenReader.Read(); err != nil { @@ -175,6 +179,7 @@ func main() { myDeviceItem.SetTitle("Connecting...") myDeviceItem.Disable() transparencyItem.Disable() + selfServiceItem.Disable() migrateMDMItem.Disable() migrateMDMItem.Hide() } @@ -198,6 +203,7 @@ func main() { myDeviceItem.SetTitle("My device") myDeviceItem.Enable() transparencyItem.Enable() + selfServiceItem.Enable() return } @@ -390,6 +396,11 @@ func main() { if err := open.Browser(openURL); err != nil { log.Error().Err(err).Str("url", openURL).Msg("open browser transparency") } + case <-selfServiceItem.ClickedCh: + openURL := client.BrowserSelfServiceURL(tokenReader.GetCached()) + if err := open.Browser(openURL); err != nil { + log.Error().Err(err).Str("url", openURL).Msg("open browser self-service") + } case <-migrateMDMItem.ClickedCh: if err := mdmMigrator.Show(); err != nil { go reportError(err, nil) diff --git a/server/datastore/mysql/activities.go b/server/datastore/mysql/activities.go index 7358ea08b4..90764b32a6 100644 --- a/server/datastore/mysql/activities.go +++ b/server/datastore/mysql/activities.go @@ -296,8 +296,10 @@ func (ds *Datastore) ListHostUpcomingActivities(ctx context.Context, hostID uint 'host_id', hsi.host_id, 'host_display_name', COALESCE(hdn.display_name, ''), 'software_title', COALESCE(st.name, ''), + 'software_package', si.filename, 'install_uuid', hsi.execution_id, - 'status', %s + 'status', CAST(%s AS CHAR), + 'self_service', si.self_service IS TRUE ) as details FROM host_software_installs hsi @@ -333,9 +335,9 @@ func (ds *Datastore) ListHostUpcomingActivities(ctx context.Context, hostID uint "ran_script_type": fleet.ActivityTypeRanScript{}.ActivityName(), "installed_software_type": fleet.ActivityTypeInstalledSoftware{}.ActivityName(), "max_wait_time": seconds, - "software_status_failed": fleet.SoftwareInstallerFailed, - "software_status_installed": fleet.SoftwareInstallerInstalled, - "software_status_pending": fleet.SoftwareInstallerPending, + "software_status_failed": string(fleet.SoftwareInstallerFailed), + "software_status_installed": string(fleet.SoftwareInstallerInstalled), + "software_status_pending": string(fleet.SoftwareInstallerPending), }) if err != nil { return nil, nil, ctxerr.Wrap(ctx, err, "build list query from named args") diff --git a/server/datastore/mysql/activities_test.go b/server/datastore/mysql/activities_test.go index 1df159b297..aad9419485 100644 --- a/server/datastore/mysql/activities_test.go +++ b/server/datastore/mysql/activities_test.go @@ -431,9 +431,9 @@ func testListHostUpcomingActivities(t *testing.T, ds *Datastore) { require.NoError(t, err) h1E := hsr.ExecutionID // create some software installs requests for h1, make some complete - h1FooFailed, err := ds.InsertSoftwareInstallRequest(ctx, h1.ID, sw1Meta.InstallerID) + h1FooFailed, err := ds.InsertSoftwareInstallRequest(ctx, h1.ID, sw1Meta.InstallerID, false) require.NoError(t, err) - h1Bar, err := ds.InsertSoftwareInstallRequest(ctx, h1.ID, sw2Meta.InstallerID) + h1Bar, err := ds.InsertSoftwareInstallRequest(ctx, h1.ID, sw2Meta.InstallerID, false) require.NoError(t, err) err = ds.SetHostSoftwareInstallResult(ctx, &fleet.HostSoftwareInstallResultPayload{ HostID: h1.ID, @@ -441,7 +441,7 @@ func testListHostUpcomingActivities(t *testing.T, ds *Datastore) { PreInstallConditionOutput: ptr.String(""), // pre-install failed }) require.NoError(t, err) - h1FooInstalled, err := ds.InsertSoftwareInstallRequest(ctx, h1.ID, sw1Meta.InstallerID) + h1FooInstalled, err := ds.InsertSoftwareInstallRequest(ctx, h1.ID, sw1Meta.InstallerID, false) require.NoError(t, err) err = ds.SetHostSoftwareInstallResult(ctx, &fleet.HostSoftwareInstallResultPayload{ HostID: h1.ID, @@ -450,7 +450,7 @@ func testListHostUpcomingActivities(t *testing.T, ds *Datastore) { InstallScriptExitCode: ptr.Int(0), }) require.NoError(t, err) - h1Foo, err := ds.InsertSoftwareInstallRequest(noUserCtx, h1.ID, sw1Meta.InstallerID) // no user for this one + h1Foo, err := ds.InsertSoftwareInstallRequest(noUserCtx, h1.ID, sw1Meta.InstallerID, false) // no user for this one require.NoError(t, err) // create a single pending request for h2, as well as a non-pending one @@ -463,7 +463,7 @@ func testListHostUpcomingActivities(t *testing.T, ds *Datastore) { require.NoError(t, err) h2F := hsr.ExecutionID // add a pending software install request for h2 - h2Bar, err := ds.InsertSoftwareInstallRequest(ctx, h2.ID, sw2Meta.InstallerID) + h2Bar, err := ds.InsertSoftwareInstallRequest(ctx, h2.ID, sw2Meta.InstallerID, false) require.NoError(t, err) // nothing for h3 diff --git a/server/datastore/mysql/migrations/tables/20240521143024_SoftwareSelfServiceBool.go b/server/datastore/mysql/migrations/tables/20240521143024_SoftwareSelfServiceBool.go new file mode 100644 index 0000000000..1d27717752 --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20240521143024_SoftwareSelfServiceBool.go @@ -0,0 +1,28 @@ +package tables + +import ( + "database/sql" + "fmt" +) + +func init() { + MigrationClient.AddMigration(Up_20240521143024, Down_20240521143024) +} + +func Up_20240521143024(tx *sql.Tx) error { + _, err := tx.Exec(`ALTER TABLE software_installers ADD COLUMN self_service bool NOT NULL DEFAULT false`) + if err != nil { + return fmt.Errorf("failed to add self_service to software_installers: %w", err) + } + + _, err = tx.Exec(`ALTER TABLE host_software_installs ADD COLUMN self_service bool NOT NULL DEFAULT false`) + if err != nil { + return fmt.Errorf("failed to add self_service bool to host_software_installs: %w", err) + } + + return nil +} + +func Down_20240521143024(tx *sql.Tx) error { + return nil +} diff --git a/server/datastore/mysql/migrations/tables/20240521143024_SoftwareSelfServiceBool_test.go b/server/datastore/mysql/migrations/tables/20240521143024_SoftwareSelfServiceBool_test.go new file mode 100644 index 0000000000..e39f899290 --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20240521143024_SoftwareSelfServiceBool_test.go @@ -0,0 +1,63 @@ +package tables + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestUp_20240521143024(t *testing.T) { + db := applyUpToPrev(t) + + // + // Insert data to test the migration + // + // ... + + script1 := execNoErrLastID(t, db, "INSERT INTO script_contents(contents, md5_checksum) VALUES ('echo hi', 'a')") + script2 := execNoErrLastID(t, db, "INSERT INTO script_contents(contents, md5_checksum) VALUES ('echo bye', 'b')") + + software := execNoErrLastID(t, db, ` +INSERT INTO software_installers ( + filename, + version, + platform, + install_script_content_id, + post_install_script_content_id, + storage_id +) VALUES ( + 'fleet', + '1.0.0', + 'windows', + ?, + ?, + 'a' +)`, script1, script2) + + host := insertHost(t, db, nil) + + install := execNoErrLastID(t, db, ` +INSERT INTO host_software_installs ( + host_id, + execution_id, + software_installer_id +) VALUES (?, ?, ?)`, host, "e", software) + + // Apply current migration. + applyNext(t, db) + + // + // Check data, insert new entries, e.g. to verify migration is safe. + // + // ... + + var self_service bool + err := db.Get(&self_service, "SELECT self_service FROM software_installers WHERE id = ?", software) + require.NoError(t, err) + require.False(t, self_service) + + var host_self_service bool + err = db.Get(&host_self_service, "SELECT self_service FROM host_software_installs WHERE id = ?", install) + require.NoError(t, err) + require.False(t, host_self_service) +} diff --git a/server/datastore/mysql/schema.sql b/server/datastore/mysql/schema.sql index 01136815c4..0d76506734 100644 --- a/server/datastore/mysql/schema.sql +++ b/server/datastore/mysql/schema.sql @@ -499,6 +499,7 @@ CREATE TABLE `host_software_installs` ( `user_id` int(10) unsigned DEFAULT NULL, `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `self_service` tinyint(1) NOT NULL DEFAULT '0', PRIMARY KEY (`id`), UNIQUE KEY `idx_host_software_installs_execution_id` (`execution_id`), KEY `fk_host_software_installs_installer_id` (`software_installer_id`), @@ -924,9 +925,9 @@ CREATE TABLE `migration_status_tables` ( `tstamp` timestamp NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `id` (`id`) -) ENGINE=InnoDB AUTO_INCREMENT=267 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +) ENGINE=InnoDB AUTO_INCREMENT=268 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; -INSERT INTO `migration_status_tables` VALUES (1,0,1,'2020-01-01 01:01:01'),(2,20161118193812,1,'2020-01-01 01:01:01'),(3,20161118211713,1,'2020-01-01 01:01:01'),(4,20161118212436,1,'2020-01-01 01:01:01'),(5,20161118212515,1,'2020-01-01 01:01:01'),(6,20161118212528,1,'2020-01-01 01:01:01'),(7,20161118212538,1,'2020-01-01 01:01:01'),(8,20161118212549,1,'2020-01-01 01:01:01'),(9,20161118212557,1,'2020-01-01 01:01:01'),(10,20161118212604,1,'2020-01-01 01:01:01'),(11,20161118212613,1,'2020-01-01 01:01:01'),(12,20161118212621,1,'2020-01-01 01:01:01'),(13,20161118212630,1,'2020-01-01 01:01:01'),(14,20161118212641,1,'2020-01-01 01:01:01'),(15,20161118212649,1,'2020-01-01 01:01:01'),(16,20161118212656,1,'2020-01-01 01:01:01'),(17,20161118212758,1,'2020-01-01 01:01:01'),(18,20161128234849,1,'2020-01-01 01:01:01'),(19,20161230162221,1,'2020-01-01 01:01:01'),(20,20170104113816,1,'2020-01-01 01:01:01'),(21,20170105151732,1,'2020-01-01 01:01:01'),(22,20170108191242,1,'2020-01-01 01:01:01'),(23,20170109094020,1,'2020-01-01 01:01:01'),(24,20170109130438,1,'2020-01-01 01:01:01'),(25,20170110202752,1,'2020-01-01 01:01:01'),(26,20170111133013,1,'2020-01-01 01:01:01'),(27,20170117025759,1,'2020-01-01 01:01:01'),(28,20170118191001,1,'2020-01-01 01:01:01'),(29,20170119234632,1,'2020-01-01 01:01:01'),(30,20170124230432,1,'2020-01-01 01:01:01'),(31,20170127014618,1,'2020-01-01 01:01:01'),(32,20170131232841,1,'2020-01-01 01:01:01'),(33,20170223094154,1,'2020-01-01 01:01:01'),(34,20170306075207,1,'2020-01-01 01:01:01'),(35,20170309100733,1,'2020-01-01 01:01:01'),(36,20170331111922,1,'2020-01-01 01:01:01'),(37,20170502143928,1,'2020-01-01 01:01:01'),(38,20170504130602,1,'2020-01-01 01:01:01'),(39,20170509132100,1,'2020-01-01 01:01:01'),(40,20170519105647,1,'2020-01-01 01:01:01'),(41,20170519105648,1,'2020-01-01 01:01:01'),(42,20170831234300,1,'2020-01-01 01:01:01'),(43,20170831234301,1,'2020-01-01 01:01:01'),(44,20170831234303,1,'2020-01-01 01:01:01'),(45,20171116163618,1,'2020-01-01 01:01:01'),(46,20171219164727,1,'2020-01-01 01:01:01'),(47,20180620164811,1,'2020-01-01 01:01:01'),(48,20180620175054,1,'2020-01-01 01:01:01'),(49,20180620175055,1,'2020-01-01 01:01:01'),(50,20191010101639,1,'2020-01-01 01:01:01'),(51,20191010155147,1,'2020-01-01 01:01:01'),(52,20191220130734,1,'2020-01-01 01:01:01'),(53,20200311140000,1,'2020-01-01 01:01:01'),(54,20200405120000,1,'2020-01-01 01:01:01'),(55,20200407120000,1,'2020-01-01 01:01:01'),(56,20200420120000,1,'2020-01-01 01:01:01'),(57,20200504120000,1,'2020-01-01 01:01:01'),(58,20200512120000,1,'2020-01-01 01:01:01'),(59,20200707120000,1,'2020-01-01 01:01:01'),(60,20201011162341,1,'2020-01-01 01:01:01'),(61,20201021104586,1,'2020-01-01 01:01:01'),(62,20201102112520,1,'2020-01-01 01:01:01'),(63,20201208121729,1,'2020-01-01 01:01:01'),(64,20201215091637,1,'2020-01-01 01:01:01'),(65,20210119174155,1,'2020-01-01 01:01:01'),(66,20210326182902,1,'2020-01-01 01:01:01'),(67,20210421112652,1,'2020-01-01 01:01:01'),(68,20210506095025,1,'2020-01-01 01:01:01'),(69,20210513115729,1,'2020-01-01 01:01:01'),(70,20210526113559,1,'2020-01-01 01:01:01'),(71,20210601000001,1,'2020-01-01 01:01:01'),(72,20210601000002,1,'2020-01-01 01:01:01'),(73,20210601000003,1,'2020-01-01 01:01:01'),(74,20210601000004,1,'2020-01-01 01:01:01'),(75,20210601000005,1,'2020-01-01 01:01:01'),(76,20210601000006,1,'2020-01-01 01:01:01'),(77,20210601000007,1,'2020-01-01 01:01:01'),(78,20210601000008,1,'2020-01-01 01:01:01'),(79,20210606151329,1,'2020-01-01 01:01:01'),(80,20210616163757,1,'2020-01-01 01:01:01'),(81,20210617174723,1,'2020-01-01 01:01:01'),(82,20210622160235,1,'2020-01-01 01:01:01'),(83,20210623100031,1,'2020-01-01 01:01:01'),(84,20210623133615,1,'2020-01-01 01:01:01'),(85,20210708143152,1,'2020-01-01 01:01:01'),(86,20210709124443,1,'2020-01-01 01:01:01'),(87,20210712155608,1,'2020-01-01 01:01:01'),(88,20210714102108,1,'2020-01-01 01:01:01'),(89,20210719153709,1,'2020-01-01 01:01:01'),(90,20210721171531,1,'2020-01-01 01:01:01'),(91,20210723135713,1,'2020-01-01 01:01:01'),(92,20210802135933,1,'2020-01-01 01:01:01'),(93,20210806112844,1,'2020-01-01 01:01:01'),(94,20210810095603,1,'2020-01-01 01:01:01'),(95,20210811150223,1,'2020-01-01 01:01:01'),(96,20210818151827,1,'2020-01-01 01:01:01'),(97,20210818151828,1,'2020-01-01 01:01:01'),(98,20210818182258,1,'2020-01-01 01:01:01'),(99,20210819131107,1,'2020-01-01 01:01:01'),(100,20210819143446,1,'2020-01-01 01:01:01'),(101,20210903132338,1,'2020-01-01 01:01:01'),(102,20210915144307,1,'2020-01-01 01:01:01'),(103,20210920155130,1,'2020-01-01 01:01:01'),(104,20210927143115,1,'2020-01-01 01:01:01'),(105,20210927143116,1,'2020-01-01 01:01:01'),(106,20211013133706,1,'2020-01-01 01:01:01'),(107,20211013133707,1,'2020-01-01 01:01:01'),(108,20211102135149,1,'2020-01-01 01:01:01'),(109,20211109121546,1,'2020-01-01 01:01:01'),(110,20211110163320,1,'2020-01-01 01:01:01'),(111,20211116184029,1,'2020-01-01 01:01:01'),(112,20211116184030,1,'2020-01-01 01:01:01'),(113,20211202092042,1,'2020-01-01 01:01:01'),(114,20211202181033,1,'2020-01-01 01:01:01'),(115,20211207161856,1,'2020-01-01 01:01:01'),(116,20211216131203,1,'2020-01-01 01:01:01'),(117,20211221110132,1,'2020-01-01 01:01:01'),(118,20220107155700,1,'2020-01-01 01:01:01'),(119,20220125105650,1,'2020-01-01 01:01:01'),(120,20220201084510,1,'2020-01-01 01:01:01'),(121,20220208144830,1,'2020-01-01 01:01:01'),(122,20220208144831,1,'2020-01-01 01:01:01'),(123,20220215152203,1,'2020-01-01 01:01:01'),(124,20220223113157,1,'2020-01-01 01:01:01'),(125,20220307104655,1,'2020-01-01 01:01:01'),(126,20220309133956,1,'2020-01-01 01:01:01'),(127,20220316155700,1,'2020-01-01 01:01:01'),(128,20220323152301,1,'2020-01-01 01:01:01'),(129,20220330100659,1,'2020-01-01 01:01:01'),(130,20220404091216,1,'2020-01-01 01:01:01'),(131,20220419140750,1,'2020-01-01 01:01:01'),(132,20220428140039,1,'2020-01-01 01:01:01'),(133,20220503134048,1,'2020-01-01 01:01:01'),(134,20220524102918,1,'2020-01-01 01:01:01'),(135,20220526123327,1,'2020-01-01 01:01:01'),(136,20220526123328,1,'2020-01-01 01:01:01'),(137,20220526123329,1,'2020-01-01 01:01:01'),(138,20220608113128,1,'2020-01-01 01:01:01'),(139,20220627104817,1,'2020-01-01 01:01:01'),(140,20220704101843,1,'2020-01-01 01:01:01'),(141,20220708095046,1,'2020-01-01 01:01:01'),(142,20220713091130,1,'2020-01-01 01:01:01'),(143,20220802135510,1,'2020-01-01 01:01:01'),(144,20220818101352,1,'2020-01-01 01:01:01'),(145,20220822161445,1,'2020-01-01 01:01:01'),(146,20220831100036,1,'2020-01-01 01:01:01'),(147,20220831100151,1,'2020-01-01 01:01:01'),(148,20220908181826,1,'2020-01-01 01:01:01'),(149,20220914154915,1,'2020-01-01 01:01:01'),(150,20220915165115,1,'2020-01-01 01:01:01'),(151,20220915165116,1,'2020-01-01 01:01:01'),(152,20220928100158,1,'2020-01-01 01:01:01'),(153,20221014084130,1,'2020-01-01 01:01:01'),(154,20221027085019,1,'2020-01-01 01:01:01'),(155,20221101103952,1,'2020-01-01 01:01:01'),(156,20221104144401,1,'2020-01-01 01:01:01'),(157,20221109100749,1,'2020-01-01 01:01:01'),(158,20221115104546,1,'2020-01-01 01:01:01'),(159,20221130114928,1,'2020-01-01 01:01:01'),(160,20221205112142,1,'2020-01-01 01:01:01'),(161,20221216115820,1,'2020-01-01 01:01:01'),(162,20221220195934,1,'2020-01-01 01:01:01'),(163,20221220195935,1,'2020-01-01 01:01:01'),(164,20221223174807,1,'2020-01-01 01:01:01'),(165,20221227163855,1,'2020-01-01 01:01:01'),(166,20221227163856,1,'2020-01-01 01:01:01'),(167,20230202224725,1,'2020-01-01 01:01:01'),(168,20230206163608,1,'2020-01-01 01:01:01'),(169,20230214131519,1,'2020-01-01 01:01:01'),(170,20230303135738,1,'2020-01-01 01:01:01'),(171,20230313135301,1,'2020-01-01 01:01:01'),(172,20230313141819,1,'2020-01-01 01:01:01'),(173,20230315104937,1,'2020-01-01 01:01:01'),(174,20230317173844,1,'2020-01-01 01:01:01'),(175,20230320133602,1,'2020-01-01 01:01:01'),(176,20230330100011,1,'2020-01-01 01:01:01'),(177,20230330134823,1,'2020-01-01 01:01:01'),(178,20230405232025,1,'2020-01-01 01:01:01'),(179,20230408084104,1,'2020-01-01 01:01:01'),(180,20230411102858,1,'2020-01-01 01:01:01'),(181,20230421155932,1,'2020-01-01 01:01:01'),(182,20230425082126,1,'2020-01-01 01:01:01'),(183,20230425105727,1,'2020-01-01 01:01:01'),(184,20230501154913,1,'2020-01-01 01:01:01'),(185,20230503101418,1,'2020-01-01 01:01:01'),(186,20230515144206,1,'2020-01-01 01:01:01'),(187,20230517140952,1,'2020-01-01 01:01:01'),(188,20230517152807,1,'2020-01-01 01:01:01'),(189,20230518114155,1,'2020-01-01 01:01:01'),(190,20230520153236,1,'2020-01-01 01:01:01'),(191,20230525151159,1,'2020-01-01 01:01:01'),(192,20230530122103,1,'2020-01-01 01:01:01'),(193,20230602111827,1,'2020-01-01 01:01:01'),(194,20230608103123,1,'2020-01-01 01:01:01'),(195,20230629140529,1,'2020-01-01 01:01:01'),(196,20230629140530,1,'2020-01-01 01:01:01'),(197,20230711144622,1,'2020-01-01 01:01:01'),(198,20230721135421,1,'2020-01-01 01:01:01'),(199,20230721161508,1,'2020-01-01 01:01:01'),(200,20230726115701,1,'2020-01-01 01:01:01'),(201,20230807100822,1,'2020-01-01 01:01:01'),(202,20230814150442,1,'2020-01-01 01:01:01'),(203,20230823122728,1,'2020-01-01 01:01:01'),(204,20230906152143,1,'2020-01-01 01:01:01'),(205,20230911163618,1,'2020-01-01 01:01:01'),(206,20230912101759,1,'2020-01-01 01:01:01'),(207,20230915101341,1,'2020-01-01 01:01:01'),(208,20230918132351,1,'2020-01-01 01:01:01'),(209,20231004144339,1,'2020-01-01 01:01:01'),(210,20231009094541,1,'2020-01-01 01:01:01'),(211,20231009094542,1,'2020-01-01 01:01:01'),(212,20231009094543,1,'2020-01-01 01:01:01'),(213,20231009094544,1,'2020-01-01 01:01:01'),(214,20231016091915,1,'2020-01-01 01:01:01'),(215,20231024174135,1,'2020-01-01 01:01:01'),(216,20231025120016,1,'2020-01-01 01:01:01'),(217,20231025160156,1,'2020-01-01 01:01:01'),(218,20231031165350,1,'2020-01-01 01:01:01'),(219,20231106144110,1,'2020-01-01 01:01:01'),(220,20231107130934,1,'2020-01-01 01:01:01'),(221,20231109115838,1,'2020-01-01 01:01:01'),(222,20231121054530,1,'2020-01-01 01:01:01'),(223,20231122101320,1,'2020-01-01 01:01:01'),(224,20231130132828,1,'2020-01-01 01:01:01'),(225,20231130132931,1,'2020-01-01 01:01:01'),(226,20231204155427,1,'2020-01-01 01:01:01'),(227,20231206142340,1,'2020-01-01 01:01:01'),(228,20231207102320,1,'2020-01-01 01:01:01'),(229,20231207102321,1,'2020-01-01 01:01:01'),(230,20231207133731,1,'2020-01-01 01:01:01'),(231,20231212094238,1,'2020-01-01 01:01:01'),(232,20231212095734,1,'2020-01-01 01:01:01'),(233,20231212161121,1,'2020-01-01 01:01:01'),(234,20231215122713,1,'2020-01-01 01:01:01'),(235,20231219143041,1,'2020-01-01 01:01:01'),(236,20231224070653,1,'2020-01-01 01:01:01'),(237,20240110134315,1,'2020-01-01 01:01:01'),(238,20240119091637,1,'2020-01-01 01:01:01'),(239,20240126020642,1,'2020-01-01 01:01:01'),(240,20240126020643,1,'2020-01-01 01:01:01'),(241,20240129162819,1,'2020-01-01 01:01:01'),(242,20240130115133,1,'2020-01-01 01:01:01'),(243,20240131083822,1,'2020-01-01 01:01:01'),(244,20240205095928,1,'2020-01-01 01:01:01'),(245,20240205121956,1,'2020-01-01 01:01:01'),(246,20240209110212,1,'2020-01-01 01:01:01'),(247,20240212111533,1,'2020-01-01 01:01:01'),(248,20240221112844,1,'2020-01-01 01:01:01'),(249,20240222073518,1,'2020-01-01 01:01:01'),(250,20240222135115,1,'2020-01-01 01:01:01'),(251,20240226082255,1,'2020-01-01 01:01:01'),(252,20240228082706,1,'2020-01-01 01:01:01'),(253,20240301173035,1,'2020-01-01 01:01:01'),(254,20240302111134,1,'2020-01-01 01:01:01'),(255,20240312103753,1,'2020-01-01 01:01:01'),(256,20240313143416,1,'2020-01-01 01:01:01'),(257,20240314085226,1,'2020-01-01 01:01:01'),(258,20240314151747,1,'2020-01-01 01:01:01'),(259,20240320145650,1,'2020-01-01 01:01:01'),(260,20240327115530,1,'2020-01-01 01:01:01'),(261,20240327115617,1,'2020-01-01 01:01:01'),(262,20240408085837,1,'2020-01-01 01:01:01'),(263,20240415104633,1,'2020-01-01 01:01:01'),(264,20240430111727,1,'2020-01-01 01:01:01'),(265,20240515200020,1,'2020-01-01 01:01:01'),(266,20240521143023,1,'2020-01-01 01:01:01'); +INSERT INTO `migration_status_tables` VALUES (1,0,1,'2020-01-01 01:01:01'),(2,20161118193812,1,'2020-01-01 01:01:01'),(3,20161118211713,1,'2020-01-01 01:01:01'),(4,20161118212436,1,'2020-01-01 01:01:01'),(5,20161118212515,1,'2020-01-01 01:01:01'),(6,20161118212528,1,'2020-01-01 01:01:01'),(7,20161118212538,1,'2020-01-01 01:01:01'),(8,20161118212549,1,'2020-01-01 01:01:01'),(9,20161118212557,1,'2020-01-01 01:01:01'),(10,20161118212604,1,'2020-01-01 01:01:01'),(11,20161118212613,1,'2020-01-01 01:01:01'),(12,20161118212621,1,'2020-01-01 01:01:01'),(13,20161118212630,1,'2020-01-01 01:01:01'),(14,20161118212641,1,'2020-01-01 01:01:01'),(15,20161118212649,1,'2020-01-01 01:01:01'),(16,20161118212656,1,'2020-01-01 01:01:01'),(17,20161118212758,1,'2020-01-01 01:01:01'),(18,20161128234849,1,'2020-01-01 01:01:01'),(19,20161230162221,1,'2020-01-01 01:01:01'),(20,20170104113816,1,'2020-01-01 01:01:01'),(21,20170105151732,1,'2020-01-01 01:01:01'),(22,20170108191242,1,'2020-01-01 01:01:01'),(23,20170109094020,1,'2020-01-01 01:01:01'),(24,20170109130438,1,'2020-01-01 01:01:01'),(25,20170110202752,1,'2020-01-01 01:01:01'),(26,20170111133013,1,'2020-01-01 01:01:01'),(27,20170117025759,1,'2020-01-01 01:01:01'),(28,20170118191001,1,'2020-01-01 01:01:01'),(29,20170119234632,1,'2020-01-01 01:01:01'),(30,20170124230432,1,'2020-01-01 01:01:01'),(31,20170127014618,1,'2020-01-01 01:01:01'),(32,20170131232841,1,'2020-01-01 01:01:01'),(33,20170223094154,1,'2020-01-01 01:01:01'),(34,20170306075207,1,'2020-01-01 01:01:01'),(35,20170309100733,1,'2020-01-01 01:01:01'),(36,20170331111922,1,'2020-01-01 01:01:01'),(37,20170502143928,1,'2020-01-01 01:01:01'),(38,20170504130602,1,'2020-01-01 01:01:01'),(39,20170509132100,1,'2020-01-01 01:01:01'),(40,20170519105647,1,'2020-01-01 01:01:01'),(41,20170519105648,1,'2020-01-01 01:01:01'),(42,20170831234300,1,'2020-01-01 01:01:01'),(43,20170831234301,1,'2020-01-01 01:01:01'),(44,20170831234303,1,'2020-01-01 01:01:01'),(45,20171116163618,1,'2020-01-01 01:01:01'),(46,20171219164727,1,'2020-01-01 01:01:01'),(47,20180620164811,1,'2020-01-01 01:01:01'),(48,20180620175054,1,'2020-01-01 01:01:01'),(49,20180620175055,1,'2020-01-01 01:01:01'),(50,20191010101639,1,'2020-01-01 01:01:01'),(51,20191010155147,1,'2020-01-01 01:01:01'),(52,20191220130734,1,'2020-01-01 01:01:01'),(53,20200311140000,1,'2020-01-01 01:01:01'),(54,20200405120000,1,'2020-01-01 01:01:01'),(55,20200407120000,1,'2020-01-01 01:01:01'),(56,20200420120000,1,'2020-01-01 01:01:01'),(57,20200504120000,1,'2020-01-01 01:01:01'),(58,20200512120000,1,'2020-01-01 01:01:01'),(59,20200707120000,1,'2020-01-01 01:01:01'),(60,20201011162341,1,'2020-01-01 01:01:01'),(61,20201021104586,1,'2020-01-01 01:01:01'),(62,20201102112520,1,'2020-01-01 01:01:01'),(63,20201208121729,1,'2020-01-01 01:01:01'),(64,20201215091637,1,'2020-01-01 01:01:01'),(65,20210119174155,1,'2020-01-01 01:01:01'),(66,20210326182902,1,'2020-01-01 01:01:01'),(67,20210421112652,1,'2020-01-01 01:01:01'),(68,20210506095025,1,'2020-01-01 01:01:01'),(69,20210513115729,1,'2020-01-01 01:01:01'),(70,20210526113559,1,'2020-01-01 01:01:01'),(71,20210601000001,1,'2020-01-01 01:01:01'),(72,20210601000002,1,'2020-01-01 01:01:01'),(73,20210601000003,1,'2020-01-01 01:01:01'),(74,20210601000004,1,'2020-01-01 01:01:01'),(75,20210601000005,1,'2020-01-01 01:01:01'),(76,20210601000006,1,'2020-01-01 01:01:01'),(77,20210601000007,1,'2020-01-01 01:01:01'),(78,20210601000008,1,'2020-01-01 01:01:01'),(79,20210606151329,1,'2020-01-01 01:01:01'),(80,20210616163757,1,'2020-01-01 01:01:01'),(81,20210617174723,1,'2020-01-01 01:01:01'),(82,20210622160235,1,'2020-01-01 01:01:01'),(83,20210623100031,1,'2020-01-01 01:01:01'),(84,20210623133615,1,'2020-01-01 01:01:01'),(85,20210708143152,1,'2020-01-01 01:01:01'),(86,20210709124443,1,'2020-01-01 01:01:01'),(87,20210712155608,1,'2020-01-01 01:01:01'),(88,20210714102108,1,'2020-01-01 01:01:01'),(89,20210719153709,1,'2020-01-01 01:01:01'),(90,20210721171531,1,'2020-01-01 01:01:01'),(91,20210723135713,1,'2020-01-01 01:01:01'),(92,20210802135933,1,'2020-01-01 01:01:01'),(93,20210806112844,1,'2020-01-01 01:01:01'),(94,20210810095603,1,'2020-01-01 01:01:01'),(95,20210811150223,1,'2020-01-01 01:01:01'),(96,20210818151827,1,'2020-01-01 01:01:01'),(97,20210818151828,1,'2020-01-01 01:01:01'),(98,20210818182258,1,'2020-01-01 01:01:01'),(99,20210819131107,1,'2020-01-01 01:01:01'),(100,20210819143446,1,'2020-01-01 01:01:01'),(101,20210903132338,1,'2020-01-01 01:01:01'),(102,20210915144307,1,'2020-01-01 01:01:01'),(103,20210920155130,1,'2020-01-01 01:01:01'),(104,20210927143115,1,'2020-01-01 01:01:01'),(105,20210927143116,1,'2020-01-01 01:01:01'),(106,20211013133706,1,'2020-01-01 01:01:01'),(107,20211013133707,1,'2020-01-01 01:01:01'),(108,20211102135149,1,'2020-01-01 01:01:01'),(109,20211109121546,1,'2020-01-01 01:01:01'),(110,20211110163320,1,'2020-01-01 01:01:01'),(111,20211116184029,1,'2020-01-01 01:01:01'),(112,20211116184030,1,'2020-01-01 01:01:01'),(113,20211202092042,1,'2020-01-01 01:01:01'),(114,20211202181033,1,'2020-01-01 01:01:01'),(115,20211207161856,1,'2020-01-01 01:01:01'),(116,20211216131203,1,'2020-01-01 01:01:01'),(117,20211221110132,1,'2020-01-01 01:01:01'),(118,20220107155700,1,'2020-01-01 01:01:01'),(119,20220125105650,1,'2020-01-01 01:01:01'),(120,20220201084510,1,'2020-01-01 01:01:01'),(121,20220208144830,1,'2020-01-01 01:01:01'),(122,20220208144831,1,'2020-01-01 01:01:01'),(123,20220215152203,1,'2020-01-01 01:01:01'),(124,20220223113157,1,'2020-01-01 01:01:01'),(125,20220307104655,1,'2020-01-01 01:01:01'),(126,20220309133956,1,'2020-01-01 01:01:01'),(127,20220316155700,1,'2020-01-01 01:01:01'),(128,20220323152301,1,'2020-01-01 01:01:01'),(129,20220330100659,1,'2020-01-01 01:01:01'),(130,20220404091216,1,'2020-01-01 01:01:01'),(131,20220419140750,1,'2020-01-01 01:01:01'),(132,20220428140039,1,'2020-01-01 01:01:01'),(133,20220503134048,1,'2020-01-01 01:01:01'),(134,20220524102918,1,'2020-01-01 01:01:01'),(135,20220526123327,1,'2020-01-01 01:01:01'),(136,20220526123328,1,'2020-01-01 01:01:01'),(137,20220526123329,1,'2020-01-01 01:01:01'),(138,20220608113128,1,'2020-01-01 01:01:01'),(139,20220627104817,1,'2020-01-01 01:01:01'),(140,20220704101843,1,'2020-01-01 01:01:01'),(141,20220708095046,1,'2020-01-01 01:01:01'),(142,20220713091130,1,'2020-01-01 01:01:01'),(143,20220802135510,1,'2020-01-01 01:01:01'),(144,20220818101352,1,'2020-01-01 01:01:01'),(145,20220822161445,1,'2020-01-01 01:01:01'),(146,20220831100036,1,'2020-01-01 01:01:01'),(147,20220831100151,1,'2020-01-01 01:01:01'),(148,20220908181826,1,'2020-01-01 01:01:01'),(149,20220914154915,1,'2020-01-01 01:01:01'),(150,20220915165115,1,'2020-01-01 01:01:01'),(151,20220915165116,1,'2020-01-01 01:01:01'),(152,20220928100158,1,'2020-01-01 01:01:01'),(153,20221014084130,1,'2020-01-01 01:01:01'),(154,20221027085019,1,'2020-01-01 01:01:01'),(155,20221101103952,1,'2020-01-01 01:01:01'),(156,20221104144401,1,'2020-01-01 01:01:01'),(157,20221109100749,1,'2020-01-01 01:01:01'),(158,20221115104546,1,'2020-01-01 01:01:01'),(159,20221130114928,1,'2020-01-01 01:01:01'),(160,20221205112142,1,'2020-01-01 01:01:01'),(161,20221216115820,1,'2020-01-01 01:01:01'),(162,20221220195934,1,'2020-01-01 01:01:01'),(163,20221220195935,1,'2020-01-01 01:01:01'),(164,20221223174807,1,'2020-01-01 01:01:01'),(165,20221227163855,1,'2020-01-01 01:01:01'),(166,20221227163856,1,'2020-01-01 01:01:01'),(167,20230202224725,1,'2020-01-01 01:01:01'),(168,20230206163608,1,'2020-01-01 01:01:01'),(169,20230214131519,1,'2020-01-01 01:01:01'),(170,20230303135738,1,'2020-01-01 01:01:01'),(171,20230313135301,1,'2020-01-01 01:01:01'),(172,20230313141819,1,'2020-01-01 01:01:01'),(173,20230315104937,1,'2020-01-01 01:01:01'),(174,20230317173844,1,'2020-01-01 01:01:01'),(175,20230320133602,1,'2020-01-01 01:01:01'),(176,20230330100011,1,'2020-01-01 01:01:01'),(177,20230330134823,1,'2020-01-01 01:01:01'),(178,20230405232025,1,'2020-01-01 01:01:01'),(179,20230408084104,1,'2020-01-01 01:01:01'),(180,20230411102858,1,'2020-01-01 01:01:01'),(181,20230421155932,1,'2020-01-01 01:01:01'),(182,20230425082126,1,'2020-01-01 01:01:01'),(183,20230425105727,1,'2020-01-01 01:01:01'),(184,20230501154913,1,'2020-01-01 01:01:01'),(185,20230503101418,1,'2020-01-01 01:01:01'),(186,20230515144206,1,'2020-01-01 01:01:01'),(187,20230517140952,1,'2020-01-01 01:01:01'),(188,20230517152807,1,'2020-01-01 01:01:01'),(189,20230518114155,1,'2020-01-01 01:01:01'),(190,20230520153236,1,'2020-01-01 01:01:01'),(191,20230525151159,1,'2020-01-01 01:01:01'),(192,20230530122103,1,'2020-01-01 01:01:01'),(193,20230602111827,1,'2020-01-01 01:01:01'),(194,20230608103123,1,'2020-01-01 01:01:01'),(195,20230629140529,1,'2020-01-01 01:01:01'),(196,20230629140530,1,'2020-01-01 01:01:01'),(197,20230711144622,1,'2020-01-01 01:01:01'),(198,20230721135421,1,'2020-01-01 01:01:01'),(199,20230721161508,1,'2020-01-01 01:01:01'),(200,20230726115701,1,'2020-01-01 01:01:01'),(201,20230807100822,1,'2020-01-01 01:01:01'),(202,20230814150442,1,'2020-01-01 01:01:01'),(203,20230823122728,1,'2020-01-01 01:01:01'),(204,20230906152143,1,'2020-01-01 01:01:01'),(205,20230911163618,1,'2020-01-01 01:01:01'),(206,20230912101759,1,'2020-01-01 01:01:01'),(207,20230915101341,1,'2020-01-01 01:01:01'),(208,20230918132351,1,'2020-01-01 01:01:01'),(209,20231004144339,1,'2020-01-01 01:01:01'),(210,20231009094541,1,'2020-01-01 01:01:01'),(211,20231009094542,1,'2020-01-01 01:01:01'),(212,20231009094543,1,'2020-01-01 01:01:01'),(213,20231009094544,1,'2020-01-01 01:01:01'),(214,20231016091915,1,'2020-01-01 01:01:01'),(215,20231024174135,1,'2020-01-01 01:01:01'),(216,20231025120016,1,'2020-01-01 01:01:01'),(217,20231025160156,1,'2020-01-01 01:01:01'),(218,20231031165350,1,'2020-01-01 01:01:01'),(219,20231106144110,1,'2020-01-01 01:01:01'),(220,20231107130934,1,'2020-01-01 01:01:01'),(221,20231109115838,1,'2020-01-01 01:01:01'),(222,20231121054530,1,'2020-01-01 01:01:01'),(223,20231122101320,1,'2020-01-01 01:01:01'),(224,20231130132828,1,'2020-01-01 01:01:01'),(225,20231130132931,1,'2020-01-01 01:01:01'),(226,20231204155427,1,'2020-01-01 01:01:01'),(227,20231206142340,1,'2020-01-01 01:01:01'),(228,20231207102320,1,'2020-01-01 01:01:01'),(229,20231207102321,1,'2020-01-01 01:01:01'),(230,20231207133731,1,'2020-01-01 01:01:01'),(231,20231212094238,1,'2020-01-01 01:01:01'),(232,20231212095734,1,'2020-01-01 01:01:01'),(233,20231212161121,1,'2020-01-01 01:01:01'),(234,20231215122713,1,'2020-01-01 01:01:01'),(235,20231219143041,1,'2020-01-01 01:01:01'),(236,20231224070653,1,'2020-01-01 01:01:01'),(237,20240110134315,1,'2020-01-01 01:01:01'),(238,20240119091637,1,'2020-01-01 01:01:01'),(239,20240126020642,1,'2020-01-01 01:01:01'),(240,20240126020643,1,'2020-01-01 01:01:01'),(241,20240129162819,1,'2020-01-01 01:01:01'),(242,20240130115133,1,'2020-01-01 01:01:01'),(243,20240131083822,1,'2020-01-01 01:01:01'),(244,20240205095928,1,'2020-01-01 01:01:01'),(245,20240205121956,1,'2020-01-01 01:01:01'),(246,20240209110212,1,'2020-01-01 01:01:01'),(247,20240212111533,1,'2020-01-01 01:01:01'),(248,20240221112844,1,'2020-01-01 01:01:01'),(249,20240222073518,1,'2020-01-01 01:01:01'),(250,20240222135115,1,'2020-01-01 01:01:01'),(251,20240226082255,1,'2020-01-01 01:01:01'),(252,20240228082706,1,'2020-01-01 01:01:01'),(253,20240301173035,1,'2020-01-01 01:01:01'),(254,20240302111134,1,'2020-01-01 01:01:01'),(255,20240312103753,1,'2020-01-01 01:01:01'),(256,20240313143416,1,'2020-01-01 01:01:01'),(257,20240314085226,1,'2020-01-01 01:01:01'),(258,20240314151747,1,'2020-01-01 01:01:01'),(259,20240320145650,1,'2020-01-01 01:01:01'),(260,20240327115530,1,'2020-01-01 01:01:01'),(261,20240327115617,1,'2020-01-01 01:01:01'),(262,20240408085837,1,'2020-01-01 01:01:01'),(263,20240415104633,1,'2020-01-01 01:01:01'),(264,20240430111727,1,'2020-01-01 01:01:01'),(265,20240515200020,1,'2020-01-01 01:01:01'),(266,20240521143023,1,'2020-01-01 01:01:01'),(267,20240521143024,1,'2020-01-01 01:01:01'); /*!40101 SET @saved_cs_client = @@character_set_client */; /*!40101 SET character_set_client = utf8 */; CREATE TABLE `mobile_device_management_solutions` ( @@ -1527,6 +1528,7 @@ CREATE TABLE `software_installers` ( `post_install_script_content_id` int(10) unsigned DEFAULT NULL, `storage_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL, `uploaded_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `self_service` tinyint(1) NOT NULL DEFAULT '0', PRIMARY KEY (`id`), UNIQUE KEY `idx_software_installers_team_id_title_id` (`global_or_team_id`,`title_id`), KEY `fk_software_installers_title` (`title_id`), diff --git a/server/datastore/mysql/software.go b/server/datastore/mysql/software.go index b4db0c72cb..4856e1b831 100644 --- a/server/datastore/mysql/software.go +++ b/server/datastore/mysql/software.go @@ -1825,6 +1825,9 @@ func softwareInstallerHostStatusNamedQuery(tblAlias, colAlias string) string { if colAlias != "" { colAlias = " AS " + colAlias } + // the computed column assumes that all results (pre, install and post) are + // stored at once, so that if there is an exit code for the install script + // and none for the post-install, it is because there is no post-install. return fmt.Sprintf(` CASE WHEN %[1]spost_install_script_exit_code IS NOT NULL AND @@ -1848,17 +1851,19 @@ func softwareInstallerHostStatusNamedQuery(tblAlias, colAlias string) string { END %[2]s `, tblAlias, colAlias) } -func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, includeAvailableForInstall bool, opts fleet.ListOptions) ([]*fleet.HostSoftwareWithInstaller, *fleet.PaginationMetadata, error) { - // `status` computed column assumes that all results (pre, install and post) - // are stored at once, so that if there is an exit code for the install - // script and none for the post-install, it is because there is no - // post-install. +func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, opts fleet.HostSoftwareTitleListOptions) ([]*fleet.HostSoftwareWithInstaller, *fleet.PaginationMetadata, error) { + var onlySelfServiceClause string + if opts.SelfServiceOnly { + onlySelfServiceClause = ` AND si.self_service = 1 ` + } stmtInstalled := fmt.Sprintf(` SELECT st.id, st.name, st.source, + si.self_service as self_service, si.filename as package_available_for_install, + si.version as package_version, hsi.created_at as last_install_installed_at, hsi.execution_id as last_install_install_uuid, %s, @@ -1890,14 +1895,17 @@ func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, inc ) OR -- or software install has been attempted on host hsi.host_id IS NOT NULL ) -`, softwareInstallerHostStatusNamedQuery("hsi", "status")) + %s +`, softwareInstallerHostStatusNamedQuery("hsi", "status"), onlySelfServiceClause) const stmtAvailable = ` SELECT st.id, st.name, st.source, + si.self_service as self_service, si.filename as package_available_for_install, + si.version as package_version, NULL as last_install_installed_at, NULL as last_install_install_uuid, NULL as status, @@ -1929,6 +1937,7 @@ func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, inc hsi.software_installer_id = si.id ) AND si.global_or_team_id = (SELECT COALESCE(h.team_id, 0) FROM hosts h WHERE h.id = ?) + %s ` const selectColNames = ` @@ -1936,7 +1945,9 @@ func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, inc id, name, source, + self_service, package_available_for_install, + package_version, last_install_installed_at, last_install_install_uuid, status @@ -1954,7 +1965,7 @@ func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, inc return nil, nil, ctxerr.Wrap(ctx, err, "build named query for list host software") } - if includeAvailableForInstall { + if opts.IncludeAvailableForInstall { platformArgs := []string{host.Platform} if fleet.IsLinux(host.Platform) { platformArgs = fleet.HostLinuxOSs @@ -1964,20 +1975,20 @@ func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, inc placeholders += "?," args = append(args, p) } - stmt += ` UNION ` + fmt.Sprintf(stmtAvailable, strings.TrimSuffix(placeholders, ",")) + stmt += ` UNION ` + fmt.Sprintf(stmtAvailable, strings.TrimSuffix(placeholders, ","), onlySelfServiceClause) args = append(args, host.ID, host.ID, host.ID) } stmt = selectColNames + ` FROM ( ` + stmt + ` ) AS tbl ` - if opts.MatchQuery != "" { + if opts.ListOptions.MatchQuery != "" { stmt += " WHERE TRUE " // searchLike adds a "AND " - stmt, args = searchLike(stmt, args, opts.MatchQuery, "name") + stmt, args = searchLike(stmt, args, opts.ListOptions.MatchQuery, "name") } // build the count statement before adding pagination constraints countStmt := fmt.Sprintf(`SELECT COUNT(DISTINCT s.id) FROM (%s) AS s`, stmt) - stmt, _ = appendListOptionsToSQL(stmt, &opts) + stmt, _ = appendListOptionsToSQL(stmt, &opts.ListOptions) // perform a second query to grab the titleCount var titleCount uint @@ -1990,6 +2001,7 @@ func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, inc LastInstallInstalledAt *time.Time `db:"last_install_installed_at"` LastInstallInstallUUID *string `db:"last_install_install_uuid"` StatusSort sql.NullInt32 `db:"status_sort"` + PackageVersion *string `db:"package_version"` } var hostSoftwareList []*hostSoftware if err := sqlx.SelectContext(ctx, ds.reader(ctx), &hostSoftwareList, stmt, args...); err != nil { @@ -2010,6 +2022,21 @@ func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, inc hs.LastInstall.InstalledAt = *hs.LastInstallInstalledAt } } + + // promote the package name and version to the proper destination fields + // (the service layer will arbitrate whether package_available_for_install + // or package fields are returned). + if hs.PackageAvailableForInstall != nil { + var version string + if hs.PackageVersion != nil { + version = *hs.PackageVersion + } + hs.Package = &fleet.DeviceSoftwarePackage{ + Name: *hs.PackageAvailableForInstall, + Version: version, + } + } + titleIDs = append(titleIDs, hs.ID) byTitleID[hs.ID] = hs } @@ -2115,14 +2142,14 @@ func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, inc } } - perPage := opts.PerPage + perPage := opts.ListOptions.PerPage var metaData *fleet.PaginationMetadata - if opts.IncludeMetadata { + if opts.ListOptions.IncludeMetadata { if perPage <= 0 { perPage = defaultSelectLimit } metaData = &fleet.PaginationMetadata{ - HasPreviousResults: opts.Page > 0, + HasPreviousResults: opts.ListOptions.Page > 0, TotalResults: titleCount, } if len(hostSoftwareList) > int(perPage) { diff --git a/server/datastore/mysql/software_installers.go b/server/datastore/mysql/software_installers.go index 40e88ecdad..e4aaaaf765 100644 --- a/server/datastore/mysql/software_installers.go +++ b/server/datastore/mysql/software_installers.go @@ -41,6 +41,7 @@ func (ds *Datastore) GetSoftwareInstallDetails(ctx context.Context, executionId hsi.host_id AS host_id, hsi.execution_id AS execution_id, hsi.software_installer_id AS installer_id, + hsi.self_service AS self_service, COALESCE(si.pre_install_query, '') AS pre_install_condition, inst.contents AS install_script, COALESCE(pisnt.contents, '') AS post_install_script @@ -104,8 +105,9 @@ INSERT INTO software_installers ( install_script_content_id, pre_install_query, post_install_script_content_id, - platform -) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + platform, + self_service +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` args := []interface{}{ payload.TeamID, @@ -118,6 +120,7 @@ INSERT INTO software_installers ( payload.PreInstallQuery, postInstallScriptID, payload.Platform, + payload.SelfService, } res, err := ds.writer(ctx).ExecContext(ctx, stmt, args...) @@ -204,6 +207,7 @@ SELECT si.pre_install_query, si.post_install_script_content_id, si.uploaded_at, + si.self_service, COALESCE(st.name, '') AS software_title %s FROM @@ -245,13 +249,13 @@ func (ds *Datastore) DeleteSoftwareInstaller(ctx context.Context, id uint) error return nil } -func (ds *Datastore) InsertSoftwareInstallRequest(ctx context.Context, hostID uint, softwareInstallerID uint) (string, error) { +func (ds *Datastore) InsertSoftwareInstallRequest(ctx context.Context, hostID uint, softwareInstallerID uint, selfService bool) (string, error) { const ( insertStmt = ` INSERT INTO host_software_installs - (execution_id, host_id, software_installer_id, user_id) + (execution_id, host_id, software_installer_id, user_id, self_service) VALUES - (?, ?, ?, ?) + (?, ?, ?, ?, ?) ` hostExistsStmt = `SELECT 1 FROM hosts WHERE id = ?` @@ -278,6 +282,7 @@ func (ds *Datastore) InsertSoftwareInstallRequest(ctx context.Context, hostID ui hostID, softwareInstallerID, userID, + selfService, ) return installID, ctxerr.Wrap(ctx, err, "inserting new install software request") @@ -299,7 +304,8 @@ SELECT h.team_id AS host_team_id, hsi.user_id AS user_id, hsi.post_install_script_exit_code, - hsi.install_script_exit_code + hsi.install_script_exit_code, + hsi.self_service FROM host_software_installs hsi JOIN hosts h ON h.id = hsi.host_id diff --git a/server/datastore/mysql/software_installers_test.go b/server/datastore/mysql/software_installers_test.go index 4fd6e7e5d5..8542002b69 100644 --- a/server/datastore/mysql/software_installers_test.go +++ b/server/datastore/mysql/software_installers_test.go @@ -71,16 +71,30 @@ func testListPendingSoftwareInstalls(t *testing.T, ds *Datastore) { }) require.NoError(t, err) - hostInstall1, err := ds.InsertSoftwareInstallRequest(ctx, host1.ID, installerID1) + installerID3, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "banana", + PreInstallQuery: "SELECT 3", + PostInstallScript: "apple", + InstallerFile: bytes.NewReader([]byte("hello")), + StorageID: "storage3", + Filename: "file3", + Title: "file3", + Version: "3.0", + Source: "apps", + SelfService: true, + }) require.NoError(t, err) - hostInstall2, err := ds.InsertSoftwareInstallRequest(ctx, host1.ID, installerID2) + hostInstall1, err := ds.InsertSoftwareInstallRequest(ctx, host1.ID, installerID1, false) require.NoError(t, err) - hostInstall3, err := ds.InsertSoftwareInstallRequest(ctx, host2.ID, installerID1) + hostInstall2, err := ds.InsertSoftwareInstallRequest(ctx, host1.ID, installerID2, false) require.NoError(t, err) - hostInstall4, err := ds.InsertSoftwareInstallRequest(ctx, host2.ID, installerID2) + hostInstall3, err := ds.InsertSoftwareInstallRequest(ctx, host2.ID, installerID1, false) + require.NoError(t, err) + + hostInstall4, err := ds.InsertSoftwareInstallRequest(ctx, host2.ID, installerID2, false) require.NoError(t, err) err = ds.SetHostSoftwareInstallResult(ctx, &fleet.HostSoftwareInstallResultPayload{ @@ -90,7 +104,7 @@ func testListPendingSoftwareInstalls(t *testing.T, ds *Datastore) { }) require.NoError(t, err) - hostInstall5, err := ds.InsertSoftwareInstallRequest(ctx, host2.ID, installerID2) + hostInstall5, err := ds.InsertSoftwareInstallRequest(ctx, host2.ID, installerID2, false) require.NoError(t, err) err = ds.SetHostSoftwareInstallResult(ctx, &fleet.HostSoftwareInstallResultPayload{ @@ -122,6 +136,29 @@ func testListPendingSoftwareInstalls(t *testing.T, ds *Datastore) { require.Equal(t, "world", exec1.PostInstallScript) require.Equal(t, installerID1, exec1.InstallerID) require.Equal(t, "SELECT 1", exec1.PreInstallCondition) + require.False(t, exec1.SelfService) + + hostInstall6, err := ds.InsertSoftwareInstallRequest(ctx, host1.ID, installerID3, true) + require.NoError(t, err) + + err = ds.SetHostSoftwareInstallResult(ctx, &fleet.HostSoftwareInstallResultPayload{ + HostID: host1.ID, + InstallUUID: hostInstall6, + PreInstallConditionOutput: ptr.String("output"), + }) + require.NoError(t, err) + + exec2, err := ds.GetSoftwareInstallDetails(ctx, hostInstall6) + require.NoError(t, err) + + require.Equal(t, host1.ID, exec2.HostID) + require.Equal(t, hostInstall6, exec2.ExecutionID) + require.Equal(t, "banana", exec2.InstallScript) + require.Equal(t, "apple", exec2.PostInstallScript) + require.Equal(t, installerID3, exec2.InstallerID) + require.Equal(t, "SELECT 3", exec2.PreInstallCondition) + require.True(t, exec2.SelfService) + } func testSoftwareInstallRequests(t *testing.T, ds *Datastore) { @@ -161,7 +198,7 @@ func testSoftwareInstallRequests(t *testing.T, ds *Datastore) { require.Equal(t, "foo.pkg", si.Name) // non-existent host - _, err = ds.InsertSoftwareInstallRequest(ctx, 12, si.InstallerID) + _, err = ds.InsertSoftwareInstallRequest(ctx, 12, si.InstallerID, false) require.ErrorAs(t, err, &nfe) // successful insert @@ -174,7 +211,7 @@ func testSoftwareInstallRequests(t *testing.T, ds *Datastore) { TeamID: teamID, }) require.NoError(t, err) - _, err = ds.InsertSoftwareInstallRequest(ctx, host.ID, si.InstallerID) + _, err = ds.InsertSoftwareInstallRequest(ctx, host.ID, si.InstallerID, false) require.NoError(t, err) // list hosts with software install requests @@ -270,7 +307,7 @@ func testGetSoftwareInstallResult(t *testing.T, ds *Datastore) { }) require.NoError(t, err) - installUUID, err := ds.InsertSoftwareInstallRequest(ctx, host.ID, installerID) + installUUID, err := ds.InsertSoftwareInstallRequest(ctx, host.ID, installerID, false) require.NoError(t, err) err = ds.SetHostSoftwareInstallResult(ctx, &fleet.HostSoftwareInstallResultPayload{ HostID: host.ID, diff --git a/server/datastore/mysql/software_test.go b/server/datastore/mysql/software_test.go index 163ed5a724..01327ea037 100644 --- a/server/datastore/mysql/software_test.go +++ b/server/datastore/mysql/software_test.go @@ -3045,20 +3045,35 @@ func testListHostSoftware(t *testing.T, ds *Datastore) { ctx := context.Background() host := test.NewHost(t, ds, "host1", "", "host1key", "host1uuid", time.Now(), test.WithPlatform("linux")) otherHost := test.NewHost(t, ds, "host2", "", "host2key", "host2uuid", time.Now()) - opts := fleet.ListOptions{PerPage: 10, IncludeMetadata: true, OrderKey: "name", TestSecondaryOrderKey: "source"} + opts := fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{PerPage: 10, IncludeMetadata: true, OrderKey: "name", TestSecondaryOrderKey: "source"}} expectStatus := func(s fleet.SoftwareInstallerStatus) *fleet.SoftwareInstallerStatus { return &s } // no software yet - sw, meta, err := ds.ListHostSoftware(ctx, host, false, opts) + sw, meta, err := ds.ListHostSoftware(ctx, host, opts) require.NoError(t, err) require.Empty(t, sw) require.Equal(t, &fleet.PaginationMetadata{}, meta) // works with available software too - sw, meta, err = ds.ListHostSoftware(ctx, host, true, opts) + opts.IncludeAvailableForInstall = true + sw, meta, err = ds.ListHostSoftware(ctx, host, opts) + require.NoError(t, err) + require.Empty(t, sw) + require.Equal(t, &fleet.PaginationMetadata{}, meta) + + // self-service only works too + opts.SelfServiceOnly = true + opts.IncludeAvailableForInstall = true + sw, meta, err = ds.ListHostSoftware(ctx, host, opts) + require.NoError(t, err) + require.Empty(t, sw) + require.Equal(t, &fleet.PaginationMetadata{}, meta) + + opts.IncludeAvailableForInstall = false + sw, meta, err = ds.ListHostSoftware(ctx, host, opts) require.NoError(t, err) require.Empty(t, sw) require.Equal(t, &fleet.PaginationMetadata{}, meta) @@ -3175,6 +3190,15 @@ func testListHostSoftware(t *testing.T, ds *Datastore) { require.True(t, ok) require.Equal(t, e.Name, g.Name) require.Equal(t, e.Source, g.Source) + if e.SelfService != nil { + // there is a software installer, so package information should be present + require.Equal(t, e.SelfService, g.SelfService) + require.NotNil(t, g.Package) + require.NotNil(t, g.PackageAvailableForInstall) + require.Equal(t, e.PackageAvailableForInstall, g.PackageAvailableForInstall) + require.Equal(t, *e.PackageAvailableForInstall, g.Package.Name) + require.NotEmpty(t, g.Package.Version) + } require.Len(t, g.InstalledVersions, len(e.InstalledVersions)) if len(e.InstalledVersions) > 0 { byVers := make(map[string]fleet.HostSoftwareInstalledVersion, len(e.InstalledVersions)) @@ -3201,7 +3225,9 @@ func testListHostSoftware(t *testing.T, ds *Datastore) { } // it now returns the software with vulnerabilities and installed paths - sw, meta, err = ds.ListHostSoftware(ctx, host, false, opts) + opts.SelfServiceOnly = false + opts.IncludeAvailableForInstall = false + sw, meta, err = ds.ListHostSoftware(ctx, host, opts) require.NoError(t, err) require.Equal(t, &fleet.PaginationMetadata{TotalResults: 5}, meta) compareResults(expected, sw, true) @@ -3258,16 +3284,17 @@ func testListHostSoftware(t *testing.T, ds *Datastore) { } res, err := q.ExecContext(ctx, ` INSERT INTO software_installers - (team_id, global_or_team_id, title_id, filename, version, install_script_content_id, storage_id, platform) + (team_id, global_or_team_id, title_id, filename, version, install_script_content_id, storage_id, platform, self_service) VALUES - (?, ?, ?, ?, ?, ?, unhex(?), ?)`, - teamID, globalOrTeamID, titleID, fmt.Sprintf("installer-%d.pkg", i), fmt.Sprintf("v%d.0.0", i), scriptContentID, hex.EncodeToString([]byte("test")), "linux") + (?, ?, ?, ?, ?, ?, unhex(?), ?, ?)`, + teamID, globalOrTeamID, titleID, fmt.Sprintf("installer-%d.pkg", i), fmt.Sprintf("v%d.0.0", i), scriptContentID, hex.EncodeToString([]byte("test")), "linux", i < 2) if err != nil { return err } id, _ := res.LastInsertId() swiIDs = append(swiIDs, uint(id)) } + // sw1Pending and swi2Installed are self-service installers swi1Pending, swi2Installed, swi3Failed, swi4Available, swi5Tm = swiIDs[0], swiIDs[1], swiIDs[2], swiIDs[3], swiIDs[4] // create the results for the host @@ -3341,6 +3368,7 @@ func testListHostSoftware(t *testing.T, ds *Datastore) { Status: expectStatus(fleet.SoftwareInstallerPending), LastInstall: &fleet.HostSoftwareInstall{InstallUUID: "uuid1"}, PackageAvailableForInstall: ptr.String("installer-0.pkg"), + SelfService: ptr.Bool(true), InstalledVersions: []*fleet.HostSoftwareInstalledVersion{ {Version: byNSV[b].Version, Vulnerabilities: []string{vulns[3].CVE}, InstalledPaths: []string{installPaths[2]}}, }, @@ -3350,6 +3378,7 @@ func testListHostSoftware(t *testing.T, ds *Datastore) { Source: "apps", Status: expectStatus(fleet.SoftwareInstallerInstalled), LastInstall: &fleet.HostSoftwareInstall{InstallUUID: "uuid2"}, + SelfService: ptr.Bool(true), PackageAvailableForInstall: ptr.String("installer-1.pkg"), } expected[i0.Name+i0.Source] = i0 @@ -3359,12 +3388,14 @@ func testListHostSoftware(t *testing.T, ds *Datastore) { Source: "apps", Status: expectStatus(fleet.SoftwareInstallerFailed), LastInstall: &fleet.HostSoftwareInstall{InstallUUID: "uuid3"}, + SelfService: ptr.Bool(false), PackageAvailableForInstall: ptr.String("installer-2.pkg"), } expected[i1.Name+i1.Source] = i1 // request without available software - sw, meta, err = ds.ListHostSoftware(ctx, host, false, opts) + opts.IncludeAvailableForInstall = false + sw, meta, err = ds.ListHostSoftware(ctx, host, opts) require.NoError(t, err) require.Equal(t, &fleet.PaginationMetadata{TotalResults: 7}, meta) compareResults(expected, sw, true) @@ -3376,6 +3407,7 @@ func testListHostSoftware(t *testing.T, ds *Datastore) { Status: nil, LastInstall: nil, PackageAvailableForInstall: ptr.String("installer-3.pkg"), + SelfService: ptr.Bool(false), } expected[i2.Name+i2.Source] = i2 @@ -3385,23 +3417,26 @@ func testListHostSoftware(t *testing.T, ds *Datastore) { Status: nil, LastInstall: nil, PackageAvailableForInstall: ptr.String("installer-4.pkg"), + SelfService: ptr.Bool(false), } expected[i3.Name+i3.Source] = i3 - sw, meta, err = ds.ListHostSoftware(ctx, host, true, opts) + opts.IncludeAvailableForInstall = true + sw, meta, err = ds.ListHostSoftware(ctx, host, opts) require.NoError(t, err) require.Equal(t, &fleet.PaginationMetadata{TotalResults: 8}, meta) compareResults(expected, sw, true, i3.Name+i3.Source) // request in descending order - opts.OrderDirection = fleet.OrderDescending - opts.TestSecondaryOrderDirection = fleet.OrderDescending - sw, meta, err = ds.ListHostSoftware(ctx, host, false, opts) + opts.ListOptions.OrderDirection = fleet.OrderDescending + opts.ListOptions.TestSecondaryOrderDirection = fleet.OrderDescending + opts.IncludeAvailableForInstall = false + sw, meta, err = ds.ListHostSoftware(ctx, host, opts) require.NoError(t, err) require.Equal(t, &fleet.PaginationMetadata{TotalResults: 7}, meta) compareResults(expected, sw, false, i2.Name+i2.Source, i3.Name+i3.Source) - opts.OrderDirection = fleet.OrderAscending - opts.TestSecondaryOrderDirection = fleet.OrderAscending + opts.ListOptions.OrderDirection = fleet.OrderAscending + opts.ListOptions.TestSecondaryOrderDirection = fleet.OrderAscending // record a new install request for i1, this time as pending, and mark install request for b (swi1) as failed time.Sleep(time.Second) // ensure the timestamp is later @@ -3430,6 +3465,7 @@ func testListHostSoftware(t *testing.T, ds *Datastore) { Status: expectStatus(fleet.SoftwareInstallerFailed), LastInstall: &fleet.HostSoftwareInstall{InstallUUID: "uuid1"}, PackageAvailableForInstall: ptr.String("installer-0.pkg"), + SelfService: ptr.Bool(true), InstalledVersions: []*fleet.HostSoftwareInstalledVersion{ {Version: byNSV[b].Version, Vulnerabilities: []string{vulns[3].CVE}, InstalledPaths: []string{installPaths[2]}}, }, @@ -3439,17 +3475,20 @@ func testListHostSoftware(t *testing.T, ds *Datastore) { Source: "apps", Status: expectStatus(fleet.SoftwareInstallerPending), LastInstall: &fleet.HostSoftwareInstall{InstallUUID: "uuid4"}, + SelfService: ptr.Bool(false), PackageAvailableForInstall: ptr.String("installer-2.pkg"), } // request without available software - sw, meta, err = ds.ListHostSoftware(ctx, host, false, opts) + opts.IncludeAvailableForInstall = false + sw, meta, err = ds.ListHostSoftware(ctx, host, opts) require.NoError(t, err) require.Equal(t, &fleet.PaginationMetadata{TotalResults: 7}, meta) compareResults(expected, sw, true, i2.Name+i2.Source, i3.Name+i3.Source) // request with available software) - sw, meta, err = ds.ListHostSoftware(ctx, host, true, opts) + opts.IncludeAvailableForInstall = true + sw, meta, err = ds.ListHostSoftware(ctx, host, opts) require.NoError(t, err) require.Equal(t, &fleet.PaginationMetadata{TotalResults: 8}, meta) compareResults(expected, sw, true, i3.Name+i3.Source) @@ -3460,13 +3499,15 @@ func testListHostSoftware(t *testing.T, ds *Datastore) { require.NoError(t, err) // no installed software for this host - sw, meta, err = ds.ListHostSoftware(ctx, tmHost, false, opts) + opts.IncludeAvailableForInstall = false + sw, meta, err = ds.ListHostSoftware(ctx, tmHost, opts) require.NoError(t, err) require.Empty(t, sw) require.Equal(t, &fleet.PaginationMetadata{}, meta) // sees the available installer in its team - sw, meta, err = ds.ListHostSoftware(ctx, tmHost, true, opts) + opts.IncludeAvailableForInstall = true + sw, meta, err = ds.ListHostSoftware(ctx, tmHost, opts) require.NoError(t, err) require.Equal(t, &fleet.PaginationMetadata{TotalResults: 1}, meta) compareResults(map[string]fleet.HostSoftwareWithInstaller{ @@ -3474,86 +3515,92 @@ func testListHostSoftware(t *testing.T, ds *Datastore) { }, sw, true) // test with a search query (searches on name), with and without available software - opts.MatchQuery = "a" - sw, _, err = ds.ListHostSoftware(ctx, host, false, opts) + opts.ListOptions.MatchQuery = "a" + opts.IncludeAvailableForInstall = false + sw, _, err = ds.ListHostSoftware(ctx, host, opts) require.NoError(t, err) compareResults(map[string]fleet.HostSoftwareWithInstaller{ byNSV[a1].Name + byNSV[a1].Source: expected[byNSV[a1].Name+byNSV[a1].Source], byNSV[a2].Name + byNSV[a2].Source: expected[byNSV[a2].Name+byNSV[a2].Source], }, sw, true) - sw, _, err = ds.ListHostSoftware(ctx, host, true, opts) + opts.IncludeAvailableForInstall = true + sw, _, err = ds.ListHostSoftware(ctx, host, opts) require.NoError(t, err) compareResults(map[string]fleet.HostSoftwareWithInstaller{ byNSV[a1].Name + byNSV[a1].Source: expected[byNSV[a1].Name+byNSV[a1].Source], byNSV[a2].Name + byNSV[a2].Source: expected[byNSV[a2].Name+byNSV[a2].Source], }, sw, true) - opts.MatchQuery = "zz" - sw, _, err = ds.ListHostSoftware(ctx, host, false, opts) + opts.ListOptions.MatchQuery = "zz" + opts.IncludeAvailableForInstall = false + sw, _, err = ds.ListHostSoftware(ctx, host, opts) require.NoError(t, err) require.Empty(t, sw) - sw, _, err = ds.ListHostSoftware(ctx, host, true, opts) + opts.IncludeAvailableForInstall = true + sw, _, err = ds.ListHostSoftware(ctx, host, opts) require.NoError(t, err) require.Empty(t, sw) // test the pagination cases := []struct { - opts fleet.ListOptions - withAvailable bool - wantNames []string - wantMeta *fleet.PaginationMetadata + opts fleet.HostSoftwareTitleListOptions + wantNames []string + wantMeta *fleet.PaginationMetadata }{ { - opts: fleet.ListOptions{PerPage: 3}, - withAvailable: false, - wantNames: []string{byNSV[a1].Name, byNSV[a2].Name, byNSV[b].Name}, - wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: false, TotalResults: 7}, + opts: fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{PerPage: 3}, IncludeAvailableForInstall: false}, + wantNames: []string{byNSV[a1].Name, byNSV[a2].Name, byNSV[b].Name}, + wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: false, TotalResults: 7}, }, { - opts: fleet.ListOptions{Page: 1, PerPage: 3}, - withAvailable: false, - wantNames: []string{byNSV[c1].Name, byNSV[d].Name, i0.Name}, - wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: true, TotalResults: 7}, + opts: fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{Page: 1, PerPage: 3}, IncludeAvailableForInstall: false}, + wantNames: []string{byNSV[c1].Name, byNSV[d].Name, i0.Name}, + wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: true, TotalResults: 7}, }, { - opts: fleet.ListOptions{Page: 2, PerPage: 3}, - withAvailable: false, - wantNames: []string{i1.Name}, - wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true, TotalResults: 7}, + opts: fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{Page: 2, PerPage: 3}, IncludeAvailableForInstall: false}, + wantNames: []string{i1.Name}, + wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true, TotalResults: 7}, }, { - opts: fleet.ListOptions{Page: 3, PerPage: 3}, - withAvailable: false, - wantNames: []string{}, - wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true, TotalResults: 7}, + opts: fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{Page: 3, PerPage: 3}, IncludeAvailableForInstall: false}, + wantNames: []string{}, + wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true, TotalResults: 7}, }, { - opts: fleet.ListOptions{PerPage: 4}, - withAvailable: true, - wantNames: []string{byNSV[a1].Name, byNSV[a2].Name, byNSV[b].Name, byNSV[c1].Name}, - wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: false, TotalResults: 8}, + opts: fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{PerPage: 4}, IncludeAvailableForInstall: true}, + wantNames: []string{byNSV[a1].Name, byNSV[a2].Name, byNSV[b].Name, byNSV[c1].Name}, + wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: false, TotalResults: 8}, }, { - opts: fleet.ListOptions{Page: 1, PerPage: 4}, - withAvailable: true, - wantNames: []string{byNSV[d].Name, i0.Name, i1.Name, i2.Name}, - wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true, TotalResults: 8}, + opts: fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{Page: 1, PerPage: 4}, IncludeAvailableForInstall: true}, + wantNames: []string{byNSV[d].Name, i0.Name, i1.Name, i2.Name}, + wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true, TotalResults: 8}, }, { - opts: fleet.ListOptions{Page: 2, PerPage: 4}, - withAvailable: true, - wantNames: []string{}, - wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true, TotalResults: 8}, + opts: fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{Page: 2, PerPage: 4}, IncludeAvailableForInstall: true}, + wantNames: []string{}, + wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true, TotalResults: 8}, + }, + { + opts: fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{PerPage: 2}, IncludeAvailableForInstall: true, SelfServiceOnly: true}, + wantNames: []string{byNSV[b].Name, i0.Name}, + wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: false, TotalResults: 2}, + }, + { + opts: fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{Page: 1, PerPage: 2}, IncludeAvailableForInstall: true, SelfServiceOnly: true}, + wantNames: []string{}, + wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true, TotalResults: 2}, }, } for _, c := range cases { - t.Run(fmt.Sprintf("%t: %#v", c.withAvailable, c.opts), func(t *testing.T) { + t.Run(fmt.Sprintf("%#v", c.opts), func(t *testing.T) { // always include metadata - c.opts.IncludeMetadata = true - c.opts.OrderKey = "name" - c.opts.TestSecondaryOrderKey = "source" + c.opts.ListOptions.IncludeMetadata = true + c.opts.ListOptions.OrderKey = "name" + c.opts.ListOptions.TestSecondaryOrderKey = "source" - sw, meta, err := ds.ListHostSoftware(ctx, host, c.withAvailable, c.opts) + sw, meta, err := ds.ListHostSoftware(ctx, host, c.opts) require.NoError(t, err) require.Equal(t, len(c.wantNames), len(sw)) diff --git a/server/datastore/mysql/software_titles.go b/server/datastore/mysql/software_titles.go index a74cf98c18..2364614534 100644 --- a/server/datastore/mysql/software_titles.go +++ b/server/datastore/mysql/software_titles.go @@ -35,7 +35,7 @@ SELECT MAX(sthc.updated_at) as counts_updated_at FROM software_titles st LEFT JOIN software_titles_host_counts sthc ON sthc.software_title_id = st.id AND %s -WHERE st.id = ? +WHERE st.id = ? AND (sthc.hosts_count > 0 OR EXISTS (SELECT 1 FROM software_installers si WHERE si.title_id = st.id AND si.global_or_team_id = ?)) GROUP BY st.id, @@ -202,9 +202,10 @@ SELECT st.browser, MAX(COALESCE(sthc.hosts_count, 0)) as hosts_count, MAX(COALESCE(sthc.updated_at, date('0001-01-01 00:00:00'))) as counts_updated_at, - si.filename as software_package + si.filename as software_package, + COALESCE(si.self_service, false) as self_service FROM software_titles st -LEFT JOIN software_installers si ON si.title_id = st.id AND COALESCE(si.team_id, 0) = ? +LEFT JOIN software_installers si ON si.title_id = st.id AND si.global_or_team_id = ? LEFT JOIN software_titles_host_counts sthc ON sthc.software_title_id = st.id AND sthc.team_id = ? -- placeholder for JOIN on software/software_cve %s @@ -212,19 +213,16 @@ LEFT JOIN software_titles_host_counts sthc ON sthc.software_title_id = st.id AND WHERE %s -- placeholder for filter based on software installed on hosts + software installers AND (%s) -GROUP BY st.id, software_package` +GROUP BY st.id, software_package, self_service` cveJoinType := "LEFT" if opt.VulnerableOnly { cveJoinType = "INNER" } - var globalOrTeamID uint args := []any{0, 0} if opt.TeamID != nil { - args[0] = *opt.TeamID - args[1] = *opt.TeamID - globalOrTeamID = *opt.TeamID + args[0], args[1] = *opt.TeamID, *opt.TeamID } additionalWhere := "TRUE" @@ -248,15 +246,9 @@ GROUP BY st.id, software_package` args = append(args, match, match) } + // default to "a software installer exists", and see next condition. defaultFilter := ` - EXISTS ( - SELECT 1 - FROM - software_installers si - WHERE - si.title_id = st.id - AND si.global_or_team_id = ? - ) + si.id IS NOT NULL ` // add software installed for hosts if any of this is true: @@ -264,10 +256,11 @@ GROUP BY st.id, software_package` // - we're not filtering for "available for install" only // - we're filtering by vulnerable only if !opt.AvailableForInstall || opt.VulnerableOnly { - defaultFilter += `OR sthc.hosts_count > 0` + defaultFilter = ` ( ` + defaultFilter + ` OR sthc.hosts_count > 0 ) ` + } + if opt.SelfServiceOnly { + defaultFilter += ` AND si.self_service = 1 ` } - - args = append(args, globalOrTeamID) stmt = fmt.Sprintf(stmt, softwareJoin, additionalWhere, defaultFilter) return stmt, args diff --git a/server/datastore/mysql/software_titles_test.go b/server/datastore/mysql/software_titles_test.go index ea70177055..cb837cb0a5 100644 --- a/server/datastore/mysql/software_titles_test.go +++ b/server/datastore/mysql/software_titles_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + "github.com/jmoiron/sqlx" "github.com/stretchr/testify/assert" "github.com/fleetdm/fleet/v4/server/fleet" @@ -297,6 +298,11 @@ func testOrderSoftwareTitles(t *testing.T, ds *Datastore) { }) require.NoError(t, err) require.NotZero(t, installer1) + // make installer1 "self-service" available + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + _, err := q.ExecContext(ctx, `UPDATE software_installers SET self_service = 1 WHERE id = ?`, installer1) + return err + }) // create a software installer with an install request on host1 installer2, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ Title: "installer2", @@ -305,7 +311,7 @@ func testOrderSoftwareTitles(t *testing.T, ds *Datastore) { Filename: "installer2.pkg", }) require.NoError(t, err) - _, err = ds.InsertSoftwareInstallRequest(ctx, host1.ID, installer2) + _, err = ds.InsertSoftwareInstallRequest(ctx, host1.ID, installer2, false) require.NoError(t, err) require.NoError(t, ds.SyncHostsSoftware(ctx, time.Now())) @@ -455,6 +461,16 @@ func testOrderSoftwareTitles(t *testing.T, ds *Datastore) { require.Equal(t, "apps", titles[0].Source) require.Equal(t, "installer1", titles[1].Name) require.Equal(t, "apps", titles[1].Source) + + // filter on self-service only + titles, _, _, err = ds.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{ListOptions: fleet.ListOptions{ + OrderKey: "name", + OrderDirection: fleet.OrderDescending, + }, SelfServiceOnly: true}, fleet.TeamFilter{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}}) + require.NoError(t, err) + require.Len(t, titles, 1) + require.Equal(t, "installer1", titles[0].Name) + require.Equal(t, "apps", titles[0].Source) } func listSoftwareTitlesCheckCount(t *testing.T, ds *Datastore, expectedListCount int, expectedFullCount int, opts fleet.SoftwareTitleListOptions) []fleet.SoftwareTitleListResult { @@ -509,6 +525,11 @@ func testTeamFilterSoftwareTitles(t *testing.T, ds *Datastore) { }) require.NoError(t, err) require.NotZero(t, installer1) + // make installer1 "self-service" available + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + _, err := q.ExecContext(ctx, `UPDATE software_installers SET self_service = 1 WHERE id = ?`, installer1) + return err + }) // create a software installer for team2 installer2, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ Title: "installer2", @@ -605,6 +626,24 @@ func testTeamFilterSoftwareTitles(t *testing.T, ds *Datastore) { require.Equal(t, uint(1), titles[0].VersionsCount) require.Equal(t, uint(1), titles[1].VersionsCount) require.Equal(t, uint(0), titles[2].VersionsCount) + + // Testing the team 1 user with self-service only + titles, _, _, err = ds.ListSoftwareTitles( + context.Background(), fleet.SoftwareTitleListOptions{ListOptions: fleet.ListOptions{}, SelfServiceOnly: true, TeamID: &team1.ID}, team1TeamFilter, + ) + // installer1 is associated with team 1 + require.NoError(t, err) + require.Len(t, titles, 1) + require.Equal(t, "installer1", titles[0].Name) + require.Equal(t, "apps", titles[0].Source) + + // Testing the team 2 user with self-service only + titles, _, _, err = ds.ListSoftwareTitles(context.Background(), fleet.SoftwareTitleListOptions{ListOptions: fleet.ListOptions{}, SelfServiceOnly: true, TeamID: &team2.ID}, fleet.TeamFilter{ + User: userTeam2Admin, + IncludeObserver: true, + }) + require.NoError(t, err) + require.Len(t, titles, 0) } func sortTitlesByName(titles []fleet.SoftwareTitleListResult) { diff --git a/server/fleet/activities.go b/server/fleet/activities.go index cfd15960c8..c3d2f3c0f0 100644 --- a/server/fleet/activities.go +++ b/server/fleet/activities.go @@ -1427,6 +1427,8 @@ type ActivityTypeInstalledSoftware struct { HostID uint `json:"host_id"` HostDisplayName string `json:"host_display_name"` SoftwareTitle string `json:"software_title"` + SoftwarePackage string `json:"software_package"` + SelfService bool `json:"self_service"` InstallUUID string `json:"install_uuid"` Status string `json:"status"` } @@ -1445,11 +1447,15 @@ func (a ActivityTypeInstalledSoftware) Documentation() (activity, details, detai - "host_id": ID of the host. - "host_display_name": Display name of the host. - "install_uuid": ID of the software installation. +- "self_service": Whether the installation was initiated by the end user. - "software_title": Name of the software. +- "software_package": Filename of the installer. - "status": Status of the software installation.`, `{ "host_id": 1, "host_display_name": "Anna's MacBook Pro", "software_title": "Falcon.app", + "software_package": "FalconSensor-6.44.pkg", + "self_service": true, "install_uuid": "d6cffa75-b5b5-41ef-9230-15073c8a88cf", "status": "pending" }` @@ -1460,6 +1466,7 @@ type ActivityTypeAddedSoftware struct { SoftwarePackage string `json:"software_package"` TeamName *string `json:"team_name"` TeamID *uint `json:"team_id"` + SelfService bool `json:"self_service"` } func (a ActivityTypeAddedSoftware) ActivityName() string { @@ -1471,14 +1478,14 @@ func (a ActivityTypeAddedSoftware) Documentation() (string, string, string) { - "software_title": Name of the software. - "software_package": Filename of the installer. - "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.`, - `{ +- "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": "Falcon.app", "software_package": "FalconSensor-6.44.pkg", "team_name": "Workstations", - "team_id": 123 -} -` + "team_id": 123, + "self_service": true +}` } type ActivityTypeDeletedSoftware struct { @@ -1486,6 +1493,7 @@ type ActivityTypeDeletedSoftware struct { SoftwarePackage string `json:"software_package"` TeamName *string `json:"team_name"` TeamID *uint `json:"team_id"` + SelfService bool `json:"self_service"` } func (a ActivityTypeDeletedSoftware) ActivityName() string { @@ -1497,14 +1505,14 @@ func (a ActivityTypeDeletedSoftware) Documentation() (string, string, string) { - "software_title": Name of the software. - "software_package": Filename of the installer. - "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.`, - `{ +- "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.`, `{ "software_title": "Falcon.app", "software_package": "FalconSensor-6.44.pkg", "team_name": "Workstations", - "team_id": 123 -} -` + "team_id": 123, + "self_service": true +}` } // LogRoleChangeActivities logs activities for each role change, globally and one for each change in teams. diff --git a/server/fleet/app.go b/server/fleet/app.go index 2b378be1f5..4c10781c60 100644 --- a/server/fleet/app.go +++ b/server/fleet/app.go @@ -1261,7 +1261,8 @@ type KafkaRESTConfig struct { // DeviceGlobalConfig is a subset of AppConfig with information used by the // device endpoints type DeviceGlobalConfig struct { - MDM DeviceGlobalMDMConfig `json:"mdm"` + MDM DeviceGlobalMDMConfig `json:"mdm"` + Features DeviceFeatures `json:"features"` } // DeviceGlobalMDMConfig is a subset of AppConfig.MDM with information used by @@ -1270,6 +1271,14 @@ type DeviceGlobalMDMConfig struct { EnabledAndConfigured bool `json:"enabled_and_configured"` } +// DeviceFeatures is a subset of AppConfig.Features with information used by +// the device endpoints. +type DeviceFeatures struct { + // EnableSoftwareInventory is the setting used by the device's team (or + // globally in the AppConfig if the device is not in any team). + EnableSoftwareInventory bool `json:"enable_software_inventory"` +} + // Version is the authz type used to check access control to the version endpoint. type Version struct{} diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index 4dd68620cc..89d129a439 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -499,7 +499,7 @@ type Datastore interface { // InsertSoftwareInstallRequest tracks a new request to install the provided // software installer in the host. It returns the auto-generated installation // uuid. - InsertSoftwareInstallRequest(ctx context.Context, hostID uint, softwareTitleID uint) (string, error) + InsertSoftwareInstallRequest(ctx context.Context, hostID uint, softwareTitleID uint, selfService bool) (string, error) /////////////////////////////////////////////////////////////////////////////// // SoftwareStore @@ -555,7 +555,7 @@ type Datastore interface { InsertCVEMeta(ctx context.Context, cveMeta []CVEMeta) error ListCVEs(ctx context.Context, maxAge time.Duration) ([]CVEMeta, error) - ListHostSoftware(ctx context.Context, host *Host, includeAvailableForInstall bool, opts ListOptions) ([]*HostSoftwareWithInstaller, *PaginationMetadata, error) + ListHostSoftware(ctx context.Context, host *Host, opts HostSoftwareTitleListOptions) ([]*HostSoftwareWithInstaller, *PaginationMetadata, error) // SetHostSoftwareInstallResult records the result of a software installation // attempt on the host. diff --git a/server/fleet/scripts.go b/server/fleet/scripts.go index 70ccb510d3..6f803f28c1 100644 --- a/server/fleet/scripts.go +++ b/server/fleet/scripts.go @@ -363,6 +363,7 @@ type SoftwareInstallerPayload struct { PreInstallQuery string `json:"pre_install_query"` InstallScript string `json:"install_script"` PostInstallScript string `json:"post_install_script"` + SelfService bool `json:"self_service"` } type HostLockWipeStatus struct { diff --git a/server/fleet/service.go b/server/fleet/service.go index 01d5a6bc55..732202b139 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -415,7 +415,7 @@ type Service interface { // ListHostSoftware lists the software installed or available for install on // the specified host. - ListHostSoftware(ctx context.Context, hostID uint, opts ListOptions) ([]*HostSoftwareWithInstaller, *PaginationMetadata, error) + ListHostSoftware(ctx context.Context, hostID uint, opts HostSoftwareTitleListOptions) ([]*HostSoftwareWithInstaller, *PaginationMetadata, error) // ///////////////////////////////////////////////////////////////////////////// // AppConfigService provides methods for configuring the Fleet application @@ -642,6 +642,10 @@ type Service interface { // specified team BatchSetSoftwareInstallers(ctx context.Context, tmName string, payloads []SoftwareInstallerPayload, dryRun bool) error + // SelfServiceInstallSoftwareTitle installs a software title + // initiated by the user + SelfServiceInstallSoftwareTitle(ctx context.Context, host *Host, softwareTitleID uint) error + // ///////////////////////////////////////////////////////////////////////////// // Vulnerabilities diff --git a/server/fleet/software.go b/server/fleet/software.go index 0842d74933..6cab811cc4 100644 --- a/server/fleet/software.go +++ b/server/fleet/software.go @@ -184,6 +184,8 @@ type SoftwareTitleListResult struct { CountsUpdatedAt *time.Time `json:"-" db:"counts_updated_at"` // SoftwarePackage is the filename of the installer for this software title. SoftwarePackage *string `json:"software_package" db:"software_package"` + // SelfService indicates if the end user can initiate the installation + SelfService bool `json:"self_service" db:"self_service"` } type SoftwareTitleListOptions struct { @@ -193,6 +195,22 @@ type SoftwareTitleListOptions struct { TeamID *uint `query:"team_id,optional"` VulnerableOnly bool `query:"vulnerable,optional"` AvailableForInstall bool `query:"available_for_install,optional"` + SelfServiceOnly bool `query:"self_service,optional"` +} + +type HostSoftwareTitleListOptions struct { + // ListOptions cannot be embedded in order to unmarshal with validation. + ListOptions ListOptions `url:"list_options"` + + // SelfServiceOnly limits the returned software titles to those that are + // available to install by the end user via the self-service. Implies + // AvailableForInstall. + SelfServiceOnly bool `query:"self_service,optional"` + + // IncludeAvailableForInstall is not a query argument, it is set in the + // service layer to indicate to the datastore if software available for + // install (but not currently installed on the host) should be returned. + IncludeAvailableForInstall bool } // AuthzSoftwareInventory is used for access controls on software inventory. diff --git a/server/fleet/software_installer.go b/server/fleet/software_installer.go index 068e539fc6..ee7f544d15 100644 --- a/server/fleet/software_installer.go +++ b/server/fleet/software_installer.go @@ -61,6 +61,8 @@ type SoftwareInstallDetails struct { InstallScript string `json:"install_script" db:"install_script"` // PostInstallScript is the script to run after installing the software package. PostInstallScript string `json:"post_install_script" db:"post_install_script"` + // SelfService indicates the install was initiated by the device user + SelfService bool `json:"self_service" db:"self_service"` } // SoftwareInstaller represents a software installer package that can be used to install software on @@ -95,6 +97,9 @@ type SoftwareInstaller struct { Status *SoftwareInstallerStatusSummary `json:"status,omitempty" db:"-"` // SoftwareTitle is the title of the software pointed installed by this installer. SoftwareTitle string `json:"-" db:"software_title"` + // SelfService indicates that the software can be installed by the + // end user without admin intervention + SelfService bool `json:"self_service" db:"self_service"` } // AuthzType implements authz.AuthzTyper. @@ -175,6 +180,9 @@ type HostSoftwareInstallerResult struct { InstallScriptExitCode *int `json:"-" db:"install_script_exit_code"` // PostInstallScriptExitCode is used internally to determine the output displayed to the user. PostInstallScriptExitCode *int `json:"-" db:"post_install_script_exit_code"` + // SelfService indicates that the installation was queued by the + // end user and not an administrator + SelfService bool `json:"self_service" db:"self_service"` } const ( @@ -252,6 +260,7 @@ type UploadSoftwareInstallerPayload struct { Version string Source string Platform string + SelfService bool } // DownloadSoftwareInstallerPayload is the payload for downloading a software installer. @@ -296,14 +305,26 @@ type HostSoftwareWithInstaller struct { ID uint `json:"id" db:"id"` Name string `json:"name" db:"name"` Source string `json:"source" db:"source"` + SelfService *bool `json:"self_service,omitempty" db:"self_service"` Status *SoftwareInstallerStatus `json:"status" db:"status"` LastInstall *HostSoftwareInstall `json:"last_install"` InstalledVersions []*HostSoftwareInstalledVersion `json:"installed_versions"` // PackageAvailableForInstall is only present for the user-authenticated - // endpoint, not the device-authenticated one. I.e. when - // available-but-not-installed software are part of the response. + // endpoint, not the device-authenticated one. PackageAvailableForInstall *string `json:"package_available_for_install,omitempty" db:"package_available_for_install"` + + // Package provides software installer package information, it is only + // present for the device-authenticated endpoint, not for the + // user-authenticated one. + Package *DeviceSoftwarePackage `json:"package,omitempty"` +} + +// DeviceSoftwarePackage provides information about a software installer +// package for self-service on a device. +type DeviceSoftwarePackage struct { + Name string `json:"name"` + Version string `json:"version"` } // HostSoftwareInstall represents installation of software on a host from a diff --git a/server/fleet/teams.go b/server/fleet/teams.go index d7c41f1d8b..984d295343 100644 --- a/server/fleet/teams.go +++ b/server/fleet/teams.go @@ -163,6 +163,7 @@ type TeamSpecSoftwareAsset struct { type TeamSpecSoftware struct { URL string `json:"url"` + SelfService bool `json:"self_service"` PreInstallQuery TeamSpecSoftwareAsset `json:"pre_install_query"` InstallScript TeamSpecSoftwareAsset `json:"install_script"` PostInstallScript TeamSpecSoftwareAsset `json:"post_install_script"` diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index 02c4a3ce40..32b3a98910 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -369,7 +369,7 @@ type ListSoftwareTitlesFunc func(ctx context.Context, opt fleet.SoftwareTitleLis type SoftwareTitleByIDFunc func(ctx context.Context, id uint, teamID *uint, tmFilter fleet.TeamFilter) (*fleet.SoftwareTitle, error) -type InsertSoftwareInstallRequestFunc func(ctx context.Context, hostID uint, softwareTitleID uint) (string, error) +type InsertSoftwareInstallRequestFunc func(ctx context.Context, hostID uint, softwareTitleID uint, selfService bool) (string, error) type ListSoftwareForVulnDetectionFunc func(ctx context.Context, hostID uint) ([]fleet.Software, error) @@ -405,7 +405,7 @@ type InsertCVEMetaFunc func(ctx context.Context, cveMeta []fleet.CVEMeta) error type ListCVEsFunc func(ctx context.Context, maxAge time.Duration) ([]fleet.CVEMeta, error) -type ListHostSoftwareFunc func(ctx context.Context, host *fleet.Host, includeAvailableForInstall bool, opts fleet.ListOptions) ([]*fleet.HostSoftwareWithInstaller, *fleet.PaginationMetadata, error) +type ListHostSoftwareFunc func(ctx context.Context, host *fleet.Host, opts fleet.HostSoftwareTitleListOptions) ([]*fleet.HostSoftwareWithInstaller, *fleet.PaginationMetadata, error) type SetHostSoftwareInstallResultFunc func(ctx context.Context, result *fleet.HostSoftwareInstallResultPayload) error @@ -3613,11 +3613,11 @@ func (s *DataStore) SoftwareTitleByID(ctx context.Context, id uint, teamID *uint return s.SoftwareTitleByIDFunc(ctx, id, teamID, tmFilter) } -func (s *DataStore) InsertSoftwareInstallRequest(ctx context.Context, hostID uint, softwareTitleID uint) (string, error) { +func (s *DataStore) InsertSoftwareInstallRequest(ctx context.Context, hostID uint, softwareTitleID uint, selfService bool) (string, error) { s.mu.Lock() s.InsertSoftwareInstallRequestFuncInvoked = true s.mu.Unlock() - return s.InsertSoftwareInstallRequestFunc(ctx, hostID, softwareTitleID) + return s.InsertSoftwareInstallRequestFunc(ctx, hostID, softwareTitleID, selfService) } func (s *DataStore) ListSoftwareForVulnDetection(ctx context.Context, hostID uint) ([]fleet.Software, error) { @@ -3739,11 +3739,11 @@ func (s *DataStore) ListCVEs(ctx context.Context, maxAge time.Duration) ([]fleet return s.ListCVEsFunc(ctx, maxAge) } -func (s *DataStore) ListHostSoftware(ctx context.Context, host *fleet.Host, includeAvailableForInstall bool, opts fleet.ListOptions) ([]*fleet.HostSoftwareWithInstaller, *fleet.PaginationMetadata, error) { +func (s *DataStore) ListHostSoftware(ctx context.Context, host *fleet.Host, opts fleet.HostSoftwareTitleListOptions) ([]*fleet.HostSoftwareWithInstaller, *fleet.PaginationMetadata, error) { s.mu.Lock() s.ListHostSoftwareFuncInvoked = true s.mu.Unlock() - return s.ListHostSoftwareFunc(ctx, host, includeAvailableForInstall, opts) + return s.ListHostSoftwareFunc(ctx, host, opts) } func (s *DataStore) SetHostSoftwareInstallResult(ctx context.Context, result *fleet.HostSoftwareInstallResultPayload) error { diff --git a/server/service/client.go b/server/service/client.go index 53fb01a653..69a1537f0d 100644 --- a/server/service/client.go +++ b/server/service/client.go @@ -616,6 +616,7 @@ func (c *Client) ApplyGroup( softwarePayloads[i] = fleet.SoftwareInstallerPayload{ URL: si.URL, + SelfService: si.SelfService, PreInstallQuery: qc, InstallScript: string(ic), PostInstallScript: string(pc), diff --git a/server/service/device_client.go b/server/service/device_client.go index 8d89378ee4..64e3c36c58 100644 --- a/server/service/device_client.go +++ b/server/service/device_client.go @@ -125,6 +125,15 @@ func (dc *DeviceClient) BrowserTransparencyURL(token string) string { return transparencyURL.String() } +// BrowserSelfServiceURL returns the "Self-service" URL for the browser. +func (dc *DeviceClient) BrowserSelfServiceURL(token string) string { + selfServiceURL := dc.baseClient.url("/device/"+token+"/self-service", "") + if dc.fleetAlternativeBrowserHost != "" { + selfServiceURL.Host = dc.fleetAlternativeBrowserHost + } + return selfServiceURL.String() +} + // BrowserDeviceURL returns the "My device" URL for the browser. func (dc *DeviceClient) BrowserDeviceURL(token string) string { deviceURL := dc.baseClient.url("/device/"+token, "") diff --git a/server/service/devices.go b/server/service/devices.go index 55239a91c6..01d463d5ef 100644 --- a/server/service/devices.go +++ b/server/service/devices.go @@ -110,6 +110,7 @@ type getDeviceHostResponse struct { Host *HostDetailResponse `json:"host"` OrgLogoURL string `json:"org_logo_url"` OrgLogoURLLightBackground string `json:"org_logo_url_light_background"` + OrgContactURL string `json:"org_contact_url"` Err error `json:"error,omitempty"` License fleet.LicenseInfo `json:"license"` GlobalConfig fleet.DeviceGlobalConfig `json:"global_config"` @@ -152,12 +153,6 @@ func getDeviceHostEndpoint(ctx context.Context, request interface{}, svc fleet.S return getDeviceHostResponse{Err: err}, nil } - deviceGlobalConfig := fleet.DeviceGlobalConfig{ - MDM: fleet.DeviceGlobalMDMConfig{ - EnabledAndConfigured: ac.MDM.EnabledAndConfigured, - }, - } - resp.DEPAssignedToFleet = ptr.Bool(false) if ac.MDM.EnabledAndConfigured && license.IsPremium() { hdep, err := svc.GetHostDEPAssignment(ctx, host) @@ -167,11 +162,36 @@ func getDeviceHostEndpoint(ctx context.Context, request interface{}, svc fleet.S resp.DEPAssignedToFleet = ptr.Bool(hdep.IsDEPAssignedToFleet()) } + softwareInventoryEnabled := ac.Features.EnableSoftwareInventory + if resp.TeamID != nil { + // load the team to get the device's team's software inventory config. + tm, err := svc.GetTeam(ctx, *resp.TeamID) + if err != nil && !fleet.IsNotFound(err) { + return getDeviceHostResponse{Err: err}, nil + } + if tm != nil { + softwareInventoryEnabled = tm.Config.Features.EnableSoftwareInventory + } + } + + deviceGlobalConfig := fleet.DeviceGlobalConfig{ + MDM: fleet.DeviceGlobalMDMConfig{ + // TODO(mna): It currently only returns the Apple enabled and configured, + // regardless of the platform of the device. See + // https://github.com/fleetdm/fleet/pull/19304#discussion_r1618792410. + EnabledAndConfigured: ac.MDM.EnabledAndConfigured, + }, + Features: fleet.DeviceFeatures{ + EnableSoftwareInventory: softwareInventoryEnabled, + }, + } + return getDeviceHostResponse{ - Host: resp, - OrgLogoURL: ac.OrgInfo.OrgLogoURL, - License: *license, - GlobalConfig: deviceGlobalConfig, + Host: resp, + OrgLogoURL: ac.OrgInfo.OrgLogoURL, + OrgContactURL: ac.OrgInfo.ContactURL, + License: *license, + GlobalConfig: deviceGlobalConfig, }, nil } @@ -601,8 +621,8 @@ func (svc *Service) TriggerMigrateMDMDevice(ctx context.Context, host *fleet.Hos //////////////////////////////////////////////////////////////////////////////// type getDeviceSoftwareRequest struct { - Token string `url:"token"` - ListOptions fleet.ListOptions `url:"list_options"` + Token string `url:"token"` + fleet.HostSoftwareTitleListOptions } func (r *getDeviceSoftwareRequest) deviceAuthToken() string { @@ -626,7 +646,7 @@ func getDeviceSoftwareEndpoint(ctx context.Context, request interface{}, svc fle } req := request.(*getDeviceSoftwareRequest) - res, meta, err := svc.ListHostSoftware(ctx, host.ID, req.ListOptions) + res, meta, err := svc.ListHostSoftware(ctx, host.ID, req.HostSoftwareTitleListOptions) if err != nil { return getDeviceSoftwareResponse{Err: err}, nil } diff --git a/server/service/handler.go b/server/service/handler.go index 7f4484f990..22d9e09a8f 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -776,6 +776,9 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC de.WithCustomMiddleware( errorLimiter.Limit("get_device_software", desktopQuota), ).GET("/api/_version_/fleet/device/{token}/software", getDeviceSoftwareEndpoint, getDeviceSoftwareRequest{}) + de.WithCustomMiddleware( + errorLimiter.Limit("install_self_service", desktopQuota), + ).POST("/api/_version_/fleet/device/{token}/software/install/{software_title_id}", submitSelfServiceSoftwareInstall, fleetSelfServiceSoftwareInstallRequest{}) // mdm-related endpoints available via device authentication demdm := de.WithCustomMiddleware(mdmConfiguredMiddleware.VerifyAppleMDM()) diff --git a/server/service/hosts.go b/server/service/hosts.go index a30cb412fa..530c7fd829 100644 --- a/server/service/hosts.go +++ b/server/service/hosts.go @@ -2462,8 +2462,8 @@ func (svc *Service) validateLabelNames(ctx context.Context, action string, label //////////////////////////////////////////////////////////////////////////////// type getHostSoftwareRequest struct { - ID uint `url:"id"` - ListOptions fleet.ListOptions `url:"list_options"` + ID uint `url:"id"` + fleet.HostSoftwareTitleListOptions } type getHostSoftwareResponse struct { @@ -2477,7 +2477,7 @@ func (r getHostSoftwareResponse) error() error { return r.Err } func getHostSoftwareEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { req := request.(*getHostSoftwareRequest) - res, meta, err := svc.ListHostSoftware(ctx, req.ID, req.ListOptions) + res, meta, err := svc.ListHostSoftware(ctx, req.ID, req.HostSoftwareTitleListOptions) if err != nil { return getHostSoftwareResponse{Err: err}, nil } @@ -2487,9 +2487,11 @@ func getHostSoftwareEndpoint(ctx context.Context, request interface{}, svc fleet return getHostSoftwareResponse{Software: res, Meta: meta, Count: int(meta.TotalResults)}, nil } -func (svc *Service) ListHostSoftware(ctx context.Context, hostID uint, opts fleet.ListOptions) ([]*fleet.HostSoftwareWithInstaller, *fleet.PaginationMetadata, error) { - // if the request is token-authenticated ("My device" page), we don't include software - // that is not installed but for which there's an installer available for that host. +func (svc *Service) ListHostSoftware(ctx context.Context, hostID uint, opts fleet.HostSoftwareTitleListOptions) ([]*fleet.HostSoftwareWithInstaller, *fleet.PaginationMetadata, error) { + // if the request is token-authenticated ("My device" page), we don't include + // software that is not installed but for which there's an installer + // available for that host (unless the request filters for self-service + // software only). var includeAvailableForInstall bool var host *fleet.Host @@ -2519,15 +2521,23 @@ func (svc *Service) ListHostSoftware(ctx context.Context, hostID uint, opts flee } // cursor-based pagination is not supported - opts.After = "" + opts.ListOptions.After = "" // custom ordering is not supported, always by name (but asc/desc is configurable) - opts.OrderKey = "name" + opts.ListOptions.OrderKey = "name" // always include metadata - opts.IncludeMetadata = true + opts.ListOptions.IncludeMetadata = true + opts.IncludeAvailableForInstall = includeAvailableForInstall || opts.SelfServiceOnly - software, meta, err := svc.ds.ListHostSoftware(ctx, host, includeAvailableForInstall, opts) - if !includeAvailableForInstall { - // for the device page, we don't want to return the package name + software, meta, err := svc.ds.ListHostSoftware(ctx, host, opts) + if includeAvailableForInstall { + // for the host software page, we don't want to return the package object, + // only the package name + for _, s := range software { + s.Package = nil + } + } else { + // for the device page, we don't want to return the package name, only the + // package object for _, s := range software { s.PackageAvailableForInstall = nil } diff --git a/server/service/hosts_test.go b/server/service/hosts_test.go index 511dc29d97..bc07f5d7d2 100644 --- a/server/service/hosts_test.go +++ b/server/service/hosts_test.go @@ -617,7 +617,7 @@ func TestHostAuth(t *testing.T) { ds.GetHostLockWipeStatusFunc = func(ctx context.Context, host *fleet.Host) (*fleet.HostLockWipeStatus, error) { return &fleet.HostLockWipeStatus{}, nil } - ds.ListHostSoftwareFunc = func(ctx context.Context, host *fleet.Host, includeAvailableForInstall bool, opts fleet.ListOptions) ([]*fleet.HostSoftwareWithInstaller, *fleet.PaginationMetadata, error) { + ds.ListHostSoftwareFunc = func(ctx context.Context, host *fleet.Host, opts fleet.HostSoftwareTitleListOptions) ([]*fleet.HostSoftwareWithInstaller, *fleet.PaginationMetadata, error) { return nil, nil, nil } @@ -762,10 +762,10 @@ func TestHostAuth(t *testing.T) { _, err = svc.SetCustomHostDeviceMapping(ctx, 2, "a@b.c") checkAuthErr(t, tt.shouldFailGlobalWrite, err) - _, _, err = svc.ListHostSoftware(ctx, 1, fleet.ListOptions{}) + _, _, err = svc.ListHostSoftware(ctx, 1, fleet.HostSoftwareTitleListOptions{}) checkAuthErr(t, tt.shouldFailTeamRead, err) - _, _, err = svc.ListHostSoftware(ctx, 2, fleet.ListOptions{}) + _, _, err = svc.ListHostSoftware(ctx, 2, fleet.HostSoftwareTitleListOptions{}) checkAuthErr(t, tt.shouldFailGlobalRead, err) }) } diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index 5347ca84bd..a5af5c5d36 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -6672,7 +6672,7 @@ func (s *integrationTestSuite) TestListSoftwareAndSoftwareDetails() { // create a bunch of software sws := make([]fleet.Software, 20) for i := range sws { - sw := fleet.Software{Name: "sw" + strconv.Itoa(i), Version: "0.0." + strconv.Itoa(i), Source: "apps"} + sw := fleet.Software{Name: fmt.Sprintf("sw%02d", i), Version: fmt.Sprintf("0.0.%02d", i), Source: "apps"} if i%2 == 0 { sw.Source = "chrome_extensions" sw.Browser = "chrome" @@ -11254,7 +11254,7 @@ func (s *integrationTestSuite) TestListHostUpcomingActivities() { require.NoError(t, err) s1Meta, err := s.ds.GetSoftwareInstallerMetadataByID(ctx, sw1) require.NoError(t, err) - h1Foo, err := s.ds.InsertSoftwareInstallRequest(ctx, host1.ID, s1Meta.InstallerID) + h1Foo, err := s.ds.InsertSoftwareInstallRequest(ctx, host1.ID, s1Meta.InstallerID, false) require.NoError(t, err) // force an order to the activities diff --git a/server/service/integration_desktop_test.go b/server/service/integration_desktop_test.go index d3d994fd05..6a540eda51 100644 --- a/server/service/integration_desktop_test.go +++ b/server/service/integration_desktop_test.go @@ -22,6 +22,8 @@ func (s *integrationTestSuite) TestDeviceAuthenticatedEndpoints() { ac, err := s.ds.AppConfig(context.Background()) require.NoError(t, err) ac.OrgInfo.OrgLogoURL = "http://example.com/logo" + ac.OrgInfo.ContactURL = "http://example.com/contact" + ac.Features.EnableSoftwareInventory = true err = s.ds.SaveAppConfig(context.Background(), ac) require.NoError(t, err) @@ -74,10 +76,12 @@ func (s *integrationTestSuite) TestDeviceAuthenticatedEndpoints() { require.Equal(t, hosts[0].ID, getHostResp.Host.ID) require.False(t, getHostResp.Host.RefetchRequested) require.Equal(t, "http://example.com/logo", getHostResp.OrgLogoURL) + require.Equal(t, "http://example.com/contact", getHostResp.OrgContactURL) require.Nil(t, getHostResp.Host.Policies) require.NotNil(t, getHostResp.Host.Batteries) require.Equal(t, &fleet.HostBattery{CycleCount: 1, Health: "Normal"}, (*getHostResp.Host.Batteries)[0]) require.True(t, getHostResp.GlobalConfig.MDM.EnabledAndConfigured) + require.True(t, getHostResp.GlobalConfig.Features.EnableSoftwareInventory) hostDevResp := getHostResp.Host // make request for same host on the host details API endpoint, diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index 279efe8df2..6993bc6eb0 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -80,7 +80,11 @@ func (s *integrationEnterpriseTestSuite) SetupSuite() { return func() (fleet.CronSchedule, error) { // We set 24-hour interval so that it only runs when triggered. var err error - calendarSchedule, err = cron.NewCalendarSchedule(ctx, s.T().Name(), s.ds, 24*time.Hour, log.NewJSONLogger(os.Stdout)) + cronLog := log.NewJSONLogger(os.Stdout) + if os.Getenv("FLEET_INTEGRATION_TESTS_DISABLE_LOG") != "" { + cronLog = kitlog.NewNopLogger() + } + calendarSchedule, err = cron.NewCalendarSchedule(ctx, s.T().Name(), s.ds, 24*time.Hour, cronLog) return calendarSchedule, err } }, @@ -2471,14 +2475,22 @@ func (s *integrationEnterpriseTestSuite) TestMacOSUpdatesTeamConfig() { func (s *integrationEnterpriseTestSuite) TestListDevicePolicies() { t := s.T() + ctx := context.Background() // set the logo via the modify appconfig endpoint, so that the cache is // properly updated. var acResp appConfigResponse - s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{"org_info":{"org_logo_url": "http://example.com/logo"}}`), http.StatusOK, &acResp) + s.DoJSON("PATCH", "/api/latest/fleet/config", + json.RawMessage(`{ + "org_info":{ + "org_logo_url": "http://example.com/logo", + "contact_url": "http://example.com/contact" + } + }`), http.StatusOK, &acResp) require.Equal(t, "http://example.com/logo", acResp.OrgInfo.OrgLogoURL) + require.Equal(t, "http://example.com/contact", acResp.OrgInfo.ContactURL) - team, err := s.ds.NewTeam(context.Background(), &fleet.Team{ + team, err := s.ds.NewTeam(ctx, &fleet.Team{ ID: 51, Name: "team1-policies", Description: "desc team1", @@ -2487,10 +2499,10 @@ func (s *integrationEnterpriseTestSuite) TestListDevicePolicies() { token := "much_valid" host := createHostAndDeviceToken(t, s.ds, token) - err = s.ds.AddHostsToTeam(context.Background(), &team.ID, []uint{host.ID}) + err = s.ds.AddHostsToTeam(ctx, &team.ID, []uint{host.ID}) require.NoError(t, err) - qr, err := s.ds.NewQuery(context.Background(), &fleet.Query{ + qr, err := s.ds.NewQuery(ctx, &fleet.Query{ Name: "TestQueryEnterpriseGlobalPolicy", Description: "Some description", Query: "select * from osquery;", @@ -2509,7 +2521,7 @@ func (s *integrationEnterpriseTestSuite) TestListDevicePolicies() { require.NotNil(t, gpResp.Policy) // add a policy execution - require.NoError(t, s.ds.RecordPolicyQueryExecutions(context.Background(), host, + require.NoError(t, s.ds.RecordPolicyQueryExecutions(ctx, host, map[uint]*bool{gpResp.Policy.ID: ptr.Bool(false)}, time.Now(), false)) // add a policy to team @@ -2534,7 +2546,7 @@ func (s *integrationEnterpriseTestSuite) TestListDevicePolicies() { } require.NoError(t, u.SetPassword(password, 10, 10)) - _, err = s.ds.NewUser(context.Background(), u) + _, err = s.ds.NewUser(ctx, u) require.NoError(t, err) s.token = s.getTestToken(email, password) @@ -2574,7 +2586,9 @@ func (s *integrationEnterpriseTestSuite) TestListDevicePolicies() { require.Equal(t, host.ID, getDeviceHostResp.Host.ID) require.False(t, getDeviceHostResp.Host.RefetchRequested) require.Equal(t, "http://example.com/logo", getDeviceHostResp.OrgLogoURL) + require.Equal(t, "http://example.com/contact", getDeviceHostResp.OrgContactURL) require.Len(t, *getDeviceHostResp.Host.Policies, 2) + require.False(t, getDeviceHostResp.GlobalConfig.Features.EnableSoftwareInventory) // GET `/api/_version_/fleet/device/{token}/desktop` getDesktopResp := fleetDesktopResponse{} @@ -2586,6 +2600,17 @@ func (s *integrationEnterpriseTestSuite) TestListDevicePolicies() { require.NoError(t, getDesktopResp.Err) require.Equal(t, *getDesktopResp.FailingPolicies, uint(1)) require.False(t, getDesktopResp.Notifications.NeedsMDMMigration) + + // update the team to enable software inventory + team.Config.Features.EnableSoftwareInventory = true + _, err = s.ds.SaveTeam(ctx, team) + require.NoError(t, err) + + getDeviceHostResp = getDeviceHostResponse{} + res = s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token, nil, http.StatusOK) + err = json.NewDecoder(res.Body).Decode(&getDeviceHostResp) + require.NoError(t, err) + require.True(t, getDeviceHostResp.GlobalConfig.Features.EnableSoftwareInventory) } // TestCustomTransparencyURL tests that Fleet Premium licensees can use custom transparency urls. @@ -7010,6 +7035,16 @@ func (s *integrationEnterpriseTestSuite) TestAllSoftwareTitles() { sort.Slice(want, func(i, j int) bool { return want[i].Name < want[j].Name }) + for _, v := range got { + sort.Slice(v.Versions, func(i, j int) bool { + return v.Versions[i].Version < v.Versions[j].Version + }) + } + for _, v := range want { + sort.Slice(v.Versions, func(i, j int) bool { + return v.Versions[i].Version < v.Versions[j].Version + }) + } require.EqualValues(t, want, got) } @@ -7124,6 +7159,9 @@ func (s *integrationEnterpriseTestSuite) TestAllSoftwareTitles() { require.NoError(t, s.ds.SyncHostsSoftwareTitles(ctx, hostsCountTs)) var resp listSoftwareTitlesResponse + // no self-service software yet + s.DoJSON("GET", "/api/latest/fleet/software/titles", listSoftwareTitlesRequest{}, http.StatusOK, &resp, "self_service", "1") + require.Empty(t, resp.SoftwareTitles) s.DoJSON("GET", "/api/latest/fleet/software/titles", listSoftwareTitlesRequest{}, http.StatusOK, &resp) require.Equal(t, 2, resp.Count) require.NotEmpty(t, resp.CountsUpdatedAt) @@ -7137,6 +7175,7 @@ func (s *integrationEnterpriseTestSuite) TestAllSoftwareTitles() { {Version: "0.0.1", Vulnerabilities: nil}, {Version: "0.0.3", Vulnerabilities: nil}, }, + SelfService: false, }, { Name: "bar", @@ -7146,6 +7185,7 @@ func (s *integrationEnterpriseTestSuite) TestAllSoftwareTitles() { Versions: []fleet.SoftwareVersion{ {Version: "0.0.4", Vulnerabilities: &fleet.SliceString{"cve-123-123-132"}}, }, + SelfService: false, }, }, resp.SoftwareTitles) @@ -7626,12 +7666,20 @@ func (s *integrationEnterpriseTestSuite) TestAllSoftwareTitles() { ) // verify that software installers contain SoftwarePackage field - payload := &fleet.UploadSoftwareInstallerPayload{ + payloadRubyTm1 := &fleet.UploadSoftwareInstallerPayload{ InstallScript: "install", Filename: "ruby.deb", + SelfService: false, TeamID: &team1.ID, } - s.uploadSoftwareInstaller(payload, http.StatusOK, "") + s.uploadSoftwareInstaller(payloadRubyTm1, http.StatusOK, "") + + payloadEmacs := &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "install", + Filename: "emacs.deb", + SelfService: true, + } + s.uploadSoftwareInstaller(payloadEmacs, http.StatusOK, "") resp = listSoftwareTitlesResponse{} s.DoJSON( @@ -7647,15 +7695,14 @@ func (s *integrationEnterpriseTestSuite) TestAllSoftwareTitles() { require.Equal(t, "ruby.deb", *resp.SoftwareTitles[0].SoftwarePackage) // Upload an installer for the same software but different arch to a different team - payload = &fleet.UploadSoftwareInstallerPayload{ + payloadRubyTm2 := &fleet.UploadSoftwareInstallerPayload{ InstallScript: "install", Filename: "ruby_arm64.deb", TeamID: &team2.ID, } - s.uploadSoftwareInstaller(payload, http.StatusOK, "") + s.uploadSoftwareInstaller(payloadRubyTm2, http.StatusOK, "") // We should only see the one we uploaded to team 1 - resp = listSoftwareTitlesResponse{} s.DoJSON( "GET", "/api/latest/fleet/software/titles", @@ -7664,10 +7711,50 @@ func (s *integrationEnterpriseTestSuite) TestAllSoftwareTitles() { "query", "ruby", "team_id", fmt.Sprintf("%d", team1.ID), ) - require.Len(t, resp.SoftwareTitles, 1) require.NotNil(t, resp.SoftwareTitles[0].SoftwarePackage) require.Equal(t, "ruby.deb", *resp.SoftwareTitles[0].SoftwarePackage) + + // software installer not returned with self-service only (not marked as such) + resp = listSoftwareTitlesResponse{} + s.DoJSON("GET", "/api/latest/fleet/software/titles", listSoftwareTitlesRequest{}, http.StatusOK, &resp, + "self_service", "1", "query", "ruby", "team_id", fmt.Sprint(team1.ID)) + require.Len(t, resp.SoftwareTitles, 0) + + // update it to be self-service, check that it gets returned + mysql.ExecAdhocSQL(t, s.ds, func(tx sqlx.ExtContext) error { + _, err := tx.ExecContext(ctx, "UPDATE software_installers SET self_service = 1 WHERE filename = ?", payloadRubyTm1.Filename) + return err + }) + resp = listSoftwareTitlesResponse{} + s.DoJSON("GET", "/api/latest/fleet/software/titles", listSoftwareTitlesRequest{}, http.StatusOK, &resp, + "self_service", "1", "query", "ruby", "team_id", fmt.Sprint(team1.ID)) + require.Len(t, resp.SoftwareTitles, 1) + require.NotNil(t, resp.SoftwareTitles[0].SoftwarePackage) + require.Equal(t, "ruby.deb", *resp.SoftwareTitles[0].SoftwarePackage) + require.True(t, *&resp.SoftwareTitles[0].SelfService) + + // no team but self-service returns the emacs software (technically impossible via the UI) + resp = listSoftwareTitlesResponse{} + s.DoJSON( + "GET", "/api/latest/fleet/software/titles", + listSoftwareTitlesRequest{}, + http.StatusOK, &resp, + "self_service", "true", + ) + + require.Len(t, resp.SoftwareTitles, 1) + require.NotNil(t, resp.SoftwareTitles[0].SoftwarePackage) + require.Equal(t, "emacs.deb", *resp.SoftwareTitles[0].SoftwarePackage) + require.True(t, *&resp.SoftwareTitles[0].SelfService) + + emacsPath := fmt.Sprintf("/api/latest/fleet/software/titles/%d", resp.SoftwareTitles[0].ID) + respTitle := getSoftwareTitleResponse{} + s.DoJSON("GET", emacsPath, listSoftwareTitlesRequest{}, http.StatusOK, &respTitle) + + require.NotNil(t, respTitle.SoftwareTitle) + require.Equal(t, "emacs.deb", respTitle.SoftwareTitle.SoftwarePackage.Name) + require.True(t, respTitle.SoftwareTitle.SoftwarePackage.SelfService) } func (s *integrationEnterpriseTestSuite) TestLockUnlockWipeWindowsLinux() { @@ -8940,12 +9027,18 @@ func (s *integrationEnterpriseTestSuite) TestListHostSoftware() { var getHostSw getHostSoftwareResponse s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host.ID), nil, http.StatusOK, &getHostSw) require.Len(t, getHostSw.Software, 0) + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host.ID), nil, http.StatusOK, &getHostSw, "self_service", "true") + require.Len(t, getHostSw.Software, 0) var getDeviceSw getDeviceSoftwareResponse res := s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/software", nil, http.StatusOK) err := json.NewDecoder(res.Body).Decode(&getDeviceSw) require.NoError(t, err) require.Len(t, getDeviceSw.Software, 0) + res = s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/software?self_service=1", nil, http.StatusOK) + err = json.NewDecoder(res.Body).Decode(&getDeviceSw) + require.NoError(t, err) + require.Len(t, getDeviceSw.Software, 0) // create some software for that host software := []fleet.Software{ @@ -8964,6 +9057,13 @@ func (s *integrationEnterpriseTestSuite) TestListHostSoftware() { require.Equal(t, getHostSw.Software[0].Name, "bar") require.Equal(t, getHostSw.Software[1].Name, "foo") require.Len(t, getHostSw.Software[1].InstalledVersions, 2) + // no package information as there is no installer + require.Nil(t, getHostSw.Software[0].SelfService) + require.Nil(t, getHostSw.Software[0].Package) + require.Nil(t, getHostSw.Software[0].PackageAvailableForInstall) + require.Nil(t, getHostSw.Software[1].SelfService) + require.Nil(t, getHostSw.Software[1].Package) + require.Nil(t, getHostSw.Software[1].PackageAvailableForInstall) res = s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/software", nil, http.StatusOK) getDeviceSw = getDeviceSoftwareResponse{} @@ -8973,26 +9073,52 @@ func (s *integrationEnterpriseTestSuite) TestListHostSoftware() { require.Equal(t, getDeviceSw.Software[0].Name, "bar") require.Equal(t, getDeviceSw.Software[1].Name, "foo") require.Len(t, getDeviceSw.Software[1].InstalledVersions, 2) + // no package information as there is no installer + require.Nil(t, getHostSw.Software[0].SelfService) + require.Nil(t, getHostSw.Software[0].Package) + require.Nil(t, getHostSw.Software[0].PackageAvailableForInstall) + require.Nil(t, getHostSw.Software[1].SelfService) + require.Nil(t, getHostSw.Software[1].Package) + require.Nil(t, getHostSw.Software[1].PackageAvailableForInstall) // create a software installer, not installed on the host payload := &fleet.UploadSoftwareInstallerPayload{ InstallScript: "install", Filename: "ruby.deb", + Version: "1:2.5.1", } s.uploadSoftwareInstaller(payload, http.StatusOK, "") titleID := getSoftwareTitleID(t, s.ds, "ruby", "deb_packages") + // update it to be self-service + mysql.ExecAdhocSQL(t, s.ds, func(tx sqlx.ExtContext) error { + _, err := tx.ExecContext(ctx, "UPDATE software_installers SET self_service = 1 WHERE filename = ?", payload.Filename) + return err + }) + // available installer is returned by user-authenticated endpoint getHostSw = getHostSoftwareResponse{} s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host.ID), nil, http.StatusOK, &getHostSw) require.Len(t, getHostSw.Software, 3) // foo, bar and ruby.deb require.Equal(t, getHostSw.Software[0].Name, "bar") + require.Nil(t, getHostSw.Software[0].PackageAvailableForInstall) require.Equal(t, getHostSw.Software[1].Name, "foo") + require.Nil(t, getHostSw.Software[1].PackageAvailableForInstall) require.Equal(t, getHostSw.Software[2].Name, "ruby") require.Len(t, getHostSw.Software[1].InstalledVersions, 2) require.NotNil(t, getHostSw.Software[2].PackageAvailableForInstall) require.Equal(t, "ruby.deb", *getHostSw.Software[2].PackageAvailableForInstall) + require.NotNil(t, getHostSw.Software[2].SelfService) + require.True(t, *getHostSw.Software[2].SelfService) require.Nil(t, getHostSw.Software[2].Status) + // package object is not returned for user-authenticated endpoint + require.Nil(t, getHostSw.Software[2].Package) + + // only the installer is returned for self-service only + getHostSw = getHostSoftwareResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host.ID), nil, http.StatusOK, &getHostSw, "self_service", "true") + require.Len(t, getHostSw.Software, 1) + require.Equal(t, getHostSw.Software[0].Name, "ruby") // available installer is not returned by device-authenticated endpoint res = s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/software", nil, http.StatusOK) @@ -9006,6 +9132,22 @@ func (s *integrationEnterpriseTestSuite) TestListHostSoftware() { require.Nil(t, getDeviceSw.Software[0].PackageAvailableForInstall) require.Nil(t, getDeviceSw.Software[1].PackageAvailableForInstall) + // but it gets returned for self-service only + res = s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/software?self_service=1", nil, http.StatusOK) + getDeviceSw = getDeviceSoftwareResponse{} + err = json.NewDecoder(res.Body).Decode(&getDeviceSw) + require.NoError(t, err) + require.Len(t, getDeviceSw.Software, 1) + require.Equal(t, getDeviceSw.Software[0].Name, "ruby") + // package available for install is not returned for device-authenticated + require.Nil(t, getDeviceSw.Software[0].PackageAvailableForInstall) + // but package object is + require.NotNil(t, getDeviceSw.Software[0].Package) + require.NotNil(t, getDeviceSw.Software[0].SelfService) + require.True(t, *getDeviceSw.Software[0].SelfService) + require.Equal(t, payload.Filename, getDeviceSw.Software[0].Package.Name) + require.Equal(t, payload.Version, getDeviceSw.Software[0].Package.Version) + // request installation on the host var installResp installSoftwareResponse s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/install/%d", @@ -9023,8 +9165,17 @@ func (s *integrationEnterpriseTestSuite) TestListHostSoftware() { require.Equal(t, "ruby.deb", *getHostSw.Software[2].PackageAvailableForInstall) require.NotNil(t, getHostSw.Software[2].Status) require.Equal(t, fleet.SoftwareInstallerPending, *getHostSw.Software[2].Status) + require.NotNil(t, getHostSw.Software[2].SelfService) + require.True(t, *getHostSw.Software[2].SelfService) + require.Nil(t, getHostSw.Software[2].Package) - // now returned by device-authenticated endpoin + // still returned with self-service filter + getHostSw = getHostSoftwareResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host.ID), nil, http.StatusOK, &getHostSw, "self_service", "true") + require.Len(t, getHostSw.Software, 1) + require.Equal(t, getHostSw.Software[0].Name, "ruby") + + // now returned by device-authenticated endpoint res = s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/software", nil, http.StatusOK) getDeviceSw = getDeviceSoftwareResponse{} err = json.NewDecoder(res.Body).Decode(&getDeviceSw) @@ -9037,6 +9188,21 @@ func (s *integrationEnterpriseTestSuite) TestListHostSoftware() { require.Nil(t, getDeviceSw.Software[2].PackageAvailableForInstall) require.NotNil(t, getDeviceSw.Software[2].Status) require.Equal(t, fleet.SoftwareInstallerPending, *getDeviceSw.Software[2].Status) + require.NotNil(t, getDeviceSw.Software[2].SelfService) + require.True(t, *getDeviceSw.Software[2].SelfService) + require.NotNil(t, getDeviceSw.Software[2].Package) + + // still returned for self-service only too + res = s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/software?self_service=1", nil, http.StatusOK) + getDeviceSw = getDeviceSoftwareResponse{} + err = json.NewDecoder(res.Body).Decode(&getDeviceSw) + require.NoError(t, err) + require.Len(t, getDeviceSw.Software, 1) + require.Equal(t, getDeviceSw.Software[0].Name, "ruby") + require.NotNil(t, getDeviceSw.Software[0].SelfService) + require.True(t, *getDeviceSw.Software[0].SelfService) + require.NotNil(t, getDeviceSw.Software[0].Package) + require.Nil(t, getDeviceSw.Software[0].PackageAvailableForInstall) // test with a query getHostSw = getHostSoftwareResponse{} @@ -9165,7 +9331,7 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndD s.uploadSoftwareInstaller(payload, http.StatusOK, "") // check activity - s.lastActivityOfTypeMatches(fleet.ActivityTypeAddedSoftware{}.ActivityName(), `{"software_title": "ruby", "software_package": "ruby.deb", "team_name": null, "team_id": null}`, 0) + s.lastActivityOfTypeMatches(fleet.ActivityTypeAddedSoftware{}.ActivityName(), `{"software_title": "ruby", "software_package": "ruby.deb", "team_name": null, "team_id": null, "self_service": false}`, 0) // check the software installer _, titleID := checkSoftwareInstaller(t, payload) @@ -9200,11 +9366,12 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndD PostInstallScript: "another post install script", Filename: "ruby.deb", // additional fields below are pre-populated so we can re-use the payload later for the test assertions - Title: "ruby", - Version: "1:2.5.1", - Source: "deb_packages", - StorageID: "df06d9ce9e2090d9cb2e8cd1f4d7754a803dc452bf93e3204e3acd3b95508628", - Platform: "linux", + Title: "ruby", + Version: "1:2.5.1", + Source: "deb_packages", + StorageID: "df06d9ce9e2090d9cb2e8cd1f4d7754a803dc452bf93e3204e3acd3b95508628", + Platform: "linux", + SelfService: true, } s.uploadSoftwareInstaller(payload, http.StatusOK, "") @@ -9212,7 +9379,7 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndD installerID, titleID := checkSoftwareInstaller(t, payload) // check activity - s.lastActivityOfTypeMatches(fleet.ActivityTypeAddedSoftware{}.ActivityName(), fmt.Sprintf(`{"software_title": "ruby", "software_package": "ruby.deb", "team_name": "%s", "team_id": %d}`, createTeamResp.Team.Name, createTeamResp.Team.ID), 0) + s.lastActivityOfTypeMatches(fleet.ActivityTypeAddedSoftware{}.ActivityName(), fmt.Sprintf(`{"software_title": "ruby", "software_package": "ruby.deb", "team_name": "%s", "team_id": %d, "self_service": true}`, createTeamResp.Team.Name, createTeamResp.Team.ID), 0) // upload again fails s.uploadSoftwareInstaller(payload, http.StatusConflict, "already exists") @@ -9252,7 +9419,7 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndD s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/software/%d/package", titleID), nil, http.StatusNoContent, "team_id", fmt.Sprintf("%d", *payload.TeamID)) // check activity - s.lastActivityOfTypeMatches(fleet.ActivityTypeDeletedSoftware{}.ActivityName(), fmt.Sprintf(`{"software_title": "ruby", "software_package": "ruby.deb", "team_name": "%s", "team_id": %d}`, createTeamResp.Team.Name, createTeamResp.Team.ID), 0) + s.lastActivityOfTypeMatches(fleet.ActivityTypeDeletedSoftware{}.ActivityName(), fmt.Sprintf(`{"software_title": "ruby", "software_package": "ruby.deb", "team_name": "%s", "team_id": %d, "self_service": true}`, createTeamResp.Team.Name, createTeamResp.Team.ID), 0) }) } @@ -9279,7 +9446,8 @@ func (s *integrationEnterpriseTestSuite) TestApplyTeamsSoftwareConfig() { "name": teamName, "software": []map[string]any{ { - "url": "http://foo.com", + "url": "http://foo.com", + "self_service": true, "install_script": map[string]string{ "path": "./foo/install-script.sh", }, @@ -9311,12 +9479,14 @@ func (s *integrationEnterpriseTestSuite) TestApplyTeamsSoftwareConfig() { wantSoftware := []fleet.TeamSpecSoftware{ { URL: "http://foo.com", + SelfService: true, InstallScript: fleet.TeamSpecSoftwareAsset{Path: "./foo/install-script.sh"}, PostInstallScript: fleet.TeamSpecSoftwareAsset{Path: "./foo/post-install-script.sh"}, PreInstallQuery: fleet.TeamSpecSoftwareAsset{Path: "./foo/query.yaml"}, }, { URL: "http://bar.com", + SelfService: false, InstallScript: fleet.TeamSpecSoftwareAsset{Path: "./bar/install-script.sh"}, PostInstallScript: fleet.TeamSpecSoftwareAsset{Path: "./bar/post-install-script.sh"}, PreInstallQuery: fleet.TeamSpecSoftwareAsset{Path: "./bar/query.yaml"}, @@ -9749,6 +9919,88 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerHostRequests() { require.Contains(t, extractServerErrorText(r.Body), "Invalid parameters. The combination of software_id and software_title_id is not allowed.") } +func (s *integrationEnterpriseTestSuite) TestSelfServiceSoftwareInstall() { + t := s.T() + + host1 := createOrbitEnrolledHost(t, "linux", "", s.ds) + token := "secret_token" + createDeviceTokenForHost(t, s.ds, host1.ID, token) + + payloadNoSS := &fleet.UploadSoftwareInstallerPayload{ + PreInstallQuery: "SELECT 1", + InstallScript: "install", + PostInstallScript: "echo hi", + Filename: "ruby.deb", + Title: "ruby", + SelfService: false, + } + s.uploadSoftwareInstaller(payloadNoSS, http.StatusOK, "") + titleIDNoSS := getSoftwareTitleID(t, s.ds, payloadNoSS.Title, "deb_packages") + + payloadSS := &fleet.UploadSoftwareInstallerPayload{ + PreInstallQuery: "SELECT 2", + InstallScript: "install again", + PostInstallScript: "echo bye", + Filename: "emacs.deb", + Title: "emacs", + SelfService: true, + } + s.uploadSoftwareInstaller(payloadSS, http.StatusOK, "") + titleIDSS := getSoftwareTitleID(t, s.ds, payloadSS.Title, "deb_packages") + + // cannot self-install if software installer does not allow it + res := s.DoRawNoAuth("POST", fmt.Sprintf("/api/v1/fleet/device/%s/software/install/%d", token, titleIDNoSS), nil, http.StatusBadRequest) + errMsg := extractServerErrorText(res.Body) + require.Contains(t, errMsg, "Software title is not available through self-service") + + // request self-install of software that allows it + s.DoRawNoAuth("POST", fmt.Sprintf("/api/v1/fleet/device/%s/software/install/%d", token, titleIDSS), nil, http.StatusAccepted) + + // it shows up as "self-installed" in the upcoming activities of the host + var listUpcomingAct listHostUpcomingActivitiesResponse + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities/upcoming", host1.ID), nil, http.StatusOK, &listUpcomingAct) + require.Len(t, listUpcomingAct.Activities, 1) + require.Nil(t, listUpcomingAct.Activities[0].ActorID) + + var details fleet.ActivityTypeInstalledSoftware + err := json.Unmarshal([]byte(*listUpcomingAct.Activities[0].Details), &details) + require.NoError(t, err) + require.Equal(t, host1.ID, details.HostID) + require.Equal(t, details.SoftwareTitle, payloadSS.Title) + require.True(t, details.SelfService) + require.EqualValues(t, fleet.SoftwareInstallerPending, details.Status) + installID := details.InstallUUID + + // record the installation results + s.Do("POST", "/api/fleet/orbit/software_install/result", + json.RawMessage(fmt.Sprintf(`{ + "orbit_node_key": %q, + "install_uuid": %q, + "pre_install_condition_output": "1", + "install_script_exit_code": 0, + "install_script_output": "ok" + }`, *host1.OrbitNodeKey, installID)), + http.StatusNoContent) + + // nothing in upcoming activities anymore + listUpcomingAct = listHostUpcomingActivitiesResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities/upcoming", host1.ID), nil, http.StatusOK, &listUpcomingAct) + require.Len(t, listUpcomingAct.Activities, 0) + + // installation shows up in past activities + var listPastAct listActivitiesResponse + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities", host1.ID), nil, http.StatusOK, &listPastAct) + require.Len(t, listPastAct.Activities, 1) + require.Nil(t, listPastAct.Activities[0].ActorID) + + err = json.Unmarshal([]byte(*listPastAct.Activities[0].Details), &details) + require.NoError(t, err) + require.Equal(t, host1.ID, details.HostID) + require.Equal(t, details.SoftwareTitle, payloadSS.Title) + require.True(t, details.SelfService) + require.EqualValues(t, fleet.SoftwareInstallerInstalled, details.Status) +} + func (s *integrationEnterpriseTestSuite) TestHostSoftwareInstallResult() { ctx := context.Background() t := s.T() @@ -9822,6 +10074,7 @@ func (s *integrationEnterpriseTestSuite) TestHostSoftwareInstallResult() { HostID: host.ID, HostDisplayName: host.DisplayName(), SoftwareTitle: payload.Title, + SoftwarePackage: payload.Filename, InstallUUID: installUUIDs[0], Status: string(fleet.SoftwareInstallerFailed), } @@ -9909,6 +10162,9 @@ func (s *integrationEnterpriseTestSuite) uploadSoftwareInstaller(payload *fleet. require.NoError(t, w.WriteField("install_script", payload.InstallScript)) require.NoError(t, w.WriteField("pre_install_query", payload.PreInstallQuery)) require.NoError(t, w.WriteField("post_install_script", payload.PostInstallScript)) + if payload.SelfService { + require.NoError(t, w.WriteField("self_service", "true")) + } w.Close() diff --git a/server/service/orbit.go b/server/service/orbit.go index 8cfb8f43aa..e5b4c4f474 100644 --- a/server/service/orbit.go +++ b/server/service/orbit.go @@ -910,7 +910,7 @@ func (svc *Service) SaveHostSoftwareInstallResult(ctx context.Context, result *f } var user *fleet.User - if hsi.UserID != nil { + if hsi.UserID != nil && !hsi.SelfService { user, err = svc.ds.UserByID(ctx, *hsi.UserID) if err != nil { return ctxerr.Wrap(ctx, err, "get host software installation user") @@ -924,8 +924,10 @@ func (svc *Service) SaveHostSoftwareInstallResult(ctx context.Context, result *f HostID: host.ID, HostDisplayName: host.DisplayName(), SoftwareTitle: hsi.SoftwareTitle, + SoftwarePackage: hsi.SoftwarePackage, InstallUUID: result.InstallUUID, Status: string(status), + SelfService: hsi.SelfService, }, ); err != nil { return ctxerr.Wrap(ctx, err, "create activity for software installation") diff --git a/server/service/orbit_client_test.go b/server/service/orbit_client_test.go index 377d8b0c69..8f76595a60 100644 --- a/server/service/orbit_client_test.go +++ b/server/service/orbit_client_test.go @@ -149,10 +149,11 @@ func TestExecuteConfigReceiversCancel(t *testing.T) { func TestExecuteConfigReceiversInterrupt(t *testing.T) { client := clientWithConfig(&fleet.OrbitConfig{}) - client.ReceiverUpdateInterval = 200 * time.Millisecond + defer client.ReceiverUpdateCancelFunc() + + client.ReceiverUpdateInterval = 100 * time.Millisecond var called bool - rfunc := fleet.OrbitConfigReceiverFunc(func(cfg *fleet.OrbitConfig) error { called = true return nil @@ -160,14 +161,13 @@ func TestExecuteConfigReceiversInterrupt(t *testing.T) { client.RegisterConfigReceiver(rfunc) - finChan := make(chan error, 1) - + finChan := make(chan error) go func() { finChan <- client.ExecuteConfigReceivers() }() go func() { - time.Sleep(200 * time.Millisecond) + time.Sleep(500 * time.Millisecond) client.ReceiverUpdateCancelFunc() }() @@ -178,6 +178,4 @@ func TestExecuteConfigReceiversInterrupt(t *testing.T) { case <-time.NewTimer(2 * time.Second).C: require.Fail(t, "receiver interrupt cancel didn't work") } - - client.ReceiverUpdateCancelFunc() } diff --git a/server/service/software_installers.go b/server/service/software_installers.go index 9a51b7f5f4..f03f05d0b7 100644 --- a/server/service/software_installers.go +++ b/server/service/software_installers.go @@ -9,6 +9,8 @@ import ( "strconv" "github.com/docker/go-units" + "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" + hostctx "github.com/fleetdm/fleet/v4/server/contexts/host" "github.com/fleetdm/fleet/v4/server/contexts/logging" "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/ptr" @@ -20,6 +22,7 @@ type uploadSoftwareInstallerRequest struct { InstallScript string PreInstallQuery string PostInstallScript string + SelfService bool } type uploadSoftwareInstallerResponse struct { @@ -79,6 +82,15 @@ func (uploadSoftwareInstallerRequest) DecodeRequest(ctx context.Context, r *http decoded.PostInstallScript = val[0] } + val, ok = r.MultipartForm.Value["self_service"] + if ok && len(val) > 0 && val[0] != "" { + parsed, err := strconv.ParseBool(val[0]) + if err != nil { + return nil, &fleet.BadRequestError{Message: fmt.Sprintf("failed to decode self_service bool in multipart form: %s", err.Error())} + } + decoded.SelfService = parsed + } + return &decoded, nil } @@ -99,6 +111,7 @@ func uploadSoftwareInstallerEndpoint(ctx context.Context, request interface{}, s PostInstallScript: req.PostInstallScript, InstallerFile: ff, Filename: req.File.Filename, + SelfService: req.SelfService, } if err := svc.UploadSoftwareInstaller(ctx, payload); err != nil { @@ -312,3 +325,46 @@ func (svc *Service) BatchSetSoftwareInstallers(ctx context.Context, tmName strin return fleet.ErrMissingLicense } + +////////////////////////////////////////////////////////////////////////////// +// Self Service Install +////////////////////////////////////////////////////////////////////////////// + +type fleetSelfServiceSoftwareInstallRequest struct { + Token string `url:"token"` + SoftwareTitleID uint `url:"software_title_id"` +} + +func (r *fleetSelfServiceSoftwareInstallRequest) deviceAuthToken() string { + return r.Token +} + +type submitSelfServiceSoftwareInstallResponse struct { + Err error `json:"error,omitempty"` +} + +func (r submitSelfServiceSoftwareInstallResponse) error() error { return r.Err } +func (r submitSelfServiceSoftwareInstallResponse) Status() int { return http.StatusAccepted } + +func submitSelfServiceSoftwareInstall(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { + host, ok := hostctx.FromContext(ctx) + if !ok { + err := ctxerr.Wrap(ctx, fleet.NewAuthRequiredError("internal error: missing host from request context")) + return submitSelfServiceSoftwareInstallResponse{Err: err}, nil + } + + req := request.(*fleetSelfServiceSoftwareInstallRequest) + if err := svc.SelfServiceInstallSoftwareTitle(ctx, host, req.SoftwareTitleID); err != nil { + return submitSelfServiceSoftwareInstallResponse{Err: err}, nil + } + + return submitSelfServiceSoftwareInstallResponse{}, nil +} + +func (svc *Service) SelfServiceInstallSoftwareTitle(ctx context.Context, host *fleet.Host, softwareTitleID uint) error { + // skipauth: No authorization check needed due to implementation returning + // only license error. + svc.authz.SkipAuthorization(ctx) + + return fleet.ErrMissingLicense +} diff --git a/server/service/testdata/software-installers/emacs.deb b/server/service/testdata/software-installers/emacs.deb new file mode 100644 index 0000000000..90c58f1045 Binary files /dev/null and b/server/service/testdata/software-installers/emacs.deb differ