From d38d180357165d21551b7b2890f5d98ba0dc93fc Mon Sep 17 00:00:00 2001 From: Tim Lee Date: Mon, 3 Feb 2025 12:16:21 -0500 Subject: [PATCH] Add labels and editing for VPP apps (#25979) For #24609 --------- Co-authored-by: Jahziel Villasana-Espinoza Co-authored-by: Jahziel Villasana-Espinoza --- cmd/fleetctl/gitops_test.go | 38 +- .../team_vpp_invalid_app_labels_both.yml | 21 + ...eam_vpp_invalid_app_labels_exclude_any.yml | 20 + ...eam_vpp_invalid_app_labels_include_any.yml | 20 + .../team_vpp_valid_app_labels_exclude_any.yml | 20 + .../team_vpp_valid_app_labels_include_any.yml | 20 + docs/Contributing/Audit-logs.md | 67 ++- ee/server/service/software_installers.go | 39 +- ee/server/service/vpp.go | 132 ++++- package.json | 3 +- pkg/spec/gitops.go | 6 + server/datastore/mysql/software.go | 96 +++- server/datastore/mysql/software_installers.go | 55 +- server/datastore/mysql/software_test.go | 238 +++++++- server/datastore/mysql/vpp.go | 179 +++++- server/datastore/mysql/vpp_test.go | 230 +++++++- server/fleet/activities.go | 108 +++- server/fleet/datastore.go | 7 +- server/fleet/labels.go | 32 ++ server/fleet/service.go | 1 + server/fleet/software.go | 10 +- server/fleet/software_installer.go | 10 +- server/fleet/teams.go | 6 +- server/fleet/vpp.go | 18 + server/mock/datastore_mock.go | 48 +- server/service/client.go | 2 + server/service/handler.go | 1 + server/service/integration_mdm_test.go | 541 ++++++++++++++++-- server/service/osquery.go | 25 +- server/service/software_installers_test.go | 4 +- server/service/vpp.go | 55 +- 31 files changed, 1856 insertions(+), 196 deletions(-) create mode 100644 cmd/fleetctl/testdata/gitops/team_vpp_invalid_app_labels_both.yml create mode 100644 cmd/fleetctl/testdata/gitops/team_vpp_invalid_app_labels_exclude_any.yml create mode 100644 cmd/fleetctl/testdata/gitops/team_vpp_invalid_app_labels_include_any.yml create mode 100644 cmd/fleetctl/testdata/gitops/team_vpp_valid_app_labels_exclude_any.yml create mode 100644 cmd/fleetctl/testdata/gitops/team_vpp_valid_app_labels_include_any.yml diff --git a/cmd/fleetctl/gitops_test.go b/cmd/fleetctl/gitops_test.go index b5daa20e4f14..d10e06bad721 100644 --- a/cmd/fleetctl/gitops_test.go +++ b/cmd/fleetctl/gitops_test.go @@ -2200,15 +2200,21 @@ func TestGitOpsTeamVPPApps(t *testing.T) { file string wantErr string tokenExpiration time.Time + expectedLabels map[string]uint }{ - {"testdata/gitops/team_vpp_valid_app.yml", "", time.Now().Add(24 * time.Hour)}, - {"testdata/gitops/team_vpp_valid_app_self_service.yml", "", time.Now().Add(24 * time.Hour)}, - {"testdata/gitops/team_vpp_valid_empty.yml", "", time.Now().Add(24 * time.Hour)}, - {"testdata/gitops/team_vpp_valid_empty.yml", "", time.Now().Add(-24 * time.Hour)}, - {"testdata/gitops/team_vpp_valid_app.yml", "VPP token expired", time.Now().Add(-24 * time.Hour)}, - {"testdata/gitops/team_vpp_invalid_app.yml", "app not available on vpp account", time.Now().Add(24 * time.Hour)}, - {"testdata/gitops/team_vpp_incorrect_type.yml", "\"app_store_apps.app_store_id\" must be a string, found number", time.Now().Add(24 * time.Hour)}, - {"testdata/gitops/team_vpp_empty_adamid.yml", "software app store id required", time.Now().Add(24 * time.Hour)}, + {"testdata/gitops/team_vpp_valid_app.yml", "", time.Now().Add(24 * time.Hour), map[string]uint{}}, + {"testdata/gitops/team_vpp_valid_app_self_service.yml", "", time.Now().Add(24 * time.Hour), map[string]uint{}}, + {"testdata/gitops/team_vpp_valid_empty.yml", "", time.Now().Add(24 * time.Hour), map[string]uint{}}, + {"testdata/gitops/team_vpp_valid_empty.yml", "", time.Now().Add(-24 * time.Hour), map[string]uint{}}, + {"testdata/gitops/team_vpp_valid_app.yml", "VPP token expired", time.Now().Add(-24 * time.Hour), map[string]uint{}}, + {"testdata/gitops/team_vpp_invalid_app.yml", "app not available on vpp account", time.Now().Add(24 * time.Hour), map[string]uint{}}, + {"testdata/gitops/team_vpp_incorrect_type.yml", "\"app_store_apps.app_store_id\" must be a string, found number", time.Now().Add(24 * time.Hour), map[string]uint{}}, + {"testdata/gitops/team_vpp_empty_adamid.yml", "software app store id required", time.Now().Add(24 * time.Hour), map[string]uint{}}, + {"testdata/gitops/team_vpp_valid_app_labels_exclude_any.yml", "", time.Now().Add(24 * time.Hour), map[string]uint{"label 1": 1, "label 2": 2}}, + {"testdata/gitops/team_vpp_valid_app_labels_include_any.yml", "", time.Now().Add(24 * time.Hour), map[string]uint{"label 1": 1, "label 2": 2}}, + {"testdata/gitops/team_vpp_invalid_app_labels_exclude_any.yml", "some or all the labels provided don't exist", time.Now().Add(24 * time.Hour), map[string]uint{"label 1": 1, "label 2": 2}}, + {"testdata/gitops/team_vpp_invalid_app_labels_include_any.yml", "some or all the labels provided don't exist", time.Now().Add(24 * time.Hour), map[string]uint{"label 1": 1, "label 2": 2}}, + {"testdata/gitops/team_vpp_invalid_app_labels_both.yml", `only one of "labels_exclude_any" or "labels_include_any" can be specified for app store app`, time.Now().Add(24 * time.Hour), map[string]uint{}}, } for _, c := range cases { @@ -2238,9 +2244,25 @@ func TestGitOpsTeamVPPApps(t *testing.T) { }, nil } + found := make(map[string]uint) + ds.LabelIDsByNameFunc = func(ctx context.Context, labels []string) (map[string]uint, error) { + for _, l := range labels { + if id, ok := c.expectedLabels[l]; ok { + found[l] = id + } + } + return found, nil + } + _, err = runAppNoChecks([]string{"gitops", "-f", c.file}) + if c.wantErr == "" { require.NoError(t, err) + if len(c.expectedLabels) > 0 { + require.True(t, ds.LabelIDsByNameFuncInvoked) + } + + require.Equal(t, c.expectedLabels, found) } else { require.ErrorContains(t, err, c.wantErr) } diff --git a/cmd/fleetctl/testdata/gitops/team_vpp_invalid_app_labels_both.yml b/cmd/fleetctl/testdata/gitops/team_vpp_invalid_app_labels_both.yml new file mode 100644 index 000000000000..0fd5ebcf447c --- /dev/null +++ b/cmd/fleetctl/testdata/gitops/team_vpp_invalid_app_labels_both.yml @@ -0,0 +1,21 @@ +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: + app_store_apps: + - app_store_id: "1" + labels_exclude_any: + - "label 1" + labels_include_any: + - "label 2" diff --git a/cmd/fleetctl/testdata/gitops/team_vpp_invalid_app_labels_exclude_any.yml b/cmd/fleetctl/testdata/gitops/team_vpp_invalid_app_labels_exclude_any.yml new file mode 100644 index 000000000000..158f703bd177 --- /dev/null +++ b/cmd/fleetctl/testdata/gitops/team_vpp_invalid_app_labels_exclude_any.yml @@ -0,0 +1,20 @@ +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: + app_store_apps: + - app_store_id: "1" + labels_exclude_any: + - "label non-existent" + - "label 2" diff --git a/cmd/fleetctl/testdata/gitops/team_vpp_invalid_app_labels_include_any.yml b/cmd/fleetctl/testdata/gitops/team_vpp_invalid_app_labels_include_any.yml new file mode 100644 index 000000000000..1ed53b474222 --- /dev/null +++ b/cmd/fleetctl/testdata/gitops/team_vpp_invalid_app_labels_include_any.yml @@ -0,0 +1,20 @@ +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: + app_store_apps: + - app_store_id: "1" + labels_include_any: + - "label non-existent" + - "label 2" diff --git a/cmd/fleetctl/testdata/gitops/team_vpp_valid_app_labels_exclude_any.yml b/cmd/fleetctl/testdata/gitops/team_vpp_valid_app_labels_exclude_any.yml new file mode 100644 index 000000000000..374f1f5a36b0 --- /dev/null +++ b/cmd/fleetctl/testdata/gitops/team_vpp_valid_app_labels_exclude_any.yml @@ -0,0 +1,20 @@ +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: + app_store_apps: + - app_store_id: "1" + labels_exclude_any: + - "label 1" + - "label 2" diff --git a/cmd/fleetctl/testdata/gitops/team_vpp_valid_app_labels_include_any.yml b/cmd/fleetctl/testdata/gitops/team_vpp_valid_app_labels_include_any.yml new file mode 100644 index 000000000000..023e5dc33257 --- /dev/null +++ b/cmd/fleetctl/testdata/gitops/team_vpp_valid_app_labels_include_any.yml @@ -0,0 +1,20 @@ +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: + app_store_apps: + - app_store_id: "1" + labels_include_any: + - "label 1" + - "label 2" diff --git a/docs/Contributing/Audit-logs.md b/docs/Contributing/Audit-logs.md index fc95af194a30..dd67705008bd 100644 --- a/docs/Contributing/Audit-logs.md +++ b/docs/Contributing/Audit-logs.md @@ -1382,6 +1382,8 @@ This activity contains the following fields: - "self_service": App installation can be initiated by device owner. - "team_name": Name of the team to which this App Store app was added, or `null` if it was added to no team. - "team_id": ID of the team to which this App Store app was added, or `null`if it was added to no team. +- "labels_include_any": Target hosts that have any label in the array. +- "labels_exclude_any": Target hosts that don't have any label in the array. #### Example @@ -1393,7 +1395,17 @@ This activity contains the following fields: "platform": "darwin", "self_service": false, "team_name": "Workstations", - "team_id": 1 + "team_id": 1, + "labels_include_any": [ + { + "name": "Engineering", + "id": 12 + }, + { + "name": "Product", + "id": 17 + } + ] } ``` @@ -1407,6 +1419,8 @@ This activity contains the following fields: - "platform": Platform of the app (`darwin`, `ios`, or `ipados`). - "team_name": Name of the team from which this App Store app was deleted, or `null` if it was deleted from no team. - "team_id": ID of the team from which this App Store app was deleted, or `null`if it was deleted from no team. +- "labels_include_any": Target hosts that have any label in the array. +- "labels_exclude_any": Target hosts that don't have any label in the array #### Example @@ -1416,7 +1430,17 @@ This activity contains the following fields: "app_store_id": "1234567", "platform": "darwin", "team_name": "Workstations", - "team_id": 1 + "team_id": 1, + "labels_include_any": [ + { + "name": "Engineering", + "id": 12 + }, + { + "name": "Product", + "id": 17 + } + ] } ``` @@ -1450,6 +1474,45 @@ This activity contains the following fields: } ``` +## edited_app_store_app + +Generated when an App Store app is updated in Fleet. + +This activity contains the following fields: +- "software_title": Name of the App Store app. +- "software_title_id": ID of the updated app's software title. +- "app_store_id": ID of the app on the Apple App Store. +- "platform": Platform of the app (`darwin`, `ios`, or `ipados`). +- "self_service": App installation can be initiated by device owner. +- "team_name": Name of the team on which this App Store app was updated, or `null` if it was updated on no team. +- "team_id": ID of the team on which this App Store app was updated, or `null`if it was updated on no team. +- "labels_include_any": Target hosts that have any label in the array. +- "labels_exclude_any": Target hosts that don't have any label in the array. + +#### Example + +```json +{ + "software_title": "Logic Pro", + "software_title_id": 123, + "app_store_id": "1234567", + "platform": "darwin", + "self_service": true, + "team_name": "Workstations", + "team_id": 1, + "labels_include_any": [ + { + "name": "Engineering", + "id": 12 + }, + { + "name": "Product", + "id": 17 + } + ] +} +``` + ## added_ndes_scep_proxy Generated when NDES SCEP proxy is configured in Fleet. diff --git a/ee/server/service/software_installers.go b/ee/server/service/software_installers.go index 02794196d037..5a2900776f97 100644 --- a/ee/server/service/software_installers.go +++ b/ee/server/service/software_installers.go @@ -638,12 +638,16 @@ func (svc *Service) deleteVPPApp(ctx context.Context, teamID *uint, meta *fleet. teamName = &t.Name } + actLabelsIncl, actLabelsExcl := activitySoftwareLabelsFromSoftwareScopeLabels(meta.LabelsIncludeAny, meta.LabelsExcludeAny) + if err := svc.NewActivity(ctx, vc.User, fleet.ActivityDeletedAppStoreApp{ - AppStoreID: meta.AdamID, - SoftwareTitle: meta.Name, - TeamName: teamName, - TeamID: teamID, - Platform: meta.Platform, + AppStoreID: meta.AdamID, + SoftwareTitle: meta.Name, + TeamName: teamName, + TeamID: teamID, + Platform: meta.Platform, + LabelsIncludeAny: actLabelsIncl, + LabelsExcludeAny: actLabelsExcl, }); err != nil { return ctxerr.Wrap(ctx, err, "creating activity for deleted VPP app") } @@ -968,7 +972,7 @@ func (svc *Service) InstallSoftwareTitle(ctx context.Context, hostID uint, softw if lastInstallRequest != nil && lastInstallRequest.Status != nil && (*lastInstallRequest.Status == fleet.SoftwareInstallPending || *lastInstallRequest.Status == fleet.SoftwareUninstallPending) { return &fleet.BadRequestError{ - Message: "Couldn't install software. Host has a pending install/uninstall request.", + Message: "Couldn't install. This host isn't a member of the labels defined for this software title.", InternalErr: ctxerr.WrapWithData( ctx, err, "host already has a pending install/uninstall for this installer", map[string]any{ @@ -1001,6 +1005,18 @@ func (svc *Service) InstallSoftwareTitle(ctx context.Context, hostID uint, softw return ctxerr.Wrap(ctx, err, "finding VPP app for title") } + // check the label scoping for this VPP app and host + scoped, err := svc.ds.IsVPPAppLabelScoped(ctx, vppApp.VPPAppTeam.AppTeamID, hostID) + if err != nil { + return ctxerr.Wrap(ctx, err, "checking label scoping during vpp software install attempt") + } + + if !scoped { + return &fleet.BadRequestError{ + Message: "Couldn't install. This host isn't a member of the labels defined for this software title.", + } + } + _, err = svc.installSoftwareFromVPP(ctx, host, vppApp, mobileAppleDevice || fleet.AppleDevicePlatform(platform) == fleet.MacOSPlatform, false) return err } @@ -1849,6 +1865,17 @@ func (svc *Service) SelfServiceInstallSoftwareTitle(ctx context.Context, host *f } } + scoped, err := svc.ds.IsVPPAppLabelScoped(ctx, vppApp.VPPAppTeam.AppTeamID, host.ID) + if err != nil { + return ctxerr.Wrap(ctx, err, "checking vpp label scoping during software install attempt") + } + + if !scoped { + return &fleet.BadRequestError{ + Message: "Couldn't install. This software is not available for this host.", + } + } + platform := host.FleetPlatform() mobileAppleDevice := fleet.AppleDevicePlatform(platform) == fleet.IOSPlatform || fleet.AppleDevicePlatform(platform) == fleet.IPadOSPlatform diff --git a/ee/server/service/vpp.go b/ee/server/service/vpp.go index e0ea0a473341..0d38dadd3ce7 100644 --- a/ee/server/service/vpp.go +++ b/ee/server/service/vpp.go @@ -70,18 +70,24 @@ func (svc *Service) BatchAssociateVPPApps(ctx context.Context, teamName string, // Currently only macOS is supported for self-service. Don't // import vpp apps as self-service for ios or ipados payloadsWithPlatform = append(payloadsWithPlatform, []fleet.VPPBatchPayloadWithPlatform{{ - AppStoreID: payload.AppStoreID, - SelfService: false, - Platform: fleet.IOSPlatform, + AppStoreID: payload.AppStoreID, + SelfService: false, + Platform: fleet.IOSPlatform, + LabelsExcludeAny: payload.LabelsExcludeAny, + LabelsIncludeAny: payload.LabelsIncludeAny, }, { - AppStoreID: payload.AppStoreID, - SelfService: false, - Platform: fleet.IPadOSPlatform, + AppStoreID: payload.AppStoreID, + SelfService: false, + Platform: fleet.IPadOSPlatform, + LabelsExcludeAny: payload.LabelsExcludeAny, + LabelsIncludeAny: payload.LabelsIncludeAny, }, { AppStoreID: payload.AppStoreID, SelfService: payload.SelfService, Platform: fleet.MacOSPlatform, InstallDuringSetup: payload.InstallDuringSetup, + LabelsExcludeAny: payload.LabelsExcludeAny, + LabelsIncludeAny: payload.LabelsIncludeAny, }}...) } @@ -107,6 +113,10 @@ func (svc *Service) BatchAssociateVPPApps(ctx context.Context, teamName string, return nil, fleet.NewInvalidArgumentError("app_store_apps.platform", fmt.Sprintf("platform must be one of '%s', '%s', or '%s", fleet.IOSPlatform, fleet.IPadOSPlatform, fleet.MacOSPlatform)) } + validatedLabels, err := ValidateSoftwareLabels(ctx, svc, payload.LabelsIncludeAny, payload.LabelsExcludeAny) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "validating software labels for batch adding vpp app") + } vppAppTeams = append(vppAppTeams, fleet.VPPAppTeam{ VPPAppID: fleet.VPPAppID{ AdamID: payload.AppStoreID, @@ -114,6 +124,7 @@ func (svc *Service) BatchAssociateVPPApps(ctx context.Context, teamName string, }, SelfService: payload.SelfService, InstallDuringSetup: payload.InstallDuringSetup, + ValidatedLabels: validatedLabels, }) } @@ -299,6 +310,11 @@ func (svc *Service) AddAppStoreApp(ctx context.Context, teamID *uint, appID flee fmt.Sprintf("platform must be one of '%s', '%s', or '%s", fleet.IOSPlatform, fleet.IPadOSPlatform, fleet.MacOSPlatform)) } + validatedLabels, err := ValidateSoftwareLabels(ctx, svc, appID.LabelsIncludeAny, appID.LabelsExcludeAny) + if err != nil { + return ctxerr.Wrap(ctx, err, "validating software labels for adding vpp app") + } + var teamName string if teamID != nil && *teamID != 0 { tm, err := svc.ds.Team(ctx, *teamID) @@ -360,6 +376,7 @@ func (svc *Service) AddAppStoreApp(ctx context.Context, teamID *uint, appID flee } } + appID.ValidatedLabels = validatedLabels app := &fleet.VPPApp{ VPPAppTeam: appID, BundleIdentifier: assetMD.BundleID, @@ -373,14 +390,18 @@ func (svc *Service) AddAppStoreApp(ctx context.Context, teamID *uint, appID flee return ctxerr.Wrap(ctx, err, "writing VPP app to db") } + actLabelsIncl, actLabelsExcl := activitySoftwareLabelsFromValidatedLabels(addedApp.ValidatedLabels) + act := fleet.ActivityAddedAppStoreApp{ - AppStoreID: app.AdamID, - Platform: app.Platform, - TeamName: &teamName, - SoftwareTitle: app.Name, - SoftwareTitleId: addedApp.TitleID, - TeamID: teamID, - SelfService: app.SelfService, + AppStoreID: app.AdamID, + Platform: app.Platform, + TeamName: &teamName, + SoftwareTitle: app.Name, + SoftwareTitleId: addedApp.TitleID, + TeamID: teamID, + SelfService: app.SelfService, + LabelsIncludeAny: actLabelsIncl, + LabelsExcludeAny: actLabelsExcl, } if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), act); err != nil { return ctxerr.Wrap(ctx, err, "create activity for add app store app") @@ -398,9 +419,9 @@ func getVPPAppsMetadata(ctx context.Context, ids []fleet.VPPAppTeam) ([]*fleet.V for _, id := range ids { if _, ok := adamIDMap[id.AdamID]; !ok { adamIDMap[id.AdamID] = make(map[fleet.AppleDevicePlatform]fleet.VPPAppTeam, 1) - adamIDMap[id.AdamID][id.Platform] = fleet.VPPAppTeam{SelfService: id.SelfService, InstallDuringSetup: id.InstallDuringSetup} + adamIDMap[id.AdamID][id.Platform] = fleet.VPPAppTeam{SelfService: id.SelfService, InstallDuringSetup: id.InstallDuringSetup, ValidatedLabels: id.ValidatedLabels} } else { - adamIDMap[id.AdamID][id.Platform] = fleet.VPPAppTeam{SelfService: id.SelfService, InstallDuringSetup: id.InstallDuringSetup} + adamIDMap[id.AdamID][id.Platform] = fleet.VPPAppTeam{SelfService: id.SelfService, InstallDuringSetup: id.InstallDuringSetup, ValidatedLabels: id.ValidatedLabels} } } @@ -425,6 +446,7 @@ func getVPPAppsMetadata(ctx context.Context, ids []fleet.VPPAppTeam) ([]*fleet.V }, SelfService: props.SelfService, InstallDuringSetup: props.InstallDuringSetup, + ValidatedLabels: props.ValidatedLabels, }, BundleIdentifier: metadata.BundleID, IconURL: metadata.ArtworkURL, @@ -441,6 +463,86 @@ func getVPPAppsMetadata(ctx context.Context, ids []fleet.VPPAppTeam) ([]*fleet.V return apps, nil } +func (svc *Service) UpdateAppStoreApp(ctx context.Context, titleID uint, teamID *uint, selfService bool, labelsIncludeAny, labelsExcludeAny []string) (*fleet.VPPAppStoreApp, error) { + if err := svc.authz.Authorize(ctx, &fleet.VPPApp{TeamID: teamID}, fleet.ActionWrite); err != nil { + return nil, err + } + + var teamName string + if teamID != nil && *teamID != 0 { + tm, err := svc.ds.Team(ctx, *teamID) + if fleet.IsNotFound(err) { + return nil, fleet.NewInvalidArgumentError("team_id", fmt.Sprintf("team %d does not exist", *teamID)). + WithStatus(http.StatusNotFound) + } else if err != nil { + return nil, ctxerr.Wrap(ctx, err, "UpdateAppStoreApp: checking if team exists") + } + + teamName = tm.Name + } + + validatedLabels, err := ValidateSoftwareLabels(ctx, svc, labelsIncludeAny, labelsExcludeAny) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "UpdateAppStoreApp: validating software labels") + } + + meta, err := svc.ds.GetVPPAppMetadataByTeamAndTitleID(ctx, teamID, titleID) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "UpdateAppStoreApp: getting vpp app metadata") + } + + if selfService && meta.Platform != fleet.MacOSPlatform { + return nil, fleet.NewUserMessageError(errors.New("Currently, self-service only supports macOS"), http.StatusBadRequest) + } + + appToWrite := &fleet.VPPApp{ + VPPAppTeam: fleet.VPPAppTeam{ + VPPAppID: fleet.VPPAppID{ + AdamID: meta.AdamID, Platform: meta.Platform, + }, + SelfService: selfService, + ValidatedLabels: validatedLabels, + }, + TeamID: teamID, + TitleID: titleID, + BundleIdentifier: meta.BundleIdentifier, + Name: meta.Name, + LatestVersion: meta.LatestVersion, + } + if meta.IconURL != nil { + appToWrite.IconURL = *meta.IconURL + } + + _, err = svc.ds.InsertVPPAppWithTeam(ctx, appToWrite, teamID) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "UpdateAppStoreApp: write app to db") + } + + actLabelsIncl, actLabelsExcl := activitySoftwareLabelsFromValidatedLabels(validatedLabels) + + act := fleet.ActivityEditedAppStoreApp{ + TeamName: &teamName, + TeamID: teamID, + SelfService: selfService, + SoftwareTitleID: titleID, + SoftwareTitle: meta.Name, + AppStoreID: meta.AdamID, + Platform: meta.Platform, + LabelsIncludeAny: actLabelsIncl, + LabelsExcludeAny: actLabelsExcl, + } + if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), act); err != nil { + return nil, ctxerr.Wrap(ctx, err, "create activity for update app store app") + } + + updatedAppMeta, err := svc.ds.GetVPPAppMetadataByTeamAndTitleID(ctx, teamID, titleID) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "UpdateAppStoreApp: getting updated app metadata") + } + + return updatedAppMeta, nil +} + func (svc *Service) UploadVPPToken(ctx context.Context, token io.ReadSeeker) (*fleet.VPPTokenDB, error) { if err := svc.authz.Authorize(ctx, &fleet.AppleCSR{}, fleet.ActionWrite); err != nil { return nil, err diff --git a/package.json b/package.json index 559abc0779de..f8e87872d2a7 100644 --- a/package.json +++ b/package.json @@ -183,5 +183,6 @@ "browserslist": [ "defaults" ], - "license": "SEE LICENSE IN ./LICENSE" + "license": "SEE LICENSE IN ./LICENSE", + "packageManager": "yarn@1.22.22+sha1.ac34549e6aa8e7ead463a7407e1c7390f61a6610" } diff --git a/pkg/spec/gitops.go b/pkg/spec/gitops.go index 1d35eb8b65b2..deda14007b93 100644 --- a/pkg/spec/gitops.go +++ b/pkg/spec/gitops.go @@ -848,6 +848,12 @@ func parseSoftware(top map[string]json.RawMessage, result *GitOps, baseDir strin multiError = multierror.Append(multiError, errors.New("software app store id required")) continue } + + if len(item.LabelsExcludeAny) > 0 && len(item.LabelsIncludeAny) > 0 { + multiError = multierror.Append(multiError, fmt.Errorf(`only one of "labels_exclude_any" or "labels_include_any" can be specified for app store app %q`, item.AppStoreID)) + continue + } + result.Software.AppStoreApps = append(result.Software.AppStoreApps, &item) } for _, item := range software.Packages { diff --git a/server/datastore/mysql/software.go b/server/datastore/mysql/software.go index 6636ac24bccb..f81c7e06a3ac 100644 --- a/server/datastore/mysql/software.go +++ b/server/datastore/mysql/software.go @@ -2375,9 +2375,8 @@ INNER JOIN software_cve scve ON scve.software_id = s.id AND -- label membership check ( - -- do the label membership check only for software installers - CASE WHEN (si.ID IS NOT NULL AND hsi.last_uninstalled_at IS NOT NULL AND hsr.exit_code = 0) THEN - ( + CASE WHEN ((si.ID IS NOT NULL AND hsi.last_uninstalled_at > hsi.last_installed_at AND hsr.exit_code = 0) OR (:avail OR :self_service)) THEN ( + -- do the label membership check for software installers and VPP apps EXISTS ( SELECT 1 FROM ( @@ -2386,7 +2385,7 @@ INNER JOIN software_cve scve ON scve.software_id = s.id SELECT 0 AS count_installer_labels, 0 AS count_host_labels, 0 as count_host_updated_after_labels WHERE NOT EXISTS ( SELECT 1 FROM software_installer_labels sil WHERE sil.software_installer_id = si.id - ) + ) AND NOT EXISTS (SELECT 1 FROM vpp_app_team_labels vatl WHERE vatl.vpp_app_team_id = vat.id) UNION @@ -2426,11 +2425,45 @@ INNER JOIN software_cve scve ON scve.software_id = s.id AND sil.exclude = 1 HAVING count_installer_labels > 0 AND count_installer_labels = count_host_updated_after_labels AND count_host_labels = 0 + + UNION + + -- vpp include any + SELECT + COUNT(*) AS count_installer_labels, + COUNT(lm.label_id) AS count_host_labels, + 0 as count_host_updated_after_labels + FROM + vpp_app_team_labels vatl + LEFT OUTER JOIN label_membership lm ON lm.label_id = vatl.label_id + AND lm.host_id = :host_id + WHERE + vatl.vpp_app_team_id = vat.id + AND vatl.exclude = 0 + HAVING + count_installer_labels > 0 AND count_host_labels > 0 + + UNION + + -- vpp exclude any + SELECT + COUNT(*) AS count_installer_labels, + COUNT(lm.label_id) AS count_host_labels, + SUM(CASE WHEN lbl.created_at IS NOT NULL AND :host_label_updated_at >= lbl.created_at THEN 1 ELSE 0 END) as count_host_updated_after_labels + FROM + vpp_app_team_labels vatl + LEFT OUTER JOIN labels lbl + ON lbl.id = vatl.label_id + LEFT OUTER JOIN label_membership lm + ON lm.label_id = vatl.label_id AND lm.host_id = :host_id + WHERE + vatl.vpp_app_team_id = vat.id + AND vatl.exclude = 1 + HAVING + count_installer_labels > 0 AND count_installer_labels = count_host_updated_after_labels AND count_host_labels = 0 + ) t - ) - ) - -- it's some other type of software that has been checked above - ELSE true END + )) ELSE true END ) %s @@ -2502,9 +2535,7 @@ INNER JOIN software_cve scve ON scve.software_id = s.id ( si.id IS NOT NULL OR vat.platform = :host_platform ) AND -- label membership check ( - -- do the label membership check only for software installers - CASE WHEN si.ID IS NOT NULL THEN - ( + -- do the label membership check for software installers and VPP apps EXISTS ( SELECT 1 FROM ( @@ -2513,7 +2544,7 @@ INNER JOIN software_cve scve ON scve.software_id = s.id SELECT 0 AS count_installer_labels, 0 AS count_host_labels, 0 as count_host_updated_after_labels WHERE NOT EXISTS ( SELECT 1 FROM software_installer_labels sil WHERE sil.software_installer_id = si.id - ) + ) AND NOT EXISTS (SELECT 1 FROM vpp_app_team_labels vatl WHERE vatl.vpp_app_team_id = vat.id) UNION @@ -2553,11 +2584,44 @@ INNER JOIN software_cve scve ON scve.software_id = s.id AND sil.exclude = 1 HAVING count_installer_labels > 0 AND count_installer_labels = count_host_updated_after_labels AND count_host_labels = 0 + + UNION + + -- vpp include any + SELECT + COUNT(*) AS count_installer_labels, + COUNT(lm.label_id) AS count_host_labels, + 0 as count_host_updated_after_labels + FROM + vpp_app_team_labels vatl + LEFT OUTER JOIN label_membership lm ON lm.label_id = vatl.label_id + AND lm.host_id = :host_id + WHERE + vatl.vpp_app_team_id = vat.id + AND vatl.exclude = 0 + HAVING + count_installer_labels > 0 AND count_host_labels > 0 + + UNION + + -- vpp exclude any + SELECT + COUNT(*) AS count_installer_labels, + COUNT(lm.label_id) AS count_host_labels, + SUM(CASE WHEN lbl.created_at IS NOT NULL AND :host_label_updated_at >= lbl.created_at THEN 1 ELSE 0 END) as count_host_updated_after_labels + FROM + vpp_app_team_labels vatl + LEFT OUTER JOIN labels lbl + ON lbl.id = vatl.label_id + LEFT OUTER JOIN label_membership lm + ON lm.label_id = vatl.label_id AND lm.host_id = :host_id + WHERE + vatl.vpp_app_team_id = vat.id + AND vatl.exclude = 1 + HAVING + count_installer_labels > 0 AND count_installer_labels = count_host_updated_after_labels AND count_host_labels = 0 ) t ) - ) - -- it's some other type of software that has been checked above - ELSE true END ) %s %s `, onlySelfServiceClause, excludeVPPAppsClause) @@ -2599,6 +2663,8 @@ INNER JOIN software_cve scve ON scve.software_id = s.id "global_or_team_id": globalOrTeamID, "is_mdm_enrolled": opts.IsMDMEnrolled, "host_label_updated_at": host.LabelUpdatedAt, + "avail": opts.OnlyAvailableForInstall, + "self_service": opts.SelfServiceOnly, } stmt := stmtInstalled diff --git a/server/datastore/mysql/software_installers.go b/server/datastore/mysql/software_installers.go index 2ad8aa37f5cb..52cdec54515f 100644 --- a/server/datastore/mysql/software_installers.go +++ b/server/datastore/mysql/software_installers.go @@ -196,7 +196,7 @@ INSERT INTO software_installers ( id, _ := res.LastInsertId() installerID = uint(id) //nolint:gosec // dismiss G115 - if err := setOrUpdateSoftwareInstallerLabelsDB(ctx, tx, installerID, *payload.ValidatedLabels); err != nil { + if err := setOrUpdateSoftwareInstallerLabelsDB(ctx, tx, installerID, *payload.ValidatedLabels, softwareTypeInstaller); err != nil { return ctxerr.Wrap(ctx, err, "upsert software installer labels") } @@ -313,9 +313,16 @@ func (ds *Datastore) addSoftwareTitleToMatchingSoftware(ctx context.Context, tit return ctxerr.Wrap(ctx, err, "adding fk reference in software to software_titles") } +type softwareType string + +const ( + softwareTypeInstaller softwareType = "software_installer" + softwareTypeVPP softwareType = "vpp_app_team" +) + // setOrUpdateSoftwareInstallerLabelsDB sets or updates the label associations for the specified software // installer. If no labels are provided, it will remove all label associations with the software installer. -func setOrUpdateSoftwareInstallerLabelsDB(ctx context.Context, tx sqlx.ExtContext, installerID uint, labels fleet.LabelIdentsWithScope) error { +func setOrUpdateSoftwareInstallerLabelsDB(ctx context.Context, tx sqlx.ExtContext, installerID uint, labels fleet.LabelIdentsWithScope, softwareType softwareType) error { labelIds := make([]uint, 0, len(labels.ByName)) for _, label := range labels.ByName { labelIds = append(labelIds, label.LabelID) @@ -323,18 +330,18 @@ func setOrUpdateSoftwareInstallerLabelsDB(ctx context.Context, tx sqlx.ExtContex // remove existing labels delArgs := []interface{}{installerID} - delStmt := `DELETE FROM software_installer_labels WHERE software_installer_id = ?` + delStmt := fmt.Sprintf(`DELETE FROM %[1]s_labels WHERE %[1]s_id = ?`, softwareType) if len(labelIds) > 0 { inStmt, args, err := sqlx.In(` AND label_id NOT IN (?)`, labelIds) if err != nil { - return ctxerr.Wrap(ctx, err, "build delete existing software installer labels query") + return ctxerr.Wrap(ctx, err, "build delete existing software labels query") } delArgs = append(delArgs, args...) delStmt += inStmt } _, err := tx.ExecContext(ctx, delStmt, delArgs...) if err != nil { - return ctxerr.Wrap(ctx, err, "delete existing software installer labels") + return ctxerr.Wrap(ctx, err, "delete existing software labels") } // insert new labels @@ -350,7 +357,7 @@ func setOrUpdateSoftwareInstallerLabelsDB(ctx context.Context, tx sqlx.ExtContex return ctxerr.New(ctx, "invalid label scope") } - stmt := `INSERT INTO software_installer_labels (software_installer_id, label_id, exclude) VALUES %s ON DUPLICATE KEY UPDATE exclude = VALUES(exclude)` + stmt := `INSERT INTO %[1]s_labels (%[1]s_id, label_id, exclude) VALUES %s ON DUPLICATE KEY UPDATE exclude = VALUES(exclude)` var placeholders string var insertArgs []interface{} for _, lid := range labelIds { @@ -359,9 +366,9 @@ func setOrUpdateSoftwareInstallerLabelsDB(ctx context.Context, tx sqlx.ExtContex } placeholders = strings.TrimSuffix(placeholders, ",") - _, err = tx.ExecContext(ctx, fmt.Sprintf(stmt, placeholders), insertArgs...) + _, err = tx.ExecContext(ctx, fmt.Sprintf(stmt, softwareType, placeholders), insertArgs...) if err != nil { - return ctxerr.Wrap(ctx, err, "insert software installer label") + return ctxerr.Wrap(ctx, err, "insert software label") } } @@ -443,7 +450,7 @@ func (ds *Datastore) SaveInstallerUpdates(ctx context.Context, payload *fleet.Up } if payload.ValidatedLabels != nil { - if err := setOrUpdateSoftwareInstallerLabelsDB(ctx, tx, payload.InstallerID, *payload.ValidatedLabels); err != nil { + if err := setOrUpdateSoftwareInstallerLabelsDB(ctx, tx, payload.InstallerID, *payload.ValidatedLabels, softwareTypeInstaller); err != nil { return ctxerr.Wrap(ctx, err, "upsert software installer labels") } } @@ -583,7 +590,7 @@ WHERE if len(inclAny) > 0 && len(exclAny) > 0 { // there's a bug somewhere - level.Debug(ds.logger).Log("msg", "software installer has both include and exclude labels", "installer_id", dest.InstallerID, "include", fmt.Sprintf("%v", inclAny), "exclude", fmt.Sprintf("%v", exclAny)) + level.Warn(ds.logger).Log("msg", "software installer has both include and exclude labels", "installer_id", dest.InstallerID, "include", fmt.Sprintf("%v", inclAny), "exclude", fmt.Sprintf("%v", exclAny)) } dest.LabelsExcludeAny = exclAny dest.LabelsIncludeAny = inclAny @@ -1683,13 +1690,21 @@ WHERE global_or_team_id = ? } func (ds *Datastore) IsSoftwareInstallerLabelScoped(ctx context.Context, installerID, hostID uint) (bool, error) { + return ds.isSoftwareLabelScoped(ctx, installerID, hostID, softwareTypeInstaller) +} + +func (ds *Datastore) IsVPPAppLabelScoped(ctx context.Context, vppAppTeamID, hostID uint) (bool, error) { + return ds.isSoftwareLabelScoped(ctx, vppAppTeamID, hostID, softwareTypeVPP) +} + +func (ds *Datastore) isSoftwareLabelScoped(ctx context.Context, softwareID, hostID uint, swType softwareType) (bool, error) { stmt := ` SELECT 1 FROM ( -- no labels SELECT 0 AS count_installer_labels, 0 AS count_host_labels, 0 as count_host_updated_after_labels WHERE NOT EXISTS ( - SELECT 1 FROM software_installer_labels sil WHERE sil.software_installer_id = :installer_id + SELECT 1 FROM %[1]s_labels sil WHERE sil.%[1]s_id = :software_id ) UNION @@ -1700,11 +1715,11 @@ func (ds *Datastore) IsSoftwareInstallerLabelScoped(ctx context.Context, install COUNT(lm.label_id) AS count_host_labels, 0 as count_host_updated_after_labels FROM - software_installer_labels sil + %[1]s_labels sil LEFT OUTER JOIN label_membership lm ON lm.label_id = sil.label_id AND lm.host_id = :host_id WHERE - sil.software_installer_id = :installer_id + sil.%[1]s_id = :software_id AND sil.exclude = 0 HAVING count_installer_labels > 0 AND count_host_labels > 0 @@ -1725,25 +1740,27 @@ func (ds *Datastore) IsSoftwareInstallerLabelScoped(ctx context.Context, install 0 END) as count_host_updated_after_labels FROM - software_installer_labels sil + %[1]s_labels sil LEFT OUTER JOIN labels lbl ON lbl.id = sil.label_id LEFT OUTER JOIN label_membership lm ON lm.label_id = sil.label_id AND lm.host_id = :host_id WHERE - sil.software_installer_id = :installer_id + sil.%[1]s_id = :software_id AND sil.exclude = 1 HAVING count_installer_labels > 0 AND count_installer_labels = count_host_updated_after_labels AND count_host_labels = 0 ) t ` + + stmt = fmt.Sprintf(stmt, swType) namedArgs := map[string]any{ - "host_id": hostID, - "installer_id": installerID, + "host_id": hostID, + "software_id": softwareID, } stmt, args, err := sqlx.Named(stmt, namedArgs) if err != nil { - return false, ctxerr.Wrap(ctx, err, "build named query for is software installer label scoped") + return false, ctxerr.Wrap(ctx, err, "build named query for is software label scoped") } var res bool @@ -1752,7 +1769,7 @@ func (ds *Datastore) IsSoftwareInstallerLabelScoped(ctx context.Context, install return false, nil } - return false, ctxerr.Wrap(ctx, err, "is software installer label scoped") + return false, ctxerr.Wrap(ctx, err, "is software label scoped") } return res, nil diff --git a/server/datastore/mysql/software_test.go b/server/datastore/mysql/software_test.go index d654a9e0ad4f..0ebac7c82d4b 100644 --- a/server/datastore/mysql/software_test.go +++ b/server/datastore/mysql/software_test.go @@ -70,6 +70,7 @@ func TestSoftware(t *testing.T) { {"ListHostSoftwareInstallThenDeleteInstallers", testListHostSoftwareInstallThenDeleteInstallers}, {"ListSoftwareVersionsVulnerabilityFilters", testListSoftwareVersionsVulnerabilityFilters}, {"TestListHostSoftwareWithLabelScoping", testListHostSoftwareWithLabelScoping}, + {"TestListHostSoftwareWithLabelScopingVPP", testListHostSoftwareWithLabelScopingVPP}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -5339,7 +5340,7 @@ func testListHostSoftwareWithLabelScoping(t *testing.T, ds *Datastore) { err = setOrUpdateSoftwareInstallerLabelsDB(ctx, ds.writer(ctx), installerID1, fleet.LabelIdentsWithScope{ LabelScope: fleet.LabelScopeExcludeAny, ByName: map[string]fleet.LabelIdent{label1.Name: {LabelName: label1.Name, LabelID: label1.ID}}, - }) + }, softwareTypeInstaller) require.NoError(t, err) // should be empty as the installer label is "exclude any" @@ -5360,7 +5361,7 @@ func testListHostSoftwareWithLabelScoping(t *testing.T, ds *Datastore) { err = setOrUpdateSoftwareInstallerLabelsDB(ctx, ds.writer(ctx), installerID1, fleet.LabelIdentsWithScope{ LabelScope: fleet.LabelScopeIncludeAny, ByName: map[string]fleet.LabelIdent{label1.Name: {LabelName: label1.Name, LabelID: label1.ID}}, - }) + }, softwareTypeInstaller) require.NoError(t, err) software, _, err = ds.ListHostSoftware(ctx, host, opts) @@ -5416,7 +5417,7 @@ func testListHostSoftwareWithLabelScoping(t *testing.T, ds *Datastore) { label2.Name: {LabelName: label2.Name, LabelID: label2.ID}, label3.Name: {LabelName: label3.Name, LabelID: label3.ID}, }, - }) + }, softwareTypeInstaller) require.NoError(t, err) // Now host has label1, label2 @@ -5475,7 +5476,7 @@ func testListHostSoftwareWithLabelScoping(t *testing.T, ds *Datastore) { err = setOrUpdateSoftwareInstallerLabelsDB(ctx, ds.writer(ctx), installerID3, fleet.LabelIdentsWithScope{ LabelScope: fleet.LabelScopeExcludeAny, ByName: map[string]fleet.LabelIdent{label4.Name: {LabelName: label4.Name, LabelID: label4.ID}}, - }) + }, softwareTypeInstaller) require.NoError(t, err) // We should have [installerID1, installerID3], but the exclude any label has @@ -5519,7 +5520,7 @@ func testListHostSoftwareWithLabelScoping(t *testing.T, ds *Datastore) { err = setOrUpdateSoftwareInstallerLabelsDB(ctx, ds.writer(ctx), installerID3, fleet.LabelIdentsWithScope{ LabelScope: fleet.LabelScopeIncludeAny, ByName: map[string]fleet.LabelIdent{label4.Name: {LabelName: label4.Name, LabelID: label4.ID}}, - }) + }, softwareTypeInstaller) require.NoError(t, err) // We should have [installerID1] @@ -5537,3 +5538,230 @@ func testListHostSoftwareWithLabelScoping(t *testing.T, ds *Datastore) { require.NoError(t, err) require.False(t, scoped) } + +func testListHostSoftwareWithLabelScopingVPP(t *testing.T, ds *Datastore) { + ctx := context.Background() + + // create a host + host := test.NewHost(t, ds, "host1", "", "host1key", "host1uuid", time.Now(), test.WithPlatform("darwin")) + nanoEnroll(t, ds, host, false) + user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true) + + dataToken, err := test.CreateVPPTokenData(time.Now().Add(24*time.Hour), "Test org"+t.Name(), "Test location"+t.Name()) + require.NoError(t, err) + tok1, err := ds.InsertVPPToken(ctx, dataToken) + require.NoError(t, err) + _, err = ds.UpdateVPPTokenTeams(ctx, tok1.ID, []uint{}) + require.NoError(t, err) + + time.Sleep(time.Second) // ensure the labels_updated_at timestamp is before labels creation + + vppApp := &fleet.VPPApp{Name: "vpp_app_1", VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "1", Platform: fleet.MacOSPlatform}}, BundleIdentifier: "b1"} + vppApp, err = ds.InsertVPPAppWithTeam(ctx, vppApp, nil) + require.NoError(t, err) + vppAppTeamID := vppApp.VPPAppTeam.AppTeamID + + // create a software installer + tfr1, err := fleet.NewTempFileReader(strings.NewReader("hello"), t.TempDir) + require.NoError(t, err) + installer1 := &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "hello", + PreInstallQuery: "SELECT 1", + PostInstallScript: "world", + UninstallScript: "goodbye", + InstallerFile: tfr1, + StorageID: "storage1", + Filename: "file1", + Title: "file1", + Version: "1.0", + Source: "apps", + UserID: user1.ID, + BundleIdentifier: "bi1", + Platform: "darwin", + ValidatedLabels: &fleet.LabelIdentsWithScope{}, + } + installerID1, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, installer1) + require.NoError(t, err) + + // we should see installer1, since it has no label associated yet + opts := fleet.HostSoftwareTitleListOptions{ + ListOptions: fleet.ListOptions{ + PerPage: 11, + IncludeMetadata: true, + OrderKey: "name", + TestSecondaryOrderKey: "source", + }, + IncludeAvailableForInstall: true, + IsMDMEnrolled: true, + } + expectedInstallers := map[string]*fleet.SoftwarePackageOrApp{ + installer1.Filename: { + Name: installer1.Filename, + Version: installer1.Version, + SelfService: ptr.Bool(false), + }, + vppApp.Name: { + AppStoreID: vppApp.AdamID, + SelfService: ptr.Bool(false), + }, + } + + checkSoftware := func(swList []*fleet.HostSoftwareWithInstaller, excludeNames ...string) { + expectedLen := len(expectedInstallers) - len(excludeNames) + require.Equal(t, len(swList), expectedLen) + for _, got := range swList { + if got.IsPackage() { + want, ok := expectedInstallers[got.SoftwarePackage.Name] + if slices.Contains(excludeNames, got.SoftwarePackage.Name) { + require.False(t, ok) + continue + } + require.True(t, ok) + require.Equal(t, want, got.SoftwarePackage) + } + + if got.IsAppStoreApp() { + want, ok := expectedInstallers[got.Name] + if slices.Contains(excludeNames, got.AppStoreApp.AppStoreID) { + require.False(t, ok) + continue + } + require.True(t, ok) + require.Equal(t, want, got.AppStoreApp) + } + } + } + + software, _, err := ds.ListHostSoftware(ctx, host, opts) + require.NoError(t, err) + checkSoftware(software) + + // installer1 should be in scope since it has no labels + scoped, err := ds.IsSoftwareInstallerLabelScoped(ctx, installerID1, host.ID) + require.NoError(t, err) + require.True(t, scoped) + + // vppApp should be in scope since it has no labels + scoped, err = ds.IsVPPAppLabelScoped(ctx, vppApp.VPPAppTeam.AppTeamID, host.ID) + require.NoError(t, err) + require.True(t, scoped) + + // Create a couple of labels + label1, err := ds.NewLabel(ctx, &fleet.Label{Name: "label1" + t.Name()}) + require.NoError(t, err) + label2, err := ds.NewLabel(ctx, &fleet.Label{Name: "label2" + t.Name()}) + require.NoError(t, err) + + // assign the label to the host + require.NoError(t, ds.AddLabelsToHost(ctx, host.ID, []uint{label1.ID})) + host.LabelUpdatedAt = time.Now() + err = ds.UpdateHost(ctx, host) + require.NoError(t, err) + time.Sleep(time.Second) + + // assign the label to the software installer + err = setOrUpdateSoftwareInstallerLabelsDB(ctx, ds.writer(ctx), installerID1, fleet.LabelIdentsWithScope{ + LabelScope: fleet.LabelScopeExcludeAny, + ByName: map[string]fleet.LabelIdent{label1.Name: {LabelName: label1.Name, LabelID: label1.ID}}, + }, softwareTypeInstaller) + require.NoError(t, err) + + // should contain only the VPP app as the installer label is "exclude any" + software, _, err = ds.ListHostSoftware(ctx, host, opts) + require.NoError(t, err) + checkSoftware(software, installer1.Filename) + + // Assign the label to the VPP app. Now we should have an empty list + err = setOrUpdateSoftwareInstallerLabelsDB(ctx, ds.writer(ctx), vppAppTeamID, fleet.LabelIdentsWithScope{ + LabelScope: fleet.LabelScopeExcludeAny, + ByName: map[string]fleet.LabelIdent{label1.Name: {LabelName: label1.Name, LabelID: label1.ID}}, + }, softwareTypeVPP) + require.NoError(t, err) + + software, _, err = ds.ListHostSoftware(ctx, host, opts) + require.NoError(t, err) + checkSoftware(software, installer1.Filename, vppApp.Name) + + // Make the label include any. We should have both of them back. + err = setOrUpdateSoftwareInstallerLabelsDB(ctx, ds.writer(ctx), installerID1, fleet.LabelIdentsWithScope{ + LabelScope: fleet.LabelScopeIncludeAny, + ByName: map[string]fleet.LabelIdent{label1.Name: {LabelName: label1.Name, LabelID: label1.ID}}, + }, softwareTypeInstaller) + require.NoError(t, err) + + err = setOrUpdateSoftwareInstallerLabelsDB(ctx, ds.writer(ctx), vppAppTeamID, fleet.LabelIdentsWithScope{ + LabelScope: fleet.LabelScopeIncludeAny, + ByName: map[string]fleet.LabelIdent{label1.Name: {LabelName: label1.Name, LabelID: label1.ID}}, + }, softwareTypeVPP) + require.NoError(t, err) + + software, _, err = ds.ListHostSoftware(ctx, host, opts) + require.NoError(t, err) + checkSoftware(software) + + // Give the VPP app a different label. Only the installer should show up now, since the host + // only has label1. + err = setOrUpdateSoftwareInstallerLabelsDB(ctx, ds.writer(ctx), vppAppTeamID, fleet.LabelIdentsWithScope{ + LabelScope: fleet.LabelScopeIncludeAny, + ByName: map[string]fleet.LabelIdent{label2.Name: {LabelName: label2.Name, LabelID: label2.ID}}, + }, softwareTypeVPP) + require.NoError(t, err) + + software, _, err = ds.ListHostSoftware(ctx, host, opts) + require.NoError(t, err) + checkSoftware(software, vppApp.Name) + + scoped, err = ds.IsVPPAppLabelScoped(ctx, vppApp.VPPAppTeam.AppTeamID, host.ID) + require.NoError(t, err) + require.False(t, scoped) + + err = setOrUpdateSoftwareInstallerLabelsDB(ctx, ds.writer(ctx), vppAppTeamID, fleet.LabelIdentsWithScope{ + LabelScope: fleet.LabelScopeIncludeAny, + ByName: map[string]fleet.LabelIdent{label2.Name: {LabelName: label2.Name, LabelID: label2.ID}, label1.Name: {LabelName: label1.Name, LabelID: label1.ID}}, + }, softwareTypeVPP) + require.NoError(t, err) + + software, _, err = ds.ListHostSoftware(ctx, host, opts) + require.NoError(t, err) + checkSoftware(software) + + scoped, err = ds.IsVPPAppLabelScoped(ctx, vppApp.VPPAppTeam.AppTeamID, host.ID) + require.NoError(t, err) + require.True(t, scoped) + + // Create another label. + time.Sleep(time.Second) + label3, err := ds.NewLabel(ctx, &fleet.Label{Name: "label3" + t.Name()}) + require.NoError(t, err) + + err = setOrUpdateSoftwareInstallerLabelsDB(ctx, ds.writer(ctx), vppAppTeamID, fleet.LabelIdentsWithScope{ + LabelScope: fleet.LabelScopeExcludeAny, + ByName: map[string]fleet.LabelIdent{label3.Name: {LabelName: label3.Name, LabelID: label3.ID}}, + }, softwareTypeVPP) + require.NoError(t, err) + + // the VPP app is still out of scope, because label3 was added as exclude any and the host's + // LabelUpdatedAt isn't fresh enough. + software, _, err = ds.ListHostSoftware(ctx, host, opts) + require.NoError(t, err) + checkSoftware(software, vppApp.Name) + + scoped, err = ds.IsVPPAppLabelScoped(ctx, vppApp.VPPAppTeam.AppTeamID, host.ID) + require.NoError(t, err) + require.False(t, scoped) + + // mark as if label had been reported (but host is still not a member). This should bring the + // VPP app back in scope, since it's exclude any and the host doesn't have label 3. + host.LabelUpdatedAt = time.Now() + err = ds.UpdateHost(ctx, host) + require.NoError(t, err) + time.Sleep(time.Second) + + software, _, err = ds.ListHostSoftware(ctx, host, opts) + require.NoError(t, err) + checkSoftware(software) + + scoped, err = ds.IsVPPAppLabelScoped(ctx, vppApp.VPPAppTeam.AppTeamID, host.ID) + require.NoError(t, err) + require.True(t, scoped) +} diff --git a/server/datastore/mysql/vpp.go b/server/datastore/mysql/vpp.go index 56b18ede3b31..26fd256951dd 100644 --- a/server/datastore/mysql/vpp.go +++ b/server/datastore/mysql/vpp.go @@ -16,6 +16,7 @@ import ( "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm" + "github.com/go-kit/log/level" "github.com/go-sql-driver/mysql" "github.com/jmoiron/sqlx" ) @@ -29,7 +30,8 @@ SELECT vap.latest_version, vat.self_service, vat.id vpp_apps_teams_id, - NULLIF(vap.icon_url, '') AS icon_url + NULLIF(vap.icon_url, '') AS icon_url, + vap.bundle_identifier AS bundle_identifier FROM vpp_apps vap INNER JOIN vpp_apps_teams vat ON vat.adam_id = vap.adam_id AND vat.platform = vap.platform @@ -53,6 +55,26 @@ WHERE return nil, ctxerr.Wrap(ctx, err, "get VPP app metadata") } + labels, err := ds.getVPPAppLabels(ctx, app.VPPAppsTeamsID) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "get vpp app labels") + } + var exclAny, inclAny []fleet.SoftwareScopeLabel + for _, l := range labels { + if l.Exclude { + exclAny = append(exclAny, l) + } else { + inclAny = append(inclAny, l) + } + } + + if len(inclAny) > 0 && len(exclAny) > 0 { + // there's a bug somewhere + level.Warn(ds.logger).Log("msg", "vpp app has both include and exclude labels", "vpp_apps_teams_id", app.VPPAppsTeamsID, "include", fmt.Sprintf("%v", inclAny), "exclude", fmt.Sprintf("%v", exclAny)) + } + app.LabelsExcludeAny = exclAny + app.LabelsIncludeAny = inclAny + policies, err := ds.getPoliciesBySoftwareTitleIDs(ctx, []uint{titleID}, teamID) if err != nil { return nil, ctxerr.Wrap(ctx, err, "get policies by software title ID") @@ -62,6 +84,30 @@ WHERE return &app, nil } +func (ds *Datastore) getVPPAppLabels(ctx context.Context, vppAppsTeamsID uint) ([]fleet.SoftwareScopeLabel, error) { + query := ` +SELECT + label_id, + exclude, + l.name AS label_name, + va.title_id AS title_id +FROM + vpp_app_team_labels vatl + JOIN vpp_apps_teams vat ON vat.id = vatl.vpp_app_team_id + JOIN vpp_apps va ON va.adam_id = vat.adam_id + JOIN labels l ON l.id = vatl.label_id +WHERE + vatl.vpp_app_team_id = ? AND va.platform = vat.platform +` + + var labels []fleet.SoftwareScopeLabel + if err := sqlx.SelectContext(ctx, ds.reader(ctx), &labels, query, vppAppsTeamsID); err != nil { + return nil, ctxerr.Wrap(ctx, err, "get vpp app labels") + } + + return labels, nil +} + func (ds *Datastore) GetSummaryHostVPPAppInstalls(ctx context.Context, teamID *uint, appID fleet.VPPAppID) (*fleet.VPPAppStatusSummary, error, ) { @@ -170,6 +216,48 @@ func (ds *Datastore) BatchInsertVPPApps(ctx context.Context, apps []*fleet.VPPAp }) } +func (ds *Datastore) getExistingLabels(ctx context.Context, vppAppTeamID uint) (*fleet.LabelIdentsWithScope, error) { + existingLabels, err := ds.getVPPAppLabels(ctx, vppAppTeamID) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "getting existing labels") + } + + var labels fleet.LabelIdentsWithScope + var exclAny, inclAny []fleet.SoftwareScopeLabel + for _, l := range existingLabels { + if l.Exclude { + exclAny = append(exclAny, l) + } else { + inclAny = append(inclAny, l) + } + } + + if len(inclAny) > 0 && len(exclAny) > 0 { + // there's a bug somewhere + return nil, ctxerr.New(ctx, "found both include and exclude labels on a vpp app") + } + + switch { + case len(exclAny) > 0: + labels.LabelScope = fleet.LabelScopeExcludeAny + labels.ByName = make(map[string]fleet.LabelIdent, len(exclAny)) + for _, l := range exclAny { + labels.ByName[l.LabelName] = fleet.LabelIdent{LabelName: l.LabelName, LabelID: l.LabelID} + } + return &labels, nil + + case len(inclAny) > 0: + labels.LabelScope = fleet.LabelScopeExcludeAny + labels.ByName = make(map[string]fleet.LabelIdent, len(inclAny)) + for _, l := range inclAny { + labels.ByName[l.LabelName] = fleet.LabelIdent{LabelName: l.LabelName, LabelID: l.LabelID} + } + return &labels, nil + default: + return nil, nil + } +} + func (ds *Datastore) SetTeamVPPApps(ctx context.Context, teamID *uint, appFleets []fleet.VPPAppTeam) error { existingApps, err := ds.GetAssignedVPPApps(ctx, teamID) if err != nil { @@ -206,10 +294,24 @@ func (ds *Datastore) SetTeamVPPApps(ctx context.Context, teamID *uint, appFleets } for _, appFleet := range appFleets { - // upsert it if it does not exist or SelfService or InstallDuringSetup flags are changed - if existingFleet, ok := existingApps[appFleet.VPPAppID]; !ok || existingFleet.SelfService != appFleet.SelfService || + // upsert it if it does not exist or labels or SelfService or InstallDuringSetup flags are changed + existingApp, isExistingApp := existingApps[appFleet.VPPAppID] + var labelsChanged bool + if isExistingApp { + existingLabels, err := ds.getExistingLabels(ctx, appFleet.AppTeamID) + if err != nil { + return ctxerr.Wrap(ctx, err, "getting existing labels for vpp app") + } + + labelsChanged = !existingLabels.Equal(appFleet.ValidatedLabels) + } + + if !isExistingApp || + existingApp.SelfService != appFleet.SelfService || + labelsChanged || appFleet.InstallDuringSetup != nil && - existingFleet.InstallDuringSetup != nil && *appFleet.InstallDuringSetup != *existingFleet.InstallDuringSetup { + existingApp.InstallDuringSetup != nil && + *appFleet.InstallDuringSetup != *existingApp.InstallDuringSetup { toAddApps = append(toAddApps, appFleet) } } @@ -224,9 +326,16 @@ func (ds *Datastore) SetTeamVPPApps(ctx context.Context, teamID *uint, appFleets return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { for _, toAdd := range toAddApps { - if err := insertVPPAppTeams(ctx, tx, toAdd, teamID, vppToken.ID); err != nil { + vppAppTeamID, err := insertVPPAppTeams(ctx, tx, toAdd, teamID, vppToken.ID) + if err != nil { return ctxerr.Wrap(ctx, err, "SetTeamVPPApps inserting vpp app into team") } + + if toAdd.ValidatedLabels != nil { + if err := setOrUpdateSoftwareInstallerLabelsDB(ctx, tx, vppAppTeamID, *toAdd.ValidatedLabels, softwareTypeVPP); err != nil { + return ctxerr.Wrap(ctx, err, "failed to update labels on vpp apps batch operation") + } + } } for _, toRemove := range toRemoveApps { @@ -257,10 +366,19 @@ func (ds *Datastore) InsertVPPAppWithTeam(ctx context.Context, app *fleet.VPPApp return ctxerr.Wrap(ctx, err, "InsertVPPAppWithTeam insertVPPApps transaction") } - if err := insertVPPAppTeams(ctx, tx, app.VPPAppTeam, teamID, vppToken.ID); err != nil { + vppAppTeamID, err := insertVPPAppTeams(ctx, tx, app.VPPAppTeam, teamID, vppToken.ID) + if err != nil { return ctxerr.Wrap(ctx, err, "InsertVPPAppWithTeam insertVPPAppTeams transaction") } + app.VPPAppTeam.AppTeamID = vppAppTeamID + + if app.ValidatedLabels != nil { + if err := setOrUpdateSoftwareInstallerLabelsDB(ctx, tx, vppAppTeamID, *app.ValidatedLabels, softwareTypeVPP); err != nil { + return ctxerr.Wrap(ctx, err, "InsertVPPAppWithTeam setOrUpdateSoftwareInstallerLabelsDB transaction") + } + } + return nil }) if err != nil { @@ -344,7 +462,7 @@ ON DUPLICATE KEY UPDATE return ctxerr.Wrap(ctx, err, "insert VPP apps") } -func insertVPPAppTeams(ctx context.Context, tx sqlx.ExtContext, appID fleet.VPPAppTeam, teamID *uint, vppTokenID uint) error { +func insertVPPAppTeams(ctx context.Context, tx sqlx.ExtContext, appID fleet.VPPAppTeam, teamID *uint, vppTokenID uint) (uint, error) { stmt := ` INSERT INTO vpp_apps_teams (adam_id, global_or_team_id, team_id, platform, self_service, vpp_token_id, install_during_setup) @@ -364,7 +482,7 @@ ON DUPLICATE KEY UPDATE } } - _, err := tx.ExecContext(ctx, stmt, appID.AdamID, globalOrTmID, teamID, appID.Platform, appID.SelfService, vppTokenID, appID.InstallDuringSetup, appID.InstallDuringSetup) + res, err := tx.ExecContext(ctx, stmt, appID.AdamID, globalOrTmID, teamID, appID.Platform, appID.SelfService, vppTokenID, appID.InstallDuringSetup, appID.InstallDuringSetup) if IsDuplicate(err) { err = &existsError{ Identifier: fmt.Sprintf("%s %s self_service: %v", appID.AdamID, appID.Platform, appID.SelfService), @@ -373,7 +491,18 @@ ON DUPLICATE KEY UPDATE } } - return ctxerr.Wrap(ctx, err, "writing vpp app team mapping to db") + var id int64 + if insertOnDuplicateDidInsertOrUpdate(res) { + id, _ = res.LastInsertId() + } else { + stmt := `SELECT id FROM vpp_apps_teams WHERE adam_id = ? AND platform = ?` + if err := sqlx.GetContext(ctx, tx, &id, stmt, appID.AdamID, appID.Platform); err != nil { + return 0, ctxerr.Wrap(ctx, err, "vpp app teams id") + } + } + + vatID := uint(id) //nolint:gosec // dismiss G115 + return vatID, ctxerr.Wrap(ctx, err, "writing vpp app team mapping to db") } func removeVPPAppTeams(ctx context.Context, tx sqlx.ExtContext, appID fleet.VPPAppID, teamID *uint) error { @@ -513,18 +642,37 @@ func (ds *Datastore) GetTitleInfoFromVPPAppsTeamsID(ctx context.Context, vppApps return &info, nil } -func (ds *Datastore) GetVPPAppMetadataByAdamIDAndPlatform(ctx context.Context, adamID string, platform fleet.AppleDevicePlatform) (*fleet.VPPApp, error) { - stmt := `SELECT va.adam_id, va.bundle_identifier, va.icon_url, va.name, va.title_id, va.platform, va.created_at, va.updated_at - FROM vpp_apps va WHERE va.adam_id = ? AND va.platform = ? +func (ds *Datastore) GetVPPAppMetadataByAdamIDPlatformTeamID(ctx context.Context, adamID string, platform fleet.AppleDevicePlatform, teamID *uint) (*fleet.VPPApp, error) { + stmt := ` + SELECT va.adam_id, + va.bundle_identifier, + va.icon_url, + va.name, + va.platform, + vat.self_service, + va.title_id, + va.platform, + va.created_at, + va.updated_at, + vat.id + FROM vpp_apps va + JOIN vpp_apps_teams vat ON va.adam_id = vat.adam_id AND va.platform = vat.platform AND vat.global_or_team_id = ? + WHERE va.adam_id = ? AND va.platform = ? ` + // when team id is not nil, we need to filter by the global or team id given. + var tmID uint + if teamID != nil { + tmID = *teamID + } + var dest fleet.VPPApp - err := sqlx.GetContext(ctx, ds.reader(ctx), &dest, stmt, adamID, platform) + err := sqlx.GetContext(ctx, ds.reader(ctx), &dest, stmt, tmID, adamID, platform) if err != nil { if err == sql.ErrNoRows { - return nil, ctxerr.Wrap(ctx, notFound("VPPApp"), "get VPP app") + return nil, ctxerr.Wrap(ctx, notFound("VPPApp"), "get VPP app metadata by team") } - return nil, ctxerr.Wrap(ctx, err, "get VPP app") + return nil, ctxerr.Wrap(ctx, err, "get VPP app metadata by team") } return &dest, nil @@ -533,6 +681,7 @@ func (ds *Datastore) GetVPPAppMetadataByAdamIDAndPlatform(ctx context.Context, a func (ds *Datastore) GetVPPAppByTeamAndTitleID(ctx context.Context, teamID *uint, titleID uint) (*fleet.VPPApp, error) { stmt := ` SELECT + vat.id, va.adam_id, va.bundle_identifier, va.icon_url, diff --git a/server/datastore/mysql/vpp_test.go b/server/datastore/mysql/vpp_test.go index 87d3764a7fb1..5b04b6fbc601 100644 --- a/server/datastore/mysql/vpp_test.go +++ b/server/datastore/mysql/vpp_test.go @@ -25,6 +25,7 @@ func TestVPP(t *testing.T) { fn func(t *testing.T, ds *Datastore) }{ {"SetTeamVPPApps", testSetTeamVPPApps}, + {"SetTeamVPPAppsWithLabels", testSetTeamVPPAppsWithLabels}, {"VPPAppMetadata", testVPPAppMetadata}, {"VPPAppStatus", testVPPAppStatus}, {"VPPApps", testVPPApps}, @@ -68,7 +69,7 @@ func testVPPAppMetadata(t *testing.T, ds *Datastore) { // create no-team app va1, err := ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{ Name: "vpp1", BundleIdentifier: "com.app.vpp1", - VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_app_1", Platform: fleet.MacOSPlatform}}, + VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_app_1", Platform: fleet.MacOSPlatform}, SelfService: true}, }, nil) require.NoError(t, err) vpp1, titleID1 := va1.VPPAppID, va1.TitleID @@ -78,7 +79,20 @@ func testVPPAppMetadata(t *testing.T, ds *Datastore) { require.NoError(t, err) require.NotZero(t, meta.VPPAppsTeamsID) meta.VPPAppsTeamsID = 0 // we don't care about the VPP app team PK for comparison purposes - require.Equal(t, &fleet.VPPAppStoreApp{Name: "vpp1", VPPAppID: vpp1}, meta) + require.Equal(t, &fleet.VPPAppStoreApp{Name: "vpp1", VPPAppID: vpp1, BundleIdentifier: "com.app.vpp1", SelfService: true}, meta) + + // Check that getting metadata in team context works for no team + _, err = ds.GetVPPAppMetadataByAdamIDPlatformTeamID(ctx, "foo", meta.Platform, nil) + require.ErrorContains(t, err, "not found") + + gotMeta, err := ds.GetVPPAppMetadataByAdamIDPlatformTeamID(ctx, meta.AdamID, meta.Platform, nil) + require.NoError(t, err) + require.Equal(t, "com.app.vpp1", gotMeta.BundleIdentifier) + require.Equal(t, "vpp1", gotMeta.Name) + require.Equal(t, titleID1, gotMeta.TitleID) + require.Equal(t, va1.VPPAppTeam.AppTeamID, gotMeta.VPPAppTeam.AppTeamID) + require.Equal(t, fleet.VPPAppID{Platform: fleet.MacOSPlatform, AdamID: meta.AdamID}, gotMeta.VPPAppTeam.VPPAppID) + require.True(t, gotMeta.SelfService) // try to add the same app again, update self_service field _, err = ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{ @@ -94,7 +108,7 @@ func testVPPAppMetadata(t *testing.T, ds *Datastore) { require.NoError(t, err) require.Equal(t, titleID1, title.SoftwareTitleID) meta.VPPAppsTeamsID = 0 - require.Equal(t, &fleet.VPPAppStoreApp{Name: "vpp1", VPPAppID: vpp1, SelfService: true}, meta) + require.Equal(t, &fleet.VPPAppStoreApp{Name: "vpp1", VPPAppID: vpp1, SelfService: true, BundleIdentifier: "com.app.vpp1"}, meta) // get nonexistent title _, err = ds.GetTitleInfoFromVPPAppsTeamsID(ctx, 0) @@ -112,13 +126,13 @@ func testVPPAppMetadata(t *testing.T, ds *Datastore) { meta, err = ds.GetVPPAppMetadataByTeamAndTitleID(ctx, &team1.ID, titleID2) require.NoError(t, err) meta.VPPAppsTeamsID = 0 - require.Equal(t, &fleet.VPPAppStoreApp{Name: "vpp2", VPPAppID: vpp2}, meta) + require.Equal(t, &fleet.VPPAppStoreApp{Name: "vpp2", VPPAppID: vpp2, BundleIdentifier: "com.app.vpp2"}, meta) // get it for all teams meta, err = ds.GetVPPAppMetadataByTeamAndTitleID(ctx, nil, titleID2) require.NoError(t, err) meta.VPPAppsTeamsID = 0 - require.Equal(t, &fleet.VPPAppStoreApp{Name: "vpp2", VPPAppID: vpp2}, meta) + require.Equal(t, &fleet.VPPAppStoreApp{Name: "vpp2", VPPAppID: vpp2, BundleIdentifier: "com.app.vpp2"}, meta) // try to add the same app again, fails _, err = ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{ @@ -131,7 +145,7 @@ func testVPPAppMetadata(t *testing.T, ds *Datastore) { meta, err = ds.GetVPPAppMetadataByTeamAndTitleID(ctx, &team1.ID, titleID2) require.NoError(t, err) meta.VPPAppsTeamsID = 0 - require.Equal(t, &fleet.VPPAppStoreApp{Name: "vpp2", VPPAppID: vpp2, SelfService: true}, meta) + require.Equal(t, &fleet.VPPAppStoreApp{Name: "vpp2", VPPAppID: vpp2, SelfService: true, BundleIdentifier: "com.app.vpp2"}, meta) // get it for team 2, does not exist meta, err = ds.GetVPPAppMetadataByTeamAndTitleID(ctx, &team2.ID, titleID2) @@ -142,7 +156,7 @@ func testVPPAppMetadata(t *testing.T, ds *Datastore) { // create the same app for team2 _, err = ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{ Name: "vpp2", BundleIdentifier: "com.app.vpp2", - VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_app_2", Platform: fleet.MacOSPlatform}}, + VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_app_2", Platform: fleet.MacOSPlatform}, SelfService: true}, }, &team2.ID) require.NoError(t, err) @@ -150,11 +164,11 @@ func testVPPAppMetadata(t *testing.T, ds *Datastore) { meta, err = ds.GetVPPAppMetadataByTeamAndTitleID(ctx, &team1.ID, titleID2) require.NoError(t, err) meta.VPPAppsTeamsID = 0 // we don't care about the VPP app team PK - require.Equal(t, &fleet.VPPAppStoreApp{Name: "vpp2", VPPAppID: vpp2, SelfService: true}, meta) + require.Equal(t, &fleet.VPPAppStoreApp{Name: "vpp2", VPPAppID: vpp2, SelfService: true, BundleIdentifier: "com.app.vpp2"}, meta) meta, err = ds.GetVPPAppMetadataByTeamAndTitleID(ctx, &team2.ID, titleID2) require.NoError(t, err) meta.VPPAppsTeamsID = 0 - require.Equal(t, &fleet.VPPAppStoreApp{Name: "vpp2", VPPAppID: vpp2}, meta) + require.Equal(t, &fleet.VPPAppStoreApp{Name: "vpp2", VPPAppID: vpp2, BundleIdentifier: "com.app.vpp2", SelfService: true}, meta) // create another no-team app va3, err := ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{ @@ -174,7 +188,7 @@ func testVPPAppMetadata(t *testing.T, ds *Datastore) { meta, err = ds.GetVPPAppMetadataByTeamAndTitleID(ctx, nil, titleID3) require.NoError(t, err) meta.VPPAppsTeamsID = 0 - require.Equal(t, &fleet.VPPAppStoreApp{Name: "vpp3", VPPAppID: vpp3}, meta) + require.Equal(t, &fleet.VPPAppStoreApp{Name: "vpp3", VPPAppID: vpp3, BundleIdentifier: "com.app.vpp3"}, meta) // delete vpp1 err = ds.DeleteVPPAppFromTeam(ctx, nil, vpp1) @@ -187,7 +201,7 @@ func testVPPAppMetadata(t *testing.T, ds *Datastore) { meta, err = ds.GetVPPAppMetadataByTeamAndTitleID(ctx, nil, titleID3) require.NoError(t, err) meta.VPPAppsTeamsID = 0 // we don't care about the VPP app team PK - require.Equal(t, &fleet.VPPAppStoreApp{Name: "vpp3", VPPAppID: vpp3}, meta) + require.Equal(t, &fleet.VPPAppStoreApp{Name: "vpp3", VPPAppID: vpp3, BundleIdentifier: "com.app.vpp3"}, meta) // delete vpp2 for team1 err = ds.DeleteVPPAppFromTeam(ctx, &team1.ID, vpp2) @@ -199,17 +213,23 @@ func testVPPAppMetadata(t *testing.T, ds *Datastore) { // but still found for team2 meta, err = ds.GetVPPAppMetadataByTeamAndTitleID(ctx, &team2.ID, titleID2) require.NoError(t, err) + expectedVPPAppsTeamsID := meta.VPPAppsTeamsID meta.VPPAppsTeamsID = 0 // we don't care about the VPP app team PK - require.Equal(t, &fleet.VPPAppStoreApp{Name: "vpp2", VPPAppID: vpp2}, meta) + require.Equal(t, &fleet.VPPAppStoreApp{Name: "vpp2", VPPAppID: vpp2, BundleIdentifier: "com.app.vpp2", SelfService: true}, meta) - appMeta, err := ds.GetVPPAppMetadataByAdamIDAndPlatform(ctx, meta.AdamID, meta.Platform) - require.NoError(t, err) - require.Equal(t, appMeta.AdamID, meta.AdamID) - require.Equal(t, appMeta.Platform, meta.Platform) - - _, err = ds.GetVPPAppMetadataByAdamIDAndPlatform(ctx, "foo", meta.Platform) + // Check that getting metadata in team context works + _, err = ds.GetVPPAppMetadataByAdamIDPlatformTeamID(ctx, "foo", meta.Platform, &team2.ID) require.ErrorContains(t, err, "not found") + gotMeta, err = ds.GetVPPAppMetadataByAdamIDPlatformTeamID(ctx, meta.AdamID, meta.Platform, &team2.ID) + require.NoError(t, err) + require.Equal(t, "com.app.vpp2", gotMeta.BundleIdentifier) + require.Equal(t, "vpp2", gotMeta.Name) + require.Equal(t, titleID2, gotMeta.TitleID) + require.Equal(t, expectedVPPAppsTeamsID, gotMeta.VPPAppTeam.AppTeamID) + require.Equal(t, fleet.VPPAppID{Platform: fleet.MacOSPlatform, AdamID: meta.AdamID}, gotMeta.VPPAppTeam.VPPAppID) + require.True(t, gotMeta.SelfService) + // mark it as install_during_setup for team 2 ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { _, err := q.ExecContext(ctx, `UPDATE vpp_apps_teams SET install_during_setup = 1 WHERE global_or_team_id = ? AND adam_id = ?`, team2.ID, vpp2.AdamID) @@ -446,12 +466,82 @@ func createVPPAppInstallResult(t *testing.T, ds *Datastore, host *fleet.Host, cm func testVPPApps(t *testing.T, ds *Datastore) { ctx := context.Background() - // Create a team + // Create a couple of teams team, err := ds.NewTeam(ctx, &fleet.Team{Name: "foobar"}) require.NoError(t, err) test.CreateInsertGlobalVPPToken(t, ds) + t.Run("vpp apps with labels", func(t *testing.T) { + teamWithLabels, err := ds.NewTeam(ctx, &fleet.Team{Name: "labels" + t.Name()}) + require.NoError(t, err) + + // Create some labels + label1, err := ds.NewLabel(ctx, &fleet.Label{ + Name: "label1" + t.Name(), + Description: "a label", + Query: "select 1 from processes;", + Platform: "darwin", + }) + require.NoError(t, err) + + label2, err := ds.NewLabel(ctx, &fleet.Label{ + Name: "label2" + t.Name(), + Description: "a label", + Query: "select 2 from processes;", + Platform: "darwin", + }) + require.NoError(t, err) + + // insert a VPP app with include_any labels + labeledApp := &fleet.VPPApp{ + Name: "vpp_app_labels_1" + t.Name(), + VPPAppTeam: fleet.VPPAppTeam{ + VPPAppID: fleet.VPPAppID{AdamID: "5", Platform: fleet.MacOSPlatform}, + ValidatedLabels: &fleet.LabelIdentsWithScope{ + LabelScope: fleet.LabelScopeIncludeAny, + ByName: map[string]fleet.LabelIdent{label1.Name: { + LabelID: label1.ID, + LabelName: label1.Name, + }, label2.Name: {LabelID: label2.ID, LabelName: label2.Name}}, + }, + }, + BundleIdentifier: "b5", + } + _, err = ds.InsertVPPAppWithTeam(ctx, labeledApp, &teamWithLabels.ID) + require.NoError(t, err) + + meta, err := ds.GetVPPAppMetadataByTeamAndTitleID(ctx, &teamWithLabels.ID, labeledApp.TitleID) + require.NoError(t, err) + + require.Len(t, meta.LabelsIncludeAny, 2) + require.Len(t, meta.LabelsExcludeAny, 0) + + // insert a VPP app with exclude_any labels + labeledApp = &fleet.VPPApp{ + Name: "vpp_app_labels_2" + t.Name(), + VPPAppTeam: fleet.VPPAppTeam{ + VPPAppID: fleet.VPPAppID{AdamID: "6", Platform: fleet.MacOSPlatform}, + ValidatedLabels: &fleet.LabelIdentsWithScope{ + LabelScope: fleet.LabelScopeExcludeAny, + ByName: map[string]fleet.LabelIdent{label1.Name: { + LabelID: label1.ID, + LabelName: label1.Name, + }, label2.Name: {LabelID: label2.ID, LabelName: label2.Name}}, + }, + }, + BundleIdentifier: "b6", + } + _, err = ds.InsertVPPAppWithTeam(ctx, labeledApp, &teamWithLabels.ID) + require.NoError(t, err) + + meta, err = ds.GetVPPAppMetadataByTeamAndTitleID(ctx, &teamWithLabels.ID, labeledApp.TitleID) + require.NoError(t, err) + + require.Len(t, meta.LabelsIncludeAny, 0) + require.Len(t, meta.LabelsExcludeAny, 2) + }) + // create a host with some non-VPP software h1, err := ds.NewHost(ctx, &fleet.Host{ Hostname: "macos-test-1", @@ -1588,3 +1678,105 @@ func testVPPTokenTeamAssignment(t *testing.T, ds *Datastore) { require.NoError(t, err) require.Empty(t, assigned) } + +func testSetTeamVPPAppsWithLabels(t *testing.T, ds *Datastore) { + ctx := context.Background() + + // Create a team + team, err := ds.NewTeam(ctx, &fleet.Team{Name: "vpp_app_labels" + t.Name()}) + require.NoError(t, err) + + dataToken, err := test.CreateVPPTokenData(time.Now().Add(24*time.Hour), "Org"+t.Name(), "Location"+t.Name()) + require.NoError(t, err) + tok1, err := ds.InsertVPPToken(ctx, dataToken) + assert.NoError(t, err) + _, err = ds.UpdateVPPTokenTeams(ctx, tok1.ID, []uint{}) + assert.NoError(t, err) + + // Create some labels + label1, err := ds.NewLabel(ctx, &fleet.Label{ + Name: "label1" + t.Name(), + Description: "a label", + Query: "select 1 from processes;", + Platform: "darwin", + }) + require.NoError(t, err) + + label2, err := ds.NewLabel(ctx, &fleet.Label{ + Name: "label2" + t.Name(), + Description: "a label", + Query: "select 2 from processes;", + Platform: "darwin", + }) + require.NoError(t, err) + + app1 := &fleet.VPPApp{Name: "vpp_app_1", VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "1", Platform: fleet.MacOSPlatform}}, BundleIdentifier: "b1"} + _, err = ds.InsertVPPAppWithTeam(ctx, app1, nil) + require.NoError(t, err) + + app2 := &fleet.VPPApp{Name: "vpp_app_2", VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "2", Platform: fleet.MacOSPlatform}}, BundleIdentifier: "b2"} + _, err = ds.InsertVPPAppWithTeam(ctx, app2, nil) + require.NoError(t, err) + + assigned, err := ds.GetAssignedVPPApps(ctx, &team.ID) + require.NoError(t, err) + require.Len(t, assigned, 0) + + app1.VPPAppTeam = fleet.VPPAppTeam{VPPAppID: app1.VPPAppID, ValidatedLabels: &fleet.LabelIdentsWithScope{ + LabelScope: fleet.LabelScopeIncludeAny, + ByName: map[string]fleet.LabelIdent{ + label1.Name: { + LabelID: label1.ID, + LabelName: label1.Name, + }, + label2.Name: { + LabelID: label2.ID, + LabelName: label2.Name, + }, + }, + }} + + app2.VPPAppTeam = fleet.VPPAppTeam{VPPAppID: app2.VPPAppID, ValidatedLabels: &fleet.LabelIdentsWithScope{ + LabelScope: fleet.LabelScopeExcludeAny, + ByName: map[string]fleet.LabelIdent{ + label1.Name: { + LabelID: label1.ID, + LabelName: label1.Name, + }, + label2.Name: { + LabelID: label2.ID, + LabelName: label2.Name, + }, + }, + }} + + err = ds.SetTeamVPPApps(ctx, &team.ID, []fleet.VPPAppTeam{ + app1.VPPAppTeam, + app2.VPPAppTeam, + }) + require.NoError(t, err) + + assigned, err = ds.GetAssignedVPPApps(ctx, &team.ID) + require.NoError(t, err) + require.Len(t, assigned, 2) + + app1Meta, err := ds.GetVPPAppMetadataByTeamAndTitleID(ctx, &team.ID, app1.TitleID) + require.NoError(t, err) + + app2Meta, err := ds.GetVPPAppMetadataByTeamAndTitleID(ctx, &team.ID, app2.TitleID) + require.NoError(t, err) + + require.Len(t, app1Meta.LabelsIncludeAny, 2) + require.Len(t, app1Meta.LabelsExcludeAny, 0) + for _, l := range app1Meta.LabelsIncludeAny { + _, ok := app1.VPPAppTeam.ValidatedLabels.ByName[l.LabelName] + require.True(t, ok) + } + + require.Len(t, app2Meta.LabelsExcludeAny, 2) + require.Len(t, app2Meta.LabelsIncludeAny, 0) + for _, l := range app2Meta.LabelsExcludeAny { + _, ok := app2.VPPAppTeam.ValidatedLabels.ByName[l.LabelName] + require.True(t, ok) + } +} diff --git a/server/fleet/activities.go b/server/fleet/activities.go index aaa41ef5d943..50a267aa6bc9 100644 --- a/server/fleet/activities.go +++ b/server/fleet/activities.go @@ -111,6 +111,7 @@ var ActivityDetailsList = []ActivityDetails{ ActivityAddedAppStoreApp{}, ActivityDeletedAppStoreApp{}, ActivityInstalledAppStoreApp{}, + ActivityEditedAppStoreApp{}, ActivityAddedNDESSCEPProxy{}, ActivityDeletedNDESSCEPProxy{}, @@ -1947,13 +1948,15 @@ func (a ActivityDisabledVPP) Documentation() (activity string, details string, d } type ActivityAddedAppStoreApp struct { - SoftwareTitle string `json:"software_title"` - SoftwareTitleId uint `json:"software_title_id"` - AppStoreID string `json:"app_store_id"` - TeamName *string `json:"team_name"` - TeamID *uint `json:"team_id"` - Platform AppleDevicePlatform `json:"platform"` - SelfService bool `json:"self_service"` + SoftwareTitle string `json:"software_title"` + SoftwareTitleId uint `json:"software_title_id"` + AppStoreID string `json:"app_store_id"` + TeamName *string `json:"team_name"` + TeamID *uint `json:"team_id"` + Platform AppleDevicePlatform `json:"platform"` + SelfService bool `json:"self_service"` + LabelsIncludeAny []ActivitySoftwareLabel `json:"labels_include_any,omitempty"` + LabelsExcludeAny []ActivitySoftwareLabel `json:"labels_exclude_any,omitempty"` } func (a ActivityAddedAppStoreApp) ActivityName() string { @@ -1968,23 +1971,37 @@ func (a ActivityAddedAppStoreApp) Documentation() (activity string, details stri - "platform": Platform of the app (` + "`darwin`, `ios`, or `ipados`" + `). - "self_service": App installation can be initiated by device owner. - "team_name": Name of the team to which this App Store app was added, or ` + "`null`" + ` if it was added to no team. -- "team_id": ID of the team to which this App Store app was added, or ` + "`null`" + `if it was added to no team.`, `{ +- "team_id": ID of the team to which this App Store app was added, or ` + "`null`" + `if it was added to no team. +- "labels_include_any": Target hosts that have any label in the array. +- "labels_exclude_any": Target hosts that don't have any label in the array.`, `{ "software_title": "Logic Pro", "software_title_id": 123, "app_store_id": "1234567", "platform": "darwin", "self_service": false, "team_name": "Workstations", - "team_id": 1 + "team_id": 1, + "labels_include_any": [ + { + "name": "Engineering", + "id": 12 + }, + { + "name": "Product", + "id": 17 + } + ] }` } type ActivityDeletedAppStoreApp struct { - SoftwareTitle string `json:"software_title"` - AppStoreID string `json:"app_store_id"` - TeamName *string `json:"team_name"` - TeamID *uint `json:"team_id"` - Platform AppleDevicePlatform `json:"platform"` + SoftwareTitle string `json:"software_title"` + AppStoreID string `json:"app_store_id"` + TeamName *string `json:"team_name"` + TeamID *uint `json:"team_id"` + Platform AppleDevicePlatform `json:"platform"` + LabelsIncludeAny []ActivitySoftwareLabel `json:"labels_include_any,omitempty"` + LabelsExcludeAny []ActivitySoftwareLabel `json:"labels_exclude_any,omitempty"` } func (a ActivityDeletedAppStoreApp) ActivityName() string { @@ -1997,12 +2014,24 @@ func (a ActivityDeletedAppStoreApp) Documentation() (activity string, details st - "app_store_id": ID of the app on the Apple App Store. - "platform": Platform of the app (` + "`darwin`, `ios`, or `ipados`" + `). - "team_name": Name of the team from which this App Store app was deleted, or ` + "`null`" + ` if it was deleted from no team. -- "team_id": ID of the team from which this App Store app was deleted, or ` + "`null`" + `if it was deleted from no team.`, `{ +- "team_id": ID of the team from which this App Store app was deleted, or ` + "`null`" + `if it was deleted from no team. +- "labels_include_any": Target hosts that have any label in the array. +- "labels_exclude_any": Target hosts that don't have any label in the array`, `{ "software_title": "Logic Pro", "app_store_id": "1234567", "platform": "darwin", "team_name": "Workstations", - "team_id": 1 + "team_id": 1, + "labels_include_any": [ + { + "name": "Engineering", + "id": 12 + }, + { + "name": "Product", + "id": 17 + } + ] }` } @@ -2052,6 +2081,53 @@ func (a ActivityInstalledAppStoreApp) Documentation() (string, string, string) { }` } +type ActivityEditedAppStoreApp struct { + SoftwareTitle string `json:"software_title"` + SoftwareTitleID uint `json:"software_title_id"` + AppStoreID string `json:"app_store_id"` + TeamName *string `json:"team_name"` + TeamID *uint `json:"team_id"` + Platform AppleDevicePlatform `json:"platform"` + SelfService bool `json:"self_service"` + LabelsIncludeAny []ActivitySoftwareLabel `json:"labels_include_any,omitempty"` + LabelsExcludeAny []ActivitySoftwareLabel `json:"labels_exclude_any,omitempty"` +} + +func (a ActivityEditedAppStoreApp) ActivityName() string { + return "edited_app_store_app" +} + +func (a ActivityEditedAppStoreApp) Documentation() (activity string, details string, detailsExample string) { + return "Generated when an App Store app is updated in Fleet.", `This activity contains the following fields: +- "software_title": Name of the App Store app. +- "software_title_id": ID of the updated app's software title. +- "app_store_id": ID of the app on the Apple App Store. +- "platform": Platform of the app (` + "`darwin`, `ios`, or `ipados`" + `). +- "self_service": App installation can be initiated by device owner. +- "team_name": Name of the team on which this App Store app was updated, or ` + "`null`" + ` if it was updated on no team. +- "team_id": ID of the team on which this App Store app was updated, or ` + "`null`" + `if it was updated on no team. +- "labels_include_any": Target hosts that have any label in the array. +- "labels_exclude_any": Target hosts that don't have any label in the array.`, `{ + "software_title": "Logic Pro", + "software_title_id": 123, + "app_store_id": "1234567", + "platform": "darwin", + "self_service": true, + "team_name": "Workstations", + "team_id": 1, + "labels_include_any": [ + { + "name": "Engineering", + "id": 12 + }, + { + "name": "Product", + "id": 17 + } + ] +}` +} + type ActivityAddedNDESSCEPProxy struct{} func (a ActivityAddedNDESSCEPProxy) ActivityName() string { diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index 86f858311d8e..6ac72b765aff 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -621,6 +621,7 @@ type Datastore interface { // IsSoftwareInstallerLabelScoped returns whether or not the given installerID is scoped to the // given host ID by labels. IsSoftwareInstallerLabelScoped(ctx context.Context, installerID, hostID uint) (bool, error) + IsVPPAppLabelScoped(ctx context.Context, vppAppTeamID, hostID uint) (bool, error) // SetHostSoftwareInstallResult records the result of a software installation // attempt on the host. @@ -1793,9 +1794,9 @@ type Datastore interface { // GetTitleInfoFromVPPAppsTeamsID returns title ID and VPP app name corresponding to the supplied team VPP app PK GetTitleInfoFromVPPAppsTeamsID(ctx context.Context, vppAppsTeamsID uint) (*PolicySoftwareTitle, error) - // GetVPPAppMetadataByAdamIDAndPlatform returns the VPP app corresponding to the specified ADAM ID and platform. - // Note that this doesn't include the self-service flag as the app isn't in the context of a team. - GetVPPAppMetadataByAdamIDAndPlatform(ctx context.Context, adamID string, platform AppleDevicePlatform) (*VPPApp, error) + // GetVPPAppMetadataByAdamIDPlatformTeamID returns the VPP app correspoding to the specified + // ADAM ID, platform within the context of the specified team. It includes the vpp_app_team_id value. + GetVPPAppMetadataByAdamIDPlatformTeamID(ctx context.Context, adamID string, platform AppleDevicePlatform, teamID *uint) (*VPPApp, error) // DeleteSoftwareInstaller deletes the software installer corresponding to the id. DeleteSoftwareInstaller(ctx context.Context, id uint) error diff --git a/server/fleet/labels.go b/server/fleet/labels.go index 01538b6152cd..79a01af3ae45 100644 --- a/server/fleet/labels.go +++ b/server/fleet/labels.go @@ -221,3 +221,35 @@ type LabelIdentsWithScope struct { LabelScope LabelScope ByName map[string]LabelIdent } + +// Equal returns whether or not 2 LabelIdentsWithScope pointers point to equivalent values. +func (l *LabelIdentsWithScope) Equal(other *LabelIdentsWithScope) bool { + if l == nil || other == nil { + return l == other + } + + if l.LabelScope != other.LabelScope { + return false + } + + if l.ByName == nil && other.ByName == nil { + return true + } + + if len(l.ByName) != len(other.ByName) { + return false + } + + for k, v := range l.ByName { + otherV, ok := other.ByName[k] + if !ok { + return false + } + + if v != otherV { + return false + } + } + + return true +} diff --git a/server/fleet/service.go b/server/fleet/service.go index 26a91765f6dd..b7b75dd395dc 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -685,6 +685,7 @@ type Service interface { GetAppStoreApps(ctx context.Context, teamID *uint) ([]*VPPApp, error) AddAppStoreApp(ctx context.Context, teamID *uint, appTeam VPPAppTeam) error + UpdateAppStoreApp(ctx context.Context, titleID uint, teamID *uint, selfService bool, labelsIncludeAny, labelsExcludeAny []string) (*VPPAppStoreApp, error) // MDMAppleProcessOTAEnrollment handles OTA enrollment requests. // diff --git a/server/fleet/software.go b/server/fleet/software.go index 8a7ae2abc47f..787a6c9d8a78 100644 --- a/server/fleet/software.go +++ b/server/fleet/software.go @@ -441,9 +441,11 @@ func SoftwareFromOsqueryRow( } type VPPBatchPayload struct { - AppStoreID string `json:"app_store_id"` - SelfService bool `json:"self_service"` - InstallDuringSetup *bool `json:"install_during_setup"` // keep saved value if nil, otherwise set as indicated + AppStoreID string `json:"app_store_id"` + SelfService bool `json:"self_service"` + InstallDuringSetup *bool `json:"install_during_setup"` // keep saved value if nil, otherwise set as indicated + LabelsExcludeAny []string `json:"labels_exclude_any"` + LabelsIncludeAny []string `json:"labels_include_any"` } type VPPBatchPayloadWithPlatform struct { @@ -451,4 +453,6 @@ type VPPBatchPayloadWithPlatform struct { SelfService bool `json:"self_service"` Platform AppleDevicePlatform `json:"platform"` InstallDuringSetup *bool `json:"install_during_setup"` // keep saved value if nil, otherwise set as indicated + LabelsExcludeAny []string `json:"labels_exclude_any"` + LabelsIncludeAny []string `json:"labels_include_any"` } diff --git a/server/fleet/software_installer.go b/server/fleet/software_installer.go index 8ca8e9346ad5..b976f705880d 100644 --- a/server/fleet/software_installer.go +++ b/server/fleet/software_installer.go @@ -141,7 +141,7 @@ type SoftwareInstaller struct { // AutomaticInstallPolicies is the list of policies that trigger automatic // installation of this software. AutomaticInstallPolicies []AutomaticInstallPolicy `json:"automatic_install_policies" db:"-"` - // LablesIncludeAny is the list of "include any" labels for this software installer (if not nil). + // LabelsIncludeAny is the list of "include any" labels for this software installer (if not nil). LabelsIncludeAny []SoftwareScopeLabel `json:"labels_include_any" db:"labels_include_any"` // LabelsExcludeAny is the list of "exclude any" labels for this software installer (if not nil). LabelsExcludeAny []SoftwareScopeLabel `json:"labels_exclude_any" db:"labels_exclude_any"` @@ -459,6 +459,14 @@ type HostSoftwareWithInstaller struct { AppStoreApp *SoftwarePackageOrApp `json:"app_store_app"` } +func (h *HostSoftwareWithInstaller) IsPackage() bool { + return h.SoftwarePackage != nil +} + +func (h *HostSoftwareWithInstaller) IsAppStoreApp() bool { + return h.AppStoreApp != nil +} + type AutomaticInstallPolicy struct { ID uint `json:"id" db:"id"` Name string `json:"name" db:"name"` diff --git a/server/fleet/teams.go b/server/fleet/teams.go index aa878f225859..14fda849244b 100644 --- a/server/fleet/teams.go +++ b/server/fleet/teams.go @@ -183,8 +183,10 @@ type TeamSpecSoftwareAsset struct { } type TeamSpecAppStoreApp struct { - AppStoreID string `json:"app_store_id"` - SelfService bool `json:"self_service"` + AppStoreID string `json:"app_store_id"` + SelfService bool `json:"self_service"` + LabelsIncludeAny []string `json:"labels_include_any"` + LabelsExcludeAny []string `json:"labels_exclude_any"` } type TeamMDM struct { diff --git a/server/fleet/vpp.go b/server/fleet/vpp.go index 93d6da704d1a..0789877eb08c 100644 --- a/server/fleet/vpp.go +++ b/server/fleet/vpp.go @@ -16,6 +16,7 @@ type VPPAppID struct { type VPPAppTeam struct { VPPAppID + AppTeamID uint `db:"id" json:"-"` SelfService bool `db:"self_service" json:"self_service"` // InstallDuringSetup is either the stored value of that flag for the VPP app @@ -23,6 +24,17 @@ type VPPAppTeam struct { // set the value, if nil it will keep the currently saved value (or default // to false), while if not nil, it will update the flag's value in the DB. InstallDuringSetup *bool `db:"install_during_setup" json:"-"` + // LabelsIncludeAny are the names of labels associated with this app. If a host has any of + // these labels, the app is in scope for that host. If this field is set, LabelsExcludeAny + // cannot be set. + LabelsIncludeAny []string `json:"labels_include_any"` + // LabelsExcludeAny are the names of labels associated with this app. If a host has any of + // these labels, the app is out of scope for that host. If this field is set, LabelsIncludeAny + // cannot be set. + LabelsExcludeAny []string `json:"labels_exclude_any"` + // ValidatedLabels are the labels (either include or exclude any) that have been validated by + // Fleet as being valid labels. This field is only used internally. + ValidatedLabels *LabelIdentsWithScope `json:"-"` } // VPPApp represents a VPP (Volume Purchase Program) application, @@ -68,6 +80,12 @@ type VPPAppStoreApp struct { // AutomaticInstallPolicies is the list of policies that trigger automatic // installation of this software. AutomaticInstallPolicies []AutomaticInstallPolicy `json:"automatic_install_policies" db:"-"` + // LabelsIncludeAny is the list of "include any" labels for this app store app (if not nil). + LabelsIncludeAny []SoftwareScopeLabel `json:"labels_include_any" db:"labels_include_any"` + // LabelsExcludeAny is the list of "exclude any" labels for this app store app (if not nil). + LabelsExcludeAny []SoftwareScopeLabel `json:"labels_exclude_any" db:"labels_exclude_any"` + // BundleIdentifier is the bundle identifier for this app. + BundleIdentifier string `json:"-" db:"bundle_identifier"` } // VPPAppStatusSummary represents aggregated status metrics for a VPP app. diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index 17b2ee7821bf..6f0d9507f7a4 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -129,8 +129,6 @@ type ListPacksForHostFunc func(ctx context.Context, hid uint) (packs []*fleet.Pa type ApplyLabelSpecsFunc func(ctx context.Context, specs []*fleet.LabelSpec) error -type UpdateLabelMembershipByHostIDsFunc func(ctx context.Context, labelID uint, hostIDs []uint, teamFilter fleet.TeamFilter) (*fleet.Label, []uint, error) - type GetLabelSpecsFunc func(ctx context.Context) ([]*fleet.LabelSpec, error) type GetLabelSpecFunc func(ctx context.Context, name string) (*fleet.LabelSpec, error) @@ -139,6 +137,8 @@ type AddLabelsToHostFunc func(ctx context.Context, hostID uint, labelIDs []uint) type RemoveLabelsFromHostFunc func(ctx context.Context, hostID uint, labelIDs []uint) error +type UpdateLabelMembershipByHostIDsFunc func(ctx context.Context, labelID uint, hostIds []uint, teamFilter fleet.TeamFilter) (*fleet.Label, []uint, error) + type NewLabelFunc func(ctx context.Context, Label *fleet.Label, opts ...fleet.OptionalArg) (*fleet.Label, error) type SaveLabelFunc func(ctx context.Context, label *fleet.Label, teamFilter fleet.TeamFilter) (*fleet.Label, []uint, error) @@ -459,6 +459,8 @@ type ListHostSoftwareFunc func(ctx context.Context, host *fleet.Host, opts fleet type IsSoftwareInstallerLabelScopedFunc func(ctx context.Context, installerID uint, hostID uint) (bool, error) +type IsVPPAppLabelScopedFunc func(ctx context.Context, vppAppTeamID uint, hostID uint) (bool, error) + type SetHostSoftwareInstallResultFunc func(ctx context.Context, result *fleet.HostSoftwareInstallResultPayload) error type UploadedSoftwareExistsFunc func(ctx context.Context, bundleIdentifier string, teamID *uint) (bool, error) @@ -1125,7 +1127,7 @@ type MapAdamIDsPendingInstallFunc func(ctx context.Context, hostID uint) (map[st type GetTitleInfoFromVPPAppsTeamsIDFunc func(ctx context.Context, vppAppsTeamsID uint) (*fleet.PolicySoftwareTitle, error) -type GetVPPAppMetadataByAdamIDAndPlatformFunc func(ctx context.Context, adamID string, platform fleet.AppleDevicePlatform) (*fleet.VPPApp, error) +type GetVPPAppMetadataByAdamIDPlatformTeamIDFunc func(ctx context.Context, adamID string, platform fleet.AppleDevicePlatform, teamID *uint) (*fleet.VPPApp, error) type DeleteSoftwareInstallerFunc func(ctx context.Context, id uint) error @@ -1374,9 +1376,6 @@ type DataStore struct { ApplyLabelSpecsFunc ApplyLabelSpecsFunc ApplyLabelSpecsFuncInvoked bool - UpdateLabelMembershipByHostIDsFunc UpdateLabelMembershipByHostIDsFunc - UpdateLabelMembershipByHostIDsFuncInvoked bool - GetLabelSpecsFunc GetLabelSpecsFunc GetLabelSpecsFuncInvoked bool @@ -1389,6 +1388,9 @@ type DataStore struct { RemoveLabelsFromHostFunc RemoveLabelsFromHostFunc RemoveLabelsFromHostFuncInvoked bool + UpdateLabelMembershipByHostIDsFunc UpdateLabelMembershipByHostIDsFunc + UpdateLabelMembershipByHostIDsFuncInvoked bool + NewLabelFunc NewLabelFunc NewLabelFuncInvoked bool @@ -1869,6 +1871,9 @@ type DataStore struct { IsSoftwareInstallerLabelScopedFunc IsSoftwareInstallerLabelScopedFunc IsSoftwareInstallerLabelScopedFuncInvoked bool + IsVPPAppLabelScopedFunc IsVPPAppLabelScopedFunc + IsVPPAppLabelScopedFuncInvoked bool + SetHostSoftwareInstallResultFunc SetHostSoftwareInstallResultFunc SetHostSoftwareInstallResultFuncInvoked bool @@ -2868,8 +2873,8 @@ type DataStore struct { GetTitleInfoFromVPPAppsTeamsIDFunc GetTitleInfoFromVPPAppsTeamsIDFunc GetTitleInfoFromVPPAppsTeamsIDFuncInvoked bool - GetVPPAppMetadataByAdamIDAndPlatformFunc GetVPPAppMetadataByAdamIDAndPlatformFunc - GetVPPAppMetadataByAdamIDAndPlatformFuncInvoked bool + GetVPPAppMetadataByAdamIDPlatformTeamIDFunc GetVPPAppMetadataByAdamIDPlatformTeamIDFunc + GetVPPAppMetadataByAdamIDPlatformTeamIDFuncInvoked bool DeleteSoftwareInstallerFunc DeleteSoftwareInstallerFunc DeleteSoftwareInstallerFuncInvoked bool @@ -3378,13 +3383,6 @@ func (s *DataStore) ApplyLabelSpecs(ctx context.Context, specs []*fleet.LabelSpe return s.ApplyLabelSpecsFunc(ctx, specs) } -func (s *DataStore) UpdateLabelMembershipByHostIDs(ctx context.Context, labelID uint, hostIDs []uint, teamFilter fleet.TeamFilter) (*fleet.Label, []uint, error) { - s.mu.Lock() - s.UpdateLabelMembershipByHostIDsFuncInvoked = true - s.mu.Unlock() - return s.UpdateLabelMembershipByHostIDsFunc(ctx, labelID, hostIDs, teamFilter) -} - func (s *DataStore) GetLabelSpecs(ctx context.Context) ([]*fleet.LabelSpec, error) { s.mu.Lock() s.GetLabelSpecsFuncInvoked = true @@ -3413,6 +3411,13 @@ func (s *DataStore) RemoveLabelsFromHost(ctx context.Context, hostID uint, label return s.RemoveLabelsFromHostFunc(ctx, hostID, labelIDs) } +func (s *DataStore) UpdateLabelMembershipByHostIDs(ctx context.Context, labelID uint, hostIds []uint, teamFilter fleet.TeamFilter) (*fleet.Label, []uint, error) { + s.mu.Lock() + s.UpdateLabelMembershipByHostIDsFuncInvoked = true + s.mu.Unlock() + return s.UpdateLabelMembershipByHostIDsFunc(ctx, labelID, hostIds, teamFilter) +} + func (s *DataStore) NewLabel(ctx context.Context, Label *fleet.Label, opts ...fleet.OptionalArg) (*fleet.Label, error) { s.mu.Lock() s.NewLabelFuncInvoked = true @@ -4533,6 +4538,13 @@ func (s *DataStore) IsSoftwareInstallerLabelScoped(ctx context.Context, installe return s.IsSoftwareInstallerLabelScopedFunc(ctx, installerID, hostID) } +func (s *DataStore) IsVPPAppLabelScoped(ctx context.Context, vppAppTeamID uint, hostID uint) (bool, error) { + s.mu.Lock() + s.IsVPPAppLabelScopedFuncInvoked = true + s.mu.Unlock() + return s.IsVPPAppLabelScopedFunc(ctx, vppAppTeamID, hostID) +} + func (s *DataStore) SetHostSoftwareInstallResult(ctx context.Context, result *fleet.HostSoftwareInstallResultPayload) error { s.mu.Lock() s.SetHostSoftwareInstallResultFuncInvoked = true @@ -6864,11 +6876,11 @@ func (s *DataStore) GetTitleInfoFromVPPAppsTeamsID(ctx context.Context, vppAppsT return s.GetTitleInfoFromVPPAppsTeamsIDFunc(ctx, vppAppsTeamsID) } -func (s *DataStore) GetVPPAppMetadataByAdamIDAndPlatform(ctx context.Context, adamID string, platform fleet.AppleDevicePlatform) (*fleet.VPPApp, error) { +func (s *DataStore) GetVPPAppMetadataByAdamIDPlatformTeamID(ctx context.Context, adamID string, platform fleet.AppleDevicePlatform, teamID *uint) (*fleet.VPPApp, error) { s.mu.Lock() - s.GetVPPAppMetadataByAdamIDAndPlatformFuncInvoked = true + s.GetVPPAppMetadataByAdamIDPlatformTeamIDFuncInvoked = true s.mu.Unlock() - return s.GetVPPAppMetadataByAdamIDAndPlatformFunc(ctx, adamID, platform) + return s.GetVPPAppMetadataByAdamIDPlatformTeamIDFunc(ctx, adamID, platform, teamID) } func (s *DataStore) DeleteSoftwareInstaller(ctx context.Context, id uint) error { diff --git a/server/service/client.go b/server/service/client.go index d46dfca124b8..6f4b8edff141 100644 --- a/server/service/client.go +++ b/server/service/client.go @@ -696,6 +696,8 @@ func (c *Client) ApplyGroup( AppStoreID: app.AppStoreID, SelfService: app.SelfService, InstallDuringSetup: installDuringSetup, + LabelsExcludeAny: app.LabelsExcludeAny, + LabelsIncludeAny: app.LabelsIncludeAny, }) // can be referenced by macos_setup.software.app_store_id if tmSoftwareAppsByAppID[tmName] == nil { diff --git a/server/service/handler.go b/server/service/handler.go index f0aee564d621..7990369cb89c 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -394,6 +394,7 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC // App store software ue.GET("/api/_version_/fleet/software/app_store_apps", getAppStoreAppsEndpoint, getAppStoreAppsRequest{}) ue.POST("/api/_version_/fleet/software/app_store_apps", addAppStoreAppEndpoint, addAppStoreAppRequest{}) + ue.PATCH("/api/_version_/fleet/software/titles/{title_id:[0-9]+}/app_store_app", updateAppStoreAppEndpoint, updateAppStoreAppRequest{}) // Setup Experience ue.PUT("/api/_version_/fleet/setup_experience/software", putSetupExperienceSoftware, putSetupExperienceSoftwareRequest{}) diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go index db9173777858..c511d55130ff 100644 --- a/server/service/integration_mdm_test.go +++ b/server/service/integration_mdm_test.go @@ -517,6 +517,8 @@ func (s *integrationMDMTestSuite) SetupSuite() { // ipados app "3": `{"bundleId": "c-3", "artworkUrl512": "https://example.com/images/3", "version": "3.0.0", "trackName": "App 3", "TrackID": 3, "supportedDevices": ["iPadAir-iPadAir"] }`, + + "4": `{"bundleId": "d-4", "artworkUrl512": "https://example.com/images/4", "version": "4.0.0", "trackName": "App 4", "TrackID": 4}`, } adamIDString := r.URL.Query().Get("id") @@ -10968,6 +10970,206 @@ func (s *integrationMDMTestSuite) TestVPPApps() { require.Equal(t, location, resp.Tokens[0].Location) require.Equal(t, expTime, resp.Tokens[0].RenewDate) + getSoftwareTitleIDFromApp := func(app *fleet.VPPApp) uint { + var titleID uint + mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + ctx := context.Background() + return sqlx.GetContext(ctx, q, &titleID, `SELECT title_id FROM vpp_apps WHERE adam_id = ? AND platform = ?`, app.AdamID, app.Platform) + }) + + return titleID + } + + t.Run("vpp apps with labels", func(t *testing.T) { + // Create a team + var newTeamResp teamResponse + s.DoJSON("POST", "/api/latest/fleet/teams", &createTeamRequest{TeamPayload: fleet.TeamPayload{Name: ptr.String("Team Labels" + t.Name())}}, http.StatusOK, &newTeamResp) + team := newTeamResp.Team + + // Add an MDM macOS host to the team + host, mdmDevice := createHostThenEnrollMDM(s.ds, s.server.URL, t) + orbitKey := setOrbitEnrollment(t, host, s.ds) + host.OrbitNodeKey = &orbitKey + s.Do("POST", "/api/latest/fleet/hosts/transfer", &addHostsToTeamRequest{HostIDs: []uint{host.ID}, TeamID: &team.ID}, http.StatusOK) + + // Associate team to the VPP token. + var resPatchVPP patchVPPTokensTeamsResponse + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/vpp_tokens/%d/teams", resp.Tokens[0].ID), patchVPPTokensTeamsRequest{TeamIDs: []uint{team.ID}}, http.StatusOK, &resPatchVPP) + + var createLabelResp createLabelResponse + s.DoJSON("POST", "/api/latest/fleet/labels", &fleet.LabelPayload{Name: "label1" + t.Name()}, http.StatusOK, &createLabelResp) + l1 := createLabelResp.Label + require.NotNil(t, l1) + + s.DoJSON("POST", "/api/latest/fleet/labels", &fleet.LabelPayload{Name: "label2" + t.Name()}, http.StatusOK, &createLabelResp) + l2 := createLabelResp.Label + require.NotNil(t, l2) + + includeAnyApp := fleet.VPPApp{ + VPPAppTeam: fleet.VPPAppTeam{ + VPPAppID: fleet.VPPAppID{ + AdamID: "1", + Platform: fleet.MacOSPlatform, + }, + }, + Name: "App 1", + BundleIdentifier: "a-1", + IconURL: "https://example.com/images/1", + LatestVersion: "1.0.0", + } + + // Attempt to add an app with both types of labels. Should fail + var addAppResp addAppStoreAppResponse + addAppReq := &addAppStoreAppRequest{ + TeamID: &team.ID, + AppStoreID: includeAnyApp.AdamID, + SelfService: true, + LabelsIncludeAny: []string{l1.Name}, + LabelsExcludeAny: []string{l2.Name}, + } + res := s.Do("POST", "/api/latest/fleet/software/app_store_apps", addAppReq, http.StatusBadRequest) + require.Contains(t, extractServerErrorText(res.Body), `Only one of "labels_include_any" or "labels_exclude_any" can be included`) + + // Now add it for real + addAppReq.LabelsExcludeAny = []string{} + s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps", addAppReq, http.StatusOK, &addAppResp) + titleID := getSoftwareTitleIDFromApp(&includeAnyApp) + activityData := `{"team_name": "%s", "software_title": "%s", "software_title_id": %d, "app_store_id": "%s", "team_id": %d, "platform": "%s", "self_service": true, "labels_include_any": [{"id": %d, "name": %q}]}` + s.lastActivityMatches(fleet.ActivityAddedAppStoreApp{}.ActivityName(), + fmt.Sprintf(activityData, team.Name, + includeAnyApp.Name, titleID, includeAnyApp.AdamID, team.ID, includeAnyApp.Platform, l1.ID, l1.Name), 0) + + var getSWTitle getSoftwareTitleResponse + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", titleID), nil, http.StatusOK, &getSWTitle, "team_id", fmt.Sprint(team.ID)) + require.NotNil(t, getSWTitle.SoftwareTitle.AppStoreApp) + require.Equal(t, getSWTitle.SoftwareTitle.AppStoreApp.AdamID, includeAnyApp.AdamID) + require.Empty(t, getSWTitle.SoftwareTitle.AppStoreApp.LabelsExcludeAny) + require.Equal(t, getSWTitle.SoftwareTitle.AppStoreApp.LabelsIncludeAny, []fleet.SoftwareScopeLabel{{LabelName: l1.Name, LabelID: l1.ID}}) + + // Add an app with exclude_any labels + excludeAnyApp := fleet.VPPApp{ + VPPAppTeam: fleet.VPPAppTeam{ + VPPAppID: fleet.VPPAppID{ + AdamID: "2", + Platform: fleet.MacOSPlatform, + }, + }, + Name: "App 2", + BundleIdentifier: "b-2", + IconURL: "https://example.com/images/1", + LatestVersion: "1.0.0", + } + + addAppReq = &addAppStoreAppRequest{ + TeamID: &team.ID, + AppStoreID: excludeAnyApp.AdamID, + SelfService: true, + LabelsExcludeAny: []string{l2.Name}, + } + s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps", addAppReq, http.StatusOK, &addAppResp) + titleID = getSoftwareTitleIDFromApp(&excludeAnyApp) + activityData = `{"team_name": "%s", "software_title": "%s", "software_title_id": %d, "app_store_id": "%s", "team_id": %d, "platform": "%s", "self_service": true, "labels_exclude_any": [{"id": %d, "name": %q}]}` + s.lastActivityMatches(fleet.ActivityAddedAppStoreApp{}.ActivityName(), + fmt.Sprintf(activityData, team.Name, + excludeAnyApp.Name, titleID, excludeAnyApp.AdamID, team.ID, excludeAnyApp.Platform, l2.ID, l2.Name), 0) + + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", titleID), nil, http.StatusOK, &getSWTitle, "team_id", fmt.Sprint(team.ID)) + require.NotNil(t, getSWTitle.SoftwareTitle.AppStoreApp) + require.Equal(t, getSWTitle.SoftwareTitle.AppStoreApp.AdamID, excludeAnyApp.AdamID) + require.Empty(t, getSWTitle.SoftwareTitle.AppStoreApp.LabelsIncludeAny) + require.Equal(t, getSWTitle.SoftwareTitle.AppStoreApp.LabelsExcludeAny, []fleet.SoftwareScopeLabel{{LabelName: l2.Name, LabelID: l2.ID}}) + require.True(t, getSWTitle.SoftwareTitle.AppStoreApp.SelfService) + + // Add a non-VPP software + payloadRubyTm1 := &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "install", + Filename: "ruby.deb", + SelfService: false, + TeamID: &team.ID, + } + s.uploadSoftwareInstaller(t, payloadRubyTm1, http.StatusOK, "") + + resp := listSoftwareTitlesResponse{} + s.DoJSON( + "GET", "/api/latest/fleet/software/titles", + listSoftwareTitlesRequest{}, + http.StatusOK, &resp, + "query", "ruby", + "team_id", fmt.Sprintf("%d", team.ID), + ) + + require.Len(t, resp.SoftwareTitles, 1) + nonVPPTitleID := resp.SoftwareTitles[0].ID + + updateAppReq := &updateAppStoreAppRequest{TeamID: &team.ID, SelfService: false} + + // Attempt to update the non-VPP software using the VPP path. Should fail. + s.Do("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/app_store_app", nonVPPTitleID), updateAppReq, http.StatusNotFound) + + // Attempt tp update a non-existent app. Should fail. + s.Do("PATCH", "/api/latest/fleet/software/titles/9999/app_store_app", updateAppReq, http.StatusNotFound) + + // Attempt to update with both types of labels. Should fail. + updateAppReq.LabelsIncludeAny = []string{l1.Name} + updateAppReq.LabelsExcludeAny = []string{l1.Name} + res = s.Do("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/app_store_app", titleID), updateAppReq, http.StatusBadRequest) + require.Contains(t, extractServerErrorText(res.Body), `Only one of "labels_include_any" or "labels_exclude_any" can be included.`) + + // Attempt to update with a non-existent label. Should fail. + updateAppReq.LabelsExcludeAny = []string{} + updateAppReq.LabelsIncludeAny = []string{"404_notfound"} + res = s.Do("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/app_store_app", titleID), updateAppReq, http.StatusBadRequest) + require.Contains(t, extractServerErrorText(res.Body), "some or all the labels provided don't exist") + + // Update App2. Unset self service and update the labels + updateAppReq.LabelsIncludeAny = []string{l2.Name} + var updateAppResp updateAppStoreAppResponse + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/app_store_app", titleID), updateAppReq, http.StatusOK, &updateAppResp) + + require.NotNil(t, updateAppResp.AppStoreApp) + require.Equal(t, updateAppResp.AppStoreApp.AdamID, excludeAnyApp.AdamID) + require.Equal(t, updateAppResp.AppStoreApp.LabelsIncludeAny, []fleet.SoftwareScopeLabel{{LabelName: l2.Name, LabelID: l2.ID}}) + require.Empty(t, updateAppResp.AppStoreApp.LabelsExcludeAny) + require.False(t, updateAppResp.AppStoreApp.SelfService) + require.Equal(t, fleet.MacOSPlatform, updateAppResp.AppStoreApp.Platform) + + activityData = `{"team_name": "%s", "software_title": "%s", "app_store_id": "%s", "team_id": %d, "software_title_id": %d, "platform": "%s", "self_service": false, "labels_include_any": [{"id": %d, "name": %q}]}` + s.lastActivityMatches(fleet.ActivityEditedAppStoreApp{}.ActivityName(), + fmt.Sprintf(activityData, team.Name, + excludeAnyApp.Name, excludeAnyApp.AdamID, team.ID, titleID, excludeAnyApp.Platform, l2.ID, l2.Name), 0) + + // double check that our updates worked + getSWTitle = getSoftwareTitleResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", titleID), nil, http.StatusOK, &getSWTitle, "team_id", fmt.Sprint(team.ID)) + require.NotNil(t, getSWTitle.SoftwareTitle.AppStoreApp) + require.Equal(t, getSWTitle.SoftwareTitle.AppStoreApp.AdamID, excludeAnyApp.AdamID) + require.Equal(t, getSWTitle.SoftwareTitle.AppStoreApp.LabelsIncludeAny, []fleet.SoftwareScopeLabel{{LabelName: l2.Name, LabelID: l2.ID}}) + require.Empty(t, getSWTitle.SoftwareTitle.AppStoreApp.LabelsExcludeAny) + require.False(t, getSWTitle.SoftwareTitle.AppStoreApp.SelfService) + + // Attempt an install on the host. This should fail because the host doesn't have the label + // l2. + res = s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", host.ID, titleID), &installSoftwareRequest{}, http.StatusBadRequest) + require.Contains(t, extractServerErrorText(res.Body), "Couldn't install. This host isn't a member of the labels defined for this software title.") + + // Add l2 to the host. Attempt install again, should succeed + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/labels/%d", l2.ID), &fleet.ModifyLabelPayload{Hosts: []string{host.HardwareSerial}}, http.StatusOK, &createLabelResp) + + s.appleVPPConfigSrvConfig.SerialNumbers = append(s.appleVPPConfigSrvConfig.SerialNumbers, mdmDevice.SerialNumber) + + s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", host.ID, titleID), &installSoftwareRequest{}, http.StatusAccepted) + + // Attempt to delete a non-existent app. Should fail. + s.Do("DELETE", "/api/latest/fleet/software/titles/9999/available_for_install", nil, http.StatusNotFound, "team_id", fmt.Sprintf("%d", team.ID)) + + // delete the VPP app + s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/software/titles/%d/available_for_install", titleID), nil, http.StatusNoContent, "team_id", fmt.Sprintf("%d", team.ID)) + activityData = `{"team_name": "%s", "software_title": "%s", "app_store_id": "%s", "team_id": %d, "platform": "%s", "labels_include_any": [{"id": %d, "name": %q}]}` + s.lastActivityMatches(fleet.ActivityDeletedAppStoreApp{}.ActivityName(), + fmt.Sprintf(activityData, team.Name, + excludeAnyApp.Name, excludeAnyApp.AdamID, team.ID, excludeAnyApp.Platform, l2.ID, l2.Name), 0) + }) + // Create a team var newTeamResp teamResponse s.DoJSON("POST", "/api/latest/fleet/teams", &createTeamRequest{TeamPayload: fleet.TeamPayload{Name: ptr.String("Team 1")}}, http.StatusOK, &newTeamResp) @@ -11115,16 +11317,6 @@ func (s *integrationMDMTestSuite) TestVPPApps() { } assert.ElementsMatch(t, expectedApps, appResp.AppStoreApps) - getSoftwareTitleIDFromApp := func(app *fleet.VPPApp) uint { - var titleID uint - mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { - ctx := context.Background() - return sqlx.GetContext(ctx, q, &titleID, `SELECT title_id FROM vpp_apps WHERE adam_id = ? AND platform = ?;`, app.AdamID, app.Platform) - }) - - return titleID - } - // Insert/deletion flow for macOS app // Add an app store app to team 1 addedApp := expectedApps[0] @@ -11701,6 +11893,14 @@ func (s *integrationMDMTestSuite) TestVPPAppPolicyAutomation() { t := s.T() ctx := context.Background() + activitiesToString := func(activities []*fleet.Activity) []string { + var res []string + for _, activity := range activities { + res = append(res, fmt.Sprintf("%+v", activity)) + } + return res + } + // Set up VPP token orgName := "Fleet Device Management Inc." token := "mycooltoken" @@ -11721,10 +11921,36 @@ func (s *integrationMDMTestSuite) TestVPPAppPolicyAutomation() { s.DoJSON("POST", "/api/latest/fleet/teams", &createTeamRequest{TeamPayload: fleet.TeamPayload{Name: ptr.String("Team 1")}}, http.StatusOK, &newTeamResp) team := newTeamResp.Team + // Create a couple of hosts + orbitHost := createOrbitEnrolledHost(t, "darwin", "nonmdm", s.ds) + mdmHost, mdmDevice := createHostThenEnrollMDM(s.ds, s.server.URL, t) + setOrbitEnrollment(t, mdmHost, s.ds) + mdmHost2, mdmDevice2 := createHostThenEnrollMDM(s.ds, s.server.URL, t) + key := setOrbitEnrollment(t, mdmHost2, s.ds) + mdmHost2.OrbitNodeKey = &key + selfServiceHost, selfServiceDevice := createHostThenEnrollMDM(s.ds, s.server.URL, t) + setOrbitEnrollment(t, selfServiceHost, s.ds) + selfServiceToken := "selfservicetoken" + updateDeviceTokenForHost(t, s.ds, selfServiceHost.ID, selfServiceToken) + s.appleVPPConfigSrvConfig.SerialNumbers = append(s.appleVPPConfigSrvConfig.SerialNumbers, selfServiceDevice.SerialNumber) + + // Add serial number to our fake Apple server + s.appleVPPConfigSrvConfig.SerialNumbers = append(s.appleVPPConfigSrvConfig.SerialNumbers, mdmHost.HardwareSerial, mdmHost2.HardwareSerial) + s.Do("POST", "/api/latest/fleet/hosts/transfer", + &addHostsToTeamRequest{HostIDs: []uint{mdmHost.ID, mdmHost2.ID, orbitHost.ID, selfServiceHost.ID}, TeamID: &team.ID}, http.StatusOK) + // Associate team to the VPP token. var resPatchVPP patchVPPTokensTeamsResponse s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/vpp_tokens/%d/teams", resp.Tokens[0].ID), patchVPPTokensTeamsRequest{TeamIDs: []uint{team.ID}}, http.StatusOK, &resPatchVPP) + var createLabelResp createLabelResponse + s.DoJSON("POST", "/api/latest/fleet/labels", &fleet.LabelPayload{Name: "label1" + t.Name(), Hosts: []string{mdmHost.HardwareSerial}}, http.StatusOK, &createLabelResp) + l1 := createLabelResp.Label + require.NotNil(t, l1) + + s.DoJSON("POST", "/api/latest/fleet/labels", &fleet.LabelPayload{Name: "label2" + t.Name(), Hosts: []string{mdmHost2.HardwareSerial}}, http.StatusOK, &createLabelResp) + l2 := createLabelResp.Label + // Get list of VPP apps from "Apple" // We're passing team 1 here, but we haven't added any app store apps to that team, so we get // back all available apps in our VPP location. @@ -11813,7 +12039,7 @@ func (s *integrationMDMTestSuite) TestVPPAppPolicyAutomation() { // Add an app store app to team 1 addedApp := expectedApps[0] var addedMacOSApp addAppStoreAppResponse - s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{TeamID: &team.ID, AppStoreID: addedApp.AdamID, SelfService: true}, http.StatusOK, &addedMacOSApp) + s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{TeamID: &team.ID, Platform: addedApp.Platform, AppStoreID: addedApp.AdamID, SelfService: true, LabelsIncludeAny: []string{l1.Name}}, http.StatusOK, &addedMacOSApp) // list the software titles for that team, to get the title id of the VPP app var listSw listSoftwareTitlesResponse @@ -11829,27 +12055,12 @@ func (s *integrationMDMTestSuite) TestVPPAppPolicyAutomation() { &addAppStoreAppRequest{TeamID: &team.ID, AppStoreID: addedApp.AdamID, Platform: addedApp.Platform}, http.StatusOK, &addedIOSApp) - // Create a couple of hosts - orbitHost := createOrbitEnrolledHost(t, "darwin", "nonmdm", s.ds) - mdmHost, mdmDevice := createHostThenEnrollMDM(s.ds, s.server.URL, t) - setOrbitEnrollment(t, mdmHost, s.ds) - selfServiceHost, selfServiceDevice := createHostThenEnrollMDM(s.ds, s.server.URL, t) - setOrbitEnrollment(t, selfServiceHost, s.ds) - selfServiceToken := "selfservicetoken" - updateDeviceTokenForHost(t, s.ds, selfServiceHost.ID, selfServiceToken) - s.appleVPPConfigSrvConfig.SerialNumbers = append(s.appleVPPConfigSrvConfig.SerialNumbers, selfServiceDevice.SerialNumber) - - // Add serial number to our fake Apple server - s.appleVPPConfigSrvConfig.SerialNumbers = append(s.appleVPPConfigSrvConfig.SerialNumbers, mdmHost.HardwareSerial) - s.Do("POST", "/api/latest/fleet/hosts/transfer", - &addHostsToTeamRequest{HostIDs: []uint{mdmHost.ID, orbitHost.ID, selfServiceHost.ID}, TeamID: &team.ID}, http.StatusOK) - // Add all apps to the team appSelfService := expectedApps[0] // Add app 1 as self-service addedMacOSApp = addAppStoreAppResponse{} s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps", - &addAppStoreAppRequest{TeamID: &team.ID, AppStoreID: appSelfService.AdamID, Platform: appSelfService.Platform, SelfService: true}, + &addAppStoreAppRequest{TeamID: &team.ID, AppStoreID: appSelfService.AdamID, Platform: appSelfService.Platform, SelfService: true, LabelsIncludeAny: []string{l1.Name}}, http.StatusOK, &addedMacOSApp) // Add remaining as non-self-service for _, app := range expectedApps[1:] { @@ -11892,6 +12103,20 @@ func (s *integrationMDMTestSuite) TestVPPAppPolicyAutomation() { }) require.NoError(t, err) + policy3Team1, err := s.ds.NewTeamPolicy(ctx, team.ID, nil, fleet.PolicyPayload{ // will be set up with same VPP app + a script + Name: "policy3Team1", + Query: "SELECT 1;", + Platform: "darwin", + }) + require.NoError(t, err) + + savedTmScript, err := s.ds.NewScript(ctx, &fleet.Script{ + TeamID: &team.ID, + Name: "team_script.sh", + ScriptContents: "echo 'team'", + }) + require.NoError(t, err) + mtplr := modifyTeamPolicyResponse{} s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team.ID, policy1Team1.ID), modifyTeamPolicyRequest{ ModifyPolicyPayload: fleet.ModifyPolicyPayload{ @@ -11904,11 +12129,18 @@ func (s *integrationMDMTestSuite) TestVPPAppPolicyAutomation() { }, }, http.StatusOK, &mtplr) + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team.ID, policy3Team1.ID), modifyTeamPolicyRequest{ + ModifyPolicyPayload: fleet.ModifyPolicyPayload{ + SoftwareTitleID: optjson.Any[uint]{Set: true, Valid: true, Value: macOSTitleID}, + ScriptID: optjson.Any[uint]{Set: true, Valid: true, Value: savedTmScript.ID}, + }, + }, http.StatusOK, &mtplr) + titleResponse := getSoftwareTitleResponse{} s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", macOSTitleID), getSoftwareTitleRequest{ TeamID: &team.ID, }, http.StatusOK, &titleResponse) - require.Len(t, titleResponse.SoftwareTitle.AppStoreApp.AutomaticInstallPolicies, 1) + require.Len(t, titleResponse.SoftwareTitle.AppStoreApp.AutomaticInstallPolicies, 2) require.Equal(t, titleResponse.SoftwareTitle.AppStoreApp.AutomaticInstallPolicies[0].ID, policy1Team1.ID) s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team.ID, policy2Team1.ID), modifyTeamPolicyRequest{ @@ -11919,7 +12151,7 @@ func (s *integrationMDMTestSuite) TestVPPAppPolicyAutomation() { s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", macOSTitleID), getSoftwareTitleRequest{ TeamID: &team.ID, }, http.StatusOK, &titleResponse) - require.Len(t, titleResponse.SoftwareTitle.AppStoreApp.AutomaticInstallPolicies, 2) + require.Len(t, titleResponse.SoftwareTitle.AppStoreApp.AutomaticInstallPolicies, 3) // add a non-macOS host newHost := func(name string, teamID *uint, platform string) *fleet.Host { @@ -11999,6 +12231,160 @@ func (s *integrationMDMTestSuite) TestVPPAppPolicyAutomation() { fmt.Sprint(team.ID), "software_title_id", fmt.Sprint(macOSTitleID)) require.Equal(t, 1, countResp.Count) + // App is out of scope for mdmHost2, so we do not enqueue an install. + s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults( + mdmHost2, + map[uint]*bool{ + policy3Team1.ID: ptr.Bool(false), + }, + ), http.StatusOK, &distributedResp) + s.DoJSON("GET", "/api/latest/fleet/hosts/count", nil, http.StatusOK, &countResp, "software_status", "pending", "team_id", + fmt.Sprint(team.ID), "software_title_id", fmt.Sprint(macOSTitleID)) + require.Equal(t, 1, countResp.Count) + + var getHostResp getHostResponse + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", mdmHost2.ID), nil, http.StatusOK, &getHostResp, "exclude_software", "true") + require.Equal(t, getHostResp.Host.ID, mdmHost2.ID) + require.NotNil(t, getHostResp.Host.Policies) + require.Len(t, *getHostResp.Host.Policies, 4) + for _, p := range *getHostResp.Host.Policies { + if p.Name == policy3Team1.Name { + require.Empty(t, p.Response) + } + } + + // Validate that orbit got a notif (and get the exec ID for the script) + var orbitResp orbitGetConfigResponse + s.DoJSON("POST", "/api/fleet/orbit/config", + json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *mdmHost2.OrbitNodeKey)), + http.StatusOK, &orbitResp) + require.Len(t, orbitResp.Notifications.PendingScriptExecutionIDs, 1) + scriptExecID := orbitResp.Notifications.PendingScriptExecutionIDs[0] + + var hostActivitiesResp listHostUpcomingActivitiesResponse + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities/upcoming", mdmHost2.ID), + nil, http.StatusOK, &hostActivitiesResp) + + require.Len(t, hostActivitiesResp.Activities, 1, "got activities: %v", activitiesToString(hostActivitiesResp.Activities)) + assert.Equal(t, hostActivitiesResp.Activities[0].Type, fleet.ActivityTypeRanScript{}.ActivityName()) + assert.EqualValues(t, 1, hostActivitiesResp.Count) + assert.JSONEq( + t, + fmt.Sprintf( + `{"host_id": %d, "host_display_name": "%s", "script_name": "%s", "async": true, "policy_id": %d, "policy_name": "%s", "script_execution_id": "%s"}`, + mdmHost2.ID, + mdmHost2.DisplayName(), + savedTmScript.Name, + policy3Team1.ID, + policy3Team1.Name, + scriptExecID, + ), + string(*hostActivitiesResp.Activities[0].Details), + ) + + var orbitPostScriptResp orbitPostScriptResultResponse + s.DoJSON("POST", "/api/fleet/orbit/scripts/result", + json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q, "execution_id": %q, "exit_code": 0, "output": "ok"}`, *mdmHost2.OrbitNodeKey, scriptExecID)), + http.StatusOK, &orbitPostScriptResp) + + s.lastActivityMatches( + fleet.ActivityTypeRanScript{}.ActivityName(), + fmt.Sprintf( + `{"host_id": %d, "host_display_name": %q, "script_name": %q, "script_execution_id": %q, "async": true, "policy_id": %d, "policy_name": "%s"}`, + mdmHost2.ID, mdmHost2.DisplayName(), savedTmScript.Name, scriptExecID, policy3Team1.ID, policy3Team1.Name, + ), + 0, + ) + + // Update the app to exclude any with l2. We should not enqueue an install here because mdmHost2 + // has l2. + updateAppReq := &updateAppStoreAppRequest{TeamID: &team.ID, SelfService: false, LabelsExcludeAny: []string{l2.Name}} + s.Do("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/app_store_app", macOSTitleID), updateAppReq, http.StatusOK) + + s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults( + mdmHost2, + map[uint]*bool{ + policy3Team1.ID: ptr.Bool(false), + }, + ), http.StatusOK, &distributedResp) + s.DoJSON("GET", "/api/latest/fleet/hosts/count", nil, http.StatusOK, &countResp, "software_status", "pending", "team_id", + fmt.Sprint(team.ID), "software_title_id", fmt.Sprint(macOSTitleID)) + require.Equal(t, 1, countResp.Count) + + // Check that the policy shows up as "nil" status + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", mdmHost2.ID), nil, http.StatusOK, &getHostResp, "exclude_software", "true") + require.Equal(t, getHostResp.Host.ID, mdmHost2.ID) + require.NotNil(t, getHostResp.Host.Policies) + require.Len(t, *getHostResp.Host.Policies, 4) + for _, p := range *getHostResp.Host.Policies { + if p.Name == policy3Team1.Name { + require.Empty(t, p.Response) + } + } + + s.DoJSON("POST", "/api/fleet/orbit/config", + json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *mdmHost2.OrbitNodeKey)), + http.StatusOK, &orbitResp) + require.Len(t, orbitResp.Notifications.PendingScriptExecutionIDs, 1) + scriptExecID = orbitResp.Notifications.PendingScriptExecutionIDs[0] + + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities/upcoming", mdmHost2.ID), + nil, http.StatusOK, &hostActivitiesResp) + + require.Len(t, hostActivitiesResp.Activities, 1, "got activities: %v", activitiesToString(hostActivitiesResp.Activities)) + assert.Equal(t, hostActivitiesResp.Activities[0].Type, fleet.ActivityTypeRanScript{}.ActivityName()) + assert.EqualValues(t, 1, hostActivitiesResp.Count) + assert.JSONEq( + t, + fmt.Sprintf( + `{"host_id": %d, "host_display_name": "%s", "script_name": "%s", "async": true, "policy_id": %d, "policy_name": "%s", "script_execution_id": "%s"}`, + mdmHost2.ID, + mdmHost2.DisplayName(), + savedTmScript.Name, + policy3Team1.ID, + policy3Team1.Name, + scriptExecID, + ), + string(*hostActivitiesResp.Activities[0].Details), + ) + + s.DoJSON("POST", "/api/fleet/orbit/scripts/result", + json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q, "execution_id": %q, "exit_code": 0, "output": "ok"}`, *mdmHost2.OrbitNodeKey, scriptExecID)), + http.StatusOK, &orbitPostScriptResp) + + s.lastActivityMatches( + fleet.ActivityTypeRanScript{}.ActivityName(), + fmt.Sprintf( + `{"host_id": %d, "host_display_name": %q, "script_name": %q, "script_execution_id": %q, "async": true, "policy_id": %d, "policy_name": "%s"}`, + mdmHost2.ID, mdmHost2.DisplayName(), savedTmScript.Name, scriptExecID, policy3Team1.ID, policy3Team1.Name, + ), + 0, + ) + + // Update the app to include any with l1. We should now enqueue an install as the app is in scope. + updateAppReq = &updateAppStoreAppRequest{TeamID: &team.ID, SelfService: false, LabelsIncludeAny: []string{l1.Name, l2.Name}} + s.Do("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/app_store_app", macOSTitleID), updateAppReq, http.StatusOK) + + s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults( + mdmHost2, + map[uint]*bool{ + policy3Team1.ID: ptr.Bool(false), + }, + ), http.StatusOK, &distributedResp) + s.DoJSON("GET", "/api/latest/fleet/hosts/count", nil, http.StatusOK, &countResp, "software_status", "pending", "team_id", + fmt.Sprint(team.ID), "software_title_id", fmt.Sprint(macOSTitleID)) + require.Equal(t, 2, countResp.Count) + + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", mdmHost2.ID), nil, http.StatusOK, &getHostResp, "exclude_software", "true") + require.Equal(t, getHostResp.Host.ID, mdmHost2.ID) + require.NotNil(t, getHostResp.Host.Policies) + require.Len(t, *getHostResp.Host.Policies, 4) + for _, p := range *getHostResp.Host.Policies { + if p.Name == policy3Team1.Name { + require.Equal(t, "fail", p.Response) + } + } + // MDM host failing policy should not queue another install while install is pending s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults( mdmHost, @@ -12025,16 +12411,9 @@ func (s *integrationMDMTestSuite) TestVPPAppPolicyAutomation() { cmdUUID = cmd.CommandUUID // Get pending activity, confirm one pending activity - var hostActivitiesResp listHostUpcomingActivitiesResponse s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities/upcoming", mdmHost.ID), nil, http.StatusOK, &hostActivitiesResp) - activitiesToString := func(activities []*fleet.Activity) []string { - var res []string - for _, activity := range activities { - res = append(res, fmt.Sprintf("%+v", activity)) - } - return res - } + require.Len(t, hostActivitiesResp.Activities, 1, "got activities: %v", activitiesToString(hostActivitiesResp.Activities)) assert.Equal(t, hostActivitiesResp.Activities[0].Type, fleet.ActivityInstalledAppStoreApp{}.ActivityName()) assert.EqualValues(t, 1, hostActivitiesResp.Count) @@ -12073,6 +12452,94 @@ func (s *integrationMDMTestSuite) TestVPPAppPolicyAutomation() { 0, ) + // Process script execution + s.DoJSON("POST", "/api/fleet/orbit/config", + json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *mdmHost2.OrbitNodeKey)), + http.StatusOK, &orbitResp) + require.Len(t, orbitResp.Notifications.PendingScriptExecutionIDs, 1) + scriptExecID = orbitResp.Notifications.PendingScriptExecutionIDs[0] + + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities/upcoming", mdmHost2.ID), + nil, http.StatusOK, &hostActivitiesResp) + + // app install + script exec + require.Len(t, hostActivitiesResp.Activities, 2, "got activities: %v", activitiesToString(hostActivitiesResp.Activities)) + assert.Equal(t, hostActivitiesResp.Activities[0].Type, fleet.ActivityTypeRanScript{}.ActivityName()) + assert.EqualValues(t, 2, hostActivitiesResp.Count) + assert.JSONEq( + t, + fmt.Sprintf( + `{"host_id": %d, "host_display_name": "%s", "script_name": "%s", "async": true, "policy_id": %d, "policy_name": "%s", "script_execution_id": "%s"}`, + mdmHost2.ID, + mdmHost2.DisplayName(), + savedTmScript.Name, + policy3Team1.ID, + policy3Team1.Name, + scriptExecID, + ), + string(*hostActivitiesResp.Activities[0].Details), + ) + + s.DoJSON("POST", "/api/fleet/orbit/scripts/result", + json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q, "execution_id": %q, "exit_code": 0, "output": "ok"}`, *mdmHost2.OrbitNodeKey, scriptExecID)), + http.StatusOK, &orbitPostScriptResp) + + s.lastActivityMatches( + fleet.ActivityTypeRanScript{}.ActivityName(), + fmt.Sprintf( + `{"host_id": %d, "host_display_name": %q, "script_name": %q, "script_execution_id": %q, "async": true, "policy_id": %d, "policy_name": "%s"}`, + mdmHost2.ID, mdmHost2.DisplayName(), savedTmScript.Name, scriptExecID, policy3Team1.ID, policy3Team1.Name, + ), + 0, + ) + + // Process mdmHost2's installation + cmd, err = mdmDevice2.Idle() + require.NoError(t, err) + require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd)) + cmdUUID = cmd.CommandUUID + + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities/upcoming", mdmHost2.ID), + nil, http.StatusOK, &hostActivitiesResp) + + require.Len(t, hostActivitiesResp.Activities, 1, "got activities: %v", activitiesToString(hostActivitiesResp.Activities)) + assert.Equal(t, hostActivitiesResp.Activities[0].Type, fleet.ActivityInstalledAppStoreApp{}.ActivityName()) + assert.EqualValues(t, 1, hostActivitiesResp.Count) + assert.JSONEq( + t, + fmt.Sprintf( + `{"host_id": %d, "host_display_name": "%s", "software_title": "%s", "app_store_id": "%s", "command_uuid": "%s", "status": "%s", "self_service": %v}`, + mdmHost2.ID, + mdmHost2.DisplayName(), + macOSApp.Name, + macOSApp.AdamID, + cmdUUID, + fleet.SoftwareInstallPending, + false, + ), + string(*hostActivitiesResp.Activities[0].Details), + ) + + _, err = mdmDevice.Acknowledge(cmd.CommandUUID) + require.NoError(t, err) + + s.lastActivityMatches( + fleet.ActivityInstalledAppStoreApp{}.ActivityName(), + fmt.Sprintf( + `{"host_id": %d, "host_display_name": "%s", "software_title": "%s", "app_store_id": "%s", "command_uuid": "%s", "status": "%s", "self_service": %v, "policy_id": %d, "policy_name": "%s"}`, + mdmHost2.ID, + mdmHost2.DisplayName(), + macOSApp.Name, + macOSApp.AdamID, + cmdUUID, + fleet.SoftwareInstalled, + false, + policy3Team1.ID, + policy3Team1.Name, + ), + 0, + ) + // MDM host failing already-failing policies should not trigger any installs s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults( mdmHost, diff --git a/server/service/osquery.go b/server/service/osquery.go index 27d8cd308bc8..19f5e86a6717 100644 --- a/server/service/osquery.go +++ b/server/service/osquery.go @@ -1010,16 +1010,18 @@ func (svc *Service) SubmitDistributedQueryResults( logging.WithErr(ctx, err) } + if err := svc.processScriptsForNewlyFailingPolicies(ctx, host.ID, host.TeamID, host.Platform, host.OrbitNodeKey, host.ScriptsEnabled, policyResults); err != nil { + logging.WithErr(ctx, err) + } + if host.Platform == "darwin" && svc.EnterpriseOverrides != nil { + // NOTE: if the installers for the policies here are not scoped to the host via labels, we update the policy status here to stop it from showing up as "failed" in the + // host details. if err := svc.processVPPForNewlyFailingPolicies(ctx, host.ID, host.TeamID, host.Platform, policyResults); err != nil { logging.WithErr(ctx, err) } } - if err := svc.processScriptsForNewlyFailingPolicies(ctx, host.ID, host.TeamID, host.Platform, host.OrbitNodeKey, host.ScriptsEnabled, policyResults); err != nil { - logging.WithErr(ctx, err) - } - // NOTE: if the installers for the policies here are not scoped to the host via labels, we update the policy status here to stop it from showing up as "failed" in the // host details. if err := svc.processSoftwareForNewlyFailingPolicies(ctx, host.ID, host.TeamID, host.Platform, host.OrbitNodeKey, policyResults); err != nil { @@ -1964,7 +1966,7 @@ func (svc *Service) processVPPForNewlyFailingPolicies( continue } - vppMetadata, err := svc.ds.GetVPPAppMetadataByAdamIDAndPlatform(ctx, failingPolicyWithVPP.AdamID, failingPolicyWithVPP.Platform) + vppMetadata, err := svc.ds.GetVPPAppMetadataByAdamIDPlatformTeamID(ctx, failingPolicyWithVPP.AdamID, failingPolicyWithVPP.Platform, host.TeamID) if err != nil { level.Error(svc.logger).Log( "msg", "failed to get VPP metadata", @@ -1973,6 +1975,19 @@ func (svc *Service) processVPPForNewlyFailingPolicies( continue } + scoped, err := svc.ds.IsVPPAppLabelScoped(ctx, vppMetadata.VPPAppTeam.AppTeamID, hostID) + if err != nil { + return ctxerr.Wrap(ctx, err, "checking if vpp app is label scoped to host") + } + + if !scoped { + // NOTE: we update the policy status here to stop it from showing up as "failed" in the + // host details. + incomingPolicyResults[failingPolicyWithVPP.ID] = nil + level.Debug(logger).Log("msg", "not marking policy as failed since vpp app is out of scope for host") + continue + } + commandUUID, err := svc.EnterpriseOverrides.InstallVPPAppPostValidation(ctx, host, vppMetadata, vppToken, false, &policyID) if err != nil { level.Error(svc.logger).Log( diff --git a/server/service/software_installers_test.go b/server/service/software_installers_test.go index 3b1da3d56945..22d83cce8238 100644 --- a/server/service/software_installers_test.go +++ b/server/service/software_installers_test.go @@ -158,7 +158,9 @@ func TestSoftwareInstallersAuth(t *testing.T) { } } -func TestValidateSoftwareInstallerLabels(t *testing.T) { +// TestValidateSoftwareLabels tests logic for validating labels associated with software (VPP apps, +// FMAs, and custom packages) +func TestValidateSoftwareLabels(t *testing.T) { ds := new(mock.Store) license := &fleet.LicenseInfo{Tier: fleet.TierPremium, Expiration: time.Now().Add(24 * time.Hour)} diff --git a/server/service/vpp.go b/server/service/vpp.go index 92e92d9794fe..4e62367ad7c3 100644 --- a/server/service/vpp.go +++ b/server/service/vpp.go @@ -49,10 +49,12 @@ func (svc *Service) GetAppStoreApps(ctx context.Context, teamID *uint) ([]*fleet ////////////////////////////////////////////////////////////////////////////// type addAppStoreAppRequest struct { - TeamID *uint `json:"team_id"` - AppStoreID string `json:"app_store_id"` - Platform fleet.AppleDevicePlatform `json:"platform"` - SelfService bool `json:"self_service"` + TeamID *uint `json:"team_id"` + AppStoreID string `json:"app_store_id"` + Platform fleet.AppleDevicePlatform `json:"platform"` + SelfService bool `json:"self_service"` + LabelsIncludeAny []string `json:"labels_include_any"` + LabelsExcludeAny []string `json:"labels_exclude_any"` } type addAppStoreAppResponse struct { @@ -63,7 +65,12 @@ func (r addAppStoreAppResponse) error() error { return r.Err } func addAppStoreAppEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { req := request.(*addAppStoreAppRequest) - err := svc.AddAppStoreApp(ctx, req.TeamID, fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: req.AppStoreID, Platform: req.Platform}, SelfService: req.SelfService}) + err := svc.AddAppStoreApp(ctx, req.TeamID, fleet.VPPAppTeam{ + VPPAppID: fleet.VPPAppID{AdamID: req.AppStoreID, Platform: req.Platform}, + SelfService: req.SelfService, + LabelsIncludeAny: req.LabelsIncludeAny, + LabelsExcludeAny: req.LabelsExcludeAny, + }) if err != nil { return &addAppStoreAppResponse{Err: err}, nil } @@ -79,6 +86,44 @@ func (svc *Service) AddAppStoreApp(ctx context.Context, _ *uint, _ fleet.VPPAppT return fleet.ErrMissingLicense } +////////////////////////////////////////////////////////////////////////////// +// Update App Store apps +////////////////////////////////////////////////////////////////////////////// + +type updateAppStoreAppRequest struct { + TitleID uint `url:"title_id"` + TeamID *uint `json:"team_id"` + SelfService bool `json:"self_service"` + LabelsIncludeAny []string `json:"labels_include_any"` + LabelsExcludeAny []string `json:"labels_exclude_any"` +} + +type updateAppStoreAppResponse struct { + AppStoreApp *fleet.VPPAppStoreApp `json:"app_store_app,omitempty"` + Err error `json:"error,omitempty"` +} + +func (r updateAppStoreAppResponse) error() error { return r.Err } + +func updateAppStoreAppEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { + req := request.(*updateAppStoreAppRequest) + + updatedApp, err := svc.UpdateAppStoreApp(ctx, req.TitleID, req.TeamID, req.SelfService, req.LabelsIncludeAny, req.LabelsExcludeAny) + if err != nil { + return updateAppStoreAppResponse{Err: err}, nil + } + + return updateAppStoreAppResponse{AppStoreApp: updatedApp}, nil +} + +func (svc *Service) UpdateAppStoreApp(ctx context.Context, titleID uint, teamID *uint, selfService bool, labelsIncludeAny, labelsExcludeAny []string) (*fleet.VPPAppStoreApp, error) { + // skipauth: No authorization check needed due to implementation returning + // only license error. + svc.authz.SkipAuthorization(ctx) + + return nil, fleet.ErrMissingLicense +} + //////////////////////////////////////////////////////////////////////////////// // POST /api/_version_/vpp_tokens ////////////////////////////////////////////////////////////////////////////////