diff --git a/docs/Contributing/guides/upcoming-activities.md b/docs/Contributing/guides/upcoming-activities.md index e6265bfde3..e6c58f47c0 100644 --- a/docs/Contributing/guides/upcoming-activities.md +++ b/docs/Contributing/guides/upcoming-activities.md @@ -2,13 +2,14 @@ Introduced with the ["Upcoming activities run as listed (one queue)"](https://github.com/fleetdm/fleet/issues/22866) story, the upcoming activities feature (also known internally as the unified queue or the "uniq") consists of a single queue that holds the activities to execute for a specific host. -Those activities are processed in order (that is, the second activity being blocked until the first gets into a terminal state) and features like cancellation and prioritization are (or are planned to be) supported. +Those activities are processed in order (that is, the second activity being blocked until the first gets into a terminal state) and features like cancellation is supported and prioritization is planned to be supported (and already has the mechanism to be supported internally). Types of activities that can be queued include: * Script execution * Software installation (custom installers or from the Fleet-maintained apps) * VPP app installation * Software uninstallation +* In-house app installation (also known as .ipa or custom apps, added in the [Use API to deploy in-house (enterprise) iOS/iPadOS package story](https://github.com/fleetdm/fleet/issues/30936)) * MDM commands are planned to be added to the queue (including commands to install or remove profiles) ## Implementation details @@ -19,6 +20,7 @@ The unified queue itself consists of the `upcoming_activities` table, and activi * `script_upcoming_activities` for scripts * `software_install_upcoming_activities` for both software install and uninstall * `vpp_app_upcoming_activities` for VPP app install +* `in_house_app_upcoming_activities` for in-house app install The primary table contains the meta information about the activity (user, host, priority, type, etc.) and a JSON `payload` column for secondary information that does not require any foreign key constraints or indexing. The secondary table contains foreign key references required by the activity and the corresponding `ON DELETE` behavior (e.g. if a VPP app gets deleted, the corresponding upcoming activity should be deleted as well). @@ -30,8 +32,9 @@ When an activity is ready to execute (to become "active"), it is updated in `upc * For scripts, it inserts a pending execution row in `host_script_results` with the same `execution_id` as the upcoming activity, and `fleetd` (orbit) will pick it up via its notifications; * For software installs, it inserts a pending install row in `host_software_installs` with the same `execution_id` as the upcoming activity, and `fleetd` (orbit) will pick it up via its notifications; -* For VPP apps, since they are processed by an MDM command, it inserts a pending MDM command in `nano_commands` and `nano_enrollment_queue`, with the `command_uuid` set to the `execution_id` of the upcoming activity, and a push notification will be sent to the host to process it via MDM; +* For VPP apps, since they are processed by an MDM command, it inserts a pending MDM command in `nano_commands` and `nano_enrollment_queue`, with the `command_uuid` set to the `execution_id` of the upcoming activity, and a push notification will be sent to the host to process it via MDM, and it inserts in `host_vpp_software_installs`; * For software uninstalls, it is a bit more complex but it inserts in both `host_script_results` and `host_software_installs` with the proper `uninstall = TRUE` flag, and the same `execution_id` is used in both tables to link them (as was done before the unified queue), and `fleetd` (orbit) will pick it up via its notifications. +* For In-house apps, since they are processed by an MDM command, it inserts a pending MDM command in `nano_commands` and `nano_enrollment_queue`, with the `command_uuid` set to the `execution_id` of the upcoming activity, and a push notification will be sent to the host to process it via MDM, and it inserts in `host_in_house_software_installs`; The behavior described above is **very important** to ensure the queue does not become stuck, in fact those are the **two rules that every future change needs to keep in mind** when it affects the upcoming activities: @@ -56,12 +59,12 @@ Note that: ### Cancellation -Starting with Fleet v4.67.0, cancellation of upcoming activities is supported. It is implemented as follows: +Starting with Fleet v4.67.0 (with the [Cancel upcoming activities story](https://github.com/fleetdm/fleet/issues/25540)), cancellation of upcoming activities is supported. It is implemented as follows: * If the upcoming activity was not _activated_ yet, then it simply deletes the row from `upcoming_activities`. A few more cleanup steps are done to ensure that if it was a Wipe/Lock script, the host's state is properly reset to "not pending wipe/lock". -* Otherwise if it was _activated_, then a new `canceled` boolean field was added to the `host_script_results`, `host_software_installs` and `host_vpp_software_installs` tables and it is set to `true` for the corresponding row. In addition to this: +* Otherwise if it was _activated_, then a new `canceled` boolean field was added to the `host_script_results`, `host_software_installs` and `host_vpp_software_installs` tables (and was added at creation for `host_in_house_software_installs`) and it is set to `true` for the corresponding row. In addition to this: - If the software/VPP app install or script was part of the setup experience flow, the corresponding entry in setup experience is marked as "failed"; - - For VPP apps, the corresponding MDM command is marked as inactive (`active = 0`) so that it won't be sent to the host if it hasn't already been sent. + - For VPP and in-house apps, the corresponding MDM command is marked as inactive (`active = 0`) so that it won't be sent to the host if it hasn't already been sent. An _activated_ activity is not guaranteed to not run/execute, because the host may have already received request to process it. This is ok, Fleet will properly record any result of a canceled activity, it just won't show up in Fleet because it will just show as _canceled_ (there are new past activities for the cancelation of upcoming activities). Queries that return e.g. the last status of a software install or the last result of a saved script will ignore canceled executions. diff --git a/docs/Contributing/reference/audit-logs.md b/docs/Contributing/reference/audit-logs.md index 7b57661864..1fd7e28751 100644 --- a/docs/Contributing/reference/audit-logs.md +++ b/docs/Contributing/reference/audit-logs.md @@ -1346,6 +1346,7 @@ This activity contains the following fields: - "source": Software source type (e.g., "pkg_packages", "sh_packages", "ps1_packages"). - "policy_id": ID of the policy whose failure triggered the installation. Null if no associated policy. - "policy_name": Name of the policy whose failure triggered installation. Null if no associated policy. +- "command_uuid": ID of the in-house app installation. #### Example diff --git a/ee/server/service/in_house_apps.go b/ee/server/service/in_house_apps.go new file mode 100644 index 0000000000..ac9a47e518 --- /dev/null +++ b/ee/server/service/in_house_apps.go @@ -0,0 +1,220 @@ +package service + +import ( + "bytes" + "context" + "fmt" + "text/template" + + "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" + "github.com/fleetdm/fleet/v4/server/contexts/viewer" + "github.com/fleetdm/fleet/v4/server/fleet" +) + +func (svc *Service) updateInHouseAppInstaller(ctx context.Context, payload *fleet.UpdateSoftwareInstallerPayload, vc viewer.Viewer, teamName *string, software *fleet.SoftwareTitle) (*fleet.SoftwareInstaller, error) { + existingInstaller, err := svc.ds.GetInHouseAppMetadataByTeamAndTitleID(ctx, payload.TeamID, payload.TitleID) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "getting existing installer") + } + + if payload.SelfService == nil && payload.InstallerFile == nil && payload.PreInstallQuery == nil && + payload.InstallScript == nil && payload.PostInstallScript == nil && payload.UninstallScript == nil && + payload.LabelsIncludeAny == nil && payload.LabelsExcludeAny == nil { + return existingInstaller, nil // no payload, noop + } + + payload.InstallerID = existingInstaller.InstallerID + + _, validatedLabels, err := ValidateSoftwareLabelsForUpdate(ctx, svc, existingInstaller, payload.LabelsIncludeAny, payload.LabelsExcludeAny) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "validating software labels for update") + } + payload.ValidatedLabels = validatedLabels + + // activity team ID must be null if no team, not zero + var actTeamID *uint + if payload.TeamID != nil && *payload.TeamID != 0 { + actTeamID = payload.TeamID + } + activity := fleet.ActivityTypeEditedSoftware{ + SoftwareTitle: existingInstaller.SoftwareTitle, + TeamName: teamName, + TeamID: actTeamID, + SoftwarePackage: &existingInstaller.Name, + SoftwareTitleID: payload.TitleID, + SoftwareIconURL: existingInstaller.IconUrl, + } + + var payloadForNewInstallerFile *fleet.UploadSoftwareInstallerPayload + if payload.InstallerFile != nil { + payloadForNewInstallerFile = &fleet.UploadSoftwareInstallerPayload{ + InstallerFile: payload.InstallerFile, + Filename: payload.Filename, + } + + newInstallerExtension, err := svc.addMetadataToSoftwarePayload(ctx, payloadForNewInstallerFile, false) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "extracting updated installer metadata") + } + + if newInstallerExtension != existingInstaller.Extension { + return nil, &fleet.BadRequestError{ + Message: "The selected package is for a different file type.", + InternalErr: ctxerr.Wrap(ctx, err, "installer extension mismatch"), + } + } + + if payloadForNewInstallerFile.Title != software.Name { + return nil, &fleet.BadRequestError{ + Message: "The selected package is for different software.", + InternalErr: ctxerr.Wrap(ctx, err, "installer software title mismatch"), + } + } + + if payloadForNewInstallerFile.StorageID != existingInstaller.StorageID { + activity.SoftwarePackage = &payload.Filename + payload.StorageID = payloadForNewInstallerFile.StorageID + payload.Filename = payloadForNewInstallerFile.Filename + payload.Version = payloadForNewInstallerFile.Version + + } else { // noop if uploaded installer is identical to previous installer + payloadForNewInstallerFile = nil + payload.InstallerFile = nil + } + } + + if payload.InstallerFile == nil { // fill in existing existingInstaller data to payload + payload.StorageID = existingInstaller.StorageID + payload.Filename = existingInstaller.Name + payload.Version = existingInstaller.Version + } + + // persist changes starting here, now that we've done all the validation/diffing we can + if payloadForNewInstallerFile != nil { + if err := svc.storeSoftware(ctx, payloadForNewInstallerFile); err != nil { + return nil, ctxerr.Wrap(ctx, err, "storing software installer") + } + } + + if err := svc.ds.SaveInHouseAppUpdates(ctx, payload); err != nil { + return nil, ctxerr.Wrap(ctx, err, "saving installer updates") + } + + if err := svc.ds.RemovePendingInHouseAppInstalls(ctx, existingInstaller.InstallerID); err != nil { + return nil, err + } + + // now that the payload has been updated with any patches, we can set the + // final fields of the activity + actLabelsIncl, actLabelsExcl := activitySoftwareLabelsFromSoftwareScopeLabels( + existingInstaller.LabelsIncludeAny, existingInstaller.LabelsExcludeAny) + if payload.ValidatedLabels != nil { + actLabelsIncl, actLabelsExcl = activitySoftwareLabelsFromValidatedLabels(payload.ValidatedLabels) + } + activity.LabelsIncludeAny = actLabelsIncl + activity.LabelsExcludeAny = actLabelsExcl + if err := svc.NewActivity(ctx, vc.User, activity); err != nil { + return nil, ctxerr.Wrap(ctx, err, "creating activity for edited in house app") + } + + // re-pull installer from database to ensure any side effects are accounted for; may be able to optimize this out later + updatedInstaller, err := svc.ds.GetInHouseAppMetadataByTeamAndTitleID(ctx, payload.TeamID, payload.TitleID) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "re-hydrating updated installer metadata") + } + + st, err := svc.ds.GetSummaryHostInHouseAppInstalls(ctx, payload.TeamID, updatedInstaller.InstallerID) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "getting updated installer statuses") + } + updatedInstaller.Status = &fleet.SoftwareInstallerStatusSummary{Installed: st.Installed, PendingInstall: st.Pending, FailedInstall: st.Failed} + + return updatedInstaller, nil +} + +func (svc *Service) GetInHouseAppManifest(ctx context.Context, titleID uint, teamID *uint) ([]byte, error) { + // TODO(JVE): use time-based JWT auth here, this is just for testing + svc.authz.SkipAuthorization(ctx) + + appConfig, err := svc.ds.AppConfig(ctx) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "get in house app manifest: get app config") + } + + var tid uint + if teamID != nil { + tid = *teamID + } + downloadUrl := fmt.Sprintf("%s/api/latest/fleet/software/titles/%d/in_house_app?team_id=%d", appConfig.ServerSettings.ServerURL, titleID, tid) + + meta, err := svc.ds.GetInHouseAppMetadataByTeamAndTitleID(ctx, teamID, titleID) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "get in house app manifest: get in house app metadata") + } + + tmpl := template.Must(template.New("").Parse(` + + + items + + + assets + + + kind + software-package + url + {{ .URL }} + + + kind + display-image + needs-shine + + url + + + + metadata + + bundle-identifier + {{ .BundleID }} + bundle-version + {{ .Version }} + kind + software + title + {{ .Name }} + + + + +`)) + + buf := bytes.NewBuffer([]byte{}) + + err = tmpl.Execute(buf, struct { + BundleID string + Version string + Name string + URL string + }{meta.BundleIdentifier, meta.Version, meta.SoftwareTitle, downloadUrl}) + + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "rendering app manifest") + } + + return buf.Bytes(), nil +} + +func (svc *Service) GetInHouseAppPackage(ctx context.Context, titleID uint, teamID *uint) (*fleet.DownloadSoftwareInstallerPayload, error) { + // TODO(JVE): JWT with expiration for auth + svc.authz.SkipAuthorization(ctx) + + meta, err := svc.ds.GetInHouseAppMetadataByTeamAndTitleID(ctx, teamID, titleID) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "get in house app package: get in house app metadata") + } + + return svc.getSoftwareInstallerBinary(ctx, meta.StorageID, "installer.ipa") +} diff --git a/ee/server/service/software_installers.go b/ee/server/service/software_installers.go index d3fdbabf87..b88ffe22df 100644 --- a/ee/server/service/software_installers.go +++ b/ee/server/service/software_installers.go @@ -81,7 +81,9 @@ func (svc *Service) UploadSoftwareInstaller(ctx context.Context, payload *fleet. payload.PostInstallScript = file.Dos2UnixNewlines(payload.PostInstallScript) payload.UninstallScript = file.Dos2UnixNewlines(payload.UninstallScript) - if _, err := svc.addMetadataToSoftwarePayload(ctx, payload, true); err != nil { + failOnBlankScript := !strings.HasSuffix(payload.Filename, ".ipa") + + if _, err := svc.addMetadataToSoftwarePayload(ctx, payload, failOnBlankScript); err != nil { return nil, ctxerr.Wrap(ctx, err, "adding metadata to payload") } @@ -163,6 +165,15 @@ func (svc *Service) UploadSoftwareInstaller(ctx context.Context, payload *fleet. if payload.TeamID != nil { tmID = *payload.TeamID } + + if payload.Extension == "ipa" { + addedInstaller, err := svc.ds.GetInHouseAppMetadataByTeamAndTitleID(ctx, &tmID, titleID) + if err != nil { + return nil, err + } + return addedInstaller, nil + } + addedInstaller, err := svc.ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctxdb.RequirePrimary(ctx, true), &tmID, titleID, true) if err != nil { return nil, ctxerr.Wrap(ctx, err, "getting added software installer") @@ -314,6 +325,11 @@ func (svc *Service) UpdateSoftwareInstaller(ctx context.Context, payload *fleet. return nil, ctxerr.Wrap(ctx, err, "getting software title by id") } + // Handle in house apps separately + if software.InHouseAppCount == 1 { + return svc.updateInHouseAppInstaller(ctx, payload, vc, teamName, software) + } + // TODO when we start supporting multiple installers per title X team, need to rework how we determine installer to edit if software.SoftwareInstallersCount != 1 { return nil, &fleet.BadRequestError{ @@ -735,19 +751,28 @@ func (svc *Service) DeleteSoftwareInstaller(ctx context.Context, titleID uint, t } // first, look for a software installer - meta, err := svc.ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, teamID, titleID, false) - if err != nil { - if fleet.IsNotFound(err) { - // no software installer, look for a VPP app - meta, err := svc.ds.GetVPPAppMetadataByTeamAndTitleID(ctx, teamID, titleID) - if err != nil { - return ctxerr.Wrap(ctx, err, "getting software app metadata") - } - return svc.deleteVPPApp(ctx, teamID, meta) - } - return ctxerr.Wrap(ctx, err, "getting software installer metadata") + metaInstaller, errInstaller := svc.ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, teamID, titleID, false) + metaVPP, errVPP := svc.ds.GetVPPAppMetadataByTeamAndTitleID(ctx, teamID, titleID) + metaInHouse, errInHouse := svc.ds.GetInHouseAppMetadataByTeamAndTitleID(ctx, teamID, titleID) + + switch { + case errInstaller != nil && !fleet.IsNotFound(errInstaller): + return ctxerr.Wrap(ctx, errInstaller, "getting software installer metadata") + case errVPP != nil && !fleet.IsNotFound(errVPP): + return ctxerr.Wrap(ctx, errVPP, "getting vpp app metadata") + case errInHouse != nil && !fleet.IsNotFound(errInHouse): + return ctxerr.Wrap(ctx, errInHouse, "getting in house app metadata") } - return svc.deleteSoftwareInstaller(ctx, meta) + + switch { + case metaInstaller != nil: + return svc.deleteSoftwareInstaller(ctx, metaInstaller) + case metaVPP != nil: + return svc.deleteVPPApp(ctx, teamID, metaVPP) + case metaInHouse != nil: + return svc.deleteSoftwareInstaller(ctx, metaInHouse) + } + return ctxerr.Wrap(ctx, ¬FoundError{}, "getting software installer") } func (svc *Service) deleteVPPApp(ctx context.Context, teamID *uint, meta *fleet.VPPAppStoreApp) error { @@ -800,8 +825,14 @@ func (svc *Service) deleteSoftwareInstaller(ctx context.Context, meta *fleet.Sof return fleet.ErrNoContext } - if err := svc.ds.DeleteSoftwareInstaller(ctx, meta.InstallerID); err != nil { - return ctxerr.Wrap(ctx, err, "deleting software installer") + if meta.Extension == "ipa" { + if err := svc.ds.DeleteInHouseApp(ctx, meta.InstallerID); err != nil { + return ctxerr.Wrap(ctx, err, "deleting in house app") + } + } else { + if err := svc.ds.DeleteSoftwareInstaller(ctx, meta.InstallerID); err != nil { + return ctxerr.Wrap(ctx, err, "deleting software installer") + } } var teamName *string @@ -1093,6 +1124,30 @@ func (svc *Service) InstallSoftwareTitle(ctx context.Context, hostID uint, softw return err } + if mobileAppleDevice { + iha, err := svc.ds.GetInHouseAppMetadataByTeamAndTitleID(ctx, host.TeamID, softwareTitleID) + if err != nil && !fleet.IsNotFound(err) { + return ctxerr.Wrap(ctx, err, "install in house app: get metadata") + } + + if iha != nil { + scoped, err := svc.ds.IsInHouseAppLabelScoped(ctx, iha.InstallerID, hostID) + if err != nil { + return ctxerr.Wrap(ctx, err, "checking label scoping during in-house app 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.ds.InsertHostInHouseAppInstall(ctx, host.ID, iha.InstallerID, softwareTitleID, uuid.NewString(), fleet.HostSoftwareInstallOptions{}) + return ctxerr.Wrap(ctx, err, "insert in house app install") + } + // it's OK if we didn't find an in-house app; this might be a VPP app, so continue on + } + if !mobileAppleDevice { installer, err := svc.ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, host.TeamID, softwareTitleID, false) if err != nil { diff --git a/ee/server/service/software_installers_test.go b/ee/server/service/software_installers_test.go index 6eb1d6ea46..f0749938a8 100644 --- a/ee/server/service/software_installers_test.go +++ b/ee/server/service/software_installers_test.go @@ -215,6 +215,11 @@ func TestInstallSoftwareTitle(t *testing.T) { t.Parallel() ds := new(mock.Store) svc := newTestService(t, ds) + + ds.GetInHouseAppMetadataByTeamAndTitleIDFunc = func(ctx context.Context, teamID *uint, titleID uint) (*fleet.SoftwareInstaller, error) { + return nil, nil + } + ctx := viewer.NewContext(context.Background(), viewer.Viewer{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}}) host := &fleet.Host{ @@ -337,6 +342,77 @@ func TestSoftwareInstallerPayloadFromSlug(t *testing.T) { assert.False(t, payload.FleetMaintained) } +func TestGetInHouseAppManifest(t *testing.T) { + ds := new(mock.Store) + svc := newTestService(t, ds) + ctx := context.Background() + + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return &fleet.AppConfig{ServerSettings: fleet.ServerSettings{ServerURL: "https://example.com"}}, nil + } + + ds.GetInHouseAppMetadataByTeamAndTitleIDFunc = func(ctx context.Context, teamID *uint, titleID uint) (*fleet.SoftwareInstaller, error) { + if titleID == 1 { + return &fleet.SoftwareInstaller{ + BundleIdentifier: "com.foo.bar", + Version: "1.2.3", + SoftwareTitle: "test in-house app", + }, nil + } + + return nil, notFoundError{} + } + + expected := ` + + + items + + + assets + + + kind + software-package + url + https://example.com/api/latest/fleet/software/titles/1/in_house_app?team_id=0 + + + kind + display-image + needs-shine + + url + + + + metadata + + bundle-identifier + com.foo.bar + bundle-version + 1.2.3 + kind + software + title + test in-house app + + + + +` + + manifest, err := svc.GetInHouseAppManifest(ctx, 1, nil) + require.NoError(t, err) + + assert.Equal(t, expected, string(manifest)) + + _, err = svc.GetInHouseAppManifest(ctx, 2, nil) + assert.Error(t, err) + assert.True(t, fleet.IsNotFound(err)) + +} + func checkAuthErr(t *testing.T, shouldFail bool, err error) { t.Helper() if shouldFail { diff --git a/pkg/file/file.go b/pkg/file/file.go index af2a4ec3ba..5d99a672a2 100644 --- a/pkg/file/file.go +++ b/pkg/file/file.go @@ -64,6 +64,8 @@ func ExtractInstallerMetadata(tfr *fleet.TempFileReader) (*InstallerMetadata, er meta, err = ExtractXARMetadata(tfr) case "msi": meta, err = ExtractMSIMetadata(tfr) + case "ipa": + meta, err = ExtractIPAMetadata(tfr) case "tar.gz": meta, err = ValidateTarball(tfr) if err != nil { @@ -95,6 +97,9 @@ func typeFromBytes(br *bufio.Reader) (string, error) { // will capture standalone gz files but will fail on tar read attempt, so good enough case hasPrefix(br, []byte{0x1f, 0x8b}): return "tar.gz", nil + case hasPrefix(br, []byte{0x50, 0x4B, 0x03, 0x04}): + // TODO(JVE): we need to validate against the filename as well + return "ipa", nil case hasPrefix(br, []byte("MZ")): if blob, _ := br.Peek(0x3e); len(blob) == 0x3e { reloc := binary.LittleEndian.Uint16(blob[0x3c:0x3e]) diff --git a/pkg/file/ipa.go b/pkg/file/ipa.go new file mode 100644 index 0000000000..bee9d90565 --- /dev/null +++ b/pkg/file/ipa.go @@ -0,0 +1,63 @@ +package file + +import ( + "archive/zip" + "crypto/sha256" + "errors" + "fmt" + "io" + "strings" + + "github.com/fleetdm/fleet/v4/server/fleet" + "howett.net/plist" +) + +func ExtractIPAMetadata(tfr *fleet.TempFileReader) (*InstallerMetadata, error) { + h := sha256.New() + _, _ = io.Copy(h, tfr) // writes to a hash cannot fail + if err := tfr.Rewind(); err != nil { + return nil, fmt.Errorf("rewind reader: %w", err) + } + + r, err := zip.OpenReader(tfr.Name()) + if err != nil { + return nil, err + } + + var plistData struct { + BundleID string `plist:"CFBundleIdentifier"` + Name string `plist:"CFBundleName"` + Version string `plist:"CFBundleShortVersionString"` + } + for _, f := range r.File { + if strings.Contains(f.Name, "Info.plist") { + // Get data from plist file + archiveFile, err := f.Open() + if err != nil { + return nil, fmt.Errorf("could not open archive %s: %w", f.Name, err) + } + defer archiveFile.Close() + + rawData, err := io.ReadAll(archiveFile) + if err != nil { + return nil, err + } + _, err = plist.Unmarshal(rawData, &plistData) + if err != nil { + return nil, err + } + } + } + + if plistData.BundleID == "" { + return nil, errors.New("couldn't find bundle identifier for in-house app") + } + + return &InstallerMetadata{ + BundleIdentifier: plistData.BundleID, + SHASum: h.Sum(nil), + PackageIDs: []string{plistData.BundleID}, + Name: plistData.Name, + Version: plistData.Version, + }, nil +} diff --git a/server/datastore/mysql/activities.go b/server/datastore/mysql/activities.go index 6992897a7d..20a367c4a2 100644 --- a/server/datastore/mysql/activities.go +++ b/server/datastore/mysql/activities.go @@ -73,42 +73,29 @@ func (ds *Datastore) NewActivity( cols = append(cols, "user_email") } - vppPtrAct, okPtr := activity.(*fleet.ActivityInstalledAppStoreApp) - vppAct, ok := activity.(fleet.ActivityInstalledAppStoreApp) - if okPtr || ok { - hostID := vppAct.HostID - cmdUUID := vppAct.CommandUUID - if okPtr { - cmdUUID = vppPtrAct.CommandUUID - hostID = vppPtrAct.HostID - } - - activateNext := vppAct.Status != string(fleet.SoftwareInstalled) - if vppPtrAct != nil { - activateNext = vppPtrAct.Status != string(fleet.SoftwareInstalled) - } - - if activateNext { - // NOTE: ideally this would be called in the same transaction as storing - // the nanomdm command results, but the current design doesn't allow for - // that with the nano store being a distinct entity to our datastore (we - // should get rid of that distinction eventually, we've broken it already - // in some places and it doesn't bring much benefit anymore). - // - // Instead, this gets called from CommandAndReportResults, which is - // executed after the results have been saved in nano, but we already - // accept this non-transactional fact for many other states we manage in - // Fleet (wipe, lock results, setup experience results, etc. - see all - // critical data that gets updated in CommandAndReportResults) so there's - // no reason to treat the unified queue differently. - // - // This place here is a bit hacky but perfect for VPP apps as the activity - // gets created only when the MDM command status is in a final state - // (success or failure), which is exactly when we want to activate the next - // activity. - if _, err := ds.activateNextUpcomingActivity(ctx, ds.writer(ctx), hostID, cmdUUID); err != nil { - return ctxerr.Wrap(ctx, err, "activate next activity from VPP app install") - } + if aa, ok := activity.(fleet.ActivityActivator); ok && aa.MustActivateNextUpcomingActivity() { + hostID, cmdUUID := aa.ActivateNextUpcomingActivityArgs() + // NOTE: ideally this would be called in the same transaction as storing + // the nanomdm command results, but the current design doesn't allow for + // that with the nano store being a distinct entity to our datastore (we + // should get rid of that distinction eventually, we've broken it already + // in some places and it doesn't bring much benefit anymore). + // + // Instead, this gets called from CommandAndReportResults, which is + // executed after the results have been saved in nano, but we already + // accept this non-transactional fact for many other states we manage in + // Fleet (wipe, lock results, setup experience results, etc. - see all + // critical data that gets updated in CommandAndReportResults) so there's + // no reason to treat the unified queue differently. + // + // This place here is a bit hacky but perfect for VPP/InHouse apps as the activity + // gets created only when the MDM command status is in a final state + // (success or failure), which is exactly when we want to activate the next + // activity. Though note that on success of the MDM command, we wait until the + // app gets verified (or it times out waiting for verification) to activate the + // next activity, to ensure the app is actually installed. + if _, err := ds.activateNextUpcomingActivity(ctx, ds.writer(ctx), hostID, cmdUUID); err != nil { + return ctxerr.Wrap(ctx, err, "activate next activity from VPP app install") } } @@ -446,6 +433,7 @@ func (ds *Datastore) ListHostUpcomingActivities(ctx context.Context, hostID uint ua.host_id = :host_id AND activity_type = 'software_uninstall' `, + // list pending VPP apps `SELECT ua.execution_id AS uuid, IF(ua.fleet_initiated, 'Fleet', COALESCE(u.name, ua.payload->>'$.user.name')) AS name, @@ -457,8 +445,8 @@ func (ds *Datastore) ListHostUpcomingActivities(ctx context.Context, hostID uint ua.created_at AS created_at, JSON_OBJECT( 'host_id', ua.host_id, - 'host_display_name', hdn.display_name, - 'software_title', st.name, + 'host_display_name', COALESCE(hdn.display_name, ''), + 'software_title', COALESCE(st.name, ''), 'app_store_id', vaua.adam_id, 'command_uuid', ua.execution_id, 'self_service', ua.payload->'$.self_service' IS TRUE, @@ -483,6 +471,41 @@ func (ds *Datastore) ListHostUpcomingActivities(ctx context.Context, hostID uint ua.host_id = :host_id AND ua.activity_type = 'vpp_app_install' `, + // list pending in-house apps + `SELECT + ua.execution_id AS uuid, + IF(ua.fleet_initiated, 'Fleet', COALESCE(u.name, ua.payload->>'$.user.name')) AS name, + u.id AS user_id, + u.api_only as api_only, + COALESCE(u.gravatar_url, ua.payload->>'$.user.gravatar_url') as gravatar_url, + COALESCE(u.email, ua.payload->>'$.user.email') as user_email, + :installed_software_type as activity_type, + ua.created_at AS created_at, + JSON_OBJECT( + 'host_id', ua.host_id, + 'host_display_name', COALESCE(hdn.display_name, ''), + 'software_title', COALESCE(st.name, ''), + 'command_uuid', ua.execution_id, + 'self_service', false, + 'status', 'pending_install' + ) AS details, + IF(ua.activated_at IS NULL, 0, 1) as topmost, + ua.priority as priority, + ua.fleet_initiated as fleet_initiated + FROM + upcoming_activities ua + INNER JOIN + in_house_app_upcoming_activities ihua ON ihua.upcoming_activity_id = ua.id + LEFT OUTER JOIN + users u ON ua.user_id = u.id + LEFT OUTER JOIN + host_display_names hdn ON hdn.host_id = ua.host_id + LEFT OUTER JOIN + software_titles st ON st.id = ihua.software_title_id + WHERE + ua.host_id = :host_id AND + ua.activity_type = 'in_house_app_install' + `, } listStmt := ` @@ -702,6 +725,15 @@ func (ds *Datastore) CancelHostUpcomingActivity(ctx context.Context, hostID uint return details, nil } +type activityToCancel struct { + ActivityType string `db:"activity_type"` + HostID uint `db:"host_id"` + HostDisplayName string `db:"host_display_name"` + CanceledName string `db:"canceled_name"` + CanceledID *uint `db:"canceled_id"` + Activated bool `db:"activated"` +} + func (ds *Datastore) cancelHostUpcomingActivity(ctx context.Context, tx sqlx.ExtContext, hostID uint, executionID string) (fleet.ActivityDetails, error) { const ( loadScriptActivityStmt = ` @@ -799,25 +831,40 @@ func (ds *Datastore) cancelHostUpcomingActivity(ctx context.Context, tx sqlx.Ext ua.execution_id = :execution_id AND ua.activity_type = 'vpp_app_install' ` + + loadInHouseAppInstallActivityStmt = ` + SELECT + ua.activity_type, + ua.host_id, + COALESCE(hdn.display_name, '') as host_display_name, + COALESCE(st.name, '') as canceled_name, -- software title name in this case + st.id as canceled_id, + IF(ua.activated_at IS NULL, 0, 1) as activated + FROM + upcoming_activities ua + INNER JOIN + in_house_app_upcoming_activities ihua ON ihua.upcoming_activity_id = ua.id + LEFT OUTER JOIN + host_display_names hdn ON hdn.host_id = ua.host_id + LEFT OUTER JOIN + in_house_apps iha ON ihua.in_house_app_id = iha.id + LEFT OUTER JOIN + software_titles st ON st.id = iha.title_id + WHERE + ua.host_id = :host_id AND + ua.execution_id = :execution_id AND + ua.activity_type = 'in_house_app_install' +` ) - type activityToCancel struct { - ActivityType string `db:"activity_type"` - HostID uint `db:"host_id"` - HostDisplayName string `db:"host_display_name"` - CanceledName string `db:"canceled_name"` - CanceledID *uint `db:"canceled_id"` - Activated bool `db:"activated"` - } - var act activityToCancel - var pastAct fleet.ActivityDetails // read the activity along with the required information to create the // "canceled" past activity, and check if the activity was activated or // not. stmt := strings.Join([]string{ loadScriptActivityStmt, loadSoftwareInstallActivityStmt, loadSoftwareUninstallActivityStmt, loadVPPAppInstallActivityStmt, + loadInHouseAppInstallActivityStmt, }, " UNION ALL ") stmt, args, err := sqlx.Named(stmt, map[string]any{"host_id": hostID, "execution_id": executionID}) if err != nil { @@ -858,120 +905,36 @@ func (ds *Datastore) cancelHostUpcomingActivity(ctx context.Context, tx sqlx.Ext } } + var pastAct fleet.ActivityDetails switch act.ActivityType { case "script": - // if the script was part of the setup experience, then it must be marked - // as "failed" for that setup experience flow (regardless of whether or - // not it was activated). - const failSetupExpStmt = `UPDATE setup_experience_status_results SET status = ? WHERE host_uuid = ? AND script_execution_id = ?` - if _, err := tx.ExecContext(ctx, failSetupExpStmt, fleet.SetupExperienceStatusFailure, hostUUID, executionID); err != nil { - return nil, ctxerr.Wrap(ctx, err, "update setup_experience_status_results as failed") - } - - if act.Activated { - const updStmt = `UPDATE host_script_results SET canceled = 1 WHERE execution_id = ?` - if _, err := tx.ExecContext(ctx, updStmt, executionID); err != nil { - return nil, ctxerr.Wrap(ctx, err, "update host_script_results as canceled") - } - } - - pastAct = fleet.ActivityTypeCanceledRunScript{ - HostID: act.HostID, - HostDisplayName: act.HostDisplayName, - ScriptName: act.CanceledName, + pastAct, err = cancelHostScriptUpcomingActivity(ctx, tx, act, hostUUID, executionID) + if err != nil { + return nil, err } case "software_install": - // if the install was part of the setup experience, then it must be - // marked as "failed" for that setup experience flow (regardless of - // whether or not it was activated). - const failSetupExpStmt = `UPDATE setup_experience_status_results SET status = ? WHERE host_uuid = ? AND host_software_installs_execution_id = ?` - if _, err := tx.ExecContext(ctx, failSetupExpStmt, fleet.SetupExperienceStatusFailure, hostUUID, executionID); err != nil { - return nil, ctxerr.Wrap(ctx, err, "update setup_experience_status_results as failed") - } - - if act.Activated { - const updStmt = `UPDATE host_software_installs SET canceled = 1 WHERE execution_id = ?` - if _, err := tx.ExecContext(ctx, updStmt, executionID); err != nil { - return nil, ctxerr.Wrap(ctx, err, "update host_software_installs as canceled") - } - } - - var titleID uint - if act.CanceledID != nil { - titleID = *act.CanceledID - } - pastAct = fleet.ActivityTypeCanceledInstallSoftware{ - HostID: act.HostID, - HostDisplayName: act.HostDisplayName, - SoftwareTitle: act.CanceledName, - SoftwareTitleID: titleID, + pastAct, err = cancelHostSoftwareInstallUpcomingActivity(ctx, tx, act, hostUUID, executionID) + if err != nil { + return nil, err } case "software_uninstall": - // uninstall cannot be part of setup experience, so there's no update for - // that in this case. - - if act.Activated { - // uninstall is a combination of software install and script result, - // with the same execution id. - const updSoftwareStmt = `UPDATE host_software_installs SET canceled = 1 WHERE execution_id = ?` - if _, err := tx.ExecContext(ctx, updSoftwareStmt, executionID); err != nil { - return nil, ctxerr.Wrap(ctx, err, "update host_software_installs as canceled") - } - - const updScriptStmt = `UPDATE host_script_results SET canceled = 1 WHERE execution_id = ?` - if _, err := tx.ExecContext(ctx, updScriptStmt, executionID); err != nil { - return nil, ctxerr.Wrap(ctx, err, "update host_script_results as canceled") - } - } - - var titleID uint - if act.CanceledID != nil { - titleID = *act.CanceledID - } - pastAct = fleet.ActivityTypeCanceledUninstallSoftware{ - HostID: act.HostID, - HostDisplayName: act.HostDisplayName, - SoftwareTitle: act.CanceledName, - SoftwareTitleID: titleID, + pastAct, err = cancelHostSoftwareUninstallUpcomingActivity(ctx, tx, act, executionID) + if err != nil { + return nil, err } case "vpp_app_install": - // if the VPP install was part of the setup experience, then it must be - // marked as "failed" for that setup experience flow (regardless of - // whether or not it was activated). - const failSetupExpStmt = `UPDATE setup_experience_status_results SET status = ? WHERE host_uuid = ? AND nano_command_uuid = ?` - if _, err := tx.ExecContext(ctx, failSetupExpStmt, fleet.SetupExperienceStatusFailure, hostUUID, executionID); err != nil { - return nil, ctxerr.Wrap(ctx, err, "update setup_experience_status_results as failed") + pastAct, err = cancelHostVPPAppInstallUpcomingActivity(ctx, tx, act, hostID, hostUUID, executionID) + if err != nil { + return nil, err } - if act.Activated { - const updVPPStmt = `UPDATE host_vpp_software_installs SET canceled = 1 WHERE command_uuid = ?` - if _, err := tx.ExecContext(ctx, updVPPStmt, executionID); err != nil { - return nil, ctxerr.Wrap(ctx, err, "update host_vpp_software_installs as canceled") - } - - const updNanoStmt = `UPDATE nano_enrollment_queue SET active = 0 WHERE id = ? AND command_uuid = ?` - if _, err := tx.ExecContext(ctx, updNanoStmt, hostUUID, executionID); err != nil { - return nil, ctxerr.Wrap(ctx, err, "update nano_enrollment_queue as canceled") - } - - const delHostMDMCommandStmt = `DELETE FROM host_mdm_commands WHERE host_id = ? AND command_type = ?` - if _, err := tx.ExecContext(ctx, delHostMDMCommandStmt, hostID, fleet.VerifySoftwareInstallVPPPrefix); err != nil { - return nil, ctxerr.Wrap(ctx, err, "delete vpp verify from host_mdm_commands") - } - } - - var titleID uint - if act.CanceledID != nil { - titleID = *act.CanceledID - } - pastAct = fleet.ActivityTypeCanceledInstallAppStoreApp{ - HostID: act.HostID, - HostDisplayName: act.HostDisplayName, - SoftwareTitle: act.CanceledName, - SoftwareTitleID: titleID, + case "in_house_app_install": + pastAct, err = cancelHostInHouseAppInstallUpcomingActivity(ctx, tx, act, hostID, hostUUID, executionID) + if err != nil { + return nil, err } default: @@ -994,6 +957,158 @@ func (ds *Datastore) cancelHostUpcomingActivity(ctx context.Context, tx sqlx.Ext return pastAct, nil } +func cancelHostInHouseAppInstallUpcomingActivity(ctx context.Context, tx sqlx.ExtContext, act activityToCancel, hostID uint, hostUUID, executionID string) (fleet.ActivityDetails, error) { + // in-house apps currently cannot be part of setup experience, so there's no + // update for that in this case. + + if act.Activated { + const updInHouseStmt = `UPDATE host_in_house_software_installs SET canceled = 1 WHERE command_uuid = ?` + if _, err := tx.ExecContext(ctx, updInHouseStmt, executionID); err != nil { + return nil, ctxerr.Wrap(ctx, err, "update host_in_house_software_installs as canceled") + } + + const updNanoStmt = `UPDATE nano_enrollment_queue SET active = 0 WHERE id = ? AND command_uuid = ?` + if _, err := tx.ExecContext(ctx, updNanoStmt, hostUUID, executionID); err != nil { + return nil, ctxerr.Wrap(ctx, err, "update nano_enrollment_queue as canceled") + } + + const delHostMDMCommandStmt = `DELETE FROM host_mdm_commands WHERE host_id = ? AND command_type = ?` + if _, err := tx.ExecContext(ctx, delHostMDMCommandStmt, hostID, fleet.VerifySoftwareInstallVPPPrefix); err != nil { + return nil, ctxerr.Wrap(ctx, err, "delete verify from host_mdm_commands") + } + } + + var titleID uint + if act.CanceledID != nil { + titleID = *act.CanceledID + } + return fleet.ActivityTypeCanceledInstallSoftware{ + HostID: act.HostID, + HostDisplayName: act.HostDisplayName, + SoftwareTitle: act.CanceledName, + SoftwareTitleID: titleID, + }, nil +} + +func cancelHostVPPAppInstallUpcomingActivity(ctx context.Context, tx sqlx.ExtContext, act activityToCancel, hostID uint, hostUUID, executionID string) (fleet.ActivityDetails, error) { + // if the VPP install was part of the setup experience, then it must be + // marked as "failed" for that setup experience flow (regardless of + // whether or not it was activated). + const failSetupExpStmt = `UPDATE setup_experience_status_results SET status = ? WHERE host_uuid = ? AND nano_command_uuid = ?` + if _, err := tx.ExecContext(ctx, failSetupExpStmt, fleet.SetupExperienceStatusFailure, hostUUID, executionID); err != nil { + return nil, ctxerr.Wrap(ctx, err, "update setup_experience_status_results as failed") + } + + if act.Activated { + const updVPPStmt = `UPDATE host_vpp_software_installs SET canceled = 1 WHERE command_uuid = ?` + if _, err := tx.ExecContext(ctx, updVPPStmt, executionID); err != nil { + return nil, ctxerr.Wrap(ctx, err, "update host_vpp_software_installs as canceled") + } + + const updNanoStmt = `UPDATE nano_enrollment_queue SET active = 0 WHERE id = ? AND command_uuid = ?` + if _, err := tx.ExecContext(ctx, updNanoStmt, hostUUID, executionID); err != nil { + return nil, ctxerr.Wrap(ctx, err, "update nano_enrollment_queue as canceled") + } + + const delHostMDMCommandStmt = `DELETE FROM host_mdm_commands WHERE host_id = ? AND command_type = ?` + if _, err := tx.ExecContext(ctx, delHostMDMCommandStmt, hostID, fleet.VerifySoftwareInstallVPPPrefix); err != nil { + return nil, ctxerr.Wrap(ctx, err, "delete verify vpp from host_mdm_commands") + } + } + + var titleID uint + if act.CanceledID != nil { + titleID = *act.CanceledID + } + return fleet.ActivityTypeCanceledInstallAppStoreApp{ + HostID: act.HostID, + HostDisplayName: act.HostDisplayName, + SoftwareTitle: act.CanceledName, + SoftwareTitleID: titleID, + }, nil +} + +func cancelHostSoftwareUninstallUpcomingActivity(ctx context.Context, tx sqlx.ExtContext, act activityToCancel, executionID string) (fleet.ActivityDetails, error) { + // uninstall cannot be part of setup experience, so there's no update for + // that in this case. + + if act.Activated { + // uninstall is a combination of software install and script result, + // with the same execution id. + const updSoftwareStmt = `UPDATE host_software_installs SET canceled = 1 WHERE execution_id = ?` + if _, err := tx.ExecContext(ctx, updSoftwareStmt, executionID); err != nil { + return nil, ctxerr.Wrap(ctx, err, "update host_software_installs as canceled") + } + + const updScriptStmt = `UPDATE host_script_results SET canceled = 1 WHERE execution_id = ?` + if _, err := tx.ExecContext(ctx, updScriptStmt, executionID); err != nil { + return nil, ctxerr.Wrap(ctx, err, "update host_script_results as canceled") + } + } + + var titleID uint + if act.CanceledID != nil { + titleID = *act.CanceledID + } + return fleet.ActivityTypeCanceledUninstallSoftware{ + HostID: act.HostID, + HostDisplayName: act.HostDisplayName, + SoftwareTitle: act.CanceledName, + SoftwareTitleID: titleID, + }, nil +} + +func cancelHostSoftwareInstallUpcomingActivity(ctx context.Context, tx sqlx.ExtContext, act activityToCancel, hostUUID, executionID string) (fleet.ActivityDetails, error) { + // if the install was part of the setup experience, then it must be + // marked as "failed" for that setup experience flow (regardless of + // whether or not it was activated). + const failSetupExpStmt = `UPDATE setup_experience_status_results SET status = ? WHERE host_uuid = ? AND host_software_installs_execution_id = ?` + if _, err := tx.ExecContext(ctx, failSetupExpStmt, fleet.SetupExperienceStatusFailure, hostUUID, executionID); err != nil { + return nil, ctxerr.Wrap(ctx, err, "update setup_experience_status_results as failed") + } + + if act.Activated { + const updStmt = `UPDATE host_software_installs SET canceled = 1 WHERE execution_id = ?` + if _, err := tx.ExecContext(ctx, updStmt, executionID); err != nil { + return nil, ctxerr.Wrap(ctx, err, "update host_software_installs as canceled") + } + } + + var titleID uint + if act.CanceledID != nil { + titleID = *act.CanceledID + } + return fleet.ActivityTypeCanceledInstallSoftware{ + HostID: act.HostID, + HostDisplayName: act.HostDisplayName, + SoftwareTitle: act.CanceledName, + SoftwareTitleID: titleID, + }, nil +} + +func cancelHostScriptUpcomingActivity(ctx context.Context, tx sqlx.ExtContext, act activityToCancel, hostUUID, executionID string) (fleet.ActivityDetails, error) { + // if the script was part of the setup experience, then it must be marked + // as "failed" for that setup experience flow (regardless of whether or + // not it was activated). + const failSetupExpStmt = `UPDATE setup_experience_status_results SET status = ? WHERE host_uuid = ? AND script_execution_id = ?` + if _, err := tx.ExecContext(ctx, failSetupExpStmt, fleet.SetupExperienceStatusFailure, hostUUID, executionID); err != nil { + return nil, ctxerr.Wrap(ctx, err, "update setup_experience_status_results as failed") + } + + if act.Activated { + const updStmt = `UPDATE host_script_results SET canceled = 1 WHERE execution_id = ?` + if _, err := tx.ExecContext(ctx, updStmt, executionID); err != nil { + return nil, ctxerr.Wrap(ctx, err, "update host_script_results as canceled") + } + } + + return fleet.ActivityTypeCanceledRunScript{ + HostID: act.HostID, + HostDisplayName: act.HostDisplayName, + ScriptName: act.CanceledName, + }, nil +} + func clearLockWipeForCanceledActivity(ctx context.Context, tx sqlx.ExtContext, hostID uint, executionID string) error { const clearLockStmt = `DELETE FROM host_mdm_actions WHERE host_id = ? AND lock_ref = ?` resLock, err := tx.ExecContext(ctx, clearLockStmt, hostID, executionID) @@ -1155,7 +1270,8 @@ func (ds *Datastore) activateNextUpcomingActivityForBatchOfHosts(ctx context.Con // order. Activation consists of inserting the activity in its respective // table, e.g. `host_script_results` for scripts, `host_software_installs` for // software installs, `host_vpp_software_installs` and nano command queue for -// VPP installs; and setting the activated_at timestamp in the +// VPP installs, `host_in_house_software_installs` and nano command queue for +// in-house installs; and setting the activated_at timestamp in the // `upcoming_activities` table. // - As an optimization for MDM, if the activity type is `vpp_app_install` // and the next few upcoming activities are all of this type, they are @@ -1270,6 +1386,8 @@ WHERE fn = ds.activateNextSoftwareUninstallActivity case "vpp_app_install": fn = ds.activateNextVPPAppInstallActivity + case "in_house_app_install": + fn = ds.activateNextInHouseAppInstallActivity default: return nil, ctxerr.Errorf(ctx, "unsupported activity type %s", actType) } @@ -1607,3 +1725,203 @@ ORDER BY } return nil } + +func (ds *Datastore) activateNextInHouseAppInstallActivity(ctx context.Context, tx sqlx.ExtContext, hostID uint, execIDs []string) error { + const insStmt = ` +INSERT INTO + host_in_house_software_installs +(host_id, in_house_app_id, command_uuid, user_id, platform) +SELECT + ua.host_id, + ihua.in_house_app_id, + ua.execution_id, + ua.user_id, + iha.platform +FROM + upcoming_activities ua + INNER JOIN in_house_app_upcoming_activities ihua + ON ihua.upcoming_activity_id = ua.id + INNER JOIN in_house_apps iha + ON iha.id = ihua.in_house_app_id +WHERE + ua.host_id = ? AND + ua.execution_id IN (?) +ORDER BY + ua.priority DESC, ua.created_at ASC +` + + const getHostUUIDStmt = ` +SELECT + uuid, team_id +FROM + hosts +WHERE + id = ? +` + + const insCmdStmt = ` +INSERT INTO + nano_commands +(command_uuid, request_type, command, subtype) +SELECT + ua.execution_id, + 'InstallApplication', + CONCAT(:raw_cmd_part1, :manifest_url, :raw_cmd_part2, ua.execution_id, :raw_cmd_part3), + :subtype +FROM + upcoming_activities ua + INNER JOIN in_house_app_upcoming_activities ihua + ON ihua.upcoming_activity_id = ua.id +WHERE + ua.host_id = :host_id AND + ua.execution_id IN (:execution_ids) +` + + const rawCmdPart1 = ` + + + + Command + + InstallAsManaged + + ManagementFlags + 0 + ChangeManagementState + Managed + InstallAsManaged + + Options + + PurchaseMethod + 1 + + RequestType + InstallApplication + ManifestURL + ` + + const rawCmdPart2 = ` + + CommandUUID + ` + + const rawCmdPart3 = ` + +` + + const insNanoQueueStmt = ` +INSERT INTO + nano_enrollment_queue +(id, command_uuid, created_at) +SELECT + ?, + execution_id, + created_at -- force same timestamp to keep ordering +FROM + upcoming_activities +WHERE + host_id = ? AND + execution_id IN (?) +ORDER BY + priority DESC, created_at ASC +` + + // sanity-check that there's something to activate + if len(execIDs) == 0 { + return nil + } + + // get the host uuid, required for the nano tables + var hostData struct { + UUID string `db:"uuid"` + TeamID *uint `db:"team_id"` + } + if err := sqlx.GetContext(ctx, tx, &hostData, getHostUUIDStmt, hostID); err != nil { + return ctxerr.Wrap(ctx, err, "get host uuid") + } + + // insert the host in-house app row + stmt, args, err := sqlx.In(insStmt, hostID, execIDs) + if err != nil { + return ctxerr.Wrap(ctx, err, "prepare insert to activate in-house apps") + } + if _, err := tx.ExecContext(ctx, stmt, args...); err != nil { + return ctxerr.Wrap(ctx, err, "insert to activate in-house apps") + } + + appConfig, err := appConfigDB(ctx, tx) + if err != nil { + return ctxerr.Wrap(ctx, err, "activate in house app install: get app config") + } + + var tid uint + if hostData.TeamID != nil { + tid = *hostData.TeamID + } + + // Get the title ID for the in-house app being installed + var titleID uint + getTitleIDStmt := ` +SELECT + ihua.software_title_id +FROM + upcoming_activities ua + INNER JOIN in_house_app_upcoming_activities ihua + ON ihua.upcoming_activity_id = ua.id +WHERE + ua.host_id = ? AND + ua.execution_id IN (?) +` + + stmt, args, err = sqlx.In(getTitleIDStmt, hostID, execIDs) + if err != nil { + return ctxerr.Wrap(ctx, err, "prepare get in-house app title id") + } + + if err := sqlx.GetContext(ctx, tx, &titleID, stmt, args...); err != nil { + return ctxerr.Wrap(ctx, err, "get in-house app title id") + } + + manifestURL := fmt.Sprintf("%s/api/latest/fleet/software/titles/%d/in_house_app/manifest?team_id=%d", appConfig.ServerSettings.ServerURL, titleID, tid) + + // insert the nano command + namedArgs := map[string]any{ + "manifest_url": manifestURL, + "raw_cmd_part1": rawCmdPart1, + "raw_cmd_part2": rawCmdPart2, + "raw_cmd_part3": rawCmdPart3, + "subtype": mdm.CommandSubtypeNone, + "host_id": hostID, + "execution_ids": execIDs, + } + stmt, args, err = sqlx.Named(insCmdStmt, namedArgs) + if err != nil { + return ctxerr.Wrap(ctx, err, "prepare insert nano commands") + } + stmt, args, err = sqlx.In(stmt, args...) + if err != nil { + return ctxerr.Wrap(ctx, err, "expand IN arguments to insert nano commands") + } + if _, err := tx.ExecContext(ctx, stmt, args...); err != nil { + return ctxerr.Wrap(ctx, err, "insert nano commands") + } + + // enqueue the nano command in the nano queue + stmt, args, err = sqlx.In(insNanoQueueStmt, hostData.UUID, hostID, execIDs) + if err != nil { + return ctxerr.Wrap(ctx, err, "prepare insert nano queue") + } + if _, err := tx.ExecContext(ctx, stmt, args...); err != nil { + return ctxerr.Wrap(ctx, err, "insert nano queue") + } + + // best-effort APNs push notification to the host, not critical because we + // have a cron job that will retry for hosts with pending MDM commands. + if ds.pusher != nil { + if _, err := ds.pusher.Push(ctx, []string{hostData.UUID}); err != nil { + level.Error(ds.logger).Log("msg", "failed to send push notification", "err", err, "hostID", hostID, "hostUUID", hostData.UUID) //nolint:errcheck + } + } + return nil +} diff --git a/server/datastore/mysql/activities_test.go b/server/datastore/mysql/activities_test.go index 52b7ac26ae..6366b66670 100644 --- a/server/datastore/mysql/activities_test.go +++ b/server/datastore/mysql/activities_test.go @@ -1140,6 +1140,8 @@ func testActivateNextActivity(t *testing.T, ds *Datastore) { nanoEnrollAndSetHostMDMData(t, ds, h1, false) h2 := test.NewHost(t, ds, "h2.local", "10.10.10.2", "2", "2", time.Now()) nanoEnrollAndSetHostMDMData(t, ds, h2, false) + hIOS := test.NewHost(t, ds, "h3.local", "10.10.10.3", "3", "3", time.Now().Add(-1*time.Second), test.WithPlatform("ios")) + nanoEnrollAndSetHostMDMData(t, ds, hIOS, false) u := test.NewUser(t, ds, "user1", "user1@example.com", false) @@ -1160,6 +1162,12 @@ func testActivateNextActivity(t *testing.T, ds *Datastore) { } _, err = ds.InsertVPPAppWithTeam(ctx, vppApp2, nil) require.NoError(t, err) + vppApp1IOS := &fleet.VPPApp{ + Name: "vpp_1", VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "vpp1", Platform: fleet.IOSPlatform}}, + BundleIdentifier: "vpp1", + } + _, err = ds.InsertVPPAppWithTeam(ctx, vppApp1IOS, nil) + require.NoError(t, err) // create a software installer that can be installed later installer1, err := fleet.NewTempFileReader(strings.NewReader("echo"), t.TempDir) @@ -1178,6 +1186,19 @@ func testActivateNextActivity(t *testing.T, ds *Datastore) { }) require.NoError(t, err) + // create an in-house app that can be installed later + ihaID, ihaTitleID, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ + StorageID: uuid.NewString(), + Filename: "inhouse.ipa", + Title: "inhouse", + Source: "ios_apps", + Extension: "ipa", + BundleIdentifier: "inhouse", + UserID: u.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, + }) + require.NoError(t, err) + // activating an empty queue is fine, nothing activated execIDs, err := ds.activateNextUpcomingActivity(ctx, ds.writer(ctx), h1.ID, "") require.NoError(t, err) @@ -1204,6 +1225,11 @@ func testActivateNextActivity(t *testing.T, ds *Datastore) { require.NoError(t, err) script1_2 := hsr.ExecutionID + // host 2 is unaffected, activating results in nothing activated + execIDs, err = ds.activateNextUpcomingActivity(ctx, ds.writer(ctx), h2.ID, "") + require.NoError(t, err) + require.Empty(t, execIDs) + // add a couple install requests for vpp1 and vpp2 vpp1_1 := uuid.NewString() err = ds.InsertHostVPPSoftwareInstall(ctx, h1.ID, vppApp1.VPPAppID, vpp1_1, "event-id-1", fleet.HostSoftwareInstallOptions{}) @@ -1435,6 +1461,149 @@ func testActivateNextActivity(t *testing.T, ds *Datastore) { pendingActs, _, err = ds.ListHostUpcomingActivities(ctx, h1.ID, fleet.ListOptions{}) require.NoError(t, err) require.Len(t, pendingActs, 0) + + // enqueue a VPP app request for iOS host + vpp1_1_ios := uuid.NewString() + err = ds.InsertHostVPPSoftwareInstall(ctx, hIOS.ID, vppApp1IOS.VPPAppID, vpp1_1_ios, "event-id-1-ios", fleet.HostSoftwareInstallOptions{}) + require.NoError(t, err) + + // enqueue an in-house app request for the iOS host + ihaCmd := uuid.NewString() + err = ds.InsertHostInHouseAppInstall(ctx, hIOS.ID, ihaID, ihaTitleID, ihaCmd, fleet.HostSoftwareInstallOptions{}) + require.NoError(t, err) + + pendingActs, _, err = ds.ListHostUpcomingActivities(ctx, hIOS.ID, fleet.ListOptions{}) + require.NoError(t, err) + require.Len(t, pendingActs, 2) + require.Equal(t, vpp1_1_ios, pendingActs[0].UUID) + require.Equal(t, ihaCmd, pendingActs[1].UUID) + + // record a result for the VPP app install, which will activate the in-house app + cmdRes = &mdm.CommandResults{ + CommandUUID: vpp1_1_ios, + Status: "Acknowledged", + Raw: []byte(``), + } + err = nanoDB.StoreCommandReport(nanoCtx, cmdRes) + require.NoError(t, err) + + err = ds.NewActivity(ctx, nil, fleet.ActivityInstalledAppStoreApp{ + HostID: hIOS.ID, + AppStoreID: vppApp1IOS.VPPAppTeam.AdamID, + CommandUUID: vpp1_1_ios, + Status: "Error", // using a failure because otherwise it requires verification to activate next + }, []byte(`{}`), time.Now()) + require.NoError(t, err) + + // the in-house app is now activated + pendingActs, _, err = ds.ListHostUpcomingActivities(ctx, hIOS.ID, fleet.ListOptions{}) + require.NoError(t, err) + require.Len(t, pendingActs, 1) + require.Equal(t, ihaCmd, pendingActs[0].UUID) + + // enqueue a VPP app request for iOS host once more + vpp1_1_ios = uuid.NewString() + err = ds.InsertHostVPPSoftwareInstall(ctx, hIOS.ID, vppApp1IOS.VPPAppID, vpp1_1_ios, "event-id-2-ios", fleet.HostSoftwareInstallOptions{}) + require.NoError(t, err) + + pendingActs, _, err = ds.ListHostUpcomingActivities(ctx, hIOS.ID, fleet.ListOptions{}) + require.NoError(t, err) + require.Len(t, pendingActs, 2) + require.Equal(t, ihaCmd, pendingActs[0].UUID) + require.Equal(t, vpp1_1_ios, pendingActs[1].UUID) + + // record a result for in-house app and it should activate the next VPP app. + cmdRes = &mdm.CommandResults{ + CommandUUID: ihaCmd, + Status: "Acknowledged", + Raw: []byte(``), + } + err = nanoDB.StoreCommandReport(nanoCtx, cmdRes) + require.NoError(t, err) + + err = ds.NewActivity(ctx, nil, &fleet.ActivityTypeInstalledSoftware{ + HostID: hIOS.ID, + CommandUUID: ihaCmd, + Status: "Error", // using a failure because otherwise it requires verification to activate next + }, []byte(`{}`), time.Now()) + require.NoError(t, err) + + pendingActs, _, err = ds.ListHostUpcomingActivities(ctx, hIOS.ID, fleet.ListOptions{}) + require.NoError(t, err) + require.Len(t, pendingActs, 1) + require.Equal(t, vpp1_1_ios, pendingActs[0].UUID) + + // enqueue the in-house app again + ihaCmd = uuid.NewString() + err = ds.InsertHostInHouseAppInstall(ctx, hIOS.ID, ihaID, ihaTitleID, ihaCmd, fleet.HostSoftwareInstallOptions{}) + require.NoError(t, err) + + pendingActs, _, err = ds.ListHostUpcomingActivities(ctx, hIOS.ID, fleet.ListOptions{}) + require.NoError(t, err) + require.Len(t, pendingActs, 2) + require.Equal(t, vpp1_1_ios, pendingActs[0].UUID) + require.Equal(t, ihaCmd, pendingActs[1].UUID) + + // record a successful result for the VPP app, will not activate the next until verification + cmdRes = &mdm.CommandResults{ + CommandUUID: vpp1_1_ios, + Status: "Acknowledged", + Raw: []byte(``), + } + err = nanoDB.StoreCommandReport(nanoCtx, cmdRes) + require.NoError(t, err) + + err = ds.NewActivity(ctx, nil, &fleet.ActivityTypeInstalledSoftware{ + HostID: hIOS.ID, + CommandUUID: vpp1_1_ios, + Status: string(fleet.SoftwareInstalled), + }, []byte(`{}`), time.Now()) + require.NoError(t, err) + + // both are still upcoming... + pendingActs, _, err = ds.ListHostUpcomingActivities(ctx, hIOS.ID, fleet.ListOptions{}) + require.NoError(t, err) + require.Len(t, pendingActs, 2) + require.Equal(t, vpp1_1_ios, pendingActs[0].UUID) + require.Equal(t, ihaCmd, pendingActs[1].UUID) + + // mark the VPP app as verified, will activate the next activity + err = ds.SetVPPInstallAsVerified(ctx, hIOS.ID, vpp1_1_ios, uuid.NewString()) + require.NoError(t, err) + + pendingActs, _, err = ds.ListHostUpcomingActivities(ctx, hIOS.ID, fleet.ListOptions{}) + require.NoError(t, err) + require.Len(t, pendingActs, 1) + require.Equal(t, ihaCmd, pendingActs[0].UUID) + + // record a successful result for the in-house app, will not become "past" until verification + cmdRes = &mdm.CommandResults{ + CommandUUID: ihaCmd, + Status: "Acknowledged", + Raw: []byte(``), + } + err = nanoDB.StoreCommandReport(nanoCtx, cmdRes) + require.NoError(t, err) + + err = ds.NewActivity(ctx, nil, &fleet.ActivityTypeInstalledSoftware{ + HostID: hIOS.ID, + CommandUUID: ihaCmd, + Status: string(fleet.SoftwareInstalled), + }, []byte(`{}`), time.Now()) + require.NoError(t, err) + + pendingActs, _, err = ds.ListHostUpcomingActivities(ctx, hIOS.ID, fleet.ListOptions{}) + require.NoError(t, err) + require.Len(t, pendingActs, 1) + require.Equal(t, ihaCmd, pendingActs[0].UUID) + + // mark the in-house app as failed, will become "past" + err = ds.SetVPPInstallAsFailed(ctx, hIOS.ID, ihaCmd, uuid.NewString()) + require.NoError(t, err) + + pendingActs, _, err = ds.ListHostUpcomingActivities(ctx, hIOS.ID, fleet.ListOptions{}) + require.NoError(t, err) + require.Len(t, pendingActs, 0) } func testActivateItselfOnEmptyQueue(t *testing.T, ds *Datastore) { @@ -1549,6 +1718,8 @@ func testCancelNonActivatedUpcomingActivity(t *testing.T, ds *Datastore) { nanoEnrollAndSetHostMDMData(t, ds, host, false) hostLeftUntouched := test.NewHost(t, ds, "h2.local", "10.10.10.2", "2", "2", time.Now()) nanoEnrollAndSetHostMDMData(t, ds, hostLeftUntouched, false) + hostIOS := test.NewHost(t, ds, "h3.local", "10.10.10.3", "3", "3", time.Now(), test.WithPlatform("ios")) + nanoEnrollAndSetHostMDMData(t, ds, hostIOS, false) nanoDB, err := nanomdm_mysql.New(nanomdm_mysql.WithDB(ds.primary.DB)) require.NoError(t, err) @@ -1575,11 +1746,13 @@ func testCancelNonActivatedUpcomingActivity(t *testing.T, ds *Datastore) { cases := []struct { desc string + host *fleet.Host setup func(t *testing.T) []string cancelIndex int }{ { desc: "cancel software install", + host: host, setup: func(t *testing.T) []string { exec1 := test.CreateHostScriptUpcomingActivity(t, ds, host) exec2 := test.CreateHostSoftwareInstallUpcomingActivity(t, ds, host, u) @@ -1592,6 +1765,7 @@ func testCancelNonActivatedUpcomingActivity(t *testing.T, ds *Datastore) { }, { desc: "cancel script exec", + host: host, setup: func(t *testing.T) []string { exec1 := test.CreateHostSoftwareInstallUpcomingActivity(t, ds, host, u) exec2 := test.CreateHostScriptUpcomingActivity(t, ds, host) @@ -1604,6 +1778,7 @@ func testCancelNonActivatedUpcomingActivity(t *testing.T, ds *Datastore) { }, { desc: "cancel software uninstall", + host: host, setup: func(t *testing.T) []string { exec1 := test.CreateHostSoftwareInstallUpcomingActivity(t, ds, host, u) exec2 := test.CreateHostSoftwareUninstallUpcomingActivity(t, ds, host, u) @@ -1616,6 +1791,7 @@ func testCancelNonActivatedUpcomingActivity(t *testing.T, ds *Datastore) { }, { desc: "cancel vpp install", + host: host, setup: func(t *testing.T) []string { exec1 := test.CreateHostSoftwareUninstallUpcomingActivity(t, ds, host, u) exec2, _ := test.CreateHostVPPAppInstallUpcomingActivity(t, ds, host) @@ -1628,6 +1804,7 @@ func testCancelNonActivatedUpcomingActivity(t *testing.T, ds *Datastore) { }, { desc: "cancel script with another activity after", + host: host, setup: func(t *testing.T) []string { exec1, adamID := test.CreateHostVPPAppInstallUpcomingActivity(t, ds, host) exec2 := test.CreateHostScriptUpcomingActivity(t, ds, host) @@ -1642,6 +1819,7 @@ func testCancelNonActivatedUpcomingActivity(t *testing.T, ds *Datastore) { }, { desc: "cancel software uninstall with a couple activities before", + host: host, setup: func(t *testing.T) []string { exec1 := test.CreateHostSoftwareInstallUpcomingActivity(t, ds, host, u) exec2 := test.CreateHostScriptUpcomingActivity(t, ds, host) @@ -1654,22 +1832,35 @@ func testCancelNonActivatedUpcomingActivity(t *testing.T, ds *Datastore) { }, cancelIndex: 2, }, + { + desc: "cancel in-house install", + host: hostIOS, + setup: func(t *testing.T) []string { + exec1, adamID := test.CreateHostVPPAppInstallUpcomingActivity(t, ds, hostIOS) + exec2 := test.CreateHostInHouseAppInstallUpcomingActivity(t, ds, hostIOS, u) + t.Cleanup(func() { + test.SetHostVPPAppInstallResult(t, ds, nanoDB, host, exec1, adamID, "Acknowledged") + }) + return []string{exec1, exec2} + }, + cancelIndex: 1, + }, } for _, c := range cases { t.Run(c.desc, func(t *testing.T) { execIDs := c.setup(t) - got, _, err := ds.ListHostUpcomingActivities(ctx, host.ID, fleet.ListOptions{}) + got, _, err := ds.ListHostUpcomingActivities(ctx, c.host.ID, fleet.ListOptions{}) require.NoError(t, err) require.Len(t, got, len(execIDs)) require.Equal(t, execIDs, pluckExecIDs(got)) cancelExecID := execIDs[c.cancelIndex] expectedExecIDs := append(execIDs[:c.cancelIndex], execIDs[c.cancelIndex+1:]...) // nolint: gocritic - _, err = ds.CancelHostUpcomingActivity(ctx, host.ID, cancelExecID) + _, err = ds.CancelHostUpcomingActivity(ctx, c.host.ID, cancelExecID) require.NoError(t, err) - got, _, err = ds.ListHostUpcomingActivities(ctx, host.ID, fleet.ListOptions{}) + got, _, err = ds.ListHostUpcomingActivities(ctx, c.host.ID, fleet.ListOptions{}) require.NoError(t, err) require.Len(t, got, len(expectedExecIDs)) require.Equal(t, expectedExecIDs, pluckExecIDs(got)) @@ -1693,6 +1884,8 @@ func testCancelActivatedUpcomingActivity(t *testing.T, ds *Datastore) { nanoEnrollAndSetHostMDMData(t, ds, host, false) hostLeftUntouched := test.NewHost(t, ds, "h2.local", "10.10.10.2", "2", "2", time.Now()) nanoEnrollAndSetHostMDMData(t, ds, hostLeftUntouched, false) + hostIOS := test.NewHost(t, ds, "h3.local", "10.10.10.3", "3", "3", time.Now(), test.WithPlatform("ios")) + nanoEnrollAndSetHostMDMData(t, ds, hostIOS, false) nanoDB, err := nanomdm_mysql.New(nanomdm_mysql.WithDB(ds.primary.DB)) require.NoError(t, err) @@ -1710,10 +1903,12 @@ func testCancelActivatedUpcomingActivity(t *testing.T, ds *Datastore) { cases := []struct { desc string + host *fleet.Host setup func(t *testing.T) []string }{ { desc: "cancel script", + host: host, setup: func(t *testing.T) []string { exec1 := test.CreateHostScriptUpcomingActivity(t, ds, host) exec2 := test.CreateHostSoftwareInstallUpcomingActivity(t, ds, host, u) @@ -1725,6 +1920,7 @@ func testCancelActivatedUpcomingActivity(t *testing.T, ds *Datastore) { }, { desc: "cancel sofware install", + host: host, setup: func(t *testing.T) []string { exec1 := test.CreateHostSoftwareInstallUpcomingActivity(t, ds, host, u) exec2 := test.CreateHostSoftwareUninstallUpcomingActivity(t, ds, host, u) @@ -1736,6 +1932,7 @@ func testCancelActivatedUpcomingActivity(t *testing.T, ds *Datastore) { }, { desc: "cancel sofware uninstall", + host: host, setup: func(t *testing.T) []string { exec1 := test.CreateHostSoftwareUninstallUpcomingActivity(t, ds, host, u) exec2, adamID := test.CreateHostVPPAppInstallUpcomingActivity(t, ds, host) @@ -1747,6 +1944,7 @@ func testCancelActivatedUpcomingActivity(t *testing.T, ds *Datastore) { }, { desc: "cancel vpp install", + host: host, setup: func(t *testing.T) []string { exec1, _ := test.CreateHostVPPAppInstallUpcomingActivity(t, ds, host) exec2 := test.CreateHostSoftwareInstallUpcomingActivity(t, ds, host, u) @@ -1758,6 +1956,7 @@ func testCancelActivatedUpcomingActivity(t *testing.T, ds *Datastore) { }, { desc: "cancel script none after", + host: host, setup: func(t *testing.T) []string { exec1 := test.CreateHostScriptUpcomingActivity(t, ds, host) return []string{exec1} @@ -1765,6 +1964,7 @@ func testCancelActivatedUpcomingActivity(t *testing.T, ds *Datastore) { }, { desc: "cancel sofware install with a couple after", + host: host, setup: func(t *testing.T) []string { exec1 := test.CreateHostSoftwareInstallUpcomingActivity(t, ds, host, u) exec2 := test.CreateHostSoftwareUninstallUpcomingActivity(t, ds, host, u) @@ -1778,6 +1978,7 @@ func testCancelActivatedUpcomingActivity(t *testing.T, ds *Datastore) { }, { desc: "cancel sofware uninstall none after", + host: host, setup: func(t *testing.T) []string { exec1 := test.CreateHostSoftwareUninstallUpcomingActivity(t, ds, host, u) return []string{exec1} @@ -1785,6 +1986,7 @@ func testCancelActivatedUpcomingActivity(t *testing.T, ds *Datastore) { }, { desc: "cancel vpp install same after", + host: host, setup: func(t *testing.T) []string { exec1, _ := test.CreateHostVPPAppInstallUpcomingActivity(t, ds, host) exec2, adamID := test.CreateHostVPPAppInstallUpcomingActivity(t, ds, host) @@ -1794,22 +1996,46 @@ func testCancelActivatedUpcomingActivity(t *testing.T, ds *Datastore) { return []string{exec1, exec2} }, }, + { + desc: "cancel in-house install", + host: hostIOS, + setup: func(t *testing.T) []string { + exec1 := test.CreateHostInHouseAppInstallUpcomingActivity(t, ds, hostIOS, u) + exec2, adamID := test.CreateHostVPPAppInstallUpcomingActivity(t, ds, hostIOS) + t.Cleanup(func() { + test.SetHostVPPAppInstallResult(t, ds, nanoDB, hostIOS, exec2, adamID, "Acknowledged") + }) + return []string{exec1, exec2} + }, + }, + { + desc: "cancel in-house install same after", + host: hostIOS, + setup: func(t *testing.T) []string { + exec1 := test.CreateHostInHouseAppInstallUpcomingActivity(t, ds, hostIOS, u) + exec2 := test.CreateHostInHouseAppInstallUpcomingActivity(t, ds, hostIOS, u) + t.Cleanup(func() { + test.SetHostInHouseAppInstallResult(t, ds, nanoDB, hostIOS, exec2, "Acknowledged") + }) + return []string{exec1, exec2} + }, + }, } for _, c := range cases { t.Run(c.desc, func(t *testing.T) { execIDs := c.setup(t) - got, _, err := ds.ListHostUpcomingActivities(ctx, host.ID, fleet.ListOptions{}) + got, _, err := ds.ListHostUpcomingActivities(ctx, c.host.ID, fleet.ListOptions{}) require.NoError(t, err) require.Len(t, got, len(execIDs)) require.Equal(t, execIDs, pluckExecIDs(got)) cancelExecID := execIDs[0] expectedExecIDs := execIDs[1:] - _, err = ds.CancelHostUpcomingActivity(ctx, host.ID, cancelExecID) + _, err = ds.CancelHostUpcomingActivity(ctx, c.host.ID, cancelExecID) require.NoError(t, err) - got, _, err = ds.ListHostUpcomingActivities(ctx, host.ID, fleet.ListOptions{}) + got, _, err = ds.ListHostUpcomingActivities(ctx, c.host.ID, fleet.ListOptions{}) require.NoError(t, err) require.Len(t, got, len(expectedExecIDs)) require.Equal(t, expectedExecIDs, pluckExecIDs(got)) @@ -1817,21 +2043,21 @@ func testCancelActivatedUpcomingActivity(t *testing.T, ds *Datastore) { // the next upcoming activity (and only this one) should show up in those // lists of ready-to-process activities. var gotExecIDs []string - scripts, err := ds.ListReadyToExecuteScriptsForHost(ctx, host.ID, false) + scripts, err := ds.ListReadyToExecuteScriptsForHost(ctx, c.host.ID, false) require.NoError(t, err) require.True(t, len(scripts) <= 1) if len(scripts) == 1 { gotExecIDs = append(gotExecIDs, scripts[0].ExecutionID) } - sws, err := ds.ListReadyToExecuteSoftwareInstalls(ctx, host.ID) + sws, err := ds.ListReadyToExecuteSoftwareInstalls(ctx, c.host.ID) require.NoError(t, err) require.True(t, len(sws) <= 1) gotExecIDs = append(gotExecIDs, sws...) var nanoExecIDs []string ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { - err := sqlx.SelectContext(ctx, q, &nanoExecIDs, `SELECT command_uuid FROM nano_view_queue WHERE id = ? AND active = 1 AND status IS NULL`, host.UUID) + err := sqlx.SelectContext(ctx, q, &nanoExecIDs, `SELECT command_uuid FROM nano_view_queue WHERE id = ? AND active = 1 AND status IS NULL`, c.host.UUID) return err }) require.True(t, len(nanoExecIDs) <= 1) diff --git a/server/datastore/mysql/hosts.go b/server/datastore/mysql/hosts.go index 246d1a744d..5bc3e0a41e 100644 --- a/server/datastore/mysql/hosts.go +++ b/server/datastore/mysql/hosts.go @@ -566,6 +566,11 @@ var hostRefs = []string{ "host_mdm_commands", "microsoft_compliance_partner_host_statuses", "host_identity_scep_certificates", + // unlike for host_software_installs, where we use soft-delete so that + // existing activities can still access the installation details, this is not + // needed for in-house apps as the activity contains the MDM command UUID and + // can access the request/response without this table's entry. + "host_in_house_software_installs", } // NOTE: The following tables are explicity excluded from hostRefs list and accordingly are not @@ -1206,29 +1211,14 @@ func (ds *Datastore) applyHostFilters( // software (version) ID filter is mutually exclusive with software title ID // so we're reusing the same filter to avoid adding unnecessary conditions. if opt.SoftwareStatusFilter != nil { - _, err := ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, opt.TeamFilter, *opt.SoftwareTitleIDFilter, false) + installerID, vppID, inHouseID, err := ds.installerAvailableForInstallForTeamAndTitleID(ctx, opt.TeamFilter, *opt.SoftwareTitleIDFilter) switch { - case fleet.IsNotFound(err): - vppApp, err := ds.GetVPPAppByTeamAndTitleID(ctx, opt.TeamFilter, *opt.SoftwareTitleIDFilter) - if fleet.IsNotFound(err) { - // Neither installer nor VPP app exists → immediately return 0 hosts safelysts - softwareFilter = "FALSE" - break - } else if err != nil { - return "", nil, ctxerr.Wrap(ctx, err, "get vpp app by team and title id") - } - vppAppJoin, vppAppParams, err := ds.vppAppJoin(vppApp.VPPAppID, *opt.SoftwareStatusFilter) - if err != nil { - return "", nil, ctxerr.Wrap(ctx, err, "vpp app join") - } - softwareStatusJoin = vppAppJoin - joinParams = append(joinParams, vppAppParams...) - case err != nil: - return "", nil, ctxerr.Wrap(ctx, err, "get software installer metadata by team and title id") - default: - // TODO(sarah): prior code was joining on installer id but based on how list options are parsed [1] it seems like this should be the title id - // [1] https://github.com/fleetdm/fleet/blob/8aecae4d853829cb6e7f828099a4f0953643cf18/server/datastore/mysql/hosts.go#L1088-L1089 + // it does not return an error for not found, only for actual db error + return "", nil, ctxerr.Wrap(ctx, err, "get available installer by team and title id") + + case installerID > 0: + // found a software installer package installerJoin, installerParams, err := ds.softwareInstallerJoin(*opt.SoftwareTitleIDFilter, *opt.SoftwareStatusFilter) if err != nil { return "", nil, ctxerr.Wrap(ctx, err, "software installer join") @@ -1236,6 +1226,26 @@ func (ds *Datastore) applyHostFilters( softwareStatusJoin = installerJoin joinParams = append(joinParams, installerParams...) + case vppID != nil: + // found a VPP app + vppAppJoin, vppAppParams, err := ds.vppAppJoin(*vppID, *opt.SoftwareStatusFilter) + if err != nil { + return "", nil, ctxerr.Wrap(ctx, err, "vpp app join") + } + softwareStatusJoin = vppAppJoin + joinParams = append(joinParams, vppAppParams...) + + case inHouseID > 0: + inHouseJoin, inHouseParams, err := ds.inHouseAppJoin(inHouseID, *opt.SoftwareStatusFilter) + if err != nil { + return "", nil, ctxerr.Wrap(ctx, err, "in-house app join") + } + softwareStatusJoin = inHouseJoin + joinParams = append(joinParams, inHouseParams...) + + default: + // no installer found, return as was done before + softwareFilter = "FALSE" } } else { softwareFilter = "EXISTS (SELECT 1 FROM host_software hs INNER JOIN software sw ON hs.software_id = sw.id WHERE hs.host_id = h.id AND sw.title_id = ?)" diff --git a/server/datastore/mysql/hosts_test.go b/server/datastore/mysql/hosts_test.go index 999159a26d..989c228605 100644 --- a/server/datastore/mysql/hosts_test.go +++ b/server/datastore/mysql/hosts_test.go @@ -3585,7 +3585,7 @@ func testHostsListByPolicy(t *testing.T, ds *Datastore) { } func testHostsListBySoftware(t *testing.T, ds *Datastore) { - for i := 0; i < 10; i++ { + for i := range 10 { _, err := ds.NewHost(context.Background(), &fleet.Host{ DetailUpdatedAt: time.Now(), LabelUpdatedAt: time.Now(), @@ -8449,6 +8449,20 @@ func testHostsDeleteHosts(t *testing.T, ds *Datastore) { `, certSerial, host.ID, "test-host", time.Now().Add(-1*time.Hour), time.Now().Add(24*time.Hour), "-----BEGIN CERTIFICATE-----", []byte{0x04}) require.NoError(t, err) + _, _, err = ds.insertInHouseApp(ctx, &fleet.InHouseAppPayload{ + Name: "test", + StorageID: uuid.NewString(), + Platform: string(fleet.MacOSPlatform), + ValidatedLabels: &fleet.LabelIdentsWithScope{}, + }) + require.NoError(t, err) + var inHouseID uint + err = ds.writer(ctx).Get(&inHouseID, "SELECT id FROM in_house_apps WHERE name = ?", "test") + require.NoError(t, err) + _, err = ds.writer(ctx).Exec("INSERT INTO host_in_house_software_installs (host_id, in_house_app_id, command_uuid, platform) VALUES (?, ?, ?, ?)", + host.ID, inHouseID, uuid.NewString(), fleet.MacOSPlatform) + require.NoError(t, err) + // Check there's an entry for the host in all the associated tables. for _, hostRef := range hostRefs { var ok bool diff --git a/server/datastore/mysql/in_house_apps.go b/server/datastore/mysql/in_house_apps.go new file mode 100644 index 0000000000..cf829a09e6 --- /dev/null +++ b/server/datastore/mysql/in_house_apps.go @@ -0,0 +1,637 @@ +package mysql + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/fleetdm/fleet/v4/server/authz" + "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/jmoiron/sqlx" +) + +func (ds *Datastore) insertInHouseApp(ctx context.Context, payload *fleet.InHouseAppPayload) (uint, uint, error) { + selectStmt := `SELECT COUNT(id) FROM in_house_apps WHERE global_or_team_id = ? AND (bundle_identifier = ? OR name = ?)` + + var tid *uint + var globalOrTeamID uint + if payload.TeamID != nil { + globalOrTeamID = *payload.TeamID + + if *payload.TeamID > 0 { + tid = payload.TeamID + } + } + + titleIDipad, err := ds.getOrGenerateInHouseAppTitleID(ctx, payload.Name, payload.BundleID, "ipados_apps") + if err != nil { + return 0, 0, ctxerr.Wrap(ctx, err, "insertInHouseApp") + } + titleIDios, err := ds.getOrGenerateInHouseAppTitleID(ctx, payload.Name, payload.BundleID, "ios_apps") + if err != nil { + return 0, 0, ctxerr.Wrap(ctx, err, "insertInHouseApp") + } + + var installerID uint + var count uint + err = ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { + row := tx.QueryRowxContext(ctx, selectStmt, globalOrTeamID, payload.BundleID, payload.Name) + if err := row.Scan(&count); err != nil { + return ctxerr.Wrap(ctx, err, "insertInHouseApp") + } + if count > 0 { + // ios or ipados version of this installer exists + err = alreadyExists("insertInHouseApp", payload.Name) + } + + argsIos := []any{ + tid, + globalOrTeamID, + payload.Name, + payload.StorageID, + payload.Version, + payload.BundleID, + titleIDios, + "ios", + } + argsIpad := []any{ + tid, + globalOrTeamID, + payload.Name, + payload.StorageID, + payload.Version, + payload.BundleID, + titleIDipad, + "ipados", + } + + _, err := ds.insertInHouseAppDB(ctx, tx, payload, argsIpad) + if err != nil { + return ctxerr.Wrap(ctx, err, "insertInHouseApp") + } + + installerID, err = ds.insertInHouseAppDB(ctx, tx, payload, argsIos) + if err != nil { + return ctxerr.Wrap(ctx, err, "insertInHouseApp") + } + + return nil + }) + + return installerID, titleIDios, ctxerr.Wrap(ctx, err, "insertInHouseApp") +} + +func (ds *Datastore) getOrGenerateInHouseAppTitleID(ctx context.Context, name string, bundleID string, source string) (uint, error) { + selectStmt := `SELECT id FROM software_titles WHERE bundle_identifier = ? AND source = ? OR (name = ? AND source = ?)` + selectArgs := []any{bundleID, source, name, source} + insertStmt := `INSERT INTO software_titles (name, source, bundle_identifier, extension_for) VALUES (?, ?, ?, '')` + insertArgs := []any{name, source, bundleID} + + titleID, err := ds.optimisticGetOrInsert(ctx, + ¶meterizedStmt{ + Statement: selectStmt, + Args: selectArgs, + }, + ¶meterizedStmt{ + Statement: insertStmt, + Args: insertArgs, + }, + ) + if err != nil { + return 0, err + } + return titleID, nil +} + +func (ds *Datastore) insertInHouseAppDB(ctx context.Context, tx sqlx.ExtContext, payload *fleet.InHouseAppPayload, args []any) (uint, error) { + stmt := ` + INSERT INTO in_house_apps ( + team_id, + global_or_team_id, + name, + storage_id, + version, + bundle_identifier, + title_id, + platform + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + + res, err := tx.ExecContext(ctx, stmt, args...) + if err != nil { + if IsDuplicate(err) { + err = alreadyExists("insertInHouseAppDB", payload.Name) + } + return 0, ctxerr.Wrap(ctx, err, "insertInHouseAppDB") + } + id64, err := res.LastInsertId() + installerID := uint(id64) //nolint:gosec // dismiss G115 + if err != nil { + return 0, ctxerr.Wrap(ctx, err, "insertInHouseAppDB") + } + + if err := setOrUpdateSoftwareInstallerLabelsDB(ctx, tx, installerID, *payload.ValidatedLabels, softwareTypeInHouseApp); err != nil { + return 0, ctxerr.Wrap(ctx, err, "insertInHouseAppDB") + } + return installerID, nil +} + +// hihsiAlias is the table alias to use as prefix for the +// host_in_house_software_installs column names, no prefix used if empty. +// ncrAlias is the table alias to use as prefix for the nano_command_results +// column names, no prefix used if empty. +// colAlias is the name to be assigned to the computed status column, pass +// empty to have the value only, no column alias set. +func inHouseAppHostStatusNamedQuery(hihsiAlias, ncrAlias, colAlias string) string { + if hihsiAlias != "" { + hihsiAlias += "." + } + if ncrAlias != "" { + ncrAlias += "." + } + if colAlias != "" { + colAlias = " AS " + colAlias + } + + return fmt.Sprintf(` + CASE + WHEN %sverification_at IS NOT NULL THEN + :software_status_installed + WHEN %sverification_failed_at IS NOT NULL THEN + :software_status_failed + WHEN %sstatus = :mdm_status_error OR %sstatus = :mdm_status_format_error THEN + :software_status_failed + ELSE + :software_status_pending + END %s + `, hihsiAlias, hihsiAlias, ncrAlias, ncrAlias, colAlias) +} + +func (ds *Datastore) GetInHouseAppMetadataByTeamAndTitleID(ctx context.Context, teamID *uint, titleID uint) (*fleet.SoftwareInstaller, error) { + query := ` +SELECT + iha.id, + iha.team_id, + iha.title_id, + COALESCE(iha.name, '') AS software_title, + iha.platform, + iha.storage_id, + st.bundle_identifier AS bundle_identifier, + iha.version +FROM + in_house_apps iha + JOIN software_titles st ON st.id = iha.title_id +WHERE + iha.title_id = ? AND iha.global_or_team_id = ?` + + var tmID uint + if teamID != nil { + tmID = *teamID + } + + var dest fleet.SoftwareInstaller + err := sqlx.GetContext(ctx, ds.reader(ctx), &dest, query, titleID, tmID) + if err != nil { + if err == sql.ErrNoRows { + return nil, ctxerr.Wrap(ctx, notFound("InHouseApp"), "get in house app metadata") + } + return nil, ctxerr.Wrap(ctx, err, "get in house app metadata") + } + dest.Extension = "ipa" + + labels, err := ds.getSoftwareInstallerLabels(ctx, dest.InstallerID, softwareTypeInHouseApp) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "get in house 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 { + level.Warn(ds.logger).Log("msg", "in house app 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 + + return &dest, nil +} + +func (ds *Datastore) SaveInHouseAppUpdates(ctx context.Context, payload *fleet.UpdateSoftwareInstallerPayload) error { + err := ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { + stmt := `UPDATE in_house_apps SET + storage_id = ?, + name = ?, + version = ? + WHERE id = ?` + + args := []any{ + payload.StorageID, + payload.Filename, + payload.Version, + payload.InstallerID, + } + + if _, err := tx.ExecContext(ctx, stmt, args...); err != nil { + return ctxerr.Wrap(ctx, err, "update in house app") + } + + if payload.ValidatedLabels != nil { + if err := setOrUpdateSoftwareInstallerLabelsDB(ctx, tx, payload.InstallerID, *payload.ValidatedLabels, softwareTypeInHouseApp); err != nil { + return ctxerr.Wrap(ctx, err, "upsert in house app labels") + } + } + + return nil + }) + if err != nil { + return ctxerr.Wrap(ctx, err, "update in house app") + } + + return nil +} + +func (ds *Datastore) DeleteInHouseApp(ctx context.Context, id uint) error { + err := ds.withTx(ctx, func(tx sqlx.ExtContext) error { + err := ds.RemovePendingInHouseAppInstalls(ctx, id) + if err != nil && !fleet.IsNotFound(err) { + return ctxerr.Wrap(ctx, err, "delete in house app: remove pending in house app installs") + } + _, err = tx.ExecContext(ctx, `DELETE FROM in_house_apps WHERE id = ?`, id) + if err != nil { + return ctxerr.Wrap(ctx, err, "delete in house app") + } + return err + }) + return err +} + +func (ds *Datastore) RemovePendingInHouseAppInstalls(ctx context.Context, inHouseAppID uint) error { + type ipaInstall struct { + HostID uint `db:"host_id"` + ExecutionID string `db:"command_uuid"` + } + var installs []ipaInstall + err := sqlx.SelectContext(ctx, ds.reader(ctx), &installs, `SELECT host_id, command_uuid FROM host_in_house_software_installs WHERE in_house_app_id = ?`, inHouseAppID) + if err != nil { + return err + } + + for _, in := range installs { + _, err := ds.CancelHostUpcomingActivity(ctx, in.HostID, in.ExecutionID) + if err != nil { + return err + } + } + return nil +} + +func (ds *Datastore) GetSummaryHostInHouseAppInstalls(ctx context.Context, teamID *uint, inHouseAppID uint) (*fleet.VPPAppStatusSummary, error) { + var dest fleet.VPPAppStatusSummary // Using the vpp struct since it is more appropriate for ipa + stmt := ` +WITH +-- select most recent upcoming activities for each host +upcoming AS ( + SELECT + ua.host_id, + :software_status_pending AS status + FROM + upcoming_activities ua + JOIN in_house_app_upcoming_activities ihaua ON ua.id = ihaua.upcoming_activity_id + JOIN hosts h ON host_id = h.id + LEFT JOIN ( + upcoming_activities ua2 + INNER JOIN in_house_app_upcoming_activities ihaua2 + ON ua2.id = ihaua2.upcoming_activity_id + ) ON ua.host_id = ua2.host_id AND + ihaua.in_house_app_id = ihaua2.in_house_app_id AND + ua.activity_type = ua2.activity_type AND + (ua2.priority < ua.priority OR ua2.created_at > ua.created_at) + WHERE + ua.activity_type = 'in_house_app_install' + AND ua2.id IS NULL + AND ihaua.in_house_app_id = :in_house_app_id + AND (h.team_id = :team_id OR (h.team_id IS NULL AND :team_id = 0)) +), + +-- select most recent past activities for each host +past AS ( + SELECT + hihsi.host_id, + CASE + WHEN ncr.status = :mdm_status_acknowledged THEN + :software_status_installed + WHEN ncr.status = :mdm_status_error OR ncr.status = :mdm_status_format_error THEN + :software_status_failed + ELSE + NULL -- either pending or not installed + END AS status + FROM + host_in_house_software_installs hihsi + JOIN hosts h ON host_id = h.id + JOIN nano_command_results ncr ON ncr.id = h.uuid AND ncr.command_uuid = hihsi.command_uuid + LEFT JOIN host_in_house_software_installs hihsi2 + ON hihsi.host_id = hihsi2.host_id AND + hihsi.in_house_app_id = hihsi2.in_house_app_id AND + hihsi2.removed = 0 AND + hihsi2.canceled = 0 AND + (hihsi.created_at < hihsi2.created_at OR (hihsi.created_at = hihsi2.created_at AND hihsi.id < hihsi2.id)) + WHERE + hihsi2.id IS NULL + AND hihsi.in_house_app_id = :in_house_app_id + AND (h.team_id = :team_id OR (h.team_id IS NULL AND :team_id = 0)) + AND hihsi.host_id NOT IN (SELECT host_id FROM upcoming) -- antijoin to exclude hosts with upcoming activities + AND hihsi.removed = 0 + AND hihsi.canceled = 0 +) + +-- count each status +SELECT + COALESCE(SUM( IF(status = :software_status_pending, 1, 0)), 0) AS pending, + COALESCE(SUM( IF(status = :software_status_failed, 1, 0)), 0) AS failed, + COALESCE(SUM( IF(status = :software_status_installed, 1, 0)), 0) AS installed +FROM ( + +-- union most recent past and upcoming activities after joining to get statuses for most recent activities +SELECT + past.host_id, + past.status +FROM past +UNION +SELECT + upcoming.host_id, + upcoming.status +FROM upcoming +) t` + + var tmID uint + if teamID != nil { + tmID = *teamID + } + + query, args, err := sqlx.Named(stmt, map[string]any{ + "in_house_app_id": inHouseAppID, + "team_id": tmID, + "mdm_status_acknowledged": fleet.MDMAppleStatusAcknowledged, + "mdm_status_error": fleet.MDMAppleStatusError, + "mdm_status_format_error": fleet.MDMAppleStatusCommandFormatError, + "software_status_pending": fleet.SoftwarePending, + "software_status_failed": fleet.SoftwareFailed, + "software_status_installed": fleet.SoftwareInstalled, + }) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "get summary host in house app installs: named query") + } + + err = sqlx.GetContext(ctx, ds.reader(ctx), &dest, query, args...) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "get summary host in house install status") + } + return &dest, nil +} + +func (ds *Datastore) IsInHouseAppLabelScoped(ctx context.Context, inHouseAppID, hostID uint) (bool, error) { + return ds.isSoftwareLabelScoped(ctx, inHouseAppID, hostID, softwareTypeInHouseApp) +} + +func (ds *Datastore) InsertHostInHouseAppInstall(ctx context.Context, hostID uint, inHouseAppID, softwareTitleID uint, commandUUID string, opts fleet.HostSoftwareInstallOptions) error { + const ( + insertUAStmt = ` +INSERT INTO upcoming_activities + (host_id, priority, user_id, fleet_initiated, activity_type, execution_id, payload) +VALUES + (?, ?, ?, ?, 'in_house_app_install', ?, + JSON_OBJECT( + 'user', (SELECT JSON_OBJECT('name', name, 'email', email, 'gravatar_url', gravatar_url) FROM users WHERE id = ?) + ) + )` + + insertIHAUAStmt = ` +INSERT INTO in_house_app_upcoming_activities + (upcoming_activity_id, in_house_app_id, software_title_id) +VALUES + (?, ?, ?)` + + hostExistsStmt = `SELECT 1 FROM hosts WHERE id = ?` + ) + + // we need to explicitly do this check here because we can't set a FK constraint on the schema + var hostExists bool + err := sqlx.GetContext(ctx, ds.reader(ctx), &hostExists, hostExistsStmt, hostID) + if err != nil { + if err == sql.ErrNoRows { + return notFound("Host").WithID(hostID) + } + + return ctxerr.Wrap(ctx, err, "checking if host exists") + } + + var userID *uint + if ctxUser := authz.UserFromContext(ctx); ctxUser != nil { + userID = &ctxUser.ID + } + + err = ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { + res, err := tx.ExecContext(ctx, insertUAStmt, + hostID, + opts.Priority(), + userID, + opts.IsFleetInitiated(), + commandUUID, + userID, + ) + if err != nil { + return ctxerr.Wrap(ctx, err, "insert in house app install request") + } + + activityID, _ := res.LastInsertId() + _, err = tx.ExecContext(ctx, insertIHAUAStmt, + activityID, + inHouseAppID, + softwareTitleID, + ) + if err != nil { + return ctxerr.Wrap(ctx, err, "insert in house app install request join table") + } + + if _, err := ds.activateNextUpcomingActivity(ctx, tx, hostID, ""); err != nil { + return ctxerr.Wrap(ctx, err, "activate next activity") + } + return nil + }) + return err +} + +func (ds *Datastore) SetInHouseAppInstallAsVerified(ctx context.Context, hostID uint, installUUID, verificationUUID string) error { + stmt := ` +UPDATE host_in_house_software_installs +SET verification_at = CURRENT_TIMESTAMP(6), +verification_command_uuid = ? +WHERE command_uuid = ? + ` + + return ds.withTx(ctx, func(tx sqlx.ExtContext) error { + if _, err := tx.ExecContext(ctx, stmt, verificationUUID, installUUID); err != nil { + return ctxerr.Wrap(ctx, err, "set in house app install as verified") + } + + if _, err := ds.activateNextUpcomingActivity(ctx, tx, hostID, installUUID); err != nil { + return ctxerr.Wrap(ctx, err, "activate next activity from in house app install verify") + } + + return nil + }) +} + +func (ds *Datastore) SetInHouseAppInstallAsFailed(ctx context.Context, hostID uint, installUUID, verificationUUID string) error { + stmt := ` +UPDATE host_in_house_software_installs +SET verification_failed_at = CURRENT_TIMESTAMP(6), +verification_command_uuid = ? +WHERE command_uuid = ? + ` + + return ds.withTx(ctx, func(tx sqlx.ExtContext) error { + if _, err := tx.ExecContext(ctx, stmt, verificationUUID, installUUID); err != nil { + return ctxerr.Wrap(ctx, err, "set in house app install as failed") + } + + if _, err := ds.activateNextUpcomingActivity(ctx, tx, hostID, installUUID); err != nil { + return ctxerr.Wrap(ctx, err, "activate next activity from in house app install failed") + } + + return nil + }) +} + +func (ds *Datastore) ReplaceInHouseAppInstallVerificationUUID(ctx context.Context, oldVerifyUUID, verifyCommandUUID string) error { + stmt := ` +UPDATE host_in_house_software_installs +SET verification_command_uuid = ? +WHERE verification_command_uuid = ? + ` + + if _, err := ds.writer(ctx).ExecContext(ctx, stmt, verifyCommandUUID, oldVerifyUUID); err != nil { + return ctxerr.Wrap(ctx, err, "update in-house app install verification command") + } + + return nil +} + +func (ds *Datastore) GetUnverifiedInHouseAppInstallsForHost(ctx context.Context, hostUUID string) ([]*fleet.HostVPPSoftwareInstall, error) { + stmt := ` +SELECT + hihsi.host_id AS host_id, + hihsi.command_uuid AS command_uuid, + ncr.updated_at AS ack_at, + ncr.status AS install_command_status, + iha.bundle_identifier AS bundle_identifier +FROM nano_command_results ncr +JOIN host_in_house_software_installs hihsi ON hihsi.command_uuid = ncr.command_uuid +JOIN in_house_apps iha ON iha.id = hihsi.in_house_app_id AND iha.platform = hihsi.platform +WHERE ncr.id = ? +AND ncr.status = 'Acknowledged' +AND hihsi.verification_at IS NULL +AND hihsi.verification_failed_at IS NULL + ` + + var result []*fleet.HostVPPSoftwareInstall + if err := sqlx.SelectContext(ctx, ds.reader(ctx), &result, stmt, hostUUID); err != nil { + return nil, ctxerr.Wrap(ctx, err, "get unverified in-house app installs for host") + } + + return result, nil +} + +func (ds *Datastore) GetPastActivityDataForInHouseAppInstall(ctx context.Context, commandResults *mdm.CommandResults) (*fleet.User, *fleet.ActivityTypeInstalledSoftware, error) { + if commandResults == nil { + return nil, nil, nil + } + + stmt := ` +SELECT + u.name AS user_name, + u.id AS user_id, + u.email as user_email, + hihsi.host_id AS host_id, + hdn.display_name AS host_display_name, + st.name AS software_title, + hihsi.command_uuid AS command_uuid +FROM + host_in_house_software_installs hihsi + LEFT OUTER JOIN users u ON hihsi.user_id = u.id + LEFT OUTER JOIN host_display_names hdn ON hdn.host_id = hihsi.host_id + LEFT OUTER JOIN in_house_apps iha ON hihsi.in_house_app_id = iha.id + LEFT OUTER JOIN software_titles st ON st.id = iha.title_id +WHERE + hihsi.command_uuid = :command_uuid AND + hihsi.canceled = 0 + ` + + type result struct { + HostID uint `db:"host_id"` + HostDisplayName string `db:"host_display_name"` + SoftwareTitle string `db:"software_title"` + CommandUUID string `db:"command_uuid"` + UserName *string `db:"user_name"` + UserID *uint `db:"user_id"` + UserEmail *string `db:"user_email"` + } + + listStmt, args, err := sqlx.Named(stmt, map[string]any{ + "command_uuid": commandResults.CommandUUID, + "software_status_failed": string(fleet.SoftwareInstallFailed), + "software_status_installed": string(fleet.SoftwareInstalled), + }) + if err != nil { + return nil, nil, ctxerr.Wrap(ctx, err, "build list query from named args") + } + + var res result + if err := sqlx.GetContext(ctx, ds.reader(ctx), &res, listStmt, args...); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil, notFound("install_command") + } + + return nil, nil, ctxerr.Wrap(ctx, err, "select past activity data for in-house app install") + } + + var user *fleet.User + if res.UserID != nil { + user = &fleet.User{ + ID: *res.UserID, + Name: *res.UserName, + Email: *res.UserEmail, + } + } + + var status string + switch commandResults.Status { + case fleet.MDMAppleStatusAcknowledged: + status = string(fleet.SoftwareInstalled) + case fleet.MDMAppleStatusCommandFormatError, fleet.MDMAppleStatusError: + status = string(fleet.SoftwareInstallFailed) + default: + // This case shouldn't happen (we should only be doing this check if the command is in a + // "terminal" state, but adding it so we have a default + status = string(fleet.SoftwareInstallPending) + } + + act := &fleet.ActivityTypeInstalledSoftware{ + HostID: res.HostID, + HostDisplayName: res.HostDisplayName, + SoftwareTitle: res.SoftwareTitle, + CommandUUID: res.CommandUUID, + Status: status, + } + + return user, act, nil +} diff --git a/server/datastore/mysql/in_house_apps_test.go b/server/datastore/mysql/in_house_apps_test.go new file mode 100644 index 0000000000..63a7285362 --- /dev/null +++ b/server/datastore/mysql/in_house_apps_test.go @@ -0,0 +1,321 @@ +package mysql + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/fleetdm/fleet/v4/server/contexts/viewer" + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm" + nanomdm_mysql "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/storage/mysql" + "github.com/fleetdm/fleet/v4/server/test" + "github.com/google/uuid" + "github.com/jmoiron/sqlx" + "github.com/stretchr/testify/require" +) + +func TestInHouseApps(t *testing.T) { + ds := CreateMySQLDS(t) + + cases := []struct { + name string + fn func(t *testing.T, ds *Datastore) + }{ + {"TestInHouseAppsCrud", testInHouseAppsCrud}, + {"MultipleTeams", testInHouseAppsMultipleTeams}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + defer TruncateTables(t, ds) + c.fn(t, ds) + }) + } +} + +func testInHouseAppsCrud(t *testing.T, ds *Datastore) { + ctx := context.Background() + + host1 := test.NewHost(t, ds, "host1", "1", "host1key", "host1uuid", time.Now()) + host2 := test.NewHost(t, ds, "host2", "2", "host2key", "host2uuid", time.Now()) + host3 := test.NewHost(t, ds, "host3", "3", "host3key", "host3uuid", time.Now()) + + user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true) + team, err := ds.NewTeam(ctx, &fleet.Team{Name: "team 1"}) + require.NoError(t, err) + err = ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&team.ID, []uint{host1.ID, host2.ID, host3.ID})) + require.NoError(t, err) + + nanoEnroll(t, ds, host1, false) + nanoEnroll(t, ds, host2, false) + nanoEnroll(t, ds, host3, false) + + payload := fleet.UploadSoftwareInstallerPayload{ + TeamID: &team.ID, + UserID: user1.ID, + Title: "foo", + BundleIdentifier: "com.foo", + StorageID: "testingtesting123", + Platform: "ios", + Extension: "ipa", + Version: "1.2.3", + } + + // ------------------------- + // Upload software installer + _, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &payload) + require.Error(t, err, "ValidatedLabels must not be nil") + + payload.ValidatedLabels = &fleet.LabelIdentsWithScope{} + installerID, titleID, err := ds.MatchOrCreateSoftwareInstaller(ctx, &payload) + require.NoError(t, err) + require.NotZero(t, installerID) + require.NotZero(t, titleID) + + // both ios and ipados apps are created, both installer and title + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + var countI uint + var countS uint + errI := sqlx.GetContext(ctx, q, &countI, `SELECT COUNT(*) FROM in_house_apps`) + errS := sqlx.GetContext(ctx, q, &countS, `SELECT COUNT(*) FROM software_titles`) + require.NoError(t, errI) + require.NoError(t, errS) + require.Equal(t, uint(2), countI) + require.Equal(t, uint(2), countS) + return nil + }) + + installer, err := ds.GetInHouseAppMetadataByTeamAndTitleID(ctx, &team.ID, titleID) + require.NoError(t, err) + require.Equal(t, payload.Title, installer.SoftwareTitle) + require.Equal(t, payload.Version, installer.Version) + + // Install on multiple users with pending, success, failure + createInHouseAppInstallRequest(t, ds, host1.ID, installerID, titleID, user1) + cmdUUID2 := createInHouseAppInstallRequest(t, ds, host2.ID, installerID, titleID, user1) + createInHouseAppInstallResult(t, ds, host2, cmdUUID2, "Acknowledged") + cmdUUID3 := createInHouseAppInstallRequest(t, ds, host3.ID, installerID, titleID, user1) + createInHouseAppInstallResult(t, ds, host3, cmdUUID3, "Error") + + // Get summary and expect failed, installed, pending + summary, err := ds.GetSummaryHostInHouseAppInstalls(ctx, &team.ID, installerID) + require.NoError(t, err) + require.Equal(t, fleet.VPPAppStatusSummary{Installed: 1, Pending: 1, Failed: 1}, *summary) + + // ------------------------- + // Update software installer + label, err := ds.NewLabel(ctx, &fleet.Label{Name: "include-any-1", Query: "select 1"}) + require.NoError(t, err) + + validatedLabels := fleet.LabelIdentsWithScope{ + LabelScope: "include_any", + ByName: map[string]fleet.LabelIdent{ + "include-any-1": { + LabelID: label.ID, + LabelName: label.Name, + }, + }} + updatePayload := fleet.UpdateSoftwareInstallerPayload{ + TeamID: &team.ID, + TitleID: titleID, + InstallerID: installerID, + Filename: "ipa_test.ipa", + StorageID: "new_storage_id", + ValidatedLabels: &validatedLabels, + } + + err = ds.SaveInHouseAppUpdates(ctx, &updatePayload) + require.NoError(t, err) + + // Installer updates correctly + var expectedLabels []fleet.SoftwareScopeLabel + expectedLabels = append(expectedLabels, fleet.SoftwareScopeLabel{LabelID: label.ID, LabelName: label.Name, Exclude: false, TitleID: titleID}) + + newInstaller, err := ds.GetInHouseAppMetadataByTeamAndTitleID(ctx, &team.ID, titleID) + require.NoError(t, err) + require.Equal(t, "new_storage_id", newInstaller.StorageID) + require.Equal(t, expectedLabels, newInstaller.LabelsIncludeAny) + + // Summary is unchanged? + summary2, err := ds.GetSummaryHostInHouseAppInstalls(ctx, &team.ID, installerID) + require.NoError(t, err) + require.Equal(t, summary, summary2) + + // ------------------------- + // Delete software installer + err = ds.DeleteInHouseApp(ctx, installerID) + require.NoError(t, err) + + // TODO: test RemovePendingInHouseAppInstalls independently + + _, err = ds.GetInHouseAppMetadataByTeamAndTitleID(ctx, &team.ID, titleID) + require.Error(t, err) + status, err := ds.GetSummaryHostInHouseAppInstalls(ctx, &team.ID, installerID) + require.NoError(t, err) + require.Zero(t, *status) + + // Check that entire tables are empty for this test + checkEmpty := func(table string) { + var count int + err := sqlx.GetContext(ctx, ds.reader(ctx), &count, fmt.Sprintf(`SELECT COUNT(*) FROM %s`, table)) + require.NoError(t, err) + require.Zero(t, count, "expected %s to be empty", table) + } + + checkEmpty("in_house_app_labels") + checkEmpty("host_in_house_software_installs") + checkEmpty("in_house_app_upcoming_activities") + checkEmpty("upcoming_activities") + + // ipadOS installer should remain + var ipadID uint + err = sqlx.GetContext(ctx, ds.reader(ctx), &ipadID, `SELECT id FROM in_house_apps LIMIT 1`) + require.NoError(t, err) + + // Try to upload installer again, expect duplicate error + _, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &payload) + require.Error(t, err) + + // Delete ipadOS installer + err = ds.DeleteInHouseApp(ctx, ipadID) + require.NoError(t, err) + + var count int + err = sqlx.GetContext(ctx, ds.reader(ctx), &count, `SELECT COUNT(*) FROM in_house_apps`) + require.NoError(t, err) + require.Zero(t, count, "expected in_house_apps to be empty") +} + +func testInHouseAppsMultipleTeams(t *testing.T, ds *Datastore) { + ctx := context.Background() + + host1 := test.NewHost(t, ds, "host1", "1", "host1key", "host1uuid", time.Now()) + host2 := test.NewHost(t, ds, "host2", "2", "host2key", "host2uuid", time.Now()) + + user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true) + + team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "team 1"}) + require.NoError(t, err) + err = ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&team1.ID, []uint{host1.ID})) + require.NoError(t, err) + team2, err := ds.NewTeam(ctx, &fleet.Team{Name: "team 2"}) + require.NoError(t, err) + err = ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&team2.ID, []uint{host2.ID})) + require.NoError(t, err) + + nanoEnroll(t, ds, host1, false) + + payload1 := fleet.UploadSoftwareInstallerPayload{ + TeamID: &team1.ID, + UserID: user1.ID, + Title: "foo", + BundleIdentifier: "com.foo", + StorageID: "testingtesting123", + Platform: "ios", + Extension: "ipa", + Version: "1.2.3", + ValidatedLabels: &fleet.LabelIdentsWithScope{}, + } + + payload2 := payload1 + payload2.TeamID = &team2.ID + + payloadNoTeam := payload1 + payloadNoTeam.TeamID = nil + + // Add installers for both teams + _, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &payload1) + require.NoError(t, err) + installerID2, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &payload2) + require.NoError(t, err) + _, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &payloadNoTeam) + require.NoError(t, err) + + var count int + err = sqlx.GetContext(ctx, ds.reader(ctx), &count, `SELECT COUNT(id) FROM software_titles`) + require.NoError(t, err) + require.Equal(t, 2, count) + + err = sqlx.GetContext(ctx, ds.reader(ctx), &count, `SELECT COUNT(id) FROM in_house_apps`) + require.NoError(t, err) + require.Equal(t, 6, count) + + // Team 2: Delete 1 installer from 1 team + err = ds.DeleteInHouseApp(ctx, installerID2) + require.NoError(t, err) + + err = sqlx.GetContext(ctx, ds.reader(ctx), &count, `SELECT COUNT(id) FROM in_house_apps`) + require.NoError(t, err) + require.Equal(t, 5, count) + + // Team 2: Try to add installer again + _, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &payload2) + require.Error(t, err) + + err = sqlx.GetContext(ctx, ds.reader(ctx), &count, `SELECT COUNT(id) FROM in_house_apps`) + require.NoError(t, err) + require.Equal(t, 5, count) + + // Test that software titles for IHA don't get cleaned up + require.NoError(t, ds.SyncHostsSoftware(ctx, time.Now())) + require.NoError(t, ds.CleanupSoftwareTitles(ctx)) + require.NoError(t, ds.SyncHostsSoftwareTitles(ctx, time.Now())) + + err = sqlx.GetContext(ctx, ds.reader(ctx), &count, `SELECT COUNT(id) FROM software_titles`) + require.NoError(t, err) + require.Equal(t, 2, count) + +} + +func createInHouseAppInstallRequest(t *testing.T, ds *Datastore, hostID uint, appID uint, titleID uint, user *fleet.User) string { + ctx := context.Background() + ctx = viewer.NewContext(ctx, viewer.Viewer{User: user}) + + cmdUUID := uuid.NewString() + + err := ds.InsertHostInHouseAppInstall(ctx, hostID, appID, titleID, cmdUUID, fleet.HostSoftwareInstallOptions{}) + require.NoError(t, err) + return cmdUUID +} + +func createInHouseAppInstallResult(t *testing.T, ds *Datastore, host *fleet.Host, cmdUUID string, status string) { + ctx := context.Background() + ctx = context.WithValue(ctx, fleet.ActivityWebhookContextKey, true) + + nanoDB, err := nanomdm_mysql.New(nanomdm_mysql.WithDB(ds.primary.DB)) + require.NoError(t, err) + nanoCtx := &mdm.Request{EnrollID: &mdm.EnrollID{ID: host.UUID}, Context: ctx} + + cmdRes := &mdm.CommandResults{ + CommandUUID: cmdUUID, + Status: status, + Raw: []byte(``), + } + err = nanoDB.StoreCommandReport(nanoCtx, cmdRes) + require.NoError(t, err) + + // inserting the activity is what marks the upcoming activity as completed + // (and activates the next one). + err = ds.NewActivity(ctx, nil, fleet.ActivityInstalledAppStoreApp{ + HostID: host.ID, + CommandUUID: cmdUUID, + }, []byte(`{}`), time.Now()) + require.NoError(t, err) +} + +func createInHouseAppInstallResultVerified(t *testing.T, ds *Datastore, host *fleet.Host, cmdUUID string, status string) { + createInHouseAppInstallResult(t, ds, host, cmdUUID, status) + + ctx := t.Context() + timestampCol := "verification_at" + if status != "Acknowledged" { + timestampCol = "verification_failed_at" + } + ExecAdhocSQL(t, ds, func(tx sqlx.ExtContext) error { + _, err := tx.ExecContext(ctx, fmt.Sprintf(`UPDATE host_in_house_software_installs SET + %s = NOW(6), verification_command_uuid = ? WHERE host_id = ? AND command_uuid = ?`, timestampCol), + uuid.NewString(), host.ID, cmdUUID) + return err + }) +} diff --git a/server/datastore/mysql/labels.go b/server/datastore/mysql/labels.go index 48424c2e16..882342031e 100644 --- a/server/datastore/mysql/labels.go +++ b/server/datastore/mysql/labels.go @@ -816,31 +816,41 @@ func (ds *Datastore) applyHostLabelFilters(ctx context.Context, filter fleet.Tea // // TODO: Do we currently support filtering by software version ID and label? // } if opt.SoftwareTitleIDFilter != nil && opt.SoftwareStatusFilter != nil { - // check for software installer metadata - _, err := ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, opt.TeamFilter, *opt.SoftwareTitleIDFilter, false) + installerID, vppID, inHouseID, err := ds.installerAvailableForInstallForTeamAndTitleID(ctx, opt.TeamFilter, *opt.SoftwareTitleIDFilter) switch { - case fleet.IsNotFound(err): - vppApp, err := ds.GetVPPAppByTeamAndTitleID(ctx, opt.TeamFilter, *opt.SoftwareTitleIDFilter) - if err != nil { - return "", nil, ctxerr.Wrap(ctx, err, "get vpp app by team and title id") - } - vppAppJoin, vppAppParams, err := ds.vppAppJoin(vppApp.VPPAppID, *opt.SoftwareStatusFilter) - if err != nil { - return "", nil, ctxerr.Wrap(ctx, err, "vpp app join") - } - softwareStatusJoin = vppAppJoin - joinParams = append(joinParams, vppAppParams...) - case err != nil: - return "", nil, ctxerr.Wrap(ctx, err, "get software installer metadata by team and title id") + // it does not return an error for not found, only for actual db error + return "", nil, ctxerr.Wrap(ctx, err, "get available installer by team and title id") - default: + case installerID > 0: + // found a software installer package installerJoin, installerParams, err := ds.softwareInstallerJoin(*opt.SoftwareTitleIDFilter, *opt.SoftwareStatusFilter) if err != nil { return "", nil, ctxerr.Wrap(ctx, err, "software installer join") } softwareStatusJoin = installerJoin joinParams = append(joinParams, installerParams...) + + case vppID != nil: + // found a VPP app + vppAppJoin, vppAppParams, err := ds.vppAppJoin(*vppID, *opt.SoftwareStatusFilter) + if err != nil { + return "", nil, ctxerr.Wrap(ctx, err, "vpp app join") + } + softwareStatusJoin = vppAppJoin + joinParams = append(joinParams, vppAppParams...) + + case inHouseID > 0: + inHouseJoin, inHouseParams, err := ds.inHouseAppJoin(inHouseID, *opt.SoftwareStatusFilter) + if err != nil { + return "", nil, ctxerr.Wrap(ctx, err, "in-house app join") + } + softwareStatusJoin = inHouseJoin + joinParams = append(joinParams, inHouseParams...) + + default: + // no installer found, return as was done before (which was a not-found error, here, unlike in applyHostsFilter) + return "", nil, ctxerr.Wrap(ctx, notFound("installerAvailableForInstall"), "get available software installer by team and title id") } } if softwareStatusJoin != "" { diff --git a/server/datastore/mysql/mdm.go b/server/datastore/mysql/mdm.go index 398b743cee..ec0e23a83c 100644 --- a/server/datastore/mysql/mdm.go +++ b/server/datastore/mysql/mdm.go @@ -2299,7 +2299,7 @@ GROUP BY return counts, nil } -func (ds *Datastore) IsHostPendingVPPInstallVerification(ctx context.Context, hostUUID string) (bool, error) { +func (ds *Datastore) IsHostPendingMDMInstallVerification(ctx context.Context, hostUUID string) (bool, error) { stmt := ` SELECT EXISTS ( SELECT 1 diff --git a/server/datastore/mysql/migrations/tables/20251027101151_InHouseAppsSupport.go b/server/datastore/mysql/migrations/tables/20251027101151_InHouseAppsSupport.go new file mode 100644 index 0000000000..2b78a9f972 --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20251027101151_InHouseAppsSupport.go @@ -0,0 +1,60 @@ +package tables + +import ( + "database/sql" + "fmt" +) + +func init() { + MigrationClient.AddMigration(Up_20251027101151, Down_20251027101151) +} + +func Up_20251027101151(tx *sql.Tx) error { + createTableStmt := ` +CREATE TABLE in_house_apps ( + id int unsigned NOT NULL AUTO_INCREMENT, + title_id int unsigned DEFAULT NULL, + team_id int unsigned DEFAULT NULL, + global_or_team_id int unsigned NOT NULL DEFAULT '0', + name VARCHAR(255) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + version VARCHAR(255) NOT NULL DEFAULT '', + storage_id VARCHAR(64) COLLATE utf8mb4_unicode_ci NOT NULL, + created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + platform varchar(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + bundle_identifier VARCHAR(255) NOT NULL DEFAULT '', + PRIMARY KEY (id), + UNIQUE KEY (global_or_team_id,name,platform), + CONSTRAINT fk_in_house_apps_title FOREIGN KEY (title_id) REFERENCES software_titles (id) ON DELETE SET NULL ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + ` + if _, err := tx.Exec(createTableStmt); err != nil { + return fmt.Errorf("create in_house_apps table: %w", err) + } + + createLabelMappingTableStmt := ` +CREATE TABLE in_house_app_labels ( + id int unsigned NOT NULL AUTO_INCREMENT, + in_house_app_id int unsigned NOT NULL, + label_id int unsigned NOT NULL, + exclude tinyint(1) NOT NULL DEFAULT '0', + created_at timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + updated_at timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + PRIMARY KEY (id), + UNIQUE KEY id_in_house_app_labels_in_house_app_id_label_id (in_house_app_id,label_id), + KEY label_id (label_id), + CONSTRAINT in_house_app_labels_ibfk_1 FOREIGN KEY (in_house_app_id) REFERENCES in_house_apps (id) ON DELETE CASCADE, + CONSTRAINT in_house_app_labels_ibfk_2 FOREIGN KEY (label_id) REFERENCES labels (id) ON DELETE RESTRICT +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + ` + + if _, err := tx.Exec(createLabelMappingTableStmt); err != nil { + return fmt.Errorf("create in_house_app_labels table: %w", err) + } + + return nil +} + +func Down_20251027101151(tx *sql.Tx) error { + return nil +} diff --git a/server/datastore/mysql/migrations/tables/20251027101151_InHouseAppsSupport_test.go b/server/datastore/mysql/migrations/tables/20251027101151_InHouseAppsSupport_test.go new file mode 100644 index 0000000000..d4785c6363 --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20251027101151_InHouseAppsSupport_test.go @@ -0,0 +1,14 @@ +package tables + +import "testing" + +func TestUp_20251027101151(t *testing.T) { + db := applyUpToPrev(t) + + // These are brand new tables, so no logic to test here. + // Leaving it in because it's nice to validate that the migration applies successfully. + + // Apply current migration. + applyNext(t, db) + +} diff --git a/server/datastore/mysql/migrations/tables/20251027101155_AddInHouseAppsToUnifiedQueue.go b/server/datastore/mysql/migrations/tables/20251027101155_AddInHouseAppsToUnifiedQueue.go new file mode 100644 index 0000000000..f121ee4349 --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20251027101155_AddInHouseAppsToUnifiedQueue.go @@ -0,0 +1,101 @@ +package tables + +import ( + "database/sql" + "fmt" +) + +func init() { + MigrationClient.AddMigration(Up_20251027101155, Down_20251027101155) +} + +func Up_20251027101155(tx *sql.Tx) error { + // Note that at the moment of this migration, in-house apps uninstall is not + // supported, so we don't add it to the enum. + _, err := tx.Exec(` +ALTER TABLE upcoming_activities + CHANGE COLUMN activity_type activity_type ENUM('script', 'software_install', 'software_uninstall', 'vpp_app_install', 'in_house_app_install') + COLLATE utf8mb4_unicode_ci NOT NULL +`) + if err != nil { + return fmt.Errorf("failed to alter upcoming_activities activity_type: %w", err) + } + + // Note that at the moment of this migration, auto-install and self-service is not + // supported for in-house apps, so we don't need to add columns for e.g. policy_id. + // See https://www.figma.com/design/zcc45sBgdiDZT11iKjLolh/-30936-Deploy-custom--in-house--iOS-app?node-id=5363-11227&t=S1pEnokvQ83v8eJk-0 + _, err = tx.Exec(` +CREATE TABLE in_house_app_upcoming_activities ( + upcoming_activity_id BIGINT UNSIGNED NOT NULL, + + -- those are all columns and not JSON fields because we need FKs on them to + -- do processing ON DELETE, otherwise we'd have to check for existence of + -- each one when executing the activity (we need the enqueue next activity + -- action to be efficient). + in_house_app_id INT UNSIGNED NOT NULL, + + software_title_id INT UNSIGNED DEFAULT NULL, + + -- Using DATETIME instead of TIMESTAMP to prevent future Y2K38 issues + created_at DATETIME(6) NOT NULL DEFAULT NOW(6), + updated_at DATETIME(6) NOT NULL DEFAULT NOW(6) ON UPDATE NOW(6), + + PRIMARY KEY (upcoming_activity_id), + CONSTRAINT fk_in_house_app_upcoming_activities_upcoming_activity_id + FOREIGN KEY (upcoming_activity_id) REFERENCES upcoming_activities (id) ON DELETE CASCADE, + CONSTRAINT fk_in_house_app_upcoming_activities_in_house_app_id + FOREIGN KEY (in_house_app_id) REFERENCES in_house_apps (id) ON DELETE CASCADE, + CONSTRAINT fk_in_house_app_upcoming_activities_software_title_id + FOREIGN KEY (software_title_id) REFERENCES software_titles (id) ON DELETE SET NULL ON UPDATE CASCADE +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci +`, + ) + if err != nil { + return fmt.Errorf("failed to create in_house_app_upcoming_activities table: %w", err) + } + + // Note that at the time of this migration, in-house apps do not support + // auto-install and self-service installs so those columns have not been added. + // See https://www.figma.com/design/zcc45sBgdiDZT11iKjLolh/-30936-Deploy-custom--in-house--iOS-app?node-id=5363-11227&t=S1pEnokvQ83v8eJk-0 + _, err = tx.Exec(` +-- This table is the in-house app equivalent of the host_vpp_software_installs table. +-- It tracks the installation of in-house software on particular hosts. +CREATE TABLE host_in_house_software_installs ( + id INT(10) UNSIGNED NOT NULL AUTO_INCREMENT, + host_id INT(10) UNSIGNED NOT NULL, + + in_house_app_id INT(10) UNSIGNED NOT NULL, + + -- This is the UUID of the MDM command issued to install the app + command_uuid VARCHAR(127) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + user_id INT(10) UNSIGNED NULL, + platform VARCHAR(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + removed TINYINT NOT NULL DEFAULT '0', + canceled TINYINT NOT NULL DEFAULT '0', + + verification_command_uuid VARCHAR(127) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + verification_at DATETIME(6) DEFAULT NULL, + verification_failed_at DATETIME(6) DEFAULT NULL, + + -- Using DATETIME instead of TIMESTAMP to prevent future Y2K38 issues + created_at DATETIME(6) NOT NULL DEFAULT NOW(6), + updated_at DATETIME(6) NOT NULL DEFAULT NOW(6) ON UPDATE NOW(6), + + PRIMARY KEY(id), + UNIQUE INDEX idx_host_in_house_software_installs_command_uuid (command_uuid), + CONSTRAINT fk_host_in_house_software_installs_user_id + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE SET NULL, + CONSTRAINT fk_host_in_house_software_installs_in_house_app_id + FOREIGN KEY (in_house_app_id) REFERENCES in_house_apps (id) ON DELETE CASCADE, + INDEX idx_host_in_house_software_installs_verification ((verification_at IS NULL AND verification_failed_at IS NULL)) +) DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci`) + if err != nil { + return fmt.Errorf("failed to create table host_in_house_software_installs: %w", err) + } + + return nil +} + +func Down_20251027101155(tx *sql.Tx) error { + return nil +} diff --git a/server/datastore/mysql/migrations/tables/20251027101155_AddInHouseAppsToUnifiedQueue_test.go b/server/datastore/mysql/migrations/tables/20251027101155_AddInHouseAppsToUnifiedQueue_test.go new file mode 100644 index 0000000000..5fc86eae4f --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20251027101155_AddInHouseAppsToUnifiedQueue_test.go @@ -0,0 +1,46 @@ +package tables + +import ( + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +func TestUp_20251027101155(t *testing.T) { + db := applyUpToPrev(t) + + hostID := insertHost(t, db, nil) + contentIDs := insertScriptContents(t, db, 1) + + // create an upcoming activity for script run on that host + execID := uuid.NewString() + uaID := execNoErrLastID(t, db, `INSERT INTO upcoming_activities ( + host_id, activity_type, execution_id, payload + ) VALUES (?, ?, ?, ?)`, hostID, "script", execID, `{}`) + + execNoErr(t, db, `INSERT INTO script_upcoming_activities ( + upcoming_activity_id, script_content_id + ) VALUES (?, ?)`, uaID, contentIDs[0]) + + // Apply current migration. + applyNext(t, db) + + assertRowCount(t, db, "upcoming_activities", 1) + + // activity type is still "script" + var activityType string + err := db.Get(&activityType, "SELECT activity_type FROM upcoming_activities WHERE id = ?", uaID) + require.NoError(t, err) + require.Equal(t, "script", activityType) + + // activity can now be in_house_app_install + execID2 := uuid.NewString() + uaID2 := execNoErrLastID(t, db, `INSERT INTO upcoming_activities ( + host_id, activity_type, execution_id, payload + ) VALUES (?, ?, ?, ?)`, hostID, "in_house_app_install", execID2, `{}`) + + err = db.Get(&activityType, "SELECT activity_type FROM upcoming_activities WHERE id = ?", uaID2) + require.NoError(t, err) + require.Equal(t, "in_house_app_install", activityType) +} diff --git a/server/datastore/mysql/schema.sql b/server/datastore/mysql/schema.sql index 28972c4edc..918a0abfe9 100644 --- a/server/datastore/mysql/schema.sql +++ b/server/datastore/mysql/schema.sql @@ -601,6 +601,31 @@ CREATE TABLE `host_identity_scep_serials` ( /*!40101 SET character_set_client = @saved_cs_client */; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `host_in_house_software_installs` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `host_id` int unsigned NOT NULL, + `in_house_app_id` int unsigned NOT NULL, + `command_uuid` varchar(127) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `user_id` int unsigned DEFAULT NULL, + `platform` varchar(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `removed` tinyint NOT NULL DEFAULT '0', + `canceled` tinyint NOT NULL DEFAULT '0', + `verification_command_uuid` varchar(127) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `verification_at` datetime(6) DEFAULT NULL, + `verification_failed_at` datetime(6) DEFAULT NULL, + `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + `updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + PRIMARY KEY (`id`), + UNIQUE KEY `idx_host_in_house_software_installs_command_uuid` (`command_uuid`), + KEY `fk_host_in_house_software_installs_user_id` (`user_id`), + KEY `fk_host_in_house_software_installs_in_house_app_id` (`in_house_app_id`), + KEY `idx_host_in_house_software_installs_verification` ((((`verification_at` is null) and (`verification_failed_at` is null)))), + CONSTRAINT `fk_host_in_house_software_installs_in_house_app_id` FOREIGN KEY (`in_house_app_id`) REFERENCES `in_house_apps` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_host_in_house_software_installs_user_id` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `host_issues` ( `host_id` int unsigned NOT NULL, `failing_policies_count` int unsigned NOT NULL DEFAULT '0', @@ -1067,6 +1092,58 @@ CREATE TABLE `hosts` ( /*!40101 SET character_set_client = @saved_cs_client */; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `in_house_app_labels` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `in_house_app_id` int unsigned NOT NULL, + `label_id` int unsigned NOT NULL, + `exclude` tinyint(1) NOT NULL DEFAULT '0', + `created_at` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + `updated_at` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + PRIMARY KEY (`id`), + UNIQUE KEY `id_in_house_app_labels_in_house_app_id_label_id` (`in_house_app_id`,`label_id`), + KEY `label_id` (`label_id`), + CONSTRAINT `in_house_app_labels_ibfk_1` FOREIGN KEY (`in_house_app_id`) REFERENCES `in_house_apps` (`id`) ON DELETE CASCADE, + CONSTRAINT `in_house_app_labels_ibfk_2` FOREIGN KEY (`label_id`) REFERENCES `labels` (`id`) ON DELETE RESTRICT +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `in_house_app_upcoming_activities` ( + `upcoming_activity_id` bigint unsigned NOT NULL, + `in_house_app_id` int unsigned NOT NULL, + `software_title_id` int unsigned DEFAULT NULL, + `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + `updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + PRIMARY KEY (`upcoming_activity_id`), + KEY `fk_in_house_app_upcoming_activities_in_house_app_id` (`in_house_app_id`), + KEY `fk_in_house_app_upcoming_activities_software_title_id` (`software_title_id`), + CONSTRAINT `fk_in_house_app_upcoming_activities_in_house_app_id` FOREIGN KEY (`in_house_app_id`) REFERENCES `in_house_apps` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_in_house_app_upcoming_activities_software_title_id` FOREIGN KEY (`software_title_id`) REFERENCES `software_titles` (`id`) ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT `fk_in_house_app_upcoming_activities_upcoming_activity_id` FOREIGN KEY (`upcoming_activity_id`) REFERENCES `upcoming_activities` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `in_house_apps` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `title_id` int unsigned DEFAULT NULL, + `team_id` int unsigned DEFAULT NULL, + `global_or_team_id` int unsigned NOT NULL DEFAULT '0', + `name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + `version` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + `storage_id` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `platform` varchar(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `bundle_identifier` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + PRIMARY KEY (`id`), + UNIQUE KEY `global_or_team_id` (`global_or_team_id`,`name`,`platform`), + KEY `fk_in_house_apps_title` (`title_id`), + CONSTRAINT `fk_in_house_apps_title` FOREIGN KEY (`title_id`) REFERENCES `software_titles` (`id`) ON DELETE SET NULL ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `invite_teams` ( `invite_id` int unsigned NOT NULL, `team_id` int unsigned NOT NULL, @@ -1561,9 +1638,9 @@ CREATE TABLE `migration_status_tables` ( `is_applied` tinyint(1) NOT NULL, `tstamp` timestamp NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) -) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB AUTO_INCREMENT=431 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB AUTO_INCREMENT=433 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; -INSERT INTO `migration_status_tables` VALUES (1,0,1,'2020-01-01 01:01:01'),(2,20161118193812,1,'2020-01-01 01:01:01'),(3,20161118211713,1,'2020-01-01 01:01:01'),(4,20161118212436,1,'2020-01-01 01:01:01'),(5,20161118212515,1,'2020-01-01 01:01:01'),(6,20161118212528,1,'2020-01-01 01:01:01'),(7,20161118212538,1,'2020-01-01 01:01:01'),(8,20161118212549,1,'2020-01-01 01:01:01'),(9,20161118212557,1,'2020-01-01 01:01:01'),(10,20161118212604,1,'2020-01-01 01:01:01'),(11,20161118212613,1,'2020-01-01 01:01:01'),(12,20161118212621,1,'2020-01-01 01:01:01'),(13,20161118212630,1,'2020-01-01 01:01:01'),(14,20161118212641,1,'2020-01-01 01:01:01'),(15,20161118212649,1,'2020-01-01 01:01:01'),(16,20161118212656,1,'2020-01-01 01:01:01'),(17,20161118212758,1,'2020-01-01 01:01:01'),(18,20161128234849,1,'2020-01-01 01:01:01'),(19,20161230162221,1,'2020-01-01 01:01:01'),(20,20170104113816,1,'2020-01-01 01:01:01'),(21,20170105151732,1,'2020-01-01 01:01:01'),(22,20170108191242,1,'2020-01-01 01:01:01'),(23,20170109094020,1,'2020-01-01 01:01:01'),(24,20170109130438,1,'2020-01-01 01:01:01'),(25,20170110202752,1,'2020-01-01 01:01:01'),(26,20170111133013,1,'2020-01-01 01:01:01'),(27,20170117025759,1,'2020-01-01 01:01:01'),(28,20170118191001,1,'2020-01-01 01:01:01'),(29,20170119234632,1,'2020-01-01 01:01:01'),(30,20170124230432,1,'2020-01-01 01:01:01'),(31,20170127014618,1,'2020-01-01 01:01:01'),(32,20170131232841,1,'2020-01-01 01:01:01'),(33,20170223094154,1,'2020-01-01 01:01:01'),(34,20170306075207,1,'2020-01-01 01:01:01'),(35,20170309100733,1,'2020-01-01 01:01:01'),(36,20170331111922,1,'2020-01-01 01:01:01'),(37,20170502143928,1,'2020-01-01 01:01:01'),(38,20170504130602,1,'2020-01-01 01:01:01'),(39,20170509132100,1,'2020-01-01 01:01:01'),(40,20170519105647,1,'2020-01-01 01:01:01'),(41,20170519105648,1,'2020-01-01 01:01:01'),(42,20170831234300,1,'2020-01-01 01:01:01'),(43,20170831234301,1,'2020-01-01 01:01:01'),(44,20170831234303,1,'2020-01-01 01:01:01'),(45,20171116163618,1,'2020-01-01 01:01:01'),(46,20171219164727,1,'2020-01-01 01:01:01'),(47,20180620164811,1,'2020-01-01 01:01:01'),(48,20180620175054,1,'2020-01-01 01:01:01'),(49,20180620175055,1,'2020-01-01 01:01:01'),(50,20191010101639,1,'2020-01-01 01:01:01'),(51,20191010155147,1,'2020-01-01 01:01:01'),(52,20191220130734,1,'2020-01-01 01:01:01'),(53,20200311140000,1,'2020-01-01 01:01:01'),(54,20200405120000,1,'2020-01-01 01:01:01'),(55,20200407120000,1,'2020-01-01 01:01:01'),(56,20200420120000,1,'2020-01-01 01:01:01'),(57,20200504120000,1,'2020-01-01 01:01:01'),(58,20200512120000,1,'2020-01-01 01:01:01'),(59,20200707120000,1,'2020-01-01 01:01:01'),(60,20201011162341,1,'2020-01-01 01:01:01'),(61,20201021104586,1,'2020-01-01 01:01:01'),(62,20201102112520,1,'2020-01-01 01:01:01'),(63,20201208121729,1,'2020-01-01 01:01:01'),(64,20201215091637,1,'2020-01-01 01:01:01'),(65,20210119174155,1,'2020-01-01 01:01:01'),(66,20210326182902,1,'2020-01-01 01:01:01'),(67,20210421112652,1,'2020-01-01 01:01:01'),(68,20210506095025,1,'2020-01-01 01:01:01'),(69,20210513115729,1,'2020-01-01 01:01:01'),(70,20210526113559,1,'2020-01-01 01:01:01'),(71,20210601000001,1,'2020-01-01 01:01:01'),(72,20210601000002,1,'2020-01-01 01:01:01'),(73,20210601000003,1,'2020-01-01 01:01:01'),(74,20210601000004,1,'2020-01-01 01:01:01'),(75,20210601000005,1,'2020-01-01 01:01:01'),(76,20210601000006,1,'2020-01-01 01:01:01'),(77,20210601000007,1,'2020-01-01 01:01:01'),(78,20210601000008,1,'2020-01-01 01:01:01'),(79,20210606151329,1,'2020-01-01 01:01:01'),(80,20210616163757,1,'2020-01-01 01:01:01'),(81,20210617174723,1,'2020-01-01 01:01:01'),(82,20210622160235,1,'2020-01-01 01:01:01'),(83,20210623100031,1,'2020-01-01 01:01:01'),(84,20210623133615,1,'2020-01-01 01:01:01'),(85,20210708143152,1,'2020-01-01 01:01:01'),(86,20210709124443,1,'2020-01-01 01:01:01'),(87,20210712155608,1,'2020-01-01 01:01:01'),(88,20210714102108,1,'2020-01-01 01:01:01'),(89,20210719153709,1,'2020-01-01 01:01:01'),(90,20210721171531,1,'2020-01-01 01:01:01'),(91,20210723135713,1,'2020-01-01 01:01:01'),(92,20210802135933,1,'2020-01-01 01:01:01'),(93,20210806112844,1,'2020-01-01 01:01:01'),(94,20210810095603,1,'2020-01-01 01:01:01'),(95,20210811150223,1,'2020-01-01 01:01:01'),(96,20210818151827,1,'2020-01-01 01:01:01'),(97,20210818151828,1,'2020-01-01 01:01:01'),(98,20210818182258,1,'2020-01-01 01:01:01'),(99,20210819131107,1,'2020-01-01 01:01:01'),(100,20210819143446,1,'2020-01-01 01:01:01'),(101,20210903132338,1,'2020-01-01 01:01:01'),(102,20210915144307,1,'2020-01-01 01:01:01'),(103,20210920155130,1,'2020-01-01 01:01:01'),(104,20210927143115,1,'2020-01-01 01:01:01'),(105,20210927143116,1,'2020-01-01 01:01:01'),(106,20211013133706,1,'2020-01-01 01:01:01'),(107,20211013133707,1,'2020-01-01 01:01:01'),(108,20211102135149,1,'2020-01-01 01:01:01'),(109,20211109121546,1,'2020-01-01 01:01:01'),(110,20211110163320,1,'2020-01-01 01:01:01'),(111,20211116184029,1,'2020-01-01 01:01:01'),(112,20211116184030,1,'2020-01-01 01:01:01'),(113,20211202092042,1,'2020-01-01 01:01:01'),(114,20211202181033,1,'2020-01-01 01:01:01'),(115,20211207161856,1,'2020-01-01 01:01:01'),(116,20211216131203,1,'2020-01-01 01:01:01'),(117,20211221110132,1,'2020-01-01 01:01:01'),(118,20220107155700,1,'2020-01-01 01:01:01'),(119,20220125105650,1,'2020-01-01 01:01:01'),(120,20220201084510,1,'2020-01-01 01:01:01'),(121,20220208144830,1,'2020-01-01 01:01:01'),(122,20220208144831,1,'2020-01-01 01:01:01'),(123,20220215152203,1,'2020-01-01 01:01:01'),(124,20220223113157,1,'2020-01-01 01:01:01'),(125,20220307104655,1,'2020-01-01 01:01:01'),(126,20220309133956,1,'2020-01-01 01:01:01'),(127,20220316155700,1,'2020-01-01 01:01:01'),(128,20220323152301,1,'2020-01-01 01:01:01'),(129,20220330100659,1,'2020-01-01 01:01:01'),(130,20220404091216,1,'2020-01-01 01:01:01'),(131,20220419140750,1,'2020-01-01 01:01:01'),(132,20220428140039,1,'2020-01-01 01:01:01'),(133,20220503134048,1,'2020-01-01 01:01:01'),(134,20220524102918,1,'2020-01-01 01:01:01'),(135,20220526123327,1,'2020-01-01 01:01:01'),(136,20220526123328,1,'2020-01-01 01:01:01'),(137,20220526123329,1,'2020-01-01 01:01:01'),(138,20220608113128,1,'2020-01-01 01:01:01'),(139,20220627104817,1,'2020-01-01 01:01:01'),(140,20220704101843,1,'2020-01-01 01:01:01'),(141,20220708095046,1,'2020-01-01 01:01:01'),(142,20220713091130,1,'2020-01-01 01:01:01'),(143,20220802135510,1,'2020-01-01 01:01:01'),(144,20220818101352,1,'2020-01-01 01:01:01'),(145,20220822161445,1,'2020-01-01 01:01:01'),(146,20220831100036,1,'2020-01-01 01:01:01'),(147,20220831100151,1,'2020-01-01 01:01:01'),(148,20220908181826,1,'2020-01-01 01:01:01'),(149,20220914154915,1,'2020-01-01 01:01:01'),(150,20220915165115,1,'2020-01-01 01:01:01'),(151,20220915165116,1,'2020-01-01 01:01:01'),(152,20220928100158,1,'2020-01-01 01:01:01'),(153,20221014084130,1,'2020-01-01 01:01:01'),(154,20221027085019,1,'2020-01-01 01:01:01'),(155,20221101103952,1,'2020-01-01 01:01:01'),(156,20221104144401,1,'2020-01-01 01:01:01'),(157,20221109100749,1,'2020-01-01 01:01:01'),(158,20221115104546,1,'2020-01-01 01:01:01'),(159,20221130114928,1,'2020-01-01 01:01:01'),(160,20221205112142,1,'2020-01-01 01:01:01'),(161,20221216115820,1,'2020-01-01 01:01:01'),(162,20221220195934,1,'2020-01-01 01:01:01'),(163,20221220195935,1,'2020-01-01 01:01:01'),(164,20221223174807,1,'2020-01-01 01:01:01'),(165,20221227163855,1,'2020-01-01 01:01:01'),(166,20221227163856,1,'2020-01-01 01:01:01'),(167,20230202224725,1,'2020-01-01 01:01:01'),(168,20230206163608,1,'2020-01-01 01:01:01'),(169,20230214131519,1,'2020-01-01 01:01:01'),(170,20230303135738,1,'2020-01-01 01:01:01'),(171,20230313135301,1,'2020-01-01 01:01:01'),(172,20230313141819,1,'2020-01-01 01:01:01'),(173,20230315104937,1,'2020-01-01 01:01:01'),(174,20230317173844,1,'2020-01-01 01:01:01'),(175,20230320133602,1,'2020-01-01 01:01:01'),(176,20230330100011,1,'2020-01-01 01:01:01'),(177,20230330134823,1,'2020-01-01 01:01:01'),(178,20230405232025,1,'2020-01-01 01:01:01'),(179,20230408084104,1,'2020-01-01 01:01:01'),(180,20230411102858,1,'2020-01-01 01:01:01'),(181,20230421155932,1,'2020-01-01 01:01:01'),(182,20230425082126,1,'2020-01-01 01:01:01'),(183,20230425105727,1,'2020-01-01 01:01:01'),(184,20230501154913,1,'2020-01-01 01:01:01'),(185,20230503101418,1,'2020-01-01 01:01:01'),(186,20230515144206,1,'2020-01-01 01:01:01'),(187,20230517140952,1,'2020-01-01 01:01:01'),(188,20230517152807,1,'2020-01-01 01:01:01'),(189,20230518114155,1,'2020-01-01 01:01:01'),(190,20230520153236,1,'2020-01-01 01:01:01'),(191,20230525151159,1,'2020-01-01 01:01:01'),(192,20230530122103,1,'2020-01-01 01:01:01'),(193,20230602111827,1,'2020-01-01 01:01:01'),(194,20230608103123,1,'2020-01-01 01:01:01'),(195,20230629140529,1,'2020-01-01 01:01:01'),(196,20230629140530,1,'2020-01-01 01:01:01'),(197,20230711144622,1,'2020-01-01 01:01:01'),(198,20230721135421,1,'2020-01-01 01:01:01'),(199,20230721161508,1,'2020-01-01 01:01:01'),(200,20230726115701,1,'2020-01-01 01:01:01'),(201,20230807100822,1,'2020-01-01 01:01:01'),(202,20230814150442,1,'2020-01-01 01:01:01'),(203,20230823122728,1,'2020-01-01 01:01:01'),(204,20230906152143,1,'2020-01-01 01:01:01'),(205,20230911163618,1,'2020-01-01 01:01:01'),(206,20230912101759,1,'2020-01-01 01:01:01'),(207,20230915101341,1,'2020-01-01 01:01:01'),(208,20230918132351,1,'2020-01-01 01:01:01'),(209,20231004144339,1,'2020-01-01 01:01:01'),(210,20231009094541,1,'2020-01-01 01:01:01'),(211,20231009094542,1,'2020-01-01 01:01:01'),(212,20231009094543,1,'2020-01-01 01:01:01'),(213,20231009094544,1,'2020-01-01 01:01:01'),(214,20231016091915,1,'2020-01-01 01:01:01'),(215,20231024174135,1,'2020-01-01 01:01:01'),(216,20231025120016,1,'2020-01-01 01:01:01'),(217,20231025160156,1,'2020-01-01 01:01:01'),(218,20231031165350,1,'2020-01-01 01:01:01'),(219,20231106144110,1,'2020-01-01 01:01:01'),(220,20231107130934,1,'2020-01-01 01:01:01'),(221,20231109115838,1,'2020-01-01 01:01:01'),(222,20231121054530,1,'2020-01-01 01:01:01'),(223,20231122101320,1,'2020-01-01 01:01:01'),(224,20231130132828,1,'2020-01-01 01:01:01'),(225,20231130132931,1,'2020-01-01 01:01:01'),(226,20231204155427,1,'2020-01-01 01:01:01'),(227,20231206142340,1,'2020-01-01 01:01:01'),(228,20231207102320,1,'2020-01-01 01:01:01'),(229,20231207102321,1,'2020-01-01 01:01:01'),(230,20231207133731,1,'2020-01-01 01:01:01'),(231,20231212094238,1,'2020-01-01 01:01:01'),(232,20231212095734,1,'2020-01-01 01:01:01'),(233,20231212161121,1,'2020-01-01 01:01:01'),(234,20231215122713,1,'2020-01-01 01:01:01'),(235,20231219143041,1,'2020-01-01 01:01:01'),(236,20231224070653,1,'2020-01-01 01:01:01'),(237,20240110134315,1,'2020-01-01 01:01:01'),(238,20240119091637,1,'2020-01-01 01:01:01'),(239,20240126020642,1,'2020-01-01 01:01:01'),(240,20240126020643,1,'2020-01-01 01:01:01'),(241,20240129162819,1,'2020-01-01 01:01:01'),(242,20240130115133,1,'2020-01-01 01:01:01'),(243,20240131083822,1,'2020-01-01 01:01:01'),(244,20240205095928,1,'2020-01-01 01:01:01'),(245,20240205121956,1,'2020-01-01 01:01:01'),(246,20240209110212,1,'2020-01-01 01:01:01'),(247,20240212111533,1,'2020-01-01 01:01:01'),(248,20240221112844,1,'2020-01-01 01:01:01'),(249,20240222073518,1,'2020-01-01 01:01:01'),(250,20240222135115,1,'2020-01-01 01:01:01'),(251,20240226082255,1,'2020-01-01 01:01:01'),(252,20240228082706,1,'2020-01-01 01:01:01'),(253,20240301173035,1,'2020-01-01 01:01:01'),(254,20240302111134,1,'2020-01-01 01:01:01'),(255,20240312103753,1,'2020-01-01 01:01:01'),(256,20240313143416,1,'2020-01-01 01:01:01'),(257,20240314085226,1,'2020-01-01 01:01:01'),(258,20240314151747,1,'2020-01-01 01:01:01'),(259,20240320145650,1,'2020-01-01 01:01:01'),(260,20240327115530,1,'2020-01-01 01:01:01'),(261,20240327115617,1,'2020-01-01 01:01:01'),(262,20240408085837,1,'2020-01-01 01:01:01'),(263,20240415104633,1,'2020-01-01 01:01:01'),(264,20240430111727,1,'2020-01-01 01:01:01'),(265,20240515200020,1,'2020-01-01 01:01:01'),(266,20240521143023,1,'2020-01-01 01:01:01'),(267,20240521143024,1,'2020-01-01 01:01:01'),(268,20240601174138,1,'2020-01-01 01:01:01'),(269,20240607133721,1,'2020-01-01 01:01:01'),(270,20240612150059,1,'2020-01-01 01:01:01'),(271,20240613162201,1,'2020-01-01 01:01:01'),(272,20240613172616,1,'2020-01-01 01:01:01'),(273,20240618142419,1,'2020-01-01 01:01:01'),(274,20240625093543,1,'2020-01-01 01:01:01'),(275,20240626195531,1,'2020-01-01 01:01:01'),(276,20240702123921,1,'2020-01-01 01:01:01'),(277,20240703154849,1,'2020-01-01 01:01:01'),(278,20240707134035,1,'2020-01-01 01:01:01'),(279,20240707134036,1,'2020-01-01 01:01:01'),(280,20240709124958,1,'2020-01-01 01:01:01'),(281,20240709132642,1,'2020-01-01 01:01:01'),(282,20240709183940,1,'2020-01-01 01:01:01'),(283,20240710155623,1,'2020-01-01 01:01:01'),(284,20240723102712,1,'2020-01-01 01:01:01'),(285,20240725152735,1,'2020-01-01 01:01:01'),(286,20240725182118,1,'2020-01-01 01:01:01'),(287,20240726100517,1,'2020-01-01 01:01:01'),(288,20240730171504,1,'2020-01-01 01:01:01'),(289,20240730174056,1,'2020-01-01 01:01:01'),(290,20240730215453,1,'2020-01-01 01:01:01'),(291,20240730374423,1,'2020-01-01 01:01:01'),(292,20240801115359,1,'2020-01-01 01:01:01'),(293,20240802101043,1,'2020-01-01 01:01:01'),(294,20240802113716,1,'2020-01-01 01:01:01'),(295,20240814135330,1,'2020-01-01 01:01:01'),(296,20240815000000,1,'2020-01-01 01:01:01'),(297,20240815000001,1,'2020-01-01 01:01:01'),(298,20240816103247,1,'2020-01-01 01:01:01'),(299,20240820091218,1,'2020-01-01 01:01:01'),(300,20240826111228,1,'2020-01-01 01:01:01'),(301,20240826160025,1,'2020-01-01 01:01:01'),(302,20240829165448,1,'2020-01-01 01:01:01'),(303,20240829165605,1,'2020-01-01 01:01:01'),(304,20240829165715,1,'2020-01-01 01:01:01'),(305,20240829165930,1,'2020-01-01 01:01:01'),(306,20240829170023,1,'2020-01-01 01:01:01'),(307,20240829170033,1,'2020-01-01 01:01:01'),(308,20240829170044,1,'2020-01-01 01:01:01'),(309,20240905105135,1,'2020-01-01 01:01:01'),(310,20240905140514,1,'2020-01-01 01:01:01'),(311,20240905200000,1,'2020-01-01 01:01:01'),(312,20240905200001,1,'2020-01-01 01:01:01'),(313,20241002104104,1,'2020-01-01 01:01:01'),(314,20241002104105,1,'2020-01-01 01:01:01'),(315,20241002104106,1,'2020-01-01 01:01:01'),(316,20241002210000,1,'2020-01-01 01:01:01'),(317,20241003145349,1,'2020-01-01 01:01:01'),(318,20241004005000,1,'2020-01-01 01:01:01'),(319,20241008083925,1,'2020-01-01 01:01:01'),(320,20241009090010,1,'2020-01-01 01:01:01'),(321,20241017163402,1,'2020-01-01 01:01:01'),(322,20241021224359,1,'2020-01-01 01:01:01'),(323,20241022140321,1,'2020-01-01 01:01:01'),(324,20241025111236,1,'2020-01-01 01:01:01'),(325,20241025112748,1,'2020-01-01 01:01:01'),(326,20241025141855,1,'2020-01-01 01:01:01'),(327,20241110152839,1,'2020-01-01 01:01:01'),(328,20241110152840,1,'2020-01-01 01:01:01'),(329,20241110152841,1,'2020-01-01 01:01:01'),(330,20241116233322,1,'2020-01-01 01:01:01'),(331,20241122171434,1,'2020-01-01 01:01:01'),(332,20241125150614,1,'2020-01-01 01:01:01'),(333,20241203125346,1,'2020-01-01 01:01:01'),(334,20241203130032,1,'2020-01-01 01:01:01'),(335,20241205122800,1,'2020-01-01 01:01:01'),(336,20241209164540,1,'2020-01-01 01:01:01'),(337,20241210140021,1,'2020-01-01 01:01:01'),(338,20241219180042,1,'2020-01-01 01:01:01'),(339,20241220100000,1,'2020-01-01 01:01:01'),(340,20241220114903,1,'2020-01-01 01:01:01'),(341,20241220114904,1,'2020-01-01 01:01:01'),(342,20241224000000,1,'2020-01-01 01:01:01'),(343,20241230000000,1,'2020-01-01 01:01:01'),(344,20241231112624,1,'2020-01-01 01:01:01'),(345,20250102121439,1,'2020-01-01 01:01:01'),(346,20250121094045,1,'2020-01-01 01:01:01'),(347,20250121094500,1,'2020-01-01 01:01:01'),(348,20250121094600,1,'2020-01-01 01:01:01'),(349,20250121094700,1,'2020-01-01 01:01:01'),(350,20250124194347,1,'2020-01-01 01:01:01'),(351,20250127162751,1,'2020-01-01 01:01:01'),(352,20250213104005,1,'2020-01-01 01:01:01'),(353,20250214205657,1,'2020-01-01 01:01:01'),(354,20250217093329,1,'2020-01-01 01:01:01'),(355,20250219090511,1,'2020-01-01 01:01:01'),(356,20250219100000,1,'2020-01-01 01:01:01'),(357,20250219142401,1,'2020-01-01 01:01:01'),(358,20250224184002,1,'2020-01-01 01:01:01'),(359,20250225085436,1,'2020-01-01 01:01:01'),(360,20250226000000,1,'2020-01-01 01:01:01'),(361,20250226153445,1,'2020-01-01 01:01:01'),(362,20250304162702,1,'2020-01-01 01:01:01'),(363,20250306144233,1,'2020-01-01 01:01:01'),(364,20250313163430,1,'2020-01-01 01:01:01'),(365,20250317130944,1,'2020-01-01 01:01:01'),(366,20250318165922,1,'2020-01-01 01:01:01'),(367,20250320132525,1,'2020-01-01 01:01:01'),(368,20250320200000,1,'2020-01-01 01:01:01'),(369,20250326161930,1,'2020-01-01 01:01:01'),(370,20250326161931,1,'2020-01-01 01:01:01'),(371,20250331042354,1,'2020-01-01 01:01:01'),(372,20250331154206,1,'2020-01-01 01:01:01'),(373,20250401155831,1,'2020-01-01 01:01:01'),(374,20250408133233,1,'2020-01-01 01:01:01'),(375,20250410104321,1,'2020-01-01 01:01:01'),(376,20250421085116,1,'2020-01-01 01:01:01'),(377,20250422095806,1,'2020-01-01 01:01:01'),(378,20250424153059,1,'2020-01-01 01:01:01'),(379,20250430103833,1,'2020-01-01 01:01:01'),(380,20250430112622,1,'2020-01-01 01:01:01'),(381,20250501162727,1,'2020-01-01 01:01:01'),(382,20250502154517,1,'2020-01-01 01:01:01'),(383,20250502222222,1,'2020-01-01 01:01:01'),(384,20250507170845,1,'2020-01-01 01:01:01'),(385,20250513162912,1,'2020-01-01 01:01:01'),(386,20250519161614,1,'2020-01-01 01:01:01'),(387,20250519170000,1,'2020-01-01 01:01:01'),(388,20250520153848,1,'2020-01-01 01:01:01'),(389,20250528115932,1,'2020-01-01 01:01:01'),(390,20250529102706,1,'2020-01-01 01:01:01'),(391,20250603105558,1,'2020-01-01 01:01:01'),(392,20250609102714,1,'2020-01-01 01:01:01'),(393,20250609112613,1,'2020-01-01 01:01:01'),(394,20250613103810,1,'2020-01-01 01:01:01'),(395,20250616193950,1,'2020-01-01 01:01:01'),(396,20250624140757,1,'2020-01-01 01:01:01'),(397,20250626130239,1,'2020-01-01 01:01:01'),(398,20250629131032,1,'2020-01-01 01:01:01'),(399,20250701155654,1,'2020-01-01 01:01:01'),(400,20250707095725,1,'2020-01-01 01:01:01'),(401,20250716152435,1,'2020-01-01 01:01:01'),(402,20250718091828,1,'2020-01-01 01:01:01'),(403,20250728122229,1,'2020-01-01 01:01:01'),(404,20250731122715,1,'2020-01-01 01:01:01'),(405,20250731151000,1,'2020-01-01 01:01:01'),(406,20250803000000,1,'2020-01-01 01:01:01'),(407,20250805083116,1,'2020-01-01 01:01:01'),(408,20250807140441,1,'2020-01-01 01:01:01'),(409,20250808000000,1,'2020-01-01 01:01:01'),(410,20250811155036,1,'2020-01-01 01:01:01'),(411,20250813205039,1,'2020-01-01 01:01:01'),(412,20250814123333,1,'2020-01-01 01:01:01'),(413,20250815130115,1,'2020-01-01 01:01:01'),(414,20250816115553,1,'2020-01-01 01:01:01'),(415,20250817154557,1,'2020-01-01 01:01:01'),(416,20250825113751,1,'2020-01-01 01:01:01'),(417,20250827113140,1,'2020-01-01 01:01:01'),(418,20250828120836,1,'2020-01-01 01:01:01'),(419,20250902112642,1,'2020-01-01 01:01:01'),(420,20250904091745,1,'2020-01-01 01:01:01'),(421,20250905090000,1,'2020-01-01 01:01:01'),(422,20250922083056,1,'2020-01-01 01:01:01'),(423,20250923120000,1,'2020-01-01 01:01:01'),(424,20250926123048,1,'2020-01-01 01:01:01'),(425,20251015103505,1,'2020-01-01 01:01:01'),(426,20251015103600,1,'2020-01-01 01:01:01'),(427,20251015103700,1,'2020-01-01 01:01:01'),(428,20251015103800,1,'2020-01-01 01:01:01'),(429,20251015103900,1,'2020-01-01 01:01:01'),(430,20251022123456,1,'2020-01-01 01:01:01'); +INSERT INTO `migration_status_tables` VALUES (1,0,1,'2020-01-01 01:01:01'),(2,20161118193812,1,'2020-01-01 01:01:01'),(3,20161118211713,1,'2020-01-01 01:01:01'),(4,20161118212436,1,'2020-01-01 01:01:01'),(5,20161118212515,1,'2020-01-01 01:01:01'),(6,20161118212528,1,'2020-01-01 01:01:01'),(7,20161118212538,1,'2020-01-01 01:01:01'),(8,20161118212549,1,'2020-01-01 01:01:01'),(9,20161118212557,1,'2020-01-01 01:01:01'),(10,20161118212604,1,'2020-01-01 01:01:01'),(11,20161118212613,1,'2020-01-01 01:01:01'),(12,20161118212621,1,'2020-01-01 01:01:01'),(13,20161118212630,1,'2020-01-01 01:01:01'),(14,20161118212641,1,'2020-01-01 01:01:01'),(15,20161118212649,1,'2020-01-01 01:01:01'),(16,20161118212656,1,'2020-01-01 01:01:01'),(17,20161118212758,1,'2020-01-01 01:01:01'),(18,20161128234849,1,'2020-01-01 01:01:01'),(19,20161230162221,1,'2020-01-01 01:01:01'),(20,20170104113816,1,'2020-01-01 01:01:01'),(21,20170105151732,1,'2020-01-01 01:01:01'),(22,20170108191242,1,'2020-01-01 01:01:01'),(23,20170109094020,1,'2020-01-01 01:01:01'),(24,20170109130438,1,'2020-01-01 01:01:01'),(25,20170110202752,1,'2020-01-01 01:01:01'),(26,20170111133013,1,'2020-01-01 01:01:01'),(27,20170117025759,1,'2020-01-01 01:01:01'),(28,20170118191001,1,'2020-01-01 01:01:01'),(29,20170119234632,1,'2020-01-01 01:01:01'),(30,20170124230432,1,'2020-01-01 01:01:01'),(31,20170127014618,1,'2020-01-01 01:01:01'),(32,20170131232841,1,'2020-01-01 01:01:01'),(33,20170223094154,1,'2020-01-01 01:01:01'),(34,20170306075207,1,'2020-01-01 01:01:01'),(35,20170309100733,1,'2020-01-01 01:01:01'),(36,20170331111922,1,'2020-01-01 01:01:01'),(37,20170502143928,1,'2020-01-01 01:01:01'),(38,20170504130602,1,'2020-01-01 01:01:01'),(39,20170509132100,1,'2020-01-01 01:01:01'),(40,20170519105647,1,'2020-01-01 01:01:01'),(41,20170519105648,1,'2020-01-01 01:01:01'),(42,20170831234300,1,'2020-01-01 01:01:01'),(43,20170831234301,1,'2020-01-01 01:01:01'),(44,20170831234303,1,'2020-01-01 01:01:01'),(45,20171116163618,1,'2020-01-01 01:01:01'),(46,20171219164727,1,'2020-01-01 01:01:01'),(47,20180620164811,1,'2020-01-01 01:01:01'),(48,20180620175054,1,'2020-01-01 01:01:01'),(49,20180620175055,1,'2020-01-01 01:01:01'),(50,20191010101639,1,'2020-01-01 01:01:01'),(51,20191010155147,1,'2020-01-01 01:01:01'),(52,20191220130734,1,'2020-01-01 01:01:01'),(53,20200311140000,1,'2020-01-01 01:01:01'),(54,20200405120000,1,'2020-01-01 01:01:01'),(55,20200407120000,1,'2020-01-01 01:01:01'),(56,20200420120000,1,'2020-01-01 01:01:01'),(57,20200504120000,1,'2020-01-01 01:01:01'),(58,20200512120000,1,'2020-01-01 01:01:01'),(59,20200707120000,1,'2020-01-01 01:01:01'),(60,20201011162341,1,'2020-01-01 01:01:01'),(61,20201021104586,1,'2020-01-01 01:01:01'),(62,20201102112520,1,'2020-01-01 01:01:01'),(63,20201208121729,1,'2020-01-01 01:01:01'),(64,20201215091637,1,'2020-01-01 01:01:01'),(65,20210119174155,1,'2020-01-01 01:01:01'),(66,20210326182902,1,'2020-01-01 01:01:01'),(67,20210421112652,1,'2020-01-01 01:01:01'),(68,20210506095025,1,'2020-01-01 01:01:01'),(69,20210513115729,1,'2020-01-01 01:01:01'),(70,20210526113559,1,'2020-01-01 01:01:01'),(71,20210601000001,1,'2020-01-01 01:01:01'),(72,20210601000002,1,'2020-01-01 01:01:01'),(73,20210601000003,1,'2020-01-01 01:01:01'),(74,20210601000004,1,'2020-01-01 01:01:01'),(75,20210601000005,1,'2020-01-01 01:01:01'),(76,20210601000006,1,'2020-01-01 01:01:01'),(77,20210601000007,1,'2020-01-01 01:01:01'),(78,20210601000008,1,'2020-01-01 01:01:01'),(79,20210606151329,1,'2020-01-01 01:01:01'),(80,20210616163757,1,'2020-01-01 01:01:01'),(81,20210617174723,1,'2020-01-01 01:01:01'),(82,20210622160235,1,'2020-01-01 01:01:01'),(83,20210623100031,1,'2020-01-01 01:01:01'),(84,20210623133615,1,'2020-01-01 01:01:01'),(85,20210708143152,1,'2020-01-01 01:01:01'),(86,20210709124443,1,'2020-01-01 01:01:01'),(87,20210712155608,1,'2020-01-01 01:01:01'),(88,20210714102108,1,'2020-01-01 01:01:01'),(89,20210719153709,1,'2020-01-01 01:01:01'),(90,20210721171531,1,'2020-01-01 01:01:01'),(91,20210723135713,1,'2020-01-01 01:01:01'),(92,20210802135933,1,'2020-01-01 01:01:01'),(93,20210806112844,1,'2020-01-01 01:01:01'),(94,20210810095603,1,'2020-01-01 01:01:01'),(95,20210811150223,1,'2020-01-01 01:01:01'),(96,20210818151827,1,'2020-01-01 01:01:01'),(97,20210818151828,1,'2020-01-01 01:01:01'),(98,20210818182258,1,'2020-01-01 01:01:01'),(99,20210819131107,1,'2020-01-01 01:01:01'),(100,20210819143446,1,'2020-01-01 01:01:01'),(101,20210903132338,1,'2020-01-01 01:01:01'),(102,20210915144307,1,'2020-01-01 01:01:01'),(103,20210920155130,1,'2020-01-01 01:01:01'),(104,20210927143115,1,'2020-01-01 01:01:01'),(105,20210927143116,1,'2020-01-01 01:01:01'),(106,20211013133706,1,'2020-01-01 01:01:01'),(107,20211013133707,1,'2020-01-01 01:01:01'),(108,20211102135149,1,'2020-01-01 01:01:01'),(109,20211109121546,1,'2020-01-01 01:01:01'),(110,20211110163320,1,'2020-01-01 01:01:01'),(111,20211116184029,1,'2020-01-01 01:01:01'),(112,20211116184030,1,'2020-01-01 01:01:01'),(113,20211202092042,1,'2020-01-01 01:01:01'),(114,20211202181033,1,'2020-01-01 01:01:01'),(115,20211207161856,1,'2020-01-01 01:01:01'),(116,20211216131203,1,'2020-01-01 01:01:01'),(117,20211221110132,1,'2020-01-01 01:01:01'),(118,20220107155700,1,'2020-01-01 01:01:01'),(119,20220125105650,1,'2020-01-01 01:01:01'),(120,20220201084510,1,'2020-01-01 01:01:01'),(121,20220208144830,1,'2020-01-01 01:01:01'),(122,20220208144831,1,'2020-01-01 01:01:01'),(123,20220215152203,1,'2020-01-01 01:01:01'),(124,20220223113157,1,'2020-01-01 01:01:01'),(125,20220307104655,1,'2020-01-01 01:01:01'),(126,20220309133956,1,'2020-01-01 01:01:01'),(127,20220316155700,1,'2020-01-01 01:01:01'),(128,20220323152301,1,'2020-01-01 01:01:01'),(129,20220330100659,1,'2020-01-01 01:01:01'),(130,20220404091216,1,'2020-01-01 01:01:01'),(131,20220419140750,1,'2020-01-01 01:01:01'),(132,20220428140039,1,'2020-01-01 01:01:01'),(133,20220503134048,1,'2020-01-01 01:01:01'),(134,20220524102918,1,'2020-01-01 01:01:01'),(135,20220526123327,1,'2020-01-01 01:01:01'),(136,20220526123328,1,'2020-01-01 01:01:01'),(137,20220526123329,1,'2020-01-01 01:01:01'),(138,20220608113128,1,'2020-01-01 01:01:01'),(139,20220627104817,1,'2020-01-01 01:01:01'),(140,20220704101843,1,'2020-01-01 01:01:01'),(141,20220708095046,1,'2020-01-01 01:01:01'),(142,20220713091130,1,'2020-01-01 01:01:01'),(143,20220802135510,1,'2020-01-01 01:01:01'),(144,20220818101352,1,'2020-01-01 01:01:01'),(145,20220822161445,1,'2020-01-01 01:01:01'),(146,20220831100036,1,'2020-01-01 01:01:01'),(147,20220831100151,1,'2020-01-01 01:01:01'),(148,20220908181826,1,'2020-01-01 01:01:01'),(149,20220914154915,1,'2020-01-01 01:01:01'),(150,20220915165115,1,'2020-01-01 01:01:01'),(151,20220915165116,1,'2020-01-01 01:01:01'),(152,20220928100158,1,'2020-01-01 01:01:01'),(153,20221014084130,1,'2020-01-01 01:01:01'),(154,20221027085019,1,'2020-01-01 01:01:01'),(155,20221101103952,1,'2020-01-01 01:01:01'),(156,20221104144401,1,'2020-01-01 01:01:01'),(157,20221109100749,1,'2020-01-01 01:01:01'),(158,20221115104546,1,'2020-01-01 01:01:01'),(159,20221130114928,1,'2020-01-01 01:01:01'),(160,20221205112142,1,'2020-01-01 01:01:01'),(161,20221216115820,1,'2020-01-01 01:01:01'),(162,20221220195934,1,'2020-01-01 01:01:01'),(163,20221220195935,1,'2020-01-01 01:01:01'),(164,20221223174807,1,'2020-01-01 01:01:01'),(165,20221227163855,1,'2020-01-01 01:01:01'),(166,20221227163856,1,'2020-01-01 01:01:01'),(167,20230202224725,1,'2020-01-01 01:01:01'),(168,20230206163608,1,'2020-01-01 01:01:01'),(169,20230214131519,1,'2020-01-01 01:01:01'),(170,20230303135738,1,'2020-01-01 01:01:01'),(171,20230313135301,1,'2020-01-01 01:01:01'),(172,20230313141819,1,'2020-01-01 01:01:01'),(173,20230315104937,1,'2020-01-01 01:01:01'),(174,20230317173844,1,'2020-01-01 01:01:01'),(175,20230320133602,1,'2020-01-01 01:01:01'),(176,20230330100011,1,'2020-01-01 01:01:01'),(177,20230330134823,1,'2020-01-01 01:01:01'),(178,20230405232025,1,'2020-01-01 01:01:01'),(179,20230408084104,1,'2020-01-01 01:01:01'),(180,20230411102858,1,'2020-01-01 01:01:01'),(181,20230421155932,1,'2020-01-01 01:01:01'),(182,20230425082126,1,'2020-01-01 01:01:01'),(183,20230425105727,1,'2020-01-01 01:01:01'),(184,20230501154913,1,'2020-01-01 01:01:01'),(185,20230503101418,1,'2020-01-01 01:01:01'),(186,20230515144206,1,'2020-01-01 01:01:01'),(187,20230517140952,1,'2020-01-01 01:01:01'),(188,20230517152807,1,'2020-01-01 01:01:01'),(189,20230518114155,1,'2020-01-01 01:01:01'),(190,20230520153236,1,'2020-01-01 01:01:01'),(191,20230525151159,1,'2020-01-01 01:01:01'),(192,20230530122103,1,'2020-01-01 01:01:01'),(193,20230602111827,1,'2020-01-01 01:01:01'),(194,20230608103123,1,'2020-01-01 01:01:01'),(195,20230629140529,1,'2020-01-01 01:01:01'),(196,20230629140530,1,'2020-01-01 01:01:01'),(197,20230711144622,1,'2020-01-01 01:01:01'),(198,20230721135421,1,'2020-01-01 01:01:01'),(199,20230721161508,1,'2020-01-01 01:01:01'),(200,20230726115701,1,'2020-01-01 01:01:01'),(201,20230807100822,1,'2020-01-01 01:01:01'),(202,20230814150442,1,'2020-01-01 01:01:01'),(203,20230823122728,1,'2020-01-01 01:01:01'),(204,20230906152143,1,'2020-01-01 01:01:01'),(205,20230911163618,1,'2020-01-01 01:01:01'),(206,20230912101759,1,'2020-01-01 01:01:01'),(207,20230915101341,1,'2020-01-01 01:01:01'),(208,20230918132351,1,'2020-01-01 01:01:01'),(209,20231004144339,1,'2020-01-01 01:01:01'),(210,20231009094541,1,'2020-01-01 01:01:01'),(211,20231009094542,1,'2020-01-01 01:01:01'),(212,20231009094543,1,'2020-01-01 01:01:01'),(213,20231009094544,1,'2020-01-01 01:01:01'),(214,20231016091915,1,'2020-01-01 01:01:01'),(215,20231024174135,1,'2020-01-01 01:01:01'),(216,20231025120016,1,'2020-01-01 01:01:01'),(217,20231025160156,1,'2020-01-01 01:01:01'),(218,20231031165350,1,'2020-01-01 01:01:01'),(219,20231106144110,1,'2020-01-01 01:01:01'),(220,20231107130934,1,'2020-01-01 01:01:01'),(221,20231109115838,1,'2020-01-01 01:01:01'),(222,20231121054530,1,'2020-01-01 01:01:01'),(223,20231122101320,1,'2020-01-01 01:01:01'),(224,20231130132828,1,'2020-01-01 01:01:01'),(225,20231130132931,1,'2020-01-01 01:01:01'),(226,20231204155427,1,'2020-01-01 01:01:01'),(227,20231206142340,1,'2020-01-01 01:01:01'),(228,20231207102320,1,'2020-01-01 01:01:01'),(229,20231207102321,1,'2020-01-01 01:01:01'),(230,20231207133731,1,'2020-01-01 01:01:01'),(231,20231212094238,1,'2020-01-01 01:01:01'),(232,20231212095734,1,'2020-01-01 01:01:01'),(233,20231212161121,1,'2020-01-01 01:01:01'),(234,20231215122713,1,'2020-01-01 01:01:01'),(235,20231219143041,1,'2020-01-01 01:01:01'),(236,20231224070653,1,'2020-01-01 01:01:01'),(237,20240110134315,1,'2020-01-01 01:01:01'),(238,20240119091637,1,'2020-01-01 01:01:01'),(239,20240126020642,1,'2020-01-01 01:01:01'),(240,20240126020643,1,'2020-01-01 01:01:01'),(241,20240129162819,1,'2020-01-01 01:01:01'),(242,20240130115133,1,'2020-01-01 01:01:01'),(243,20240131083822,1,'2020-01-01 01:01:01'),(244,20240205095928,1,'2020-01-01 01:01:01'),(245,20240205121956,1,'2020-01-01 01:01:01'),(246,20240209110212,1,'2020-01-01 01:01:01'),(247,20240212111533,1,'2020-01-01 01:01:01'),(248,20240221112844,1,'2020-01-01 01:01:01'),(249,20240222073518,1,'2020-01-01 01:01:01'),(250,20240222135115,1,'2020-01-01 01:01:01'),(251,20240226082255,1,'2020-01-01 01:01:01'),(252,20240228082706,1,'2020-01-01 01:01:01'),(253,20240301173035,1,'2020-01-01 01:01:01'),(254,20240302111134,1,'2020-01-01 01:01:01'),(255,20240312103753,1,'2020-01-01 01:01:01'),(256,20240313143416,1,'2020-01-01 01:01:01'),(257,20240314085226,1,'2020-01-01 01:01:01'),(258,20240314151747,1,'2020-01-01 01:01:01'),(259,20240320145650,1,'2020-01-01 01:01:01'),(260,20240327115530,1,'2020-01-01 01:01:01'),(261,20240327115617,1,'2020-01-01 01:01:01'),(262,20240408085837,1,'2020-01-01 01:01:01'),(263,20240415104633,1,'2020-01-01 01:01:01'),(264,20240430111727,1,'2020-01-01 01:01:01'),(265,20240515200020,1,'2020-01-01 01:01:01'),(266,20240521143023,1,'2020-01-01 01:01:01'),(267,20240521143024,1,'2020-01-01 01:01:01'),(268,20240601174138,1,'2020-01-01 01:01:01'),(269,20240607133721,1,'2020-01-01 01:01:01'),(270,20240612150059,1,'2020-01-01 01:01:01'),(271,20240613162201,1,'2020-01-01 01:01:01'),(272,20240613172616,1,'2020-01-01 01:01:01'),(273,20240618142419,1,'2020-01-01 01:01:01'),(274,20240625093543,1,'2020-01-01 01:01:01'),(275,20240626195531,1,'2020-01-01 01:01:01'),(276,20240702123921,1,'2020-01-01 01:01:01'),(277,20240703154849,1,'2020-01-01 01:01:01'),(278,20240707134035,1,'2020-01-01 01:01:01'),(279,20240707134036,1,'2020-01-01 01:01:01'),(280,20240709124958,1,'2020-01-01 01:01:01'),(281,20240709132642,1,'2020-01-01 01:01:01'),(282,20240709183940,1,'2020-01-01 01:01:01'),(283,20240710155623,1,'2020-01-01 01:01:01'),(284,20240723102712,1,'2020-01-01 01:01:01'),(285,20240725152735,1,'2020-01-01 01:01:01'),(286,20240725182118,1,'2020-01-01 01:01:01'),(287,20240726100517,1,'2020-01-01 01:01:01'),(288,20240730171504,1,'2020-01-01 01:01:01'),(289,20240730174056,1,'2020-01-01 01:01:01'),(290,20240730215453,1,'2020-01-01 01:01:01'),(291,20240730374423,1,'2020-01-01 01:01:01'),(292,20240801115359,1,'2020-01-01 01:01:01'),(293,20240802101043,1,'2020-01-01 01:01:01'),(294,20240802113716,1,'2020-01-01 01:01:01'),(295,20240814135330,1,'2020-01-01 01:01:01'),(296,20240815000000,1,'2020-01-01 01:01:01'),(297,20240815000001,1,'2020-01-01 01:01:01'),(298,20240816103247,1,'2020-01-01 01:01:01'),(299,20240820091218,1,'2020-01-01 01:01:01'),(300,20240826111228,1,'2020-01-01 01:01:01'),(301,20240826160025,1,'2020-01-01 01:01:01'),(302,20240829165448,1,'2020-01-01 01:01:01'),(303,20240829165605,1,'2020-01-01 01:01:01'),(304,20240829165715,1,'2020-01-01 01:01:01'),(305,20240829165930,1,'2020-01-01 01:01:01'),(306,20240829170023,1,'2020-01-01 01:01:01'),(307,20240829170033,1,'2020-01-01 01:01:01'),(308,20240829170044,1,'2020-01-01 01:01:01'),(309,20240905105135,1,'2020-01-01 01:01:01'),(310,20240905140514,1,'2020-01-01 01:01:01'),(311,20240905200000,1,'2020-01-01 01:01:01'),(312,20240905200001,1,'2020-01-01 01:01:01'),(313,20241002104104,1,'2020-01-01 01:01:01'),(314,20241002104105,1,'2020-01-01 01:01:01'),(315,20241002104106,1,'2020-01-01 01:01:01'),(316,20241002210000,1,'2020-01-01 01:01:01'),(317,20241003145349,1,'2020-01-01 01:01:01'),(318,20241004005000,1,'2020-01-01 01:01:01'),(319,20241008083925,1,'2020-01-01 01:01:01'),(320,20241009090010,1,'2020-01-01 01:01:01'),(321,20241017163402,1,'2020-01-01 01:01:01'),(322,20241021224359,1,'2020-01-01 01:01:01'),(323,20241022140321,1,'2020-01-01 01:01:01'),(324,20241025111236,1,'2020-01-01 01:01:01'),(325,20241025112748,1,'2020-01-01 01:01:01'),(326,20241025141855,1,'2020-01-01 01:01:01'),(327,20241110152839,1,'2020-01-01 01:01:01'),(328,20241110152840,1,'2020-01-01 01:01:01'),(329,20241110152841,1,'2020-01-01 01:01:01'),(330,20241116233322,1,'2020-01-01 01:01:01'),(331,20241122171434,1,'2020-01-01 01:01:01'),(332,20241125150614,1,'2020-01-01 01:01:01'),(333,20241203125346,1,'2020-01-01 01:01:01'),(334,20241203130032,1,'2020-01-01 01:01:01'),(335,20241205122800,1,'2020-01-01 01:01:01'),(336,20241209164540,1,'2020-01-01 01:01:01'),(337,20241210140021,1,'2020-01-01 01:01:01'),(338,20241219180042,1,'2020-01-01 01:01:01'),(339,20241220100000,1,'2020-01-01 01:01:01'),(340,20241220114903,1,'2020-01-01 01:01:01'),(341,20241220114904,1,'2020-01-01 01:01:01'),(342,20241224000000,1,'2020-01-01 01:01:01'),(343,20241230000000,1,'2020-01-01 01:01:01'),(344,20241231112624,1,'2020-01-01 01:01:01'),(345,20250102121439,1,'2020-01-01 01:01:01'),(346,20250121094045,1,'2020-01-01 01:01:01'),(347,20250121094500,1,'2020-01-01 01:01:01'),(348,20250121094600,1,'2020-01-01 01:01:01'),(349,20250121094700,1,'2020-01-01 01:01:01'),(350,20250124194347,1,'2020-01-01 01:01:01'),(351,20250127162751,1,'2020-01-01 01:01:01'),(352,20250213104005,1,'2020-01-01 01:01:01'),(353,20250214205657,1,'2020-01-01 01:01:01'),(354,20250217093329,1,'2020-01-01 01:01:01'),(355,20250219090511,1,'2020-01-01 01:01:01'),(356,20250219100000,1,'2020-01-01 01:01:01'),(357,20250219142401,1,'2020-01-01 01:01:01'),(358,20250224184002,1,'2020-01-01 01:01:01'),(359,20250225085436,1,'2020-01-01 01:01:01'),(360,20250226000000,1,'2020-01-01 01:01:01'),(361,20250226153445,1,'2020-01-01 01:01:01'),(362,20250304162702,1,'2020-01-01 01:01:01'),(363,20250306144233,1,'2020-01-01 01:01:01'),(364,20250313163430,1,'2020-01-01 01:01:01'),(365,20250317130944,1,'2020-01-01 01:01:01'),(366,20250318165922,1,'2020-01-01 01:01:01'),(367,20250320132525,1,'2020-01-01 01:01:01'),(368,20250320200000,1,'2020-01-01 01:01:01'),(369,20250326161930,1,'2020-01-01 01:01:01'),(370,20250326161931,1,'2020-01-01 01:01:01'),(371,20250331042354,1,'2020-01-01 01:01:01'),(372,20250331154206,1,'2020-01-01 01:01:01'),(373,20250401155831,1,'2020-01-01 01:01:01'),(374,20250408133233,1,'2020-01-01 01:01:01'),(375,20250410104321,1,'2020-01-01 01:01:01'),(376,20250421085116,1,'2020-01-01 01:01:01'),(377,20250422095806,1,'2020-01-01 01:01:01'),(378,20250424153059,1,'2020-01-01 01:01:01'),(379,20250430103833,1,'2020-01-01 01:01:01'),(380,20250430112622,1,'2020-01-01 01:01:01'),(381,20250501162727,1,'2020-01-01 01:01:01'),(382,20250502154517,1,'2020-01-01 01:01:01'),(383,20250502222222,1,'2020-01-01 01:01:01'),(384,20250507170845,1,'2020-01-01 01:01:01'),(385,20250513162912,1,'2020-01-01 01:01:01'),(386,20250519161614,1,'2020-01-01 01:01:01'),(387,20250519170000,1,'2020-01-01 01:01:01'),(388,20250520153848,1,'2020-01-01 01:01:01'),(389,20250528115932,1,'2020-01-01 01:01:01'),(390,20250529102706,1,'2020-01-01 01:01:01'),(391,20250603105558,1,'2020-01-01 01:01:01'),(392,20250609102714,1,'2020-01-01 01:01:01'),(393,20250609112613,1,'2020-01-01 01:01:01'),(394,20250613103810,1,'2020-01-01 01:01:01'),(395,20250616193950,1,'2020-01-01 01:01:01'),(396,20250624140757,1,'2020-01-01 01:01:01'),(397,20250626130239,1,'2020-01-01 01:01:01'),(398,20250629131032,1,'2020-01-01 01:01:01'),(399,20250701155654,1,'2020-01-01 01:01:01'),(400,20250707095725,1,'2020-01-01 01:01:01'),(401,20250716152435,1,'2020-01-01 01:01:01'),(402,20250718091828,1,'2020-01-01 01:01:01'),(403,20250728122229,1,'2020-01-01 01:01:01'),(404,20250731122715,1,'2020-01-01 01:01:01'),(405,20250731151000,1,'2020-01-01 01:01:01'),(406,20250803000000,1,'2020-01-01 01:01:01'),(407,20250805083116,1,'2020-01-01 01:01:01'),(408,20250807140441,1,'2020-01-01 01:01:01'),(409,20250808000000,1,'2020-01-01 01:01:01'),(410,20250811155036,1,'2020-01-01 01:01:01'),(411,20250813205039,1,'2020-01-01 01:01:01'),(412,20250814123333,1,'2020-01-01 01:01:01'),(413,20250815130115,1,'2020-01-01 01:01:01'),(414,20250816115553,1,'2020-01-01 01:01:01'),(415,20250817154557,1,'2020-01-01 01:01:01'),(416,20250825113751,1,'2020-01-01 01:01:01'),(417,20250827113140,1,'2020-01-01 01:01:01'),(418,20250828120836,1,'2020-01-01 01:01:01'),(419,20250902112642,1,'2020-01-01 01:01:01'),(420,20250904091745,1,'2020-01-01 01:01:01'),(421,20250905090000,1,'2020-01-01 01:01:01'),(422,20250922083056,1,'2020-01-01 01:01:01'),(423,20250923120000,1,'2020-01-01 01:01:01'),(424,20250926123048,1,'2020-01-01 01:01:01'),(425,20251015103505,1,'2020-01-01 01:01:01'),(426,20251015103600,1,'2020-01-01 01:01:01'),(427,20251015103700,1,'2020-01-01 01:01:01'),(428,20251015103800,1,'2020-01-01 01:01:01'),(429,20251015103900,1,'2020-01-01 01:01:01'),(430,20251022123456,1,'2020-01-01 01:01:01'),(431,20251027101151,1,'2020-01-01 01:01:01'),(432,20251027101155,1,'2020-01-01 01:01:01'); /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `mobile_device_management_solutions` ( @@ -2565,7 +2642,7 @@ CREATE TABLE `upcoming_activities` ( `priority` int NOT NULL DEFAULT '0', `user_id` int unsigned DEFAULT NULL, `fleet_initiated` tinyint(1) NOT NULL DEFAULT '0', - `activity_type` enum('script','software_install','software_uninstall','vpp_app_install') COLLATE utf8mb4_unicode_ci NOT NULL, + `activity_type` enum('script','software_install','software_uninstall','vpp_app_install','in_house_app_install') CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, `execution_id` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, `payload` json NOT NULL, `activated_at` datetime(6) DEFAULT NULL, @@ -2577,7 +2654,7 @@ CREATE TABLE `upcoming_activities` ( KEY `idx_upcoming_activities_host_id_activity_type` (`activity_type`,`host_id`), KEY `fk_upcoming_activities_user_id` (`user_id`), CONSTRAINT `fk_upcoming_activities_user_id` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE SET NULL -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; diff --git a/server/datastore/mysql/software.go b/server/datastore/mysql/software.go index 281e91ba3b..b47e010567 100644 --- a/server/datastore/mysql/software.go +++ b/server/datastore/mysql/software.go @@ -2143,8 +2143,9 @@ func (ds *Datastore) CleanupSoftwareTitles(ctx context.Context) error { DELETE st FROM software_titles st LEFT JOIN software s ON st.id = s.title_id LEFT JOIN software_installers si ON st.id = si.title_id + LEFT JOIN in_house_apps iha ON st.id = iha.title_id LEFT JOIN vpp_apps vap ON st.id = vap.title_id - WHERE s.title_id IS NULL AND si.title_id IS NULL AND vap.title_id IS NULL` + WHERE s.title_id IS NULL AND si.title_id IS NULL AND iha.title_id IS NULL AND vap.title_id IS NULL` res, err := ds.writer(ctx).ExecContext(ctx, deleteOrphanedSoftwareTitlesStmt) if err != nil { @@ -2482,6 +2483,10 @@ type hostSoftware struct { VPPAppVersion *string `db:"vpp_app_version"` VPPAppPlatform *string `db:"vpp_app_platform"` VPPAppIconURL *string `db:"vpp_app_icon_url"` + InHouseAppID *uint `db:"in_house_app_id"` + InHouseAppName *string `db:"in_house_app_name"` + InHouseAppPlatform *string `db:"in_house_app_platform"` + InHouseAppVersion *string `db:"in_house_app_version"` VulnerabilitiesList *string `db:"vulnerabilities_list"` SoftwareIDList *string `db:"software_id_list"` @@ -2494,6 +2499,10 @@ type hostSoftware struct { VPPAppVersionList *string `db:"vpp_app_version_list"` VPPAppPlatformList *string `db:"vpp_app_platform_list"` VPPAppIconUrlList *string `db:"vpp_app_icon_url_list"` + InHouseAppIDList *string `db:"in_house_app_id_list"` + InHouseAppNameList *string `db:"in_house_app_name_list"` + InHouseAppPlatformList *string `db:"in_house_app_platform_list"` + InHouseAppVersionList *string `db:"in_house_app_version_list"` } func hostInstalledSoftware(ds *Datastore, ctx context.Context, hostID uint) ([]*hostSoftware, error) { @@ -2831,7 +2840,7 @@ func filterSoftwareInstallersByLabel( return filteredbySoftwareTitleID, nil } -func filterVppAppsByLabel( +func filterVPPAppsByLabel( ds *Datastore, ctx context.Context, host *fleet.Host, @@ -2981,6 +2990,156 @@ func filterVppAppsByLabel( return filteredbyVppAppID, otherVppAppsInInventory, nil } +func filterInHouseAppsByLabel( + ds *Datastore, + ctx context.Context, + host *fleet.Host, + byInHouseID map[uint]*hostSoftware, + hostInHouseInstalledTitles map[uint]*hostSoftware, +) (map[uint]*hostSoftware, map[uint]*hostSoftware, error) { + filteredByInHouseID := make(map[uint]*hostSoftware, len(byInHouseID)) + otherInHouseAppsInInventory := make(map[uint]*hostSoftware, len(hostInHouseInstalledTitles)) + + // This is the list of in-house apps that are installed on the host by fleet or the user + // that we want to check are in scope or not + inHouseIDsToCheck := make([]uint, 0, len(byInHouseID)) + + for _, st := range byInHouseID { + inHouseIDsToCheck = append(inHouseIDsToCheck, *st.InHouseAppID) + } + for _, st := range hostInHouseInstalledTitles { + if st.InHouseAppID != nil { + inHouseIDsToCheck = append(inHouseIDsToCheck, *st.InHouseAppID) + } + } + + if len(inHouseIDsToCheck) == 0 { + return filteredByInHouseID, otherInHouseAppsInInventory, nil + } + + var globalOrTeamID uint + if host.TeamID != nil { + globalOrTeamID = *host.TeamID + } + + labelSQLFilter := ` + WITH no_labels AS ( + SELECT + iha.id AS in_house_app_id, + 0 AS count_installer_labels, + 0 AS count_host_labels, + 0 as count_host_updated_after_labels + FROM + in_house_apps iha + WHERE NOT EXISTS ( + SELECT 1 + FROM in_house_app_labels ihl + WHERE ihl.in_house_app_id = iha.id + ) + ), + include_any AS ( + SELECT + iha.id AS in_house_app_id, + COUNT(ihl.label_id) AS count_installer_labels, + COUNT(lm.label_id) AS count_host_labels, + 0 as count_host_updated_after_labels + FROM + in_house_apps iha + INNER JOIN in_house_app_labels ihl ON + ihl.in_house_app_id = iha.id AND ihl.exclude = 0 + LEFT JOIN label_membership lm ON + lm.label_id = ihl.label_id AND lm.host_id = :host_id + GROUP BY + iha.id + HAVING + count_installer_labels > 0 AND count_host_labels > 0 + ), + exclude_any AS ( + SELECT + iha.id AS in_house_app_id, + COUNT(ihl.label_id) AS count_installer_labels, + COUNT(lm.label_id) AS count_host_labels, + SUM( + CASE + WHEN lbl.created_at IS NOT NULL AND lbl.label_membership_type = 0 AND :host_label_updated_at >= lbl.created_at THEN 1 + WHEN lbl.created_at IS NOT NULL AND lbl.label_membership_type = 1 THEN 1 + ELSE 0 + END + ) AS count_host_updated_after_labels + FROM + in_house_apps iha + INNER JOIN in_house_app_labels ihl ON + ihl.in_house_app_id = iha.id AND ihl.exclude = 1 + INNER JOIN labels lbl ON + lbl.id = ihl.label_id + LEFT OUTER JOIN label_membership lm ON + lm.label_id = ihl.label_id AND lm.host_id = :host_id + GROUP BY + iha.id + HAVING + count_installer_labels > 0 AND + count_installer_labels = count_host_updated_after_labels AND + count_host_labels = 0 + ) + SELECT + iha.id AS in_house_id, + iha.title_id AS title_id + FROM + in_house_apps iha + LEFT JOIN no_labels + ON no_labels.in_house_app_id = iha.id + LEFT JOIN include_any + ON include_any.in_house_app_id = iha.id + LEFT JOIN exclude_any + ON exclude_any.in_house_app_id = iha.id + WHERE + iha.global_or_team_id = :global_or_team_id AND + iha.id IN (:in_house_ids) AND ( + no_labels.in_house_app_id IS NOT NULL OR + include_any.in_house_app_id IS NOT NULL OR + exclude_any.in_house_app_id IS NOT NULL + ) + ` + + labelSQLFilter, args, err := sqlx.Named(labelSQLFilter, map[string]any{ + "host_id": host.ID, + "host_label_updated_at": host.LabelUpdatedAt, + "in_house_ids": inHouseIDsToCheck, + "global_or_team_id": globalOrTeamID, + }) + if err != nil { + return nil, nil, ctxerr.Wrap(ctx, err, "filterInHouseAppsByLabel building named query args") + } + + labelSQLFilter, args, err = sqlx.In(labelSQLFilter, args...) + if err != nil { + return nil, nil, ctxerr.Wrap(ctx, err, "filterInHouseAppsByLabel building in query args") + } + + var validInHouseApps []struct { + InHouseID uint `db:"in_house_id"` + TitleID uint `db:"title_id"` + } + err = sqlx.SelectContext(ctx, ds.reader(ctx), &validInHouseApps, labelSQLFilter, args...) + if err != nil { + return nil, nil, ctxerr.Wrap(ctx, err, "filterInHouseAppsByLabel executing query") + } + + // differentiate between in-house apps that were installed by Fleet (show install details + + // ability to reinstall in self-service) vs. in-house apps that Fleet knows about but either + // weren't installed by Fleet or were installed by Fleet but are no longer in scope + // (treat as in inventory and not re-installable in self-service) + for _, validApp := range validInHouseApps { + if _, ok := byInHouseID[validApp.InHouseID]; ok { + filteredByInHouseID[validApp.InHouseID] = byInHouseID[validApp.InHouseID] + } else if installed, ok := hostInHouseInstalledTitles[validApp.TitleID]; ok { + otherInHouseAppsInInventory[validApp.InHouseID] = installed + } + } + + return filteredByInHouseID, otherInHouseAppsInInventory, nil +} + func hostVPPInstalls(ds *Datastore, ctx context.Context, hostID uint, globalOrTeamID uint, selfServiceOnly bool, isMDMEnrolled bool) ([]*hostSoftware, error) { var selfServiceFilter string if selfServiceOnly { @@ -3089,6 +3248,102 @@ func hostVPPInstalls(ds *Datastore, ctx context.Context, hostID uint, globalOrTe return vppInstalls, nil } +func hostInHouseInstalls(ds *Datastore, ctx context.Context, hostID uint, globalOrTeamID uint, selfServiceOnly bool, isMDMEnrolled bool) ([]*hostSoftware, error) { + installsStmt := fmt.Sprintf(` +( -- upcoming_in_house_install + SELECT + iha.title_id AS id, + ua.execution_id AS last_install_install_uuid, + ua.created_at AS last_install_installed_at, + ihua.in_house_app_id AS in_house_app_id, + iha.name AS in_house_app_name, + iha.platform AS in_house_app_platform, + iha.version AS in_house_app_version, + 'pending_install' AS status + FROM + upcoming_activities ua + INNER JOIN + in_house_app_upcoming_activities ihua ON ua.id = ihua.upcoming_activity_id + LEFT JOIN ( + upcoming_activities ua2 + INNER JOIN in_house_app_upcoming_activities ihua2 ON ua2.id = ihua2.upcoming_activity_id + ) ON ua.host_id = ua2.host_id AND + ihua.in_house_app_id = ihua2.in_house_app_id AND + ua.activity_type = ua2.activity_type AND + (ua2.priority < ua.priority OR ua2.created_at > ua.created_at) + INNER JOIN + in_house_apps iha ON ihua.in_house_app_id = iha.id + WHERE + ua.host_id = :host_id AND + ua.activity_type = 'in_house_app_install' AND + iha.global_or_team_id = :global_or_team_id AND + ua2.id IS NULL +) UNION ( + -- last_in_house_install + SELECT + iha.title_id AS id, + hihsi.command_uuid AS last_install_install_uuid, + hihsi.created_at AS last_install_installed_at, + hihsi.in_house_app_id AS in_house_app_id, + iha.name AS in_house_app_name, + iha.platform AS in_house_app_platform, + iha.version AS in_house_app_version, + -- inHouseAppHostStatusNamedQuery(hvsi, ncr, status) + %s + FROM + host_in_house_software_installs hihsi + LEFT JOIN + nano_command_results ncr ON ncr.command_uuid = hihsi.command_uuid + LEFT JOIN + host_in_house_software_installs hihsi2 ON hihsi.host_id = hihsi2.host_id AND + hihsi.in_house_app_id = hihsi2.in_house_app_id AND + hihsi2.removed = 0 AND + hihsi2.canceled = 0 AND + (hihsi.created_at < hihsi2.created_at OR (hihsi.created_at = hihsi2.created_at AND hihsi.id < hihsi2.id)) + INNER JOIN + in_house_apps iha ON hihsi.in_house_app_id = iha.id + WHERE + hihsi.host_id = :host_id AND + hihsi.removed = 0 AND + hihsi.canceled = 0 AND + hihsi2.id IS NULL AND + iha.global_or_team_id = :global_or_team_id AND + NOT EXISTS ( + SELECT 1 + FROM + upcoming_activities ua + INNER JOIN + in_house_app_upcoming_activities ihua ON ua.id = ihua.upcoming_activity_id + WHERE + ua.host_id = hihsi.host_id AND + ihua.in_house_app_id = hihsi.in_house_app_id AND + ua.activity_type = 'in_house_app_install' + ) +) +`, inHouseAppHostStatusNamedQuery("hihsi", "ncr", "status")) + + installsStmt, args, err := sqlx.Named(installsStmt, map[string]any{ + "host_id": hostID, + "global_or_team_id": globalOrTeamID, + "software_status_installed": fleet.SoftwareInstalled, + "mdm_status_acknowledged": fleet.MDMAppleStatusAcknowledged, + "mdm_status_error": fleet.MDMAppleStatusError, + "mdm_status_format_error": fleet.MDMAppleStatusCommandFormatError, + "software_status_failed": fleet.SoftwareInstallFailed, + "software_status_pending": fleet.SoftwareInstallPending, + }) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "build named query for host in-house installs") + } + var installs []*hostSoftware + err = sqlx.SelectContext(ctx, ds.reader(ctx), &installs, installsStmt, args...) + if err != nil { + return nil, err + } + + return installs, nil +} + func pushVersion(softwareIDStr string, softwareTitleRecord *hostSoftware, hostInstalledSoftware hostSoftware) { seperator := "," if softwareTitleRecord.SoftwareIDList == nil { @@ -3150,6 +3405,33 @@ func hostInstalledVpps(ds *Datastore, ctx context.Context, hostID uint) ([]*host return vppInstalled, nil } +func hostInstalledInHouses(ds *Datastore, ctx context.Context, hostID uint) ([]*hostSoftware, error) { + installedStmt := ` + SELECT + iha.title_id AS id, + hihsi.command_uuid AS last_install_install_uuid, + hihsi.created_at AS last_install_installed_at, + iha.id AS in_house_app_id, + iha.name AS in_house_app_name, + iha.version AS in_house_app_version, + iha.platform as in_house_app_platform, + 'installed' AS status + FROM + host_in_house_software_installs hihsi + INNER JOIN + in_house_apps iha ON hihsi.in_house_app_id = iha.id + WHERE + hihsi.host_id = ? + ` + var installed []*hostSoftware + err := sqlx.SelectContext(ctx, ds.reader(ctx), &installed, installedStmt, hostID) + if err != nil { + return nil, err + } + + return installed, nil +} + // hydrated is the base record from the db // it contains most of the information we need to return back, however, // we need to copy over the install/uninstall data from the softwareTitle we fetched @@ -3207,9 +3489,6 @@ func promoteSoftwareTitleVPPApp(softwareTitleRecord *hostSoftware) { Platform: platform, SelfService: softwareTitleRecord.VPPAppSelfService, } - if softwareTitleRecord.VPPAppPlatform != nil { - softwareTitleRecord.AppStoreApp.Platform = *softwareTitleRecord.VPPAppPlatform - } softwareTitleRecord.IconUrl = softwareTitleRecord.VPPAppIconURL // promote the last install info to the proper destination fields @@ -3223,6 +3502,33 @@ func promoteSoftwareTitleVPPApp(softwareTitleRecord *hostSoftware) { } } +// softwareTitleRecord is the base record, we will be modifying it +func promoteSoftwareTitleInHouseApp(softwareTitleRecord *hostSoftware) { + var version, platform string + if softwareTitleRecord.InHouseAppVersion != nil { + version = *softwareTitleRecord.InHouseAppVersion + } + if softwareTitleRecord.InHouseAppPlatform != nil { + platform = *softwareTitleRecord.InHouseAppPlatform + } + softwareTitleRecord.SoftwarePackage = &fleet.SoftwarePackageOrApp{ + Name: *softwareTitleRecord.InHouseAppName, + Version: version, + Platform: platform, + SelfService: ptr.Bool(false), // unsupported for in-house apps currently + } + + // promote the last install info to the proper destination fields + if softwareTitleRecord.LastInstallInstallUUID != nil && *softwareTitleRecord.LastInstallInstallUUID != "" { + softwareTitleRecord.SoftwarePackage.LastInstall = &fleet.HostSoftwareInstall{ + CommandUUID: *softwareTitleRecord.LastInstallInstallUUID, + } + if softwareTitleRecord.LastInstallInstalledAt != nil { + softwareTitleRecord.SoftwarePackage.LastInstall.InstalledAt = *softwareTitleRecord.LastInstallInstalledAt + } + } +} + func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, opts fleet.HostSoftwareTitleListOptions) ([]*fleet.HostSoftwareWithInstaller, *fleet.PaginationMetadata, error) { if !opts.VulnerableOnly && (opts.MinimumCVSS > 0 || opts.MaximumCVSS > 0 || opts.KnownExploit) { return nil, nil, fleet.NewInvalidArgumentError( @@ -3318,11 +3624,11 @@ func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, opt } hostInstalledSoftware, err := hostInstalledSoftware(ds, ctx, host.ID) - hostInstalledSoftwareTitleSet := make(map[uint]struct{}) - hostInstalledSoftwareSet := make(map[uint]*hostSoftware) if err != nil { return nil, nil, err } + hostInstalledSoftwareTitleSet := make(map[uint]struct{}) + hostInstalledSoftwareSet := make(map[uint]*hostSoftware) for _, pointerToSoftware := range hostInstalledSoftware { s := *pointerToSoftware if pointerToSoftware.LastOpenedAt != nil { @@ -3366,7 +3672,7 @@ func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, opt // Until then if the host_software record is not a software installer, we delete it and keep the vpp app if _, exists := hostInstalledSoftwareTitleSet[s.ID]; exists { installedTitle := bySoftwareTitleID[s.ID] - if installedTitle.InstallerID == nil { + if installedTitle != nil && installedTitle.InstallerID == nil { // not a software installer, so copy over // the installed title information s.LastOpenedAt = installedTitle.LastOpenedAt @@ -3390,6 +3696,43 @@ func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, opt } } + hostInHouseInstalls, err := hostInHouseInstalls(ds, ctx, host.ID, globalOrTeamID, opts.SelfServiceOnly, opts.IsMDMEnrolled) + if err != nil { + return nil, nil, err + } + byInHouseID := make(map[uint]*hostSoftware) + for _, s := range hostInHouseInstalls { + // If an in-house app is already installed on the host, we don't need to + // double count it (what does that mean? copied from the VPP comment, + // please clarify if you know) until we merge the two fetch queries later + // on in this method. Until then if the host_software record is not a + // software installer nor VPP app, we delete it and keep the in-house app. + if _, exists := hostInstalledSoftwareTitleSet[s.ID]; exists { + installedTitle := bySoftwareTitleID[s.ID] + + if installedTitle != nil && installedTitle.InstallerID == nil { + // not a software installer, so copy over + // the installed title information + s.LastOpenedAt = installedTitle.LastOpenedAt + s.SoftwareID = installedTitle.SoftwareID + s.SoftwareSource = installedTitle.SoftwareSource + s.SoftwareExtensionFor = installedTitle.SoftwareExtensionFor + s.Version = installedTitle.Version + s.BundleIdentifier = installedTitle.BundleIdentifier + if !opts.VulnerableOnly && !hasCVEMetaFilters { + // When we are filtering by vulnerable only + // we want to treat the installed in-house app as a regular software title + delete(bySoftwareTitleID, s.ID) + } + byInHouseID[*s.InHouseAppID] = s + } else { + continue + } + } else if opts.OnlyAvailableForInstall || opts.IncludeAvailableForInstall { + byInHouseID[*s.InHouseAppID] = s + } + } + hostInstalledVppsApps, err := hostInstalledVpps(ds, ctx, host.ID) if err != nil { return nil, nil, err @@ -3435,10 +3778,54 @@ func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, opt hostVPPInstalledTitles[s.ID] = s } + hostInstalledInHouseApps, err := hostInstalledInHouses(ds, ctx, host.ID) + if err != nil { + return nil, nil, err + } + installedInHouseByID := make(map[uint]*hostSoftware) + for _, s := range hostInstalledInHouseApps { + if s.InHouseAppID != nil { + installedInHouseByID[*s.InHouseAppID] = s + } + } + + hostInHouseInstalledTitles := make(map[uint]*hostSoftware) + for _, s := range installedInHouseByID { + if _, ok := hostInstalledSoftwareTitleSet[s.ID]; ok { + // we copied over all the installed title information + // from bySoftwareTitleID, but deleted the record from the map + // when going through hostInHouseInstalls. Copy over the + // data from the byInHouseID to hostInHouseInstalledTitles + // so we can later push to InstalledVersions + installedTitle := byInHouseID[*s.InHouseAppID] + if installedTitle == nil { + // This can happen when mdm_enrolled is false + // because in hostInHouseInstalls we filter those out + installedTitle = bySoftwareTitleID[s.ID] + } + if installedTitle == nil { + // We somehow have an in-house app in host_in_house_software_installs, + // however osquery didn't pick it up in inventory + continue + } + s.SoftwareID = installedTitle.SoftwareID + s.SoftwareSource = installedTitle.SoftwareSource + s.SoftwareExtensionFor = installedTitle.SoftwareExtensionFor + s.Version = installedTitle.Version + s.BundleIdentifier = installedTitle.BundleIdentifier + } + if s.InHouseAppID != nil { + // Override the status; if there's a pending re-install, we should show that status. + if hs, ok := byInHouseID[*s.InHouseAppID]; ok { + s.Status = hs.Status + } + } + hostInHouseInstalledTitles[s.ID] = s + } + var stmtAvailable string if opts.OnlyAvailableForInstall || opts.IncludeAvailableForInstall { - namedArgs["vpp_apps_platforms"] = fleet.VPPAppsPlatforms namedArgs["host_compatible_platforms"] = host.FleetPlatform() var availableSoftwareTitles []*hostSoftware @@ -3460,6 +3847,10 @@ func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, opt vap.latest_version as vpp_app_version, vap.platform as vpp_app_platform, NULLIF(vap.icon_url, '') as vpp_app_icon_url, + iha.id as in_house_app_id, + iha.name as in_house_app_name, + iha.version as in_house_app_version, + iha.platform as in_house_app_platform, NULL as last_install_installed_at, NULL as last_install_install_uuid, NULL as last_uninstall_uninstalled_at, @@ -3475,6 +3866,8 @@ func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, opt vpp_apps vap ON st.id = vap.title_id AND :host_platform IN (:vpp_apps_platforms) LEFT OUTER JOIN vpp_apps_teams vat ON vap.adam_id = vat.adam_id AND vap.platform = vat.platform AND vat.global_or_team_id = :global_or_team_id + LEFT OUTER JOIN + in_house_apps iha ON iha.title_id = st.id AND iha.platform = :host_compatible_platforms AND iha.global_or_team_id = :global_or_team_id WHERE -- software is not installed on host (but is available in host's team) NOT EXISTS ( @@ -3533,24 +3926,48 @@ func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, opt ua.activity_type = 'vpp_app_install' AND vaua.adam_id = vat.adam_id ) AND - -- either the software installer or the vpp app exists for the host's team - ( si.id IS NOT NULL OR vat.platform = :host_platform ) AND + -- in-house install has not been attempted on host + NOT EXISTS ( + SELECT 1 + FROM + host_in_house_software_installs hihsi + WHERE + hihsi.host_id = :host_id AND + hihsi.in_house_app_id = iha.id AND + hihsi.removed = 0 AND + hihsi.canceled = 0 + ) AND + -- in-house install is not upcoming on host + NOT EXISTS ( + SELECT 1 + FROM + upcoming_activities ua + INNER JOIN + in_house_app_upcoming_activities ihua ON ihua.upcoming_activity_id = ua.id + WHERE + ua.host_id = :host_id AND + ua.activity_type = 'in_house_app_install' AND + ihua.in_house_app_id = iha.id + ) AND + -- either the software installer or the vpp app or the in-house app exists for the host's team + ( si.id IS NOT NULL OR vat.platform = :host_platform OR iha.id IS NOT NULL ) AND -- label membership check ( - -- do the label membership check for software installers and VPP apps + -- do the label membership check for software installers and VPP apps and in-house apps EXISTS ( 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 = si.id - ) AND NOT EXISTS (SELECT 1 FROM vpp_app_team_labels vatl WHERE vatl.vpp_app_team_id = vat.id) + 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) AND + NOT EXISTS (SELECT 1 FROM in_house_app_labels ihl WHERE ihl.in_house_app_id = iha.id) UNION - -- include any + -- include any for software installers SELECT COUNT(*) AS count_installer_labels, COUNT(lm.label_id) AS count_host_labels, @@ -3567,7 +3984,7 @@ func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, opt UNION - -- exclude any, ignore software that depends on labels created + -- exclude any for software installers, ignore software that depends on labels created -- _after_ the label_updated_at timestamp of the host (because -- we don't have results for that label yet, the host may or may -- not be a member). @@ -3589,7 +4006,7 @@ func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, opt UNION - -- vpp include any + -- include any for VPP apps SELECT COUNT(*) AS count_installer_labels, COUNT(lm.label_id) AS count_host_labels, @@ -3606,7 +4023,7 @@ func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, opt UNION - -- vpp exclude any + -- exclude any for VPP apps SELECT COUNT(*) AS count_installer_labels, COUNT(lm.label_id) AS count_host_labels, @@ -3625,6 +4042,42 @@ func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, opt AND vatl.exclude = 1 HAVING count_installer_labels > 0 AND count_installer_labels = count_host_updated_after_labels AND count_host_labels = 0 + + UNION + + -- include any for in-house apps + SELECT + COUNT(*) AS count_installer_labels, + COUNT(lm.label_id) AS count_host_labels, + 0 as count_host_updated_after_labels + FROM + in_house_app_labels ihl + LEFT OUTER JOIN label_membership lm ON lm.label_id = ihl.label_id AND lm.host_id = :host_id + WHERE + ihl.in_house_app_id = iha.id + AND ihl.exclude = 0 + HAVING + count_installer_labels > 0 AND count_host_labels > 0 + + UNION + + -- exclude any for in-house apps + SELECT + COUNT(*) AS count_installer_labels, + COUNT(lm.label_id) AS count_host_labels, + SUM(CASE + WHEN lbl.created_at IS NOT NULL AND lbl.label_membership_type = 0 AND :host_label_updated_at >= lbl.created_at THEN 1 + WHEN lbl.created_at IS NOT NULL AND lbl.label_membership_type = 1 THEN 1 + ELSE 0 END) as count_host_updated_after_labels + FROM + in_house_app_labels ihl + LEFT OUTER JOIN labels lbl ON lbl.id = ihl.label_id + LEFT OUTER JOIN label_membership lm ON lm.label_id = ihl.label_id AND lm.host_id = :host_id + WHERE + ihl.in_house_app_id = iha.id AND + ihl.exclude = 1 + HAVING + count_installer_labels > 0 AND count_installer_labels = count_host_updated_after_labels AND count_host_labels = 0 ) t ) ) @@ -3634,7 +4087,8 @@ func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, opt } if !opts.IsMDMEnrolled { - stmtAvailable += "\nAND vat.id IS NULL" + // both VPP apps and in-house apps require MDM + stmtAvailable += "\nAND vat.id IS NULL AND iha.id IS NULL" } stmtAvailable, args, err := sqlx.Named(stmtAvailable, namedArgs) @@ -3654,25 +4108,30 @@ func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, opt // These slices are meant to keep track of software that is available for install. // When we are filtering by `OnlyAvailableForInstall`, we will replace the existing - // software title records held in bySoftwareTitleID and byVPPAdamID. + // software title records held in bySoftwareTitleID, byVPPAdamID and byInHouseID. // If we are just using the `IncludeAvailableForInstall` options, we will simply - // add these addtional software titles to bySoftwareTitleID and byVPPAdamID. - tempBySoftwareTitleID := make(map[uint]*hostSoftware, len(availableSoftwareTitles)) + // add these addtional software titles to bySoftwareTitleID, byVPPAdamID and byInHouseID. + tmpBySoftwareTitleID := make(map[uint]*hostSoftware, len(availableSoftwareTitles)) tmpByVPPAdamID := make(map[string]*hostSoftware, len(byVPPAdamID)) + tmpByInHouseID := make(map[uint]*hostSoftware, len(byInHouseID)) if opts.OnlyAvailableForInstall { // drop in anything that has been installed or uninstalled as it can be installed again regardless of status for _, s := range hostSoftwareUninstalls { - tempBySoftwareTitleID[s.ID] = s + tmpBySoftwareTitleID[s.ID] = s } if !opts.VulnerableOnly { for _, s := range hostSoftwareInstallsList { - tempBySoftwareTitleID[s.ID] = s + tmpBySoftwareTitleID[s.ID] = s } for _, s := range hostVPPInstalls { tmpByVPPAdamID[*s.VPPAppAdamID] = s } + for _, s := range hostInHouseInstalls { + tmpByInHouseID[*s.InHouseAppID] = s + } } } + // NOTE: label conditions are applied in a subsequent step // software installed on the host not by fleet and there exists a software installer that matches this software // so that makes it available for install installedInstallersSql := ` @@ -3704,10 +4163,11 @@ func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, opt if software := bySoftwareTitleID[s.TitleID]; software != nil { software.InstallerID = &s.InstallerID software.PackageSelfService = &s.SelfService - tempBySoftwareTitleID[s.TitleID] = software + tmpBySoftwareTitleID[s.TitleID] = software } } if !opts.SelfServiceOnly || (opts.SelfServiceOnly && opts.IsMDMEnrolled) { + // NOTE: label conditions are applied in a subsequent step // software installed on the host not by fleet and there exists a vpp app that matches this software // so that makes it available for install installedVPPAppsSql := ` @@ -3764,28 +4224,98 @@ func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, opt // If a VPP app is installed on the host, but not by fleet // it will be present in bySoftwareTitleID, because osquery returned it as inventory. // We need to remove it from bySoftwareTitleID and add it to byVPPAdamID - if invetoriedSoftware, ok := bySoftwareTitleID[s.ID]; ok { - invetoriedSoftware.VPPAppAdamID = s.VPPAppAdamID - invetoriedSoftware.VPPAppVersion = s.VPPAppVersion - invetoriedSoftware.VPPAppPlatform = s.VPPAppPlatform - invetoriedSoftware.VPPAppIconURL = s.VPPAppIconURL - invetoriedSoftware.VPPAppSelfService = s.VPPAppSelfService + if inventoriedSoftware, ok := bySoftwareTitleID[s.ID]; ok { + inventoriedSoftware.VPPAppAdamID = s.VPPAppAdamID + inventoriedSoftware.VPPAppVersion = s.VPPAppVersion + inventoriedSoftware.VPPAppPlatform = s.VPPAppPlatform + inventoriedSoftware.VPPAppIconURL = s.VPPAppIconURL + inventoriedSoftware.VPPAppSelfService = s.VPPAppSelfService if !opts.VulnerableOnly && !hasCVEMetaFilters { // When we are filtering by vulnerable only // we want to treat the installed vpp app as a regular software title delete(bySoftwareTitleID, s.ID) - byVPPAdamID[*s.VPPAppAdamID] = invetoriedSoftware + byVPPAdamID[*s.VPPAppAdamID] = inventoriedSoftware } - hostVPPInstalledTitles[s.ID] = invetoriedSoftware + hostVPPInstalledTitles[s.ID] = inventoriedSoftware + } + } + + // NOTE: label conditions are applied in a subsequent step + // software installed on the host not by fleet and there exists an + // in-house app that matches this software so that makes it available for + // install + installedInHouseAppsSql := ` + SELECT + iha.title_id AS id, + iha.id AS in_house_app_id, + iha.name AS in_house_app_name, + iha.version AS in_house_app_version, + iha.platform as in_house_app_platform + FROM + host_software + INNER JOIN + software ON host_software.software_id = software.id + INNER JOIN + in_house_apps iha ON software.title_id = iha.title_id AND iha.global_or_team_id = :global_or_team_id AND iha.platform = :host_compatible_platforms + WHERE + host_software.host_id = :host_id + ` + installedInHouseAppsSql, args, err = sqlx.Named(installedInHouseAppsSql, namedArgs) + if err != nil { + return nil, nil, err + } + installedInHouseAppsSql, args, err = sqlx.In(installedInHouseAppsSql, args...) + if err != nil { + return nil, nil, err + } + var installedInHouseAppIDs []*hostSoftware + err = sqlx.SelectContext(ctx, ds.reader(ctx), &installedInHouseAppIDs, installedInHouseAppsSql, args...) + if err != nil { + return nil, nil, err + } + for _, s := range installedInHouseAppIDs { + if s.InHouseAppID != nil { + if tmpByInHouseID[*s.InHouseAppID] == nil { + // inventoried, but not installed by fleet + tmpByInHouseID[*s.InHouseAppID] = s + } else { + // inventoried, but installed by fleet + // We want to preserve the install information from host_in_house_software_installs + // so don't overwrite the existing record + tmpByInHouseID[*s.InHouseAppID].InHouseAppVersion = s.InHouseAppVersion + tmpByInHouseID[*s.InHouseAppID].InHouseAppPlatform = s.InHouseAppPlatform + tmpByInHouseID[*s.InHouseAppID].InHouseAppName = s.InHouseAppName + } + } + if inHouseAppByFleet, ok := hostInHouseInstalledTitles[s.ID]; ok { + // In-house app installed by fleet, so we need to copy over the status, + // because all fleet installed apps show an installed status if available + tmpByInHouseID[*s.InHouseAppID].Status = inHouseAppByFleet.Status + } + // If an in-house app is installed on the host, but not by fleet + // it will be present in bySoftwareTitleID, because osquery returned it as inventory. + // We need to remove it from bySoftwareTitleID and add it to byInHouseID + if inventoriedSoftware, ok := bySoftwareTitleID[s.ID]; ok { + inventoriedSoftware.InHouseAppID = s.InHouseAppID + inventoriedSoftware.InHouseAppVersion = s.InHouseAppVersion + inventoriedSoftware.InHouseAppPlatform = s.InHouseAppPlatform + inventoriedSoftware.InHouseAppName = s.InHouseAppName + if !opts.VulnerableOnly && !hasCVEMetaFilters { + // When we are filtering by vulnerable only + // we want to treat the installed in-house app as a regular software title + delete(bySoftwareTitleID, s.ID) + byInHouseID[*s.InHouseAppID] = inventoriedSoftware + } + hostInHouseInstalledTitles[s.ID] = inventoriedSoftware } } } for _, s := range availableSoftwareTitles { - // If it's a VPP app - if s.VPPAppAdamID != nil { + switch { + case s.VPPAppAdamID != nil: + // VPP app existingVPP, found := byVPPAdamID[*s.VPPAppAdamID] - if opts.OnlyAvailableForInstall { if !found { tmpByVPPAdamID[*s.VPPAppAdamID] = s @@ -3800,14 +4330,30 @@ func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, opt } } - } else { - existingSoftware, found := bySoftwareTitleID[s.ID] - + case s.InHouseAppID != nil: + // In-house app + existing, found := byInHouseID[*s.InHouseAppID] if opts.OnlyAvailableForInstall { if !found { - tempBySoftwareTitleID[s.ID] = s + tmpByInHouseID[*s.InHouseAppID] = s } else { - tempBySoftwareTitleID[s.ID] = existingSoftware + tmpByInHouseID[*s.InHouseAppID] = existing + } + } else { + // We have an existing in-house record in an installed or pending state, do not overwrite with the + // one that's available for install. We would lose specifics about the installed version + if !found { + byInHouseID[*s.InHouseAppID] = s + } + } + + default: + existingSoftware, found := bySoftwareTitleID[s.ID] + if opts.OnlyAvailableForInstall { + if !found { + tmpBySoftwareTitleID[s.ID] = s + } else { + tmpBySoftwareTitleID[s.ID] = existingSoftware } } else { // We have an existing software record in an installed or pending state, do not overwrite with the @@ -3820,8 +4366,9 @@ func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, opt } // Clear out all the previous software titles as we are only filtering for available software if opts.OnlyAvailableForInstall { - bySoftwareTitleID = tempBySoftwareTitleID + bySoftwareTitleID = tmpBySoftwareTitleID byVPPAdamID = tmpByVPPAdamID + byInHouseID = tmpByInHouseID } } @@ -3836,7 +4383,8 @@ func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, opt return nil, nil, err } - filteredByVPPAdamID, otherVppAppsInInventory, err := filterVppAppsByLabel( + // filter out VPP apps due to label scoping + filteredByVPPAdamID, otherVppAppsInInventory, err := filterVPPAppsByLabel( ds, ctx, host, @@ -3847,6 +4395,18 @@ func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, opt return nil, nil, err } + // filter out in-house apps due to label scoping + filteredByInHouseID, otherInHouseAppsInInventory, err := filterInHouseAppsByLabel( + ds, + ctx, + host, + byInHouseID, + hostInHouseInstalledTitles, + ) + if err != nil { + return nil, nil, err + } + // We ignored the VPP apps that were installed on the host while filtering in filterSoftwareInstallersByLabel // so we need to add them back in if they are allowed by filterVppAppsByLabel for _, value := range otherVppAppsInInventory { @@ -3855,9 +4415,18 @@ func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, opt } } + // We ignored the in-house apps that were installed on the host while filtering in filterSoftwareInstallersByLabel + // so we need to add them back in if they are allowed by filterInHouseAppsByLabel + for _, value := range otherInHouseAppsInInventory { + if st, ok := bySoftwareTitleID[value.ID]; ok { + filteredBySoftwareTitleID[value.ID] = st + } + } + if opts.OnlyAvailableForInstall { bySoftwareTitleID = filteredBySoftwareTitleID byVPPAdamID = filteredByVPPAdamID + byInHouseID = filteredByInHouseID } // self service impacts inventory, when a software title is excluded because of a filter, // it should be excluded from the inventory as well, because we cannot "reinstall" it on the self service page @@ -3878,21 +4447,35 @@ func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, opt } } } + // NOTE: does not apply to in-house apps for now, as self-service is not supported yet + // for inHouseID, software := range byInHouseID { + // if software.InHouseAppSelfService != nil && *software.InHouseAppSelfService { + // if filteredByInHouseID[inHouseID] == nil { + // // remove the software title from byInHouseID + // delete(byInHouseID, inHouseID) + // } + // } + // } } - // since these host installed vpp apps are already added in bySoftwareTitleID, - // we need to avoid adding them to byVPPAdamID - // but we need to store them in filteredByVPPAdamID so they are able to be + // since these host installed vpp apps/in-house apps are already added in bySoftwareTitleID, + // we need to avoid adding them to byVPPAdamID/byInHouseID + // but we need to store them in filteredBy{VPPAdamID,InHouseID} so they are able to be // promoted when returning the software title for key, value := range otherVppAppsInInventory { if _, ok := filteredByVPPAdamID[key]; !ok { filteredByVPPAdamID[key] = value } } + for key, value := range otherInHouseAppsInInventory { + if _, ok := filteredByInHouseID[key]; !ok { + filteredByInHouseID[key] = value + } + } - var softwareTitleIds []uint + var softwareTitleIDs []uint for softwareTitleID := range bySoftwareTitleID { - softwareTitleIds = append(softwareTitleIds, softwareTitleID) + softwareTitleIDs = append(softwareTitleIDs, softwareTitleID) } var softwareIDs []uint @@ -3901,19 +4484,28 @@ func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, opt } var vppAdamIDs []string - var vppTitleIds []uint + var vppTitleIDs []uint for key, v := range byVPPAdamID { vppAdamIDs = append(vppAdamIDs, key) - vppTitleIds = append(vppTitleIds, v.ID) + vppTitleIDs = append(vppTitleIDs, v.ID) + } + + var inHouseIDs []uint + var inHouseTitleIDs []uint + for key, v := range byInHouseID { + inHouseIDs = append(inHouseIDs, key) + inHouseTitleIDs = append(inHouseTitleIDs, v.ID) } var titleCount uint var hostSoftwareList []*hostSoftware - if len(softwareTitleIds) > 0 || len(vppAdamIDs) > 0 { - var args []interface{} - var stmt string - var softwareTitleStatement string - var vppAdamStatment string + if len(softwareTitleIDs) > 0 || len(vppAdamIDs) > 0 || len(inHouseIDs) > 0 { + var ( + args []interface{} + stmt string + softwareTitleStatement string + vppAdamStatment string + ) matchClause := "" matchArgs := []interface{}{} @@ -3921,6 +4513,7 @@ func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, opt matchClause, matchArgs = searchLike(matchClause, matchArgs, opts.ListOptions.MatchQuery, "software_titles.name") } + // NOTE: no self-service support for in-house apps yet var softwareOnlySelfServiceClause string var vppOnlySelfServiceClause string if opts.SelfServiceOnly { @@ -3954,7 +4547,7 @@ func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, opt } var softwareVulnerableJoin string - if len(softwareTitleIds) > 0 { + if len(softwareTitleIDs) > 0 { if opts.VulnerableOnly || opts.ListOptions.MatchQuery != "" { softwareVulnerableJoin += " AND ( " if !opts.VulnerableOnly && opts.ListOptions.MatchQuery != "" { @@ -4023,9 +4616,9 @@ func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, opt var softwareTitleArgs []interface{} if len(softwareIDs) > 0 { - softwareTitleStatement, softwareTitleArgs, err = sqlx.In(softwareTitleStatement, softwareIDs, softwareTitleIds) + softwareTitleStatement, softwareTitleArgs, err = sqlx.In(softwareTitleStatement, softwareIDs, softwareTitleIDs) } else { - softwareTitleStatement, softwareTitleArgs, err = sqlx.In(softwareTitleStatement, softwareTitleIds) + softwareTitleStatement, softwareTitleArgs, err = sqlx.In(softwareTitleStatement, softwareTitleIDs) } if err != nil { return nil, nil, ctxerr.Wrap(ctx, err, "expand IN query for software titles") @@ -4053,7 +4646,7 @@ func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, opt } if !opts.VulnerableOnly && len(vppAdamIDs) > 0 { - if len(softwareTitleIds) > 0 { + if len(softwareTitleIDs) > 0 { vppAdamStatment = ` UNION ` } @@ -4091,19 +4684,62 @@ func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, opt stmt += vppAdamStatement } + if !opts.VulnerableOnly && len(inHouseIDs) > 0 { + var inHouseStmt string + if len(softwareTitleIDs) > 0 || len(vppAdamIDs) > 0 { + inHouseStmt = ` UNION ` + } + + inHouseStmt += ` + -- SELECT for in-house apps + %s + FROM + software_titles + INNER JOIN in_house_apps ON + software_titles.id = in_house_apps.title_id AND in_house_apps.platform = :host_platform AND in_house_apps.global_or_team_id = :global_or_team_id + WHERE + in_house_apps.id IN (?) + AND true + -- GROUP BY for in-house apps + %s + ` + + inHouseStmt, inHouseArgs, err := sqlx.In(inHouseStmt, inHouseIDs) + if err != nil { + return nil, nil, ctxerr.Wrap(ctx, err, "expand IN query for in-house titles") + } + inHouseStmt, inHouseArgsNamedArgs, err := sqlx.Named(inHouseStmt, namedArgs) + if err != nil { + return nil, nil, ctxerr.Wrap(ctx, err, "build named query for in-house titles") + } + inHouseStmt = strings.ReplaceAll(inHouseStmt, "AND true", matchClause) + args = append(args, inHouseArgsNamedArgs...) + args = append(args, inHouseArgs...) + if len(matchArgs) > 0 { + args = append(args, matchArgs...) + } + stmt += inHouseStmt + } + var countStmt string - // we do not scan vulnerabilities on vpp software available for install + var sprintfArgs []any + // we do not scan vulnerabilities on vpp/in-house software available for install + includeSoftwareTitles := len(softwareTitleIDs) > 0 includeVPP := !opts.VulnerableOnly && len(vppAdamIDs) > 0 - switch { - case len(softwareTitleIds) > 0 && includeVPP: - countStmt = fmt.Sprintf(stmt, `SELECT software_titles.id`, softwareVulnerableJoin, `GROUP BY software_titles.id`, `SELECT software_titles.id`, `GROUP BY software_titles.id`) - case len(softwareTitleIds) > 0: - countStmt = fmt.Sprintf(stmt, `SELECT software_titles.id`, softwareVulnerableJoin, `GROUP BY software_titles.id`) - case includeVPP: - countStmt = fmt.Sprintf(stmt, `SELECT software_titles.id`, `GROUP BY software_titles.id`) - default: + includeInHouse := !opts.VulnerableOnly && len(inHouseIDs) > 0 + if includeSoftwareTitles { + sprintfArgs = append(sprintfArgs, `SELECT software_titles.id`, softwareVulnerableJoin, `GROUP BY software_titles.id`) + } + if includeVPP { + sprintfArgs = append(sprintfArgs, `SELECT software_titles.id`, `GROUP BY software_titles.id`) + } + if includeInHouse { + sprintfArgs = append(sprintfArgs, `SELECT software_titles.id`, `GROUP BY software_titles.id`) + } + if len(sprintfArgs) == 0 { return []*fleet.HostSoftwareWithInstaller{}, &fleet.PaginationMetadata{}, nil } + countStmt = fmt.Sprintf(stmt, sprintfArgs...) if err := sqlx.GetContext( ctx, @@ -4116,7 +4752,7 @@ func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, opt } var replacements []any - if len(softwareTitleIds) > 0 { + if len(softwareTitleIDs) > 0 { replacements = append(replacements, // For software installers ` @@ -4139,7 +4775,11 @@ func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, opt NULL AS vpp_app_version_list, NULL AS vpp_app_platform_list, NULL AS vpp_app_icon_url_list, - NULL AS vpp_app_self_service_list + NULL AS vpp_app_self_service_list, + NULL AS in_house_app_id_list, + NULL AS in_house_app_name_list, + NULL AS in_house_app_version_list, + NULL as in_house_app_platform_list `, softwareVulnerableJoin, ` GROUP BY software_titles.id, @@ -4176,7 +4816,48 @@ func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, opt GROUP_CONCAT(vpp_apps.latest_version) AS vpp_app_version_list, GROUP_CONCAT(vpp_apps.platform) as vpp_app_platform_list, GROUP_CONCAT(vpp_apps.icon_url) AS vpp_app_icon_url_list, - GROUP_CONCAT(vpp_apps_teams.self_service) AS vpp_app_self_service_list + GROUP_CONCAT(vpp_apps_teams.self_service) AS vpp_app_self_service_list, + NULL AS in_house_app_id_list, + NULL AS in_house_app_name_list, + NULL AS in_house_app_version_list, + NULL as in_house_app_platform_list + `, ` + GROUP BY + software_titles.id, + software_titles.name, + software_titles.source, + software_titles.extension_for + `) + } + + if includeInHouse { + replacements = append(replacements, + // For in-house apps + ` + SELECT + software_titles.id, + software_titles.name, + software_titles.source AS source, + software_titles.extension_for AS extension_for, + NULL AS installer_id, + NULL AS package_self_service, + NULL AS package_name, + NULL AS package_version, + NULL as package_platform, + NULL AS software_id_list, + NULL AS software_source_list, + NULL AS software_extension_for_list, + NULL AS version_list, + NULL AS bundle_identifier_list, + NULL AS vpp_app_adam_id_list, + NULL AS vpp_app_version_list, + NULL as vpp_app_platform_list, + NULL AS vpp_app_icon_url_list, + NULL AS vpp_app_self_service_list, + GROUP_CONCAT(in_house_apps.id) AS in_house_app_id_list, + GROUP_CONCAT(in_house_apps.name) AS in_house_app_name_list, + GROUP_CONCAT(in_house_apps.version) AS in_house_app_version_list, + GROUP_CONCAT(in_house_apps.platform) as in_house_app_platform_list `, ` GROUP BY software_titles.id, @@ -4248,7 +4929,8 @@ func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, opt if host.TeamID != nil { teamID = *host.TeamID // Team host } - policies, err := ds.getPoliciesBySoftwareTitleIDs(ctx, append(vppTitleIds, softwareTitleIds...), teamID) + // NOTE: in-house apps do not support automatic install policies at the moment + policies, err := ds.getPoliciesBySoftwareTitleIDs(ctx, append(vppTitleIDs, softwareTitleIDs...), teamID) if err != nil { return nil, nil, ctxerr.Wrap(ctx, err, "batch getting policies by software title IDs") } @@ -4258,7 +4940,7 @@ func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, opt policiesBySoftwareTitleId[p.TitleID] = append(policiesBySoftwareTitleId[p.TitleID], p) } - iconsBySoftwareTitleID, err := ds.GetSoftwareIconsByTeamAndTitleIds(ctx, teamID, append(vppTitleIds, softwareTitleIds...)) + iconsBySoftwareTitleID, err := ds.GetSoftwareIconsByTeamAndTitleIds(ctx, teamID, append(append(vppTitleIDs, inHouseTitleIDs...), softwareTitleIDs...)) if err != nil { return nil, nil, ctxerr.Wrap(ctx, err, "get software icons by team and title IDs") } @@ -4268,6 +4950,7 @@ func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, opt for _, softwareTitleRecord := range hostSoftwareList { softwareTitle := bySoftwareTitleID[softwareTitleRecord.ID] inventoriedVPPApp := hostVPPInstalledTitles[softwareTitleRecord.ID] + inventoriedInHouseApp := hostInHouseInstalledTitles[softwareTitleRecord.ID] if softwareTitle != nil && softwareTitle.SoftwareID != nil { // if we have a software id, that means that this record has been installed on the host, @@ -4285,6 +4968,13 @@ func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, opt pushVersion(softwareIDStr, softwareTitleRecord, *s) } } + if inventoriedInHouseApp != nil && inventoriedInHouseApp.SoftwareID != nil { + // in-house app installed on the host, we need to push this into the installed versions list as well + if s, ok := hostInstalledSoftwareSet[*inventoriedInHouseApp.SoftwareID]; ok { + softwareIDStr := strconv.FormatUint(uint64(*inventoriedInHouseApp.SoftwareID), 10) + pushVersion(softwareIDStr, softwareTitleRecord, *s) + } + } if softwareTitleRecord.SoftwareIDList != nil { softwareIDList := strings.Split(*softwareTitleRecord.SoftwareIDList, ",") @@ -4365,6 +5055,43 @@ func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, opt } } + if softwareTitleRecord.InHouseAppIDList != nil { + inHouseAppIDList := strings.Split(*softwareTitleRecord.InHouseAppIDList, ",") + inHouseAppVersionList := strings.Split(*softwareTitleRecord.InHouseAppVersionList, ",") + inHouseAppPlatformList := strings.Split(*softwareTitleRecord.InHouseAppPlatformList, ",") + inHouseAppNameList := strings.Split(*softwareTitleRecord.InHouseAppNameList, ",") + + if storedIndex, ok := indexOfSoftwareTitle[softwareTitleRecord.ID]; ok { + softwareTitleRecord = deduplicatedList[storedIndex] + } + + for index, inHouseAppIDStr := range inHouseAppIDList { + inHouseID64, err := strconv.ParseUint(inHouseAppIDStr, 10, 32) + if err != nil { + continue + } + + inHouseID := uint(inHouseID64) + + softwareTitle = byInHouseID[inHouseID] + softwareTitleRecord.InHouseAppID = &inHouseID + + inHouseAppVersion := inHouseAppVersionList[index] + if inHouseAppVersion != "" { + softwareTitleRecord.InHouseAppVersion = &inHouseAppVersion + } + + inHouseAppPlatform := inHouseAppPlatformList[index] + if inHouseAppPlatform != "" { + softwareTitleRecord.InHouseAppPlatform = &inHouseAppPlatform + } + inHouseAppName := inHouseAppNameList[index] + if inHouseAppName != "" { + softwareTitleRecord.InHouseAppName = &inHouseAppName + } + } + } + if storedIndex, ok := indexOfSoftwareTitle[softwareTitleRecord.ID]; ok { softwareTitleRecord = deduplicatedList[storedIndex] } @@ -4405,6 +5132,23 @@ func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, opt } } + // This happens when there is a software installed on the host but it is + // also an in-house record, so we want to grab the in-house data from the + // installed record and merge it onto the software record + if installedInHouseRecord, ok := hostInHouseInstalledTitles[softwareTitleRecord.ID]; ok { + softwareTitleRecord.InHouseAppID = installedInHouseRecord.InHouseAppID + softwareTitleRecord.InHouseAppName = installedInHouseRecord.InHouseAppName + softwareTitleRecord.InHouseAppVersion = installedInHouseRecord.InHouseAppVersion + softwareTitleRecord.InHouseAppPlatform = installedInHouseRecord.InHouseAppPlatform + } + // promote the in-house app id and version to the proper destination fields + if softwareTitleRecord.InHouseAppID != nil { + if _, ok := filteredByInHouseID[*softwareTitleRecord.InHouseAppID]; ok { + promoteSoftwareTitleInHouseApp(softwareTitleRecord) + } + } + + // NOTE: in-house apps do not support automatic install policies at the moment if policies, ok := policiesBySoftwareTitleId[softwareTitleRecord.ID]; ok { switch { case softwareTitleRecord.AppStoreApp != nil: @@ -4452,7 +5196,6 @@ func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, opt software := make([]*fleet.HostSoftwareWithInstaller, 0, len(hostSoftwareList)) for _, hs := range hostSoftwareList { - hs := hs software = append(software, &hs.HostSoftwareWithInstaller) } diff --git a/server/datastore/mysql/software_installers.go b/server/datastore/mysql/software_installers.go index eaacbb5d5b..29eda8e8e7 100644 --- a/server/datastore/mysql/software_installers.go +++ b/server/datastore/mysql/software_installers.go @@ -197,6 +197,24 @@ func (ds *Datastore) MatchOrCreateSoftwareInstaller(ctx context.Context, payload return 0, 0, errors.New("validated labels must not be nil") } + // Insert in house app instead of software installer + if payload.Extension == "ipa" { + // Insert both iOS and ipadOS titles per https://github.com/fleetdm/fleet/issues/34283 + installerID, titleID, err := ds.insertInHouseApp(ctx, &fleet.InHouseAppPayload{ + TeamID: payload.TeamID, + Name: payload.Title, + BundleID: payload.BundleIdentifier, + StorageID: payload.StorageID, + Platform: payload.Platform, + ValidatedLabels: payload.ValidatedLabels, + Version: payload.Version, + }) + if err != nil { + return 0, 0, ctxerr.Wrap(ctx, err, "MatchOrCreateSoftwareInstaller: ") + } + return installerID, titleID, err + } + titleID, err = ds.getOrGenerateSoftwareInstallerTitleID(ctx, payload) if err != nil { return 0, 0, ctxerr.Wrap(ctx, err, "get or generate software installer title ID") @@ -476,7 +494,6 @@ func (ds *Datastore) getOrGenerateSoftwareInstallerTitleID(ctx context.Context, if err != nil { return 0, err } - return titleID, nil } @@ -502,8 +519,9 @@ func (ds *Datastore) addSoftwareTitleToMatchingSoftware(ctx context.Context, tit type softwareType string const ( - softwareTypeInstaller softwareType = "software_installer" - softwareTypeVPP softwareType = "vpp_app_team" + softwareTypeInstaller softwareType = "software_installer" + softwareTypeVPP softwareType = "vpp_app_team" + softwareTypeInHouseApp softwareType = "in_house_app" ) // setOrUpdateSoftwareInstallerLabelsDB sets or updates the label associations for the specified software @@ -723,6 +741,74 @@ WHERE return &dest, nil } +func (ds *Datastore) installerAvailableForInstallForTeamAndTitleID(ctx context.Context, teamID *uint, titleID uint) (installerID uint, vppAppID *fleet.VPPAppID, inHouseID uint, err error) { + const stmt = ` +SELECT + si.id AS installer_id, + NULL as vpp_adam_id, + NULL as vpp_platform, + NULL as in_house_id +FROM + software_installers si +WHERE + si.title_id = ? AND si.global_or_team_id = ? + +UNION ALL + +SELECT + NULL AS installer_id, + vap.adam_id AS vpp_adam_id, + vap.platform AS vpp_platform, + NULL as in_house_id +FROM + vpp_apps vap + JOIN vpp_apps_teams vat ON vap.adam_id = vat.adam_id AND vap.platform = vat.platform +WHERE + vap.title_id = ? AND vat.global_or_team_id = ? + +UNION ALL + +SELECT + NULL AS installer_id, + NULL as vpp_adam_id, + NULL as vpp_platform, + iha.id as in_house_id +FROM + in_house_apps iha +WHERE + iha.title_id = ? AND iha.global_or_team_id = ? +` + + var tmID uint + if teamID != nil { + tmID = *teamID + } + + type resultRow struct { + InstallerID sql.Null[uint] `db:"installer_id"` + VPPAdamID sql.Null[string] `db:"vpp_adam_id"` + VPPPlatform sql.Null[string] `db:"vpp_platform"` + InHouseID sql.Null[uint] `db:"in_house_id"` + } + var row resultRow + err = sqlx.GetContext(ctx, ds.reader(ctx), &row, stmt, + titleID, tmID, titleID, tmID, titleID, tmID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return 0, nil, 0, nil + } + return 0, nil, 0, ctxerr.Wrap(ctx, err, "check installer/vpp/in-house app availability") + } + + if row.VPPAdamID.Valid { + vppAppID = &fleet.VPPAppID{ + AdamID: row.VPPAdamID.V, + Platform: fleet.AppleDevicePlatform(row.VPPPlatform.V), + } + } + return row.InstallerID.V, vppAppID, row.InHouseID.V, nil +} + func (ds *Datastore) GetSoftwareInstallerMetadataByTeamAndTitleID(ctx context.Context, teamID *uint, titleID uint, withScriptContents bool) (*fleet.SoftwareInstaller, error) { var scriptContentsSelect, scriptContentsFrom string if withScriptContents { @@ -778,7 +864,7 @@ WHERE // TODO: do we want to include labels on other queries that return software installer metadata // (e.g., GetSoftwareInstallerMetadataByID)? - labels, err := ds.getSoftwareInstallerLabels(ctx, dest.InstallerID) + labels, err := ds.getSoftwareInstallerLabels(ctx, dest.InstallerID, softwareTypeInstaller) if err != nil { return nil, ctxerr.Wrap(ctx, err, "get software installer labels") } @@ -826,23 +912,23 @@ WHERE return &dest, nil } -func (ds *Datastore) getSoftwareInstallerLabels(ctx context.Context, installerID uint) ([]fleet.SoftwareScopeLabel, error) { - query := ` +func (ds *Datastore) getSoftwareInstallerLabels(ctx context.Context, installerID uint, softwareType softwareType) ([]fleet.SoftwareScopeLabel, error) { + query := fmt.Sprintf(` SELECT label_id, exclude, l.name as label_name, si.title_id FROM - software_installer_labels sil - JOIN software_installers si ON si.id = sil.software_installer_id + %[1]s_labels sil + JOIN %[1]ss si ON si.id = sil.%[1]s_id JOIN labels l ON l.id = sil.label_id WHERE - software_installer_id = ?` + %[1]s_id = ?`, softwareType) var labels []fleet.SoftwareScopeLabel if err := sqlx.SelectContext(ctx, ds.reader(ctx), &labels, query, installerID); err != nil { - return nil, ctxerr.Wrap(ctx, err, "get software installer labels") + return nil, ctxerr.Wrap(ctx, err, fmt.Sprintf("get %s labels", softwareType)) } return labels, nil @@ -1570,6 +1656,85 @@ WHERE }) } +func (ds *Datastore) inHouseAppJoin(inHouseID uint, status fleet.SoftwareInstallerStatus) (string, []any, error) { + // for pending status, we'll join through upcoming_activities + if status == fleet.SoftwarePending || status == fleet.SoftwareInstallPending || status == fleet.SoftwareUninstallPending { + stmt := `JOIN ( +SELECT DISTINCT + host_id +FROM + upcoming_activities ua + JOIN in_house_app_upcoming_activities ihua ON ua.id = ihua.upcoming_activity_id +WHERE + %s) hss ON hss.host_id = h.id` + + filter := "ihua.in_house_app_id = ?" + switch status { + case fleet.SoftwareInstallPending: + filter += " AND ua.activity_type = 'in_house_app_install'" + case fleet.SoftwareUninstallPending: + // TODO: Update this when in-house supports uninstall, for now we map + // uninstall to install to preserve existing behavior of VPP filters + filter += " AND ua.activity_type = 'in_house_app_install'" + default: + // no change, we're just filtering by title id so it will pick up any + // activity type that is associated with the app (i.e. both install and + // uninstall) + } + + return fmt.Sprintf(stmt, filter), []any{inHouseID}, nil + } + + // TODO: Update this when in-house app supports uninstall for now we map the + // generic failed status to the install status + if status == fleet.SoftwareFailed { + status = fleet.SoftwareInstallFailed // TODO: When in-house supports uninstall this should become STATUS IN ('failed_install', 'failed_uninstall') + } + + stmt := fmt.Sprintf(`JOIN ( +SELECT + hihsi.host_id +FROM + host_in_house_software_installs hihsi + INNER JOIN + nano_command_results ncr ON ncr.command_uuid = hihsi.command_uuid + LEFT JOIN host_in_house_software_installs hihsi2 + ON hihsi.host_id = hihsi2.host_id AND + hihsi.in_house_app_id = hihsi2.in_house_app_id AND + hihsi2.canceled = 0 AND + hihsi2.removed = 0 AND + (hihsi.created_at < hihsi2.created_at OR (hihsi.created_at = hihsi2.created_at AND hihsi.id < hihsi2.id)) +WHERE + hihsi2.id IS NULL + AND hihsi.in_house_app_id = :in_house_app_id + AND hihsi.canceled = 0 + AND hihsi.removed = 0 + AND (%s) = :status + AND NOT EXISTS ( + SELECT 1 + FROM + upcoming_activities ua + JOIN in_house_app_upcoming_activities ihua ON ua.id = ihua.upcoming_activity_id + WHERE + ua.host_id = hihsi.host_id + AND ihua.in_house_app_id = hihsi.in_house_app_id + AND ua.activity_type = 'in_house_app_install' + ) +) hss ON hss.host_id = h.id +`, inHouseAppHostStatusNamedQuery("hihsi", "ncr", "")) + + return sqlx.Named(stmt, map[string]any{ + "status": status, + "in_house_app_id": inHouseID, + "software_status_installed": fleet.SoftwareInstalled, + "software_status_failed": fleet.SoftwareInstallFailed, + "software_status_pending": fleet.SoftwareInstallPending, + "mdm_status_acknowledged": fleet.MDMAppleStatusAcknowledged, + "mdm_status_error": fleet.MDMAppleStatusError, + "mdm_status_format_error": fleet.MDMAppleStatusCommandFormatError, + }) +} + func (ds *Datastore) GetHostLastInstallData(ctx context.Context, hostID, installerID uint) (*fleet.HostLastInstallData, error) { hostLastInstall, err := ds.getLatestUpcomingInstall(ctx, hostID, installerID) if err != nil && errors.Is(err, sql.ErrNoRows) { @@ -1641,9 +1806,14 @@ func (ds *Datastore) CleanupUnusedSoftwareInstallers(ctx context.Context, softwa // get the list of software installers hashes that are in use var storageIDs []string - if err := sqlx.SelectContext(ctx, ds.reader(ctx), &storageIDs, `SELECT DISTINCT storage_id FROM software_installers`); err != nil { + if err := sqlx.SelectContext(ctx, ds.reader(ctx), &storageIDs, ` + SELECT storage_id FROM software_installers + UNION + SELECT storage_id FROM in_house_apps`, + ); err != nil { return ctxerr.Wrap(ctx, err, "get list of software installers in use") } + // Add in house apps to software installers in use _, err := softwareInstallStore.Cleanup(ctx, storageIDs, removeCreatedBefore) return ctxerr.Wrap(ctx, err, "cleanup unused software installers") diff --git a/server/datastore/mysql/software_installers_test.go b/server/datastore/mysql/software_installers_test.go index ee0dc6c66b..d15c43b54c 100644 --- a/server/datastore/mysql/software_installers_test.go +++ b/server/datastore/mysql/software_installers_test.go @@ -275,8 +275,8 @@ func testListPendingSoftwareInstalls(t *testing.T, ds *Datastore) { // Insert a setup experience status result to simulate this install is part of setup experience _, err = ds.writer(ctx).ExecContext(ctx, ` - INSERT INTO setup_experience_status_results - (host_uuid, name, status, software_installer_id, host_software_installs_execution_id) + INSERT INTO setup_experience_status_results + (host_uuid, name, status, software_installer_id, host_software_installs_execution_id) VALUES (?, ?, ?, ?, ?)`, host1.UUID, "test_software", fleet.SetupExperienceStatusPending, installerID1, setupExperienceInstallID) require.NoError(t, err) @@ -298,6 +298,11 @@ func testSoftwareInstallRequests(t *testing.T, ds *Datastore) { user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true) + createBuiltinLabels(t, ds) + labelsByName, err := ds.LabelIDsByName(ctx, []string{fleet.BuiltinLabelNameAllHosts}) + require.NoError(t, err) + require.Len(t, labelsByName, 1) + cases := map[string]*uint{ "no team": nil, "team": &team.ID, @@ -332,6 +337,20 @@ func testSoftwareInstallRequests(t *testing.T, ds *Datastore) { require.NotNil(t, si) require.Equal(t, "foo.pkg", si.Name) + inHouseID, inHouseTitleID, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ + Title: "inhouse", + Source: "ios_apps", + TeamID: teamID, + Filename: "inhouse.ipa", + Extension: "ipa", + Platform: "ios", + UserID: user1.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, + }) + require.NoError(t, err) + require.NotZero(t, inHouseID) + require.NotZero(t, inHouseTitleID) + // non-existent host _, err = ds.InsertSoftwareInstallRequest(ctx, 12, si.InstallerID, fleet.HostSoftwareInstallOptions{}) require.ErrorAs(t, err, &nfe) @@ -350,6 +369,21 @@ func testSoftwareInstallRequests(t *testing.T, ds *Datastore) { _, err = ds.InsertSoftwareInstallRequest(ctx, hostPendingInstall.ID, si.InstallerID, fleet.HostSoftwareInstallOptions{}) require.NoError(t, err) + // Host with in-house app install pending + tag = "-in-house-pending_install" + hostInHousePendingInstall, err := ds.NewHost(ctx, &fleet.Host{ + Hostname: "ios-test" + tag + tc, + OsqueryHostID: ptr.String("osquery-ios" + tag + tc), + NodeKey: ptr.String("node-key-ios" + tag + tc), + UUID: uuid.NewString(), + Platform: "ios", + TeamID: teamID, + }) + require.NoError(t, err) + nanoEnroll(t, ds, hostInHousePendingInstall, false) + err = ds.InsertHostInHouseAppInstall(ctx, hostInHousePendingInstall.ID, inHouseID, inHouseTitleID, uuid.NewString(), fleet.HostSoftwareInstallOptions{}) + require.NoError(t, err) + // Host with software install failed tag = "-failed_install" hostFailedInstall, err := ds.NewHost(ctx, &fleet.Host{ @@ -370,6 +404,42 @@ func testSoftwareInstallRequests(t *testing.T, ds *Datastore) { }) require.NoError(t, err) + // Host with in-house app failed install + tag = "-in-house-failed_install" + hostInHouseFailedInstall, err := ds.NewHost(ctx, &fleet.Host{ + Hostname: "ios-test" + tag + tc, + OsqueryHostID: ptr.String("osquery-ios" + tag + tc), + NodeKey: ptr.String("node-key-ios" + tag + tc), + UUID: uuid.NewString(), + Platform: "ios", + TeamID: teamID, + }) + require.NoError(t, err) + nanoEnroll(t, ds, hostInHouseFailedInstall, false) + cmdUUID := uuid.NewString() + err = ds.InsertHostInHouseAppInstall(ctx, hostInHouseFailedInstall.ID, inHouseID, inHouseTitleID, cmdUUID, fleet.HostSoftwareInstallOptions{}) + require.NoError(t, err) + + // record a failed verification for that in-house app install + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + _, err := q.ExecContext(ctx, ` + INSERT INTO nano_command_results (id, command_uuid, status, result) + VALUES (?, ?, 'Error', '')`, + hostInHouseFailedInstall.UUID, cmdUUID) + if err != nil { + return err + } + _, err = q.ExecContext(ctx, ` + UPDATE host_in_house_software_installs + SET verification_command_uuid = ?, verification_failed_at = NOW(6) + WHERE command_uuid = ? AND host_id = ?`, + uuid.NewString(), cmdUUID, hostInHouseFailedInstall.ID, + ) + return err + }) + _, err = ds.activateNextUpcomingActivity(ctx, ds.writer(ctx), hostInHouseFailedInstall.ID, cmdUUID) + require.NoError(t, err) + // Host with software install successful tag = "-installed" hostInstalled, err := ds.NewHost(ctx, &fleet.Host{ @@ -390,6 +460,42 @@ func testSoftwareInstallRequests(t *testing.T, ds *Datastore) { }) require.NoError(t, err) + // host with in-house successful install + tag = "-in-house-installed" + hostInHouseInstalled, err := ds.NewHost(ctx, &fleet.Host{ + Hostname: "ios-test" + tag + tc, + OsqueryHostID: ptr.String("osquery-ios" + tag + tc), + NodeKey: ptr.String("node-key-ios" + tag + tc), + UUID: uuid.NewString(), + Platform: "ios", + TeamID: teamID, + }) + require.NoError(t, err) + nanoEnroll(t, ds, hostInHouseInstalled, false) + cmdUUID = uuid.NewString() + err = ds.InsertHostInHouseAppInstall(ctx, hostInHouseInstalled.ID, inHouseID, inHouseTitleID, cmdUUID, fleet.HostSoftwareInstallOptions{}) + require.NoError(t, err) + + // record a successful verification for that in-house app install + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + _, err := q.ExecContext(ctx, ` + INSERT INTO nano_command_results (id, command_uuid, status, result) + VALUES (?, ?, 'Acknowledged', '')`, + hostInHouseInstalled.UUID, cmdUUID) + if err != nil { + return err + } + _, err = q.ExecContext(ctx, ` + UPDATE host_in_house_software_installs + SET verification_command_uuid = ?, verification_at = NOW(6) + WHERE command_uuid = ? AND host_id = ?`, + uuid.NewString(), cmdUUID, hostInHouseInstalled.ID, + ) + return err + }) + _, err = ds.activateNextUpcomingActivity(ctx, ds.writer(ctx), hostInHouseInstalled.ID, cmdUUID) + require.NoError(t, err) + // Host with pending uninstall tag = "-pending_uninstall" hostPendingUninstall, err := ds.NewHost(ctx, &fleet.Host{ @@ -450,6 +556,22 @@ func testSoftwareInstallRequests(t *testing.T, ds *Datastore) { err = ds.InsertSoftwareUninstallRequest(ctx, "uuid"+tag+tc, 99999, si.InstallerID, false) assert.ErrorContains(t, err, "Host") + allHostIDs := []uint{ + hostPendingInstall.ID, + hostFailedInstall.ID, + hostInstalled.ID, + hostPendingUninstall.ID, + hostFailedUninstall.ID, + hostUninstalled.ID, + hostInHousePendingInstall.ID, + hostInHouseFailedInstall.ID, + hostInHouseInstalled.ID, + } + for _, hid := range allHostIDs { + err = ds.AddLabelsToHost(ctx, hid, []uint{labelsByName[fleet.BuiltinLabelNameAllHosts]}) + require.NoError(t, err) + } + userTeamFilter := fleet.TeamFilter{ User: &fleet.User{GlobalRole: ptr.String("admin")}, } @@ -462,107 +584,175 @@ func testSoftwareInstallRequests(t *testing.T, ds *Datastore) { teamFilter = ptr.Uint(0) } - // list hosts with software install pending requests - expectStatus := fleet.SoftwareInstallPending - hosts, err := ds.ListHosts(ctx, userTeamFilter, fleet.HostListOptions{ - ListOptions: fleet.ListOptions{PerPage: 100}, - SoftwareTitleIDFilter: installerMeta.TitleID, - SoftwareStatusFilter: &expectStatus, - TeamFilter: teamFilter, - }) - require.NoError(t, err) - // get the names of hosts, useful for debugging getHostNames := func(hosts []*fleet.Host) []string { - hostNames := make([]string, len(hosts)) + hostNames := make([]string, 0, len(hosts)) for _, h := range hosts { hostNames = append(hostNames, h.Hostname) } return hostNames } - require.Len(t, hosts, 1, getHostNames(hosts)) - require.Equal(t, hostPendingInstall.ID, hosts[0].ID) + pluckHostIDs := func(hosts []*fleet.Host) []uint { + hostIDs := make([]uint, 0, len(hosts)) + for _, h := range hosts { + hostIDs = append(hostIDs, h.ID) + } + return hostIDs + } - // list hosts with all pending requests - expectStatus = fleet.SoftwarePending - hosts, err = ds.ListHosts(ctx, userTeamFilter, fleet.HostListOptions{ - ListOptions: fleet.ListOptions{PerPage: 100}, - SoftwareTitleIDFilter: installerMeta.TitleID, - SoftwareStatusFilter: &expectStatus, - TeamFilter: teamFilter, - }) - require.NoError(t, err) - require.Len(t, hosts, 2, getHostNames(hosts)) - assert.ElementsMatch(t, []uint{hostPendingInstall.ID, hostPendingUninstall.ID}, []uint{hosts[0].ID, hosts[1].ID}) + cases := []struct { + desc string + opts fleet.HostListOptions + wantHostIDs []uint + }{ + { + desc: "list hosts with software install pending requests", + opts: fleet.HostListOptions{ + ListOptions: fleet.ListOptions{PerPage: 100}, + SoftwareTitleIDFilter: installerMeta.TitleID, + SoftwareStatusFilter: ptr.T(fleet.SoftwareInstallPending), + TeamFilter: teamFilter, + }, + wantHostIDs: []uint{hostPendingInstall.ID}, + }, + { + desc: "list hosts with in-house app pending install", + opts: fleet.HostListOptions{ + ListOptions: fleet.ListOptions{PerPage: 100}, + SoftwareTitleIDFilter: &inHouseTitleID, + SoftwareStatusFilter: ptr.T(fleet.SoftwareInstallPending), + TeamFilter: teamFilter, + }, + wantHostIDs: []uint{hostInHousePendingInstall.ID}, + }, + { + desc: "list hosts with all pending requests", + opts: fleet.HostListOptions{ + ListOptions: fleet.ListOptions{PerPage: 100}, + SoftwareTitleIDFilter: installerMeta.TitleID, + SoftwareStatusFilter: ptr.T(fleet.SoftwarePending), + TeamFilter: teamFilter, + }, + wantHostIDs: []uint{hostPendingInstall.ID, hostPendingUninstall.ID}, + }, + { + desc: "list hosts with in-house app all pending requests", + opts: fleet.HostListOptions{ + ListOptions: fleet.ListOptions{PerPage: 100}, + SoftwareTitleIDFilter: &inHouseTitleID, + SoftwareStatusFilter: ptr.T(fleet.SoftwarePending), + TeamFilter: teamFilter, + }, + wantHostIDs: []uint{hostInHousePendingInstall.ID}, + }, + { + desc: "list hosts with software install failed requests", + opts: fleet.HostListOptions{ + ListOptions: fleet.ListOptions{PerPage: 100}, + SoftwareTitleIDFilter: installerMeta.TitleID, + SoftwareStatusFilter: ptr.T(fleet.SoftwareInstallFailed), + TeamFilter: teamFilter, + }, + wantHostIDs: []uint{hostFailedInstall.ID}, + }, + { + desc: "list hosts with in-house install failed requests", + opts: fleet.HostListOptions{ + ListOptions: fleet.ListOptions{PerPage: 100}, + SoftwareTitleIDFilter: &inHouseTitleID, + SoftwareStatusFilter: ptr.T(fleet.SoftwareInstallFailed), + TeamFilter: teamFilter, + }, + wantHostIDs: []uint{hostInHouseFailedInstall.ID}, + }, + { + desc: "list hosts with all failed requests", + opts: fleet.HostListOptions{ + ListOptions: fleet.ListOptions{PerPage: 100}, + SoftwareTitleIDFilter: installerMeta.TitleID, + SoftwareStatusFilter: ptr.T(fleet.SoftwareFailed), + TeamFilter: teamFilter, + }, + wantHostIDs: []uint{hostFailedInstall.ID, hostFailedUninstall.ID}, + }, + { + desc: "list hosts with in-house all failed requests", + opts: fleet.HostListOptions{ + ListOptions: fleet.ListOptions{PerPage: 100}, + SoftwareTitleIDFilter: &inHouseTitleID, + SoftwareStatusFilter: ptr.T(fleet.SoftwareFailed), + TeamFilter: teamFilter, + }, + wantHostIDs: []uint{hostInHouseFailedInstall.ID}, + }, + { + desc: "list hosts with software installed", + opts: fleet.HostListOptions{ + ListOptions: fleet.ListOptions{PerPage: 100}, + SoftwareTitleIDFilter: installerMeta.TitleID, + SoftwareStatusFilter: ptr.T(fleet.SoftwareInstalled), + TeamFilter: teamFilter, + }, + wantHostIDs: []uint{hostInstalled.ID}, + }, + { + desc: "list hosts with in-house app installed", + opts: fleet.HostListOptions{ + ListOptions: fleet.ListOptions{PerPage: 100}, + SoftwareTitleIDFilter: &inHouseTitleID, + SoftwareStatusFilter: ptr.T(fleet.SoftwareInstalled), + TeamFilter: teamFilter, + }, + wantHostIDs: []uint{hostInHouseInstalled.ID}, + }, + { + desc: "list hosts with pending software uninstall requests", + opts: fleet.HostListOptions{ + ListOptions: fleet.ListOptions{PerPage: 100}, + SoftwareTitleIDFilter: installerMeta.TitleID, + SoftwareStatusFilter: ptr.T(fleet.SoftwareUninstallPending), + TeamFilter: teamFilter, + }, + wantHostIDs: []uint{hostPendingUninstall.ID}, + }, + { + desc: "list hosts with failed software uninstall requests", + opts: fleet.HostListOptions{ + ListOptions: fleet.ListOptions{PerPage: 100}, + SoftwareTitleIDFilter: installerMeta.TitleID, + SoftwareStatusFilter: ptr.T(fleet.SoftwareUninstallFailed), + TeamFilter: teamFilter, + }, + wantHostIDs: []uint{hostFailedUninstall.ID}, + }, + { + desc: "list all hosts with the software title", + opts: fleet.HostListOptions{ + ListOptions: fleet.ListOptions{PerPage: 100}, + SoftwareTitleIDFilter: installerMeta.TitleID, + TeamFilter: teamFilter, + }, + wantHostIDs: []uint{}, + }, + } + for _, c := range cases { + t.Run(c.desc, func(t *testing.T) { + hosts, err := ds.ListHosts(ctx, userTeamFilter, c.opts) + require.NoError(t, err) + require.Len(t, hosts, len(c.wantHostIDs), getHostNames(hosts)) + require.ElementsMatch(t, c.wantHostIDs, pluckHostIDs(hosts)) - // list hosts with software install failed requests - expectStatus = fleet.SoftwareInstallFailed - hosts, err = ds.ListHosts(ctx, userTeamFilter, fleet.HostListOptions{ - ListOptions: fleet.ListOptions{PerPage: 100}, - SoftwareTitleIDFilter: installerMeta.TitleID, - SoftwareStatusFilter: &expectStatus, - TeamFilter: teamFilter, - }) - require.NoError(t, err) - require.Len(t, hosts, 1, getHostNames(hosts)) - assert.ElementsMatch(t, []uint{hostFailedInstall.ID}, []uint{hosts[0].ID}) - - // list hosts with all failed requests - expectStatus = fleet.SoftwareFailed - hosts, err = ds.ListHosts(ctx, userTeamFilter, fleet.HostListOptions{ - ListOptions: fleet.ListOptions{PerPage: 100}, - SoftwareTitleIDFilter: installerMeta.TitleID, - SoftwareStatusFilter: &expectStatus, - TeamFilter: teamFilter, - }) - require.NoError(t, err) - require.Len(t, hosts, 2, getHostNames(hosts)) - assert.ElementsMatch(t, []uint{hostFailedInstall.ID, hostFailedUninstall.ID}, []uint{hosts[0].ID, hosts[1].ID}) - - // list hosts with software installed - expectStatus = fleet.SoftwareInstalled - hosts, err = ds.ListHosts(ctx, userTeamFilter, fleet.HostListOptions{ - ListOptions: fleet.ListOptions{PerPage: 100}, - SoftwareTitleIDFilter: installerMeta.TitleID, - SoftwareStatusFilter: &expectStatus, - TeamFilter: teamFilter, - }) - require.NoError(t, err) - require.Len(t, hosts, 1, getHostNames(hosts)) - assert.ElementsMatch(t, []uint{hostInstalled.ID}, []uint{hosts[0].ID}) - - // list hosts with pending software uninstall requests - expectStatus = fleet.SoftwareUninstallPending - hosts, err = ds.ListHosts(ctx, userTeamFilter, fleet.HostListOptions{ - ListOptions: fleet.ListOptions{PerPage: 100}, - SoftwareTitleIDFilter: installerMeta.TitleID, - SoftwareStatusFilter: &expectStatus, - TeamFilter: teamFilter, - }) - require.NoError(t, err) - require.Len(t, hosts, 1, getHostNames(hosts)) - assert.ElementsMatch(t, []uint{hostPendingUninstall.ID}, []uint{hosts[0].ID}) - - // list hosts with failed software uninstall requests - expectStatus = fleet.SoftwareUninstallFailed - hosts, err = ds.ListHosts(ctx, userTeamFilter, fleet.HostListOptions{ - ListOptions: fleet.ListOptions{PerPage: 100}, - SoftwareTitleIDFilter: installerMeta.TitleID, - SoftwareStatusFilter: &expectStatus, - TeamFilter: teamFilter, - }) - require.NoError(t, err) - require.Len(t, hosts, 1, getHostNames(hosts)) - assert.ElementsMatch(t, []uint{hostFailedUninstall.ID}, []uint{hosts[0].ID}) - - // list all hosts with the software title that shows up in host_software (after fleetd software query is run) - hosts, err = ds.ListHosts(ctx, userTeamFilter, fleet.HostListOptions{ - ListOptions: fleet.ListOptions{PerPage: 100}, - SoftwareTitleIDFilter: installerMeta.TitleID, - TeamFilter: teamFilter, - }) - require.NoError(t, err) - assert.Empty(t, hosts) + if c.opts.SoftwareStatusFilter == nil && c.opts.SoftwareTitleIDFilter != nil { + // for list hosts by label, if no status is provided, the title ID filter is ignored/no-op, + // so all host IDs are returned + c.wantHostIDs = allHostIDs + } + hosts, err = ds.ListHostsInLabel(ctx, userTeamFilter, labelsByName[fleet.BuiltinLabelNameAllHosts], c.opts) + require.NoError(t, err) + require.Len(t, hosts, len(c.wantHostIDs), getHostNames(hosts)) + require.ElementsMatch(t, c.wantHostIDs, pluckHostIDs(hosts)) + }) + } summary, err := ds.GetSummaryHostSoftwareInstalls(ctx, installerMeta.InstallerID) require.NoError(t, err) @@ -573,6 +763,14 @@ func testSoftwareInstallRequests(t *testing.T, ds *Datastore) { PendingUninstall: 1, FailedUninstall: 1, }, *summary) + + vppSummary, err := ds.GetSummaryHostInHouseAppInstalls(ctx, teamID, inHouseID) + require.NoError(t, err) + require.Equal(t, fleet.VPPAppStatusSummary{ + Installed: 1, + Pending: 1, + Failed: 1, + }, *vppSummary) }) } } diff --git a/server/datastore/mysql/software_test.go b/server/datastore/mysql/software_test.go index ab30e4dcec..b005a18f59 100644 --- a/server/datastore/mysql/software_test.go +++ b/server/datastore/mysql/software_test.go @@ -100,6 +100,7 @@ func TestSoftware(t *testing.T) { {"PreInsertSoftwareInventory", testPreInsertSoftwareInventory}, {"ListHostSoftwareWithExtensionFor", testListHostSoftwareWithExtensionFor}, {"LongestCommonPrefix", testLongestCommonPrefix}, + {"ListHostSoftwareInHouseApps", testListHostSoftwareInHouseApps}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -9649,3 +9650,390 @@ func findSoftware(sw []*fleet.HostSoftwareWithInstaller, name, extensionFor stri } return nil } + +func testListHostSoftwareInHouseApps(t *testing.T, ds *Datastore) { + ctx := context.Background() + t.Cleanup(func() { ds.testActivateSpecificNextActivities = nil }) + + // use time -1s to ensure host label-updated-at is before the labels creation timestamp + host := test.NewHost(t, ds, "host1", "", "host1key", "host1uuid", time.Now().Add(-1*time.Second), test.WithPlatform("ios")) + nanoEnroll(t, ds, host, false) + otherHost := test.NewHost(t, ds, "host2", "", "host2key", "host2uuid", time.Now(), test.WithPlatform("ubuntu")) + require.NotNil(t, otherHost) + opts := fleet.HostSoftwareTitleListOptions{ + IsMDMEnrolled: true, // required for vpp/in-house apps, and the host is MDM-enrolled + ListOptions: fleet.ListOptions{PerPage: 11, IncludeMetadata: true, OrderKey: "name", TestSecondaryOrderKey: "source"}, + } + + // create a distinct team + team, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1"}) + require.NoError(t, err) + + user, err := ds.NewUser(ctx, &fleet.User{ + Password: []byte("p4ssw0rd.123"), + Name: "user1", + Email: "user1@example.com", + GlobalRole: ptr.String(fleet.RoleAdmin), + }) + require.NoError(t, err) + + // create some in-house apps for no-team (this creates both iOS and iPadOS, + // but returns the iOS ids) + inHouseID1, inHouseTitleID1, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ + Title: "inhouse1", + Source: "ios_apps", + Filename: "inhouse1.ipa", + Extension: "ipa", + BundleIdentifier: "inhouse1", + UserID: user.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, + }) + require.NoError(t, err) + require.NotZero(t, inHouseID1) + require.NotZero(t, inHouseTitleID1) + + inHouseID2, inHouseTitleID2, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ + Title: "inhouse2", + Source: "ios_apps", + Filename: "inhouse2.ipa", + Extension: "ipa", + BundleIdentifier: "inhouse2", + UserID: user.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, + }) + require.NoError(t, err) + require.NotZero(t, inHouseID2) + require.NotZero(t, inHouseTitleID2) + + inHouseID3, inHouseTitleID3, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ + Title: "inhouse3", + Source: "ios_apps", + Filename: "inhouse3.ipa", + Extension: "ipa", + BundleIdentifier: "inhouse3", + UserID: user.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, + }) + require.NoError(t, err) + require.NotZero(t, inHouseID3) + require.NotZero(t, inHouseTitleID3) + + // add an in-house app on the team, should not affect the host's results + inHouseIDTm, inHouseTitleIDTm, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ + Title: "inhouse-tm", + Source: "ios_apps", + Filename: "inhouse-tm.ipa", + Extension: "ipa", + BundleIdentifier: "inhouse-tm", + TeamID: &team.ID, + UserID: user.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, + }) + require.NoError(t, err) + require.NotZero(t, inHouseIDTm) + require.NotZero(t, inHouseTitleIDTm) + + // add software to the host + software := []fleet.Software{ + {Name: "a", Version: "0.0.1", Source: "chrome_extensions"}, + {Name: "b", Version: "0.0.3", Source: "apps"}, + } + _, err = ds.UpdateHostSoftware(ctx, host.ID, software) + require.NoError(t, err) + require.NoError(t, ds.SyncHostsSoftware(ctx, time.Now())) + require.NoError(t, ds.SyncHostsSoftwareTitles(ctx, time.Now())) + require.NoError(t, ds.LoadHostSoftware(ctx, host, false)) + + // make software "b" vulnerable + var swBID uint + if host.Software[0].Name == "b" { + swBID = host.Software[0].ID + } else { + swBID = host.Software[1].ID + } + cpes := []fleet.SoftwareCPE{{SoftwareID: swBID, CPE: "somecpe"}} + _, err = ds.UpsertSoftwareCPEs(ctx, cpes) + require.NoError(t, err) + require.NoError(t, ds.LoadHostSoftware(context.Background(), host, false)) + + vulns := []fleet.SoftwareVulnerability{ + {SoftwareID: swBID, CVE: "CVE-2022-0001"}, + } + for _, v := range vulns { + _, err = ds.InsertSoftwareVulnerability(ctx, v, fleet.NVDSource) + require.NoError(t, err) + } + require.NoError(t, ds.LoadHostSoftware(ctx, host, false)) + + pluckSoftwareNames := func(sw []*fleet.HostSoftwareWithInstaller) []string { + names := make([]string, 0, len(sw)) + for _, s := range sw { + names = append(names, s.Name) + } + return names + } + + // there should be 2 titles installed + sw, _, err := ds.ListHostSoftware(ctx, host, opts) + require.NoError(t, err) + require.Len(t, sw, 2) + require.Equal(t, []string{"a", "b"}, pluckSoftwareNames(sw)) + + // 5 titles including the in-house apps available for install + opts.IncludeAvailableForInstall = true + sw, _, err = ds.ListHostSoftware(ctx, host, opts) + require.NoError(t, err) + require.Len(t, sw, 5) + require.Equal(t, []string{"a", "b", "inhouse1", "inhouse2", "inhouse3"}, pluckSoftwareNames(sw)) + + // vulnerable only returns "b" + opts.IncludeAvailableForInstall = false + opts.VulnerableOnly = true + sw, _, err = ds.ListHostSoftware(ctx, host, opts) + require.NoError(t, err) + require.Len(t, sw, 1) + require.Equal(t, []string{"b"}, pluckSoftwareNames(sw)) + + // only available for install returns the in-house apps + opts.VulnerableOnly = false + opts.OnlyAvailableForInstall = true + sw, _, err = ds.ListHostSoftware(ctx, host, opts) + require.NoError(t, err) + require.Len(t, sw, 3) + require.Equal(t, []string{"inhouse1", "inhouse2", "inhouse3"}, pluckSoftwareNames(sw)) + + // make inhouse-1 pending install + inhouse1InstallCmd := createInHouseAppInstallRequest(t, ds, host.ID, inHouseID1, inHouseTitleID1, user) + ds.testActivateSpecificNextActivities = []string{inhouse1InstallCmd} + _, err = ds.activateNextUpcomingActivity(ctx, ds.writer(ctx), host.ID, "") + require.NoError(t, err) + + // software inventory, no available for install, does not include the pending + // as it's not installed yet + opts.OnlyAvailableForInstall = false + sw, _, err = ds.ListHostSoftware(ctx, host, opts) + require.NoError(t, err) + require.Len(t, sw, 2) + require.Equal(t, []string{"a", "b"}, pluckSoftwareNames(sw)) + + // TODO(mna): thinking of leaving this on here for a bit as I've seen it fail + // with some flakiness before but couldn't repro locally nor on CI. Error was + // in createInHouseAppInstallResultVerified, the nano command for the result + // was not found. + ExecAdhocSQL(t, ds, func(tx sqlx.ExtContext) error { + fmt.Println(">>> command uuid: ", inhouse1InstallCmd) + DumpTable(t, tx, "hosts", "id", "uuid", "platform", "hostname", "team_id") + DumpTable(t, tx, "nano_devices") + DumpTable(t, tx, "nano_commands") + DumpTable(t, tx, "nano_command_results") + return nil + }) + + // make inhouse-1 installed, inhouse-2 pending + createInHouseAppInstallResultVerified(t, ds, host, inhouse1InstallCmd, "Acknowledged") + inhouse2InstallCmd := createInHouseAppInstallRequest(t, ds, host.ID, inHouseID2, inHouseTitleID2, user) + ds.testActivateSpecificNextActivities = []string{inhouse2InstallCmd} + _, err = ds.activateNextUpcomingActivity(ctx, ds.writer(ctx), host.ID, "") + require.NoError(t, err) + + // mark it as reported as installed on the host + software = []fleet.Software{ + {Name: "a", Version: "0.0.1", Source: "chrome_extensions"}, + {Name: "b", Version: "0.0.3", Source: "apps"}, + {Name: "inhouse1", Version: "0.0.3", Source: "ios_apps", ApplicationID: ptr.String("inhouse1")}, + } + _, err = ds.UpdateHostSoftware(ctx, host.ID, software) + require.NoError(t, err) + require.NoError(t, ds.SyncHostsSoftware(ctx, time.Now())) + require.NoError(t, ds.SyncHostsSoftwareTitles(ctx, time.Now())) + require.NoError(t, ds.LoadHostSoftware(ctx, host, false)) + + // software inventory, no available for install, includes the installed one + opts.OnlyAvailableForInstall = false + sw, _, err = ds.ListHostSoftware(ctx, host, opts) + require.NoError(t, err) + require.Len(t, sw, 3) + require.Equal(t, []string{"a", "b", "inhouse1"}, pluckSoftwareNames(sw)) + require.Equal(t, sw[2].Status, ptr.T(fleet.SoftwareInstalled)) + require.NotNil(t, sw[2].SoftwarePackage) + require.Equal(t, sw[2].SoftwarePackage.Name, "inhouse1") + require.Equal(t, sw[2].SoftwarePackage.Platform, "ios") + require.Equal(t, sw[2].SoftwarePackage.SelfService, ptr.Bool(false)) + require.NotNil(t, sw[2].SoftwarePackage.LastInstall) + require.Equal(t, sw[2].SoftwarePackage.LastInstall.CommandUUID, inhouse1InstallCmd) + + // software with available for install, also includes the pending one + opts.IncludeAvailableForInstall = true + sw, _, err = ds.ListHostSoftware(ctx, host, opts) + require.NoError(t, err) + require.Len(t, sw, 5) + require.Equal(t, []string{"a", "b", "inhouse1", "inhouse2", "inhouse3"}, pluckSoftwareNames(sw)) + require.Equal(t, sw[2].Status, ptr.T(fleet.SoftwareInstalled)) + require.Equal(t, sw[3].Status, ptr.T(fleet.SoftwareInstallPending)) + require.NotNil(t, sw[3].SoftwarePackage) + require.Equal(t, sw[3].SoftwarePackage.Name, "inhouse2") + require.Equal(t, sw[3].SoftwarePackage.Platform, "ios") + require.Equal(t, sw[3].SoftwarePackage.SelfService, ptr.Bool(false)) + require.NotNil(t, sw[3].SoftwarePackage.LastInstall) + require.Equal(t, sw[3].SoftwarePackage.LastInstall.CommandUUID, inhouse2InstallCmd) + require.Nil(t, sw[4].Status) + require.NotNil(t, sw[4].SoftwarePackage) + require.Equal(t, sw[4].SoftwarePackage.Name, "inhouse3") + require.Equal(t, sw[4].SoftwarePackage.Platform, "ios") + require.Equal(t, sw[4].SoftwarePackage.SelfService, ptr.Bool(false)) + require.Nil(t, sw[4].SoftwarePackage.LastInstall) + + // add inhouse3 as installed outside of Fleet + software = []fleet.Software{ + {Name: "a", Version: "0.0.1", Source: "chrome_extensions"}, + {Name: "b", Version: "0.0.3", Source: "apps"}, + {Name: "inhouse1", Version: "0.0.3", Source: "ios_apps", ApplicationID: ptr.String("inhouse1")}, + {Name: "inhouse3", Version: "0.0.4", Source: "ios_apps", ApplicationID: ptr.String("inhouse3")}, + } + _, err = ds.UpdateHostSoftware(ctx, host.ID, software) + require.NoError(t, err) + require.NoError(t, ds.SyncHostsSoftware(ctx, time.Now())) + require.NoError(t, ds.SyncHostsSoftwareTitles(ctx, time.Now())) + require.NoError(t, ds.LoadHostSoftware(ctx, host, false)) + + // software inventory includes it + opts.IncludeAvailableForInstall = false + sw, _, err = ds.ListHostSoftware(ctx, host, opts) + require.NoError(t, err) + require.Len(t, sw, 4) + require.Equal(t, []string{"a", "b", "inhouse1", "inhouse3"}, pluckSoftwareNames(sw)) + require.Nil(t, sw[3].Status) + + // record a failed install for inhouse2 + createInHouseAppInstallResultVerified(t, ds, host, inhouse2InstallCmd, "Error") + + // software inventory still does not list it + opts.IncludeAvailableForInstall = false + sw, _, err = ds.ListHostSoftware(ctx, host, opts) + require.NoError(t, err) + require.Len(t, sw, 4) + require.Equal(t, []string{"a", "b", "inhouse1", "inhouse3"}, pluckSoftwareNames(sw)) + + // software library shows it as failed + opts.OnlyAvailableForInstall = true + sw, _, err = ds.ListHostSoftware(ctx, host, opts) + require.NoError(t, err) + require.Len(t, sw, 3) + require.Equal(t, []string{"inhouse1", "inhouse2", "inhouse3"}, pluckSoftwareNames(sw)) + require.Equal(t, sw[1].Status, ptr.T(fleet.SoftwareInstallFailed)) + require.NotNil(t, sw[1].SoftwarePackage) + require.Equal(t, sw[1].SoftwarePackage.Name, "inhouse2") + require.Equal(t, sw[1].SoftwarePackage.Platform, "ios") + require.Equal(t, sw[1].SoftwarePackage.SelfService, ptr.Bool(false)) + require.NotNil(t, sw[1].SoftwarePackage.LastInstall) + require.Equal(t, sw[1].SoftwarePackage.LastInstall.CommandUUID, inhouse2InstallCmd) + + // test with label conditions + lbl1, err := ds.NewLabel(ctx, &fleet.Label{Name: "label1", LabelMembershipType: fleet.LabelMembershipTypeManual}) + require.NoError(t, err) + lbl2, err := ds.NewLabel(ctx, &fleet.Label{Name: "label2", Query: "select 1", LabelMembershipType: fleet.LabelMembershipTypeDynamic}) + require.NoError(t, err) + lbl3, err := ds.NewLabel(ctx, &fleet.Label{Name: "label3", LabelMembershipType: fleet.LabelMembershipTypeManual}) + require.NoError(t, err) + + // create an in-house app with include any labels + inHouseIDIncl, inHouseTitleIDIncl, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ + Title: "inhouseincl", + Source: "ios_apps", + Filename: "inhouseincl.ipa", + Extension: "ipa", + BundleIdentifier: "inhouseincl", + UserID: user.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{ + LabelScope: fleet.LabelScopeIncludeAny, + ByName: map[string]fleet.LabelIdent{ + lbl1.Name: {LabelID: lbl1.ID, LabelName: lbl1.Name}, + lbl2.Name: {LabelID: lbl2.ID, LabelName: lbl2.Name}, + }, + }, + }) + require.NoError(t, err) + require.NotZero(t, inHouseIDIncl) + require.NotZero(t, inHouseTitleIDIncl) + + // create an in-house app with exclude any labels + inHouseIDExcl, inHouseTitleIDExcl, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ + Title: "inhouseexcl", + Source: "ios_apps", + Filename: "inhouseexcl.ipa", + Extension: "ipa", + BundleIdentifier: "inhouseexcl", + UserID: user.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{ + LabelScope: fleet.LabelScopeExcludeAny, + ByName: map[string]fleet.LabelIdent{ + lbl2.Name: {LabelID: lbl2.ID, LabelName: lbl2.Name}, + lbl3.Name: {LabelID: lbl3.ID, LabelName: lbl3.Name}, + }, + }, + }) + require.NoError(t, err) + require.NotZero(t, inHouseIDExcl) + require.NotZero(t, inHouseTitleIDExcl) + + // software inventory does not list those + opts.OnlyAvailableForInstall = true + sw, _, err = ds.ListHostSoftware(ctx, host, opts) + require.NoError(t, err) + require.Len(t, sw, 3) + require.Equal(t, []string{"inhouse1", "inhouse2", "inhouse3"}, pluckSoftwareNames(sw)) + + // make host a member of lbl1 + err = ds.AddLabelsToHost(ctx, host.ID, []uint{lbl1.ID}) + require.NoError(t, err) + + // software inventory now shows the include in-house app + opts.OnlyAvailableForInstall = true + sw, _, err = ds.ListHostSoftware(ctx, host, opts) + require.NoError(t, err) + require.Len(t, sw, 4) + require.Equal(t, []string{"inhouse1", "inhouse2", "inhouse3", "inhouseincl"}, pluckSoftwareNames(sw)) + + // update the host's labels updated at timestamp so the exclude any condition kicks in + host.LabelUpdatedAt = time.Now() + host.PolicyUpdatedAt = time.Now() + err = ds.UpdateHost(ctx, host) + require.NoError(t, err) + + // software inventory now shows the exclude in-house app + opts.OnlyAvailableForInstall = true + sw, _, err = ds.ListHostSoftware(ctx, host, opts) + require.NoError(t, err) + require.Len(t, sw, 5) + require.Equal(t, []string{"inhouse1", "inhouse2", "inhouse3", "inhouseexcl", "inhouseincl"}, pluckSoftwareNames(sw)) + + // make host a member of lbl3 + err = ds.AddLabelsToHost(ctx, host.ID, []uint{lbl3.ID}) + require.NoError(t, err) + + // exclude in-house app is now removed + opts.OnlyAvailableForInstall = true + sw, _, err = ds.ListHostSoftware(ctx, host, opts) + require.NoError(t, err) + require.Len(t, sw, 4) + require.Equal(t, []string{"inhouse1", "inhouse2", "inhouse3", "inhouseincl"}, pluckSoftwareNames(sw)) + + // Useful for debugging: + // ExecAdhocSQL(t, ds, func(tx sqlx.ExtContext) error { + // DumpTable(t, tx, "hosts", "id", "uuid", "platform", "hostname", "team_id") + // DumpTable(t, tx, "host_software") + // DumpTable(t, tx, "software", "id", "title_id") + // DumpTable(t, tx, "in_house_apps", "id", "title_id", "global_or_team_id", "name", "version", "platform") + // DumpTable(t, tx, "in_house_app_labels") + // DumpTable(t, tx, "software_titles", "id", "name", "source", "bundle_identifier", "additional_identifier", "application_id", "unique_identifier") + // return nil + // }) + + // the other host is unaffected, does not see inhouse-tm since it is not + // mdm-enrolled and wrong platform + opts.IsMDMEnrolled = false + opts.IncludeAvailableForInstall = true + sw, _, err = ds.ListHostSoftware(ctx, otherHost, opts) + require.NoError(t, err) + require.Len(t, sw, 0) +} diff --git a/server/datastore/mysql/software_title_icons.go b/server/datastore/mysql/software_title_icons.go index bc8ca12f1c..fa1da51d78 100644 --- a/server/datastore/mysql/software_title_icons.go +++ b/server/datastore/mysql/software_title_icons.go @@ -119,8 +119,9 @@ func (ds *Datastore) DeleteIconsAssociatedWithTitlesWithoutInstallers(ctx contex SELECT title_id FROM vpp_apps va JOIN vpp_apps_teams vat ON vat.adam_id = va.adam_id AND vat.platform = va.platform WHERE global_or_team_id = ? - ) AND software_title_id NOT IN (SELECT title_id FROM software_installers WHERE global_or_team_id = ?)`, - teamID, teamID, teamID) + ) AND software_title_id NOT IN (SELECT title_id FROM software_installers WHERE global_or_team_id = ?) + AND software_title_id NOT IN (SELECT title_id FROM in_house_apps WHERE global_or_team_id = ?)`, + teamID, teamID, teamID, teamID) if err != nil { return ctxerr.Wrap(ctx, err, "cleaning up icons not associated with software installers") } diff --git a/server/datastore/mysql/software_titles.go b/server/datastore/mysql/software_titles.go index d446517472..59fec34e8a 100644 --- a/server/datastore/mysql/software_titles.go +++ b/server/datastore/mysql/software_titles.go @@ -23,16 +23,19 @@ func (ds *Datastore) SoftwareTitleByID(ctx context.Context, id uint, teamID *uin teamFilter string // used to filter software titles host counts by team softwareInstallerGlobalOrTeamIDFilter string vppAppsTeamsGlobalOrTeamIDFilter string + inHouseAppsTeamsGlobalOrTeamIDFilter string ) if teamID != nil { teamFilter = fmt.Sprintf("sthc.team_id = %d AND sthc.global_stats = 0", *teamID) softwareInstallerGlobalOrTeamIDFilter = fmt.Sprintf("si.global_or_team_id = %d", *teamID) vppAppsTeamsGlobalOrTeamIDFilter = fmt.Sprintf("vat.global_or_team_id = %d", *teamID) + inHouseAppsTeamsGlobalOrTeamIDFilter = fmt.Sprintf("iha.global_or_team_id = %d", *teamID) } else { teamFilter = ds.whereFilterGlobalOrTeamIDByTeams(tmFilter, "sthc") softwareInstallerGlobalOrTeamIDFilter = "TRUE" vppAppsTeamsGlobalOrTeamIDFilter = "TRUE" + inHouseAppsTeamsGlobalOrTeamIDFilter = "TRUE" } // Select software title but filter out if the software has zero host counts @@ -49,14 +52,16 @@ SELECT MAX(sthc.updated_at) AS counts_updated_at, COUNT(si.id) as software_installers_count, COUNT(vat.adam_id) AS vpp_apps_count, + COUNT(iha.id) AS in_house_apps_count, vap.icon_url AS icon_url FROM software_titles st LEFT JOIN software_titles_host_counts sthc ON sthc.software_title_id = st.id AND sthc.hosts_count > 0 AND (%s) LEFT JOIN software_installers si ON si.title_id = st.id AND %s LEFT JOIN vpp_apps vap ON vap.title_id = st.id LEFT JOIN vpp_apps_teams vat ON vat.adam_id = vap.adam_id AND vat.platform = vap.platform AND %s +LEFT JOIN in_house_apps iha ON iha.title_id = st.id AND %s WHERE st.id = ? AND - (sthc.hosts_count > 0 OR vat.adam_id IS NOT NULL OR si.id IS NOT NULL) + (sthc.hosts_count > 0 OR vat.adam_id IS NOT NULL OR si.id IS NOT NULL OR iha.title_id IS NOT NULL) GROUP BY st.id, st.name, @@ -65,7 +70,7 @@ GROUP BY st.bundle_identifier, hosts_count, vap.icon_url - `, teamFilter, softwareInstallerGlobalOrTeamIDFilter, vppAppsTeamsGlobalOrTeamIDFilter, + `, teamFilter, softwareInstallerGlobalOrTeamIDFilter, vppAppsTeamsGlobalOrTeamIDFilter, inHouseAppsTeamsGlobalOrTeamIDFilter, ) var title fleet.SoftwareTitle if err := sqlx.GetContext(ctx, ds.reader(ctx), &title, selectSoftwareTitleStmt, id); err != nil { @@ -137,6 +142,7 @@ func (ds *Datastore) ListSoftwareTitles( if err != nil { return nil, 0, nil, ctxerr.Wrap(ctx, err, "building software titles select statement") } + // build the count statement before adding the pagination constraints to `getTitlesStmt` getTitlesCountStmt := fmt.Sprintf(`SELECT COUNT(DISTINCT s.id) FROM (%s) AS s`, getTitlesStmt) @@ -156,6 +162,10 @@ func (ds *Datastore) ListSoftwareTitles( VPPAppIconURL *string `db:"vpp_app_icon_url"` VPPInstallDuringSetup *bool `db:"vpp_install_during_setup"` FleetMaintainedAppID *uint `db:"fleet_maintained_app_id"` + InHouseAppName *string `db:"in_house_app_name"` + InHouseAppVersion *string `db:"in_house_app_version"` + InHouseAppPlatform *string `db:"in_house_app_platform"` + InHouseAppStorageID *string `db:"in_house_app_storage_id"` } var softwareList []*softwareTitle getTitlesStmt, args = appendListOptionsWithCursorToSQL(getTitlesStmt, args, &opt.ListOptions) @@ -205,6 +215,31 @@ func (ds *Datastore) ListSoftwareTitles( } } + // promote in-house app properties to their proper destination fields + if title.InHouseAppName != nil { + var version string + if title.InHouseAppVersion != nil { + version = *title.InHouseAppVersion + } + var platform string + if title.InHouseAppPlatform != nil { + platform = *title.InHouseAppPlatform + } + + // as per the spec, in-house apps are returned as software packages + // https://github.com/fleetdm/fleet/pull/33950/files + title.SoftwarePackage = &fleet.SoftwarePackageOrApp{ + Name: *title.InHouseAppName, + Version: version, + Platform: platform, + SelfService: ptr.Bool(false), + } + + // this is set directly for software packages, but if this is an in-house + // app we need to set it here + title.HashSHA256 = title.InHouseAppStorageID + } + // promote the VPP app id and version to the proper destination fields if title.VPPAppAdamID != nil { var version string @@ -318,7 +353,6 @@ func (ds *Datastore) ListSoftwareTitles( titles := make([]fleet.SoftwareTitleListResult, 0, len(softwareList)) for _, st := range softwareList { - st := st titles = append(titles, st.SoftwareTitleListResult) } @@ -383,11 +417,15 @@ SELECT ,vap.latest_version as vpp_app_version ,vap.platform as vpp_app_platform ,vap.icon_url as vpp_app_icon_url + ,iha.name as in_house_app_name + ,iha.version as in_house_app_version + ,iha.platform as in_house_app_platform + ,iha.storage_id as in_house_app_storage_id {{end}} FROM software_titles st {{if hasTeamID .}} - {{$installerJoin := printf "%s JOIN software_installers si ON si.title_id = st.id AND si.global_or_team_id = %d" (yesNo .PackagesOnly "INNER" "LEFT") (teamID .)}} - {{$installerJoin}} + LEFT JOIN software_installers si ON si.title_id = st.id AND si.global_or_team_id = {{teamID .}} + LEFT JOIN in_house_apps iha ON iha.title_id = st.id AND iha.global_or_team_id = {{teamID .}} LEFT JOIN vpp_apps vap ON vap.title_id = st.id AND {{yesNo .PackagesOnly "FALSE" "TRUE"}} LEFT JOIN vpp_apps_teams vat ON vat.adam_id = vap.adam_id AND vat.platform = vap.platform AND {{if .PackagesOnly}} FALSE {{else}} vat.global_or_team_id = {{teamID .}}{{end}} @@ -419,17 +457,20 @@ FROM software_titles st {{end}} WHERE {{with $additionalWhere := "TRUE"}} + {{if and (hasTeamID $) $.PackagesOnly}} + {{$additionalWhere = "(si.id IS NOT NULL OR iha.id IS NOT NULL)"}} + {{end}} {{if $.ListOptions.MatchQuery}} {{$additionalWhere = "(st.name LIKE ? OR scve.cve LIKE ?)"}} {{end}} {{if and (hasTeamID $) $.Platform}} - {{$postfix := printf " AND (si.platform IN (%s) OR vap.platform IN (%[1]s))" (placeholders $.Platform)}} + {{$postfix := printf " AND (si.platform IN (%s) OR vap.platform IN (%[1]s) OR iha.platform IN (%[1]s))" (placeholders $.Platform)}} {{$additionalWhere = printf "%s %s" $additionalWhere $postfix}} {{end}} {{$additionalWhere}} {{end}} - -- If teamID is set, defaults to "a software installer or VPP app exists", and see next condition. - {{with $defFilter := yesNo (hasTeamID .) "(si.id IS NOT NULL OR vat.adam_id IS NOT NULL)" "FALSE"}} + -- If teamID is set, defaults to "a software installer, in-house app or VPP app exists", and see next condition. + {{with $defFilter := yesNo (hasTeamID .) "(si.id IS NOT NULL OR vat.adam_id IS NOT NULL OR iha.id IS NOT NULL)" "FALSE"}} -- add software installed for hosts if we're not filtering for "available for install" only {{if not $.AvailableForInstall}} {{$defFilter = $defFilter | printf " ( %s OR sthc.hosts_count > 0 ) "}} @@ -456,6 +497,10 @@ GROUP BY ,vpp_app_platform ,vpp_app_icon_url ,vpp_install_during_setup + ,in_house_app_name + ,in_house_app_version + ,in_house_app_platform + ,in_house_app_storage_id {{end}} ` var args []any @@ -486,6 +531,10 @@ GROUP BY for _, platform := range platforms { args = append(args, platform) } + // for in-house apps; could micro-optimize later by dropping non-Apple platforms + for _, platform := range platforms { + args = append(args, platform) + } } t, err := template.New("stm").Funcs(map[string]any{ diff --git a/server/datastore/mysql/software_titles_test.go b/server/datastore/mysql/software_titles_test.go index 396999972d..6b462ccfff 100644 --- a/server/datastore/mysql/software_titles_test.go +++ b/server/datastore/mysql/software_titles_test.go @@ -44,6 +44,7 @@ func TestSoftwareTitles(t *testing.T) { {"ListSoftwareTitlesAllTeamsWithAutomaticInstallersInNoTeam", testListSoftwareTitlesAllTeamsWithAutomaticInstallersInNoTeam}, {"ListSoftwareTitlesPackagesOnly", testSoftwareTitlesPackagesOnly}, {"SoftwareTitleByIDHostCount", testSoftwareTitleHostCount}, + {"ListSoftwareTitlesInHouseApps", testListSoftwareTitlesInHouseApps}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -2254,3 +2255,253 @@ func testSoftwareTitleHostCount(t *testing.T, ds *Datastore) { require.Equal(t, uint(1), title.VersionsCount) require.Equal(t, ptr.Uint(1), title.Versions[0].HostsCount) } + +func testListSoftwareTitlesInHouseApps(t *testing.T, ds *Datastore) { + ctx := t.Context() + + team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1"}) + require.NoError(t, err) + + host := test.NewHost(t, ds, "host1", "", "host1key", "host1uuid", time.Now()) + require.NoError(t, ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&team1.ID, []uint{host.ID}))) + user := test.NewUser(t, ds, "Alice", "alice@example.com", true) + test.CreateInsertGlobalVPPToken(t, ds) + + software := []fleet.Software{ + {Name: "foo", Version: "1.0.0", Source: "deb_packages"}, + {Name: "bar", Version: "2.0.0", Source: "apps"}, + {Name: "baz", Version: "3.0.0", Source: "rpm_packages"}, + } + _, err = ds.UpdateHostSoftware(ctx, host.ID, software) + require.NoError(t, err) + + // create a software package that matches foo + _, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ + Title: "foo", + Source: "deb_packages", + InstallScript: "echo foo", + Filename: "foo.pkg", + UserID: user.ID, + TeamID: &team1.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, + Platform: string(fleet.MacOSPlatform), + }) + require.NoError(t, err) + + // create a VPP app + _, 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.IPadOSPlatform}}, + }, &team1.ID) + require.NoError(t, err) + + // create a couple in-house apps (they always create both ios and ipados entries) + _, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ + Title: "in-house1", + Filename: "in-house1.ipa", + BundleIdentifier: "in-house1", + Extension: "ipa", + UserID: user.ID, + TeamID: &team1.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, + }) + require.NoError(t, err) + + _, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ + Title: "in-house2", + Filename: "in-house2.ipa", + BundleIdentifier: "in-house2", + Extension: "ipa", + UserID: user.ID, + TeamID: &team1.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, + }) + require.NoError(t, err) + + // Sync and reconcile + require.NoError(t, ds.SyncHostsSoftware(ctx, time.Now())) + require.NoError(t, ds.SyncHostsSoftwareTitles(ctx, time.Now())) + + pluckNames := func(titles []fleet.SoftwareTitleListResult) []string { + var out []string + for _, t := range titles { + out = append(out, t.Name) + } + return out + } + + assertInstallers := func(t *testing.T, got []fleet.SoftwareTitleListResult, want []*fleet.SoftwarePackageOrApp) { + require.Len(t, got, len(want)) + for i, sw := range got { + switch { + case want[i] == nil: + require.Nil(t, sw.SoftwarePackage) + require.Nil(t, sw.AppStoreApp) + case want[i].AppStoreID != "": + require.Nil(t, sw.SoftwarePackage) + require.NotNil(t, sw.AppStoreApp) + require.Equal(t, want[i], sw.AppStoreApp) + default: + require.Nil(t, sw.AppStoreApp) + require.NotNil(t, sw.SoftwarePackage) + require.Equal(t, want[i], sw.SoftwarePackage) + } + } + } + + adminFilter := fleet.TeamFilter{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}} + + cases := []struct { + desc string + opts fleet.SoftwareTitleListOptions + wantCount int + wantNames []string + wantInstallers []*fleet.SoftwarePackageOrApp + }{ + { + desc: "all", + opts: fleet.SoftwareTitleListOptions{ + ListOptions: fleet.ListOptions{ + OrderKey: "name", + OrderDirection: fleet.OrderAscending, + TestSecondaryOrderKey: "in_house_app_platform", + }, + TeamID: &team1.ID, + }, + wantCount: 8, + wantNames: []string{"bar", "baz", "foo", "in-house1", "in-house1", "in-house2", "in-house2", "vpp1"}, + wantInstallers: []*fleet.SoftwarePackageOrApp{ + nil, + nil, + {Name: "foo.pkg", SelfService: ptr.Bool(false), PackageURL: ptr.String(""), InstallDuringSetup: ptr.Bool(false), Platform: string(fleet.MacOSPlatform)}, + {Name: "in-house1", SelfService: ptr.Bool(false), Platform: string(fleet.IOSPlatform)}, + {Name: "in-house1", SelfService: ptr.Bool(false), Platform: string(fleet.IPadOSPlatform)}, + {Name: "in-house2", SelfService: ptr.Bool(false), Platform: string(fleet.IOSPlatform)}, + {Name: "in-house2", SelfService: ptr.Bool(false), Platform: string(fleet.IPadOSPlatform)}, + {AppStoreID: "adam_vpp_app_1", Platform: string(fleet.IPadOSPlatform), SelfService: ptr.Bool(false), InstallDuringSetup: ptr.Bool(false)}, + }, + }, + { + desc: "packages only", + opts: fleet.SoftwareTitleListOptions{ + ListOptions: fleet.ListOptions{ + OrderKey: "name", + OrderDirection: fleet.OrderAscending, + TestSecondaryOrderKey: "in_house_app_platform", + }, + TeamID: &team1.ID, + PackagesOnly: true, // should include in-house, not VPP + }, + wantCount: 5, + wantNames: []string{"foo", "in-house1", "in-house1", "in-house2", "in-house2"}, + wantInstallers: []*fleet.SoftwarePackageOrApp{ + {Name: "foo.pkg", SelfService: ptr.Bool(false), PackageURL: ptr.String(""), InstallDuringSetup: ptr.Bool(false), Platform: string(fleet.MacOSPlatform)}, + {Name: "in-house1", SelfService: ptr.Bool(false), Platform: string(fleet.IOSPlatform)}, + {Name: "in-house1", SelfService: ptr.Bool(false), Platform: string(fleet.IPadOSPlatform)}, + {Name: "in-house2", SelfService: ptr.Bool(false), Platform: string(fleet.IOSPlatform)}, + {Name: "in-house2", SelfService: ptr.Bool(false), Platform: string(fleet.IPadOSPlatform)}, + }, + }, + { + desc: "available for install", + opts: fleet.SoftwareTitleListOptions{ + ListOptions: fleet.ListOptions{ + OrderKey: "name", + OrderDirection: fleet.OrderAscending, + TestSecondaryOrderKey: "in_house_app_platform", + }, + TeamID: &team1.ID, + AvailableForInstall: true, + }, + wantCount: 6, + wantNames: []string{"foo", "in-house1", "in-house1", "in-house2", "in-house2", "vpp1"}, + wantInstallers: []*fleet.SoftwarePackageOrApp{ + {Name: "foo.pkg", SelfService: ptr.Bool(false), PackageURL: ptr.String(""), InstallDuringSetup: ptr.Bool(false), Platform: string(fleet.MacOSPlatform)}, + {Name: "in-house1", SelfService: ptr.Bool(false), Platform: string(fleet.IOSPlatform)}, + {Name: "in-house1", SelfService: ptr.Bool(false), Platform: string(fleet.IPadOSPlatform)}, + {Name: "in-house2", SelfService: ptr.Bool(false), Platform: string(fleet.IOSPlatform)}, + {Name: "in-house2", SelfService: ptr.Bool(false), Platform: string(fleet.IPadOSPlatform)}, + {AppStoreID: "adam_vpp_app_1", Platform: string(fleet.IPadOSPlatform), SelfService: ptr.Bool(false), InstallDuringSetup: ptr.Bool(false)}, + }, + }, + { + desc: "self-service only", + opts: fleet.SoftwareTitleListOptions{ + ListOptions: fleet.ListOptions{ + OrderKey: "name", + OrderDirection: fleet.OrderAscending, + TestSecondaryOrderKey: "in_house_app_platform", + }, + TeamID: &team1.ID, + SelfServiceOnly: true, + }, + wantCount: 0, + }, + { + desc: "macos only", + opts: fleet.SoftwareTitleListOptions{ + ListOptions: fleet.ListOptions{ + OrderKey: "name", + OrderDirection: fleet.OrderAscending, + TestSecondaryOrderKey: "in_house_app_platform", + }, + TeamID: &team1.ID, + Platform: "macos", + }, + wantCount: 1, + wantNames: []string{"foo"}, + wantInstallers: []*fleet.SoftwarePackageOrApp{ + {Name: "foo.pkg", SelfService: ptr.Bool(false), PackageURL: ptr.String(""), InstallDuringSetup: ptr.Bool(false), Platform: string(fleet.MacOSPlatform)}, + }, + }, + { + desc: "iOS only", + opts: fleet.SoftwareTitleListOptions{ + ListOptions: fleet.ListOptions{ + OrderKey: "name", + OrderDirection: fleet.OrderAscending, + TestSecondaryOrderKey: "in_house_app_platform", + }, + TeamID: &team1.ID, + Platform: "ios", + }, + wantCount: 2, + wantNames: []string{"in-house1", "in-house2"}, + wantInstallers: []*fleet.SoftwarePackageOrApp{ + {Name: "in-house1", SelfService: ptr.Bool(false), Platform: string(fleet.IOSPlatform)}, + {Name: "in-house2", SelfService: ptr.Bool(false), Platform: string(fleet.IOSPlatform)}, + }, + }, + { + desc: "iOS and IPadOS", + opts: fleet.SoftwareTitleListOptions{ + ListOptions: fleet.ListOptions{ + OrderKey: "name", + OrderDirection: fleet.OrderAscending, + TestSecondaryOrderKey: "in_house_app_platform", + }, + TeamID: &team1.ID, + Platform: "ios,ipados", + }, + wantCount: 5, + wantNames: []string{"in-house1", "in-house1", "in-house2", "in-house2", "vpp1"}, + wantInstallers: []*fleet.SoftwarePackageOrApp{ + {Name: "in-house1", SelfService: ptr.Bool(false), Platform: string(fleet.IOSPlatform)}, + {Name: "in-house1", SelfService: ptr.Bool(false), Platform: string(fleet.IPadOSPlatform)}, + {Name: "in-house2", SelfService: ptr.Bool(false), Platform: string(fleet.IOSPlatform)}, + {Name: "in-house2", SelfService: ptr.Bool(false), Platform: string(fleet.IPadOSPlatform)}, + {AppStoreID: "adam_vpp_app_1", Platform: string(fleet.IPadOSPlatform), SelfService: ptr.Bool(false), InstallDuringSetup: ptr.Bool(false)}, + }, + }, + } + for _, c := range cases { + t.Run(c.desc, func(t *testing.T) { + titles, counts, _, err := ds.ListSoftwareTitles(ctx, c.opts, adminFilter) + require.NoError(t, err) + require.Equal(t, c.wantCount, counts) + + require.Equal(t, c.wantNames, pluckNames(titles)) + assertInstallers(t, titles, c.wantInstallers) + }) + } +} diff --git a/server/datastore/mysql/testdata/select_software_titles_sql_fixture.gz b/server/datastore/mysql/testdata/select_software_titles_sql_fixture.gz index 68e813eb15..53bd8cf994 100644 Binary files a/server/datastore/mysql/testdata/select_software_titles_sql_fixture.gz and b/server/datastore/mysql/testdata/select_software_titles_sql_fixture.gz differ diff --git a/server/datastore/mysql/testing_utils.go b/server/datastore/mysql/testing_utils.go index 66ea690dfe..3cd06ef7c4 100644 --- a/server/datastore/mysql/testing_utils.go +++ b/server/datastore/mysql/testing_utils.go @@ -205,15 +205,15 @@ func setupDummyReplica(t testing.TB, testName string, ds *Datastore, opts *testi // Build query to avoid inserting into GENERATED columns var columns string - columnsStmt := fmt.Sprintf(`SELECT - GROUP_CONCAT(column_name ORDER BY ordinal_position) - FROM information_schema.columns + columnsStmt := fmt.Sprintf(`SELECT + GROUP_CONCAT(column_name ORDER BY ordinal_position) + FROM information_schema.columns WHERE table_schema = '%s' AND table_name = '%s' AND NOT (EXTRA LIKE '%%GENERATED%%' AND EXTRA NOT LIKE '%%DEFAULT_GENERATED%%');`, replicaDB, tbl) err = replica.GetContext(ctx, &columns, columnsStmt) require.NoError(t, err) - stmt = fmt.Sprintf(`INSERT INTO %s.%s (%s) + stmt = fmt.Sprintf(`INSERT INTO %s.%s (%s) SELECT %s FROM %s.%s;`, replicaDB, tbl, columns, columns, testName, tbl) t.Log(stmt) diff --git a/server/datastore/mysql/vpp.go b/server/datastore/mysql/vpp.go index 001d1a86d4..51de5e36d4 100644 --- a/server/datastore/mysql/vpp.go +++ b/server/datastore/mysql/vpp.go @@ -148,10 +148,6 @@ func (ds *Datastore) GetSummaryHostVPPAppInstalls(ctx context.Context, teamID *u ) { var dest fleet.VPPAppStatusSummary - // TODO(sarah): do we need to handle host_deleted_at similar to GetSummaryHostSoftwareInstalls? - // Currently there is no host_deleted_at in host_vpp_software_installs, so - // not handling it as part of the unified queue work. - stmt := ` WITH @@ -1120,8 +1116,7 @@ WHERE switch commandResults.Status { case fleet.MDMAppleStatusAcknowledged: status = string(fleet.SoftwareInstalled) - case fleet.MDMAppleStatusCommandFormatError: - case fleet.MDMAppleStatusError: + case fleet.MDMAppleStatusCommandFormatError, fleet.MDMAppleStatusError: status = string(fleet.SoftwareInstallFailed) default: // This case shouldn't happen (we should only be doing this check if the command is in a @@ -1820,9 +1815,20 @@ AND hvsi.verification_failed_at IS NULL return result, nil } -func (ds *Datastore) AssociateVPPInstallToVerificationUUID(ctx context.Context, installUUID, verifyCommandUUID string) error { +func (s softwareType) getInstallMappingTableName() string { + tableNames := map[softwareType]string{ + softwareTypeInHouseApp: "host_in_house_software_installs", + softwareTypeVPP: "host_vpp_software_installs", + } + + return tableNames[s] + +} + +func (ds *Datastore) AssociateMDMInstallToVerificationUUID(ctx context.Context, installUUID, verifyCommandUUID, hostUUID string) error { + stmt := ` -UPDATE host_vpp_software_installs +UPDATE %s SET verification_command_uuid = ? WHERE command_uuid = ? ` @@ -1830,15 +1836,33 @@ WHERE command_uuid = ? hostCmdStmt := ` INSERT INTO host_mdm_commands (host_id, command_type) -VALUES ((SELECT host_id FROM host_vpp_software_installs WHERE command_uuid = ?), ?) +VALUES ((SELECT id FROM hosts WHERE uuid = ?), ?) ` return ds.withTx(ctx, func(tx sqlx.ExtContext) error { - if _, err := tx.ExecContext(ctx, stmt, verifyCommandUUID, installUUID); err != nil { + var rowsAffected int64 + r, err := tx.ExecContext(ctx, fmt.Sprintf(stmt, softwareTypeVPP.getInstallMappingTableName()), verifyCommandUUID, installUUID) + if err != nil { return ctxerr.Wrap(ctx, err, "update vpp install verification command") } - if _, err := tx.ExecContext(ctx, hostCmdStmt, installUUID, fleet.VerifySoftwareInstallVPPPrefix); err != nil { + count, _ := r.RowsAffected() + rowsAffected += count + + r, err = tx.ExecContext(ctx, fmt.Sprintf(stmt, softwareTypeInHouseApp.getInstallMappingTableName()), verifyCommandUUID, installUUID) + if err != nil { + return ctxerr.Wrap(ctx, err, "update in-house app install verification command") + } + + count, _ = r.RowsAffected() + rowsAffected += count + + if rowsAffected == 0 { + // There's a bug somewhere + return ctxerr.WrapWithData(ctx, err, "no MDM install attempts found with given uuid", map[string]any{"install_command_uuid": installUUID, "verify_command_uuid": verifyCommandUUID, "host_uuid": hostUUID}) + } + + if _, err := tx.ExecContext(ctx, hostCmdStmt, hostUUID, fleet.VerifySoftwareInstallVPPPrefix); err != nil { return ctxerr.Wrap(ctx, err, "insert verify host mdm command") } @@ -1902,36 +1926,58 @@ WHERE command_uuid = ? }) } -func (ds *Datastore) MarkAllPendingVPPInstallsAsFailed(ctx context.Context, jobName string) error { - clearUpcomingActivitiesStmt := ` +func (ds *Datastore) MarkAllPendingVPPAndInHouseInstallsAsFailed(ctx context.Context, jobName string) error { + clearVPPUpcomingActivitiesStmt := ` DELETE ua FROM upcoming_activities ua JOIN host_vpp_software_installs hvsi ON hvsi.command_uuid = ua.execution_id WHERE ua.activity_type = ? AND hvsi.verification_failed_at IS NULL AND hvsi.verification_at IS NULL - ` +` - installFailStmt := ` + clearInHouseUpcomingActivitiesStmt := ` +DELETE ua FROM + upcoming_activities ua +JOIN + host_in_house_software_installs hihs ON hihs.command_uuid = ua.execution_id +WHERE ua.activity_type = ? AND hihs.verification_failed_at IS NULL AND hihs.verification_at IS NULL +` + + installVPPFailStmt := ` UPDATE host_vpp_software_installs SET verification_failed_at = CURRENT_TIMESTAMP(6) WHERE verification_failed_at IS NULL AND verification_at IS NULL - ` +` + + installInHouseFailStmt := ` +UPDATE host_in_house_software_installs +SET verification_failed_at = CURRENT_TIMESTAMP(6) +WHERE verification_failed_at IS NULL AND verification_at IS NULL +` deletePendingJobsStmt := ` DELETE FROM jobs WHERE name = ? AND state = ? - ` +` return ds.withTx(ctx, func(tx sqlx.ExtContext) error { - if _, err := tx.ExecContext(ctx, clearUpcomingActivitiesStmt, "vpp_app_install"); err != nil { + if _, err := tx.ExecContext(ctx, clearVPPUpcomingActivitiesStmt, "vpp_app_install"); err != nil { return ctxerr.Wrap(ctx, err, "clear vpp install upcoming activities") } - if _, err := tx.ExecContext(ctx, installFailStmt); err != nil { + if _, err := tx.ExecContext(ctx, installVPPFailStmt); err != nil { return ctxerr.Wrap(ctx, err, "set all vpp install as failed") } + if _, err := tx.ExecContext(ctx, clearInHouseUpcomingActivitiesStmt, "in_house_app_install"); err != nil { + return ctxerr.Wrap(ctx, err, "clear in-house install upcoming activities") + } + + if _, err := tx.ExecContext(ctx, installInHouseFailStmt); err != nil { + return ctxerr.Wrap(ctx, err, "set all in-house install as failed") + } + if _, err := tx.ExecContext(ctx, deletePendingJobsStmt, jobName, fleet.JobStateQueued); err != nil { return ctxerr.Wrap(ctx, err, "delete pending jobs") } diff --git a/server/fleet/activities.go b/server/fleet/activities.go index 96a84e012c..084627a09c 100644 --- a/server/fleet/activities.go +++ b/server/fleet/activities.go @@ -266,6 +266,16 @@ type ActivityHostOnly interface { HostOnly() bool } +// ActivityActivator is the optional additional interface that can be implemented by activities that +// may require activating the next upcoming activity when it gets created. Most upcoming activities get +// activated when the result of the previous one completes (such as scripts and software installs), but +// some can only be activated when the activity gets recorded (such as VPP and in-house apps). +type ActivityActivator interface { + ActivityDetails + MustActivateNextUpcomingActivity() bool + ActivateNextUpcomingActivityArgs() (hostID uint, cmdUUID string) +} + type ActivityTypeEnabledActivityAutomations struct { WebhookUrl string `json:"webhook_url"` } @@ -1843,6 +1853,7 @@ type ActivityTypeInstalledSoftware struct { PolicyID *uint `json:"policy_id"` PolicyName *string `json:"policy_name"` FromSetupExperience bool `json:"-"` + CommandUUID string `json:"command_uuid,omitempty"` } func (a ActivityTypeInstalledSoftware) ActivityName() string { @@ -1857,6 +1868,18 @@ func (a ActivityTypeInstalledSoftware) WasFromAutomation() bool { return a.PolicyID != nil || a.FromSetupExperience } +func (a ActivityTypeInstalledSoftware) MustActivateNextUpcomingActivity() bool { + // for in-house apps, we only activate the next upcoming activity if the + // installation failed, because if it succeeded (and in this case, it only + // means the command to install succeeded), we only activate the next + // activity when we verify the app is actually installed. + return a.CommandUUID != "" && a.Status != string(SoftwareInstalled) +} + +func (a ActivityTypeInstalledSoftware) ActivateNextUpcomingActivityArgs() (uint, string) { + return a.HostID, a.CommandUUID +} + func (a ActivityTypeInstalledSoftware) Documentation() (activity, details, detailsExample string) { return `Generated when a Fleet-maintained app or custom package is installed on a host.`, `This activity contains the following fields: @@ -1870,6 +1893,7 @@ func (a ActivityTypeInstalledSoftware) Documentation() (activity, details, detai - "source": Software source type (e.g., "pkg_packages", "sh_packages", "ps1_packages"). - "policy_id": ID of the policy whose failure triggered the installation. Null if no associated policy. - "policy_name": Name of the policy whose failure triggered installation. Null if no associated policy. +- "command_uuid": ID of the in-house app installation. `, `{ "host_id": 1, "host_display_name": "Anna's MacBook Pro", @@ -2286,6 +2310,18 @@ func (a ActivityInstalledAppStoreApp) WasFromAutomation() bool { return a.PolicyID != nil || a.FromSetupExperience } +func (a ActivityInstalledAppStoreApp) MustActivateNextUpcomingActivity() bool { + // for VPP apps, we only activate the next upcoming activity if the installation + // failed, because if it succeeded (and in this case, it only means the command to + // install succeeded), we only activate the next activity when we verify the + // app is actually installed. + return a.Status != string(SoftwareInstalled) +} + +func (a ActivityInstalledAppStoreApp) ActivateNextUpcomingActivityArgs() (uint, string) { + return a.HostID, a.CommandUUID +} + func (a ActivityInstalledAppStoreApp) Documentation() (string, string, string) { return "Generated when an App Store app is installed on a device.", `This activity contains the following fields: - "host_id": ID of the host on which the app was installed. diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index e6a6733381..f72c561243 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -658,8 +658,19 @@ 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 returns whether or not the given vppAppTeamID is scoped to the given hostID by labels. IsVPPAppLabelScoped(ctx context.Context, vppAppTeamID, hostID uint) (bool, error) + // IsInHouseAppLabelScoped returns whether or not the given inHouseAppID is scoped to the given hostID by labels. + IsInHouseAppLabelScoped(ctx context.Context, inHouseAppID, hostID uint) (bool, error) + + GetUnverifiedInHouseAppInstallsForHost(ctx context.Context, hostUUID string) ([]*HostVPPSoftwareInstall, error) + SetInHouseAppInstallAsVerified(ctx context.Context, hostID uint, installUUID, verificationUUID string) error + SetInHouseAppInstallAsFailed(ctx context.Context, hostID uint, installUUID, verificationUUID string) error + ReplaceInHouseAppInstallVerificationUUID(ctx context.Context, oldVerifyUUID, verifyCommandUUID string) error + GetPastActivityDataForInHouseAppInstall(ctx context.Context, commandResults *mdm.CommandResults) (*User, *ActivityTypeInstalledSoftware, error) + // SetHostSoftwareInstallResult records the result of a software installation // attempt on the host. SetHostSoftwareInstallResult(ctx context.Context, result *HostSoftwareInstallResultPayload) (wasCanceled bool, err error) @@ -682,23 +693,24 @@ type Datastore interface { // from the title IDs to the categories assigned to the installers for those titles. GetCategoriesForSoftwareTitles(ctx context.Context, softwareTitleIDs []uint, team_id *uint) (map[uint][]string, error) - // AssociateVPPInstallToVerificationUUID updates the verification command UUID associated with the - // given install attempt (InstallApplication command) - AssociateVPPInstallToVerificationUUID(ctx context.Context, installUUID, verifyCommandUUID string) error + // AssociateMDMInstallToVerificationUUID updates the verification command UUID associated with the + // given install attempt (InstallApplication command). + // It will attempt to update both VPP and in-house app installs (only one will succeed since the command UUIDs are unique). + AssociateMDMInstallToVerificationUUID(ctx context.Context, installUUID, verifyCommandUUID, hostUUID string) error // SetVPPInstallAsVerified marks the VPP app install attempt as "verified" (Fleet has validated // that it's installed on the device). SetVPPInstallAsVerified(ctx context.Context, hostID uint, installUUID, verificationUUID string) error // ReplaceVPPInstallVerificationUUID replaces the verification command UUID for all // VPP app install attempts were related to oldVerifyUUID. ReplaceVPPInstallVerificationUUID(ctx context.Context, oldVerifyUUID, verifyCommandUUID string) error - // IsHostPendingVPPInstallVerification checks if a host has a pending VPP install verification command. - IsHostPendingVPPInstallVerification(ctx context.Context, hostUUID string) (bool, error) + // IsHostPendingMDMInstallVerification checks if a host has a pending VPP or in-house install verification command. + IsHostPendingMDMInstallVerification(ctx context.Context, hostUUID string) (bool, error) // GetUnverifiedVPPInstallsForHost gets unverified HostVPPSoftwareInstalls by host UUID. GetUnverifiedVPPInstallsForHost(ctx context.Context, verificationUUID string) ([]*HostVPPSoftwareInstall, error) // SetVPPInstallAsFailed marks a VPP app install attempt as failed (Fleet couldn't validate that // it was installed on the host). SetVPPInstallAsFailed(ctx context.Context, hostID uint, installUUID, verificationUUID string) error - MarkAllPendingVPPInstallsAsFailed(ctx context.Context, jobName string) error + MarkAllPendingVPPAndInHouseInstallsAsFailed(ctx context.Context, jobName string) error /////////////////////////////////////////////////////////////////////////////// // OperatingSystemsStore @@ -1992,6 +2004,8 @@ type Datastore interface { // (if set) post-install scripts, otherwise those fields are left empty. GetSoftwareInstallerMetadataByTeamAndTitleID(ctx context.Context, teamID *uint, titleID uint, withScriptContents bool) (*SoftwareInstaller, error) + InsertHostInHouseAppInstall(ctx context.Context, hostID uint, inHouseAppID, softwareTitleID uint, commandUUID string, opts HostSoftwareInstallOptions) error + // GetSoftwareInstallersPendingUninstallScriptPopulation returns a map of software installers to storage IDs that: // 1. need uninstall scripts populated // 2. can have uninstall scripts auto-generated by Fleet @@ -2058,6 +2072,21 @@ type Datastore interface { // no references to them from the software_installers table. CleanupUnusedSoftwareInstallers(ctx context.Context, softwareInstallStore SoftwareInstallerStore, removeCreatedBefore time.Time) error + // SaveInHouseAppUpdates persists new values to an existing in house app. + SaveInHouseAppUpdates(ctx context.Context, payload *UpdateSoftwareInstallerPayload) error + + // GetInHouseAppMetadataByTeamAndTitleID returns the in house app corresponding to the specific team and title ids. + GetInHouseAppMetadataByTeamAndTitleID(ctx context.Context, teamID *uint, titleID uint) (*SoftwareInstaller, error) + + // Remove host inhouseapp installs and upcoming inhouseapp install activities + RemovePendingInHouseAppInstalls(ctx context.Context, inHouseAppID uint) error + + // GetSummaryHostSoftwareInstalls returns the software install summary for the in house app ID + GetSummaryHostInHouseAppInstalls(ctx context.Context, teamID *uint, inHouseAppID uint) (*VPPAppStatusSummary, error) + + // DeleteInHouseApp deletes an in house app and removes pending installs for it + DeleteInHouseApp(ctx context.Context, id uint) error + // CleanupUnusedSoftwareTitleIcons will remove software title icons that have // no references to them from the software_title_icons table. CleanupUnusedSoftwareTitleIcons(ctx context.Context, softwareTitleIconStore SoftwareTitleIconStore, removeCreatedBefore time.Time) error diff --git a/server/fleet/in_house_apps.go b/server/fleet/in_house_apps.go new file mode 100644 index 0000000000..fd01e8d121 --- /dev/null +++ b/server/fleet/in_house_apps.go @@ -0,0 +1,11 @@ +package fleet + +type InHouseAppPayload struct { + TeamID *uint + Name string + BundleID string + StorageID string + Platform string + ValidatedLabels *LabelIdentsWithScope + Version string +} diff --git a/server/fleet/service.go b/server/fleet/service.go index a32c707be0..0542c12586 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -708,6 +708,12 @@ type Service interface { AddAppStoreApp(ctx context.Context, teamID *uint, appTeam VPPAppTeam) (uint, error) UpdateAppStoreApp(ctx context.Context, titleID uint, teamID *uint, selfService bool, labelsIncludeAny, labelsExcludeAny, categories []string) (*VPPAppStoreApp, error) + // GetInHouseAppManifest returns a manifest XML file that points at the download URL for the given in-house app. + GetInHouseAppManifest(ctx context.Context, titleID uint, teamID *uint) ([]byte, error) + + // GetInHouseAppPackage downloads the bytes of the given in-house app. + GetInHouseAppPackage(ctx context.Context, titleID uint, teamID *uint) (*DownloadSoftwareInstallerPayload, error) + // MDMAppleProcessOTAEnrollment handles OTA enrollment requests. // // Per the [spec][1] OTA enrollment is composed of two phases, each diff --git a/server/fleet/software.go b/server/fleet/software.go index d13228a1d5..53ff6a5a7b 100644 --- a/server/fleet/software.go +++ b/server/fleet/software.go @@ -234,6 +234,9 @@ type SoftwareTitle struct { // This is an internal field for an optimization so that the extra queries to // fetch app information is done only if necessary. VPPAppsCount int `json:"-" db:"vpp_apps_count"` + // InHouseAppsCount is 0 or 1, indicating if the software title has + // an in house app (.ipa) installer + InHouseAppCount int `json:"-" db:"in_house_apps_count"` // SoftwarePackage is the software installer information for this title. SoftwarePackage *SoftwareInstaller `json:"software_package" db:"-"` // AppStoreApp is the VPP app information for this title. diff --git a/server/fleet/software_installer.go b/server/fleet/software_installer.go index a7aa77a203..1177c25dfb 100644 --- a/server/fleet/software_installer.go +++ b/server/fleet/software_installer.go @@ -128,6 +128,8 @@ type SoftwareInstaller struct { // Categories is the list of categories to which this software belongs: e.g. "Productivity", // "Browsers", etc. Categories []string `json:"categories"` + + BundleIdentifier string `json:"-" db:"bundle_identifier"` } // SoftwarePackageResponse is the response type used when applying software by batch. @@ -586,6 +588,8 @@ func SofwareInstallerSourceFromExtensionAndName(ext, name string) (string, error return "pkg_packages", nil case "tar.gz": return "tgz_packages", nil + case "ipa": + return "ipa", nil case "sh": return "sh_packages", nil case "ps1": @@ -604,6 +608,8 @@ func SoftwareInstallerPlatformFromExtension(ext string) (string, error) { return "windows", nil case "pkg": return "darwin", nil + case "ipa": // TODO(JVE): what about iPads? Can we get the platforms from the Info.plist file? + return "ios", nil default: return "", fmt.Errorf("unsupported file type: %s", ext) } diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index 6be275c09d..e84c32dbc6 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -495,6 +495,18 @@ type IsSoftwareInstallerLabelScopedFunc func(ctx context.Context, installerID ui type IsVPPAppLabelScopedFunc func(ctx context.Context, vppAppTeamID uint, hostID uint) (bool, error) +type IsInHouseAppLabelScopedFunc func(ctx context.Context, inHouseAppID uint, hostID uint) (bool, error) + +type GetUnverifiedInHouseAppInstallsForHostFunc func(ctx context.Context, hostUUID string) ([]*fleet.HostVPPSoftwareInstall, error) + +type SetInHouseAppInstallAsVerifiedFunc func(ctx context.Context, hostID uint, installUUID string, verificationUUID string) error + +type SetInHouseAppInstallAsFailedFunc func(ctx context.Context, hostID uint, installUUID string, verificationUUID string) error + +type ReplaceInHouseAppInstallVerificationUUIDFunc func(ctx context.Context, oldVerifyUUID string, verifyCommandUUID string) error + +type GetPastActivityDataForInHouseAppInstallFunc func(ctx context.Context, commandResults *mdm.CommandResults) (*fleet.User, *fleet.ActivityTypeInstalledSoftware, error) + type SetHostSoftwareInstallResultFunc func(ctx context.Context, result *fleet.HostSoftwareInstallResultPayload) (wasCanceled bool, err error) type CreateIntermediateInstallFailureRecordFunc func(ctx context.Context, result *fleet.HostSoftwareInstallResultPayload) (string, *fleet.HostSoftwareInstallerResult, bool, error) @@ -507,19 +519,19 @@ type GetSoftwareCategoryIDsFunc func(ctx context.Context, names []string) ([]uin type GetCategoriesForSoftwareTitlesFunc func(ctx context.Context, softwareTitleIDs []uint, team_id *uint) (map[uint][]string, error) -type AssociateVPPInstallToVerificationUUIDFunc func(ctx context.Context, installUUID string, verifyCommandUUID string) error +type AssociateMDMInstallToVerificationUUIDFunc func(ctx context.Context, installUUID string, verifyCommandUUID string, hostUUID string) error type SetVPPInstallAsVerifiedFunc func(ctx context.Context, hostID uint, installUUID string, verificationUUID string) error type ReplaceVPPInstallVerificationUUIDFunc func(ctx context.Context, oldVerifyUUID string, verifyCommandUUID string) error -type IsHostPendingVPPInstallVerificationFunc func(ctx context.Context, hostUUID string) (bool, error) +type IsHostPendingMDMInstallVerificationFunc func(ctx context.Context, hostUUID string) (bool, error) type GetUnverifiedVPPInstallsForHostFunc func(ctx context.Context, verificationUUID string) ([]*fleet.HostVPPSoftwareInstall, error) type SetVPPInstallAsFailedFunc func(ctx context.Context, hostID uint, installUUID string, verificationUUID string) error -type MarkAllPendingVPPInstallsAsFailedFunc func(ctx context.Context, jobName string) error +type MarkAllPendingVPPAndInHouseInstallsAsFailedFunc func(ctx context.Context, jobName string) error type GetHostOperatingSystemFunc func(ctx context.Context, hostID uint) (*fleet.OperatingSystem, error) @@ -1263,6 +1275,8 @@ type ValidateOrbitSoftwareInstallerAccessFunc func(ctx context.Context, hostID u type GetSoftwareInstallerMetadataByTeamAndTitleIDFunc func(ctx context.Context, teamID *uint, titleID uint, withScriptContents bool) (*fleet.SoftwareInstaller, error) +type InsertHostInHouseAppInstallFunc func(ctx context.Context, hostID uint, inHouseAppID uint, softwareTitleID uint, commandUUID string, opts fleet.HostSoftwareInstallOptions) error + type GetSoftwareInstallersPendingUninstallScriptPopulationFunc func(ctx context.Context) (map[uint]string, error) type GetMSIInstallersWithoutUpgradeCodeFunc func(ctx context.Context) (map[uint]string, error) @@ -1299,6 +1313,16 @@ type GetSoftwareInstallResultsFunc func(ctx context.Context, resultsUUID string) type CleanupUnusedSoftwareInstallersFunc func(ctx context.Context, softwareInstallStore fleet.SoftwareInstallerStore, removeCreatedBefore time.Time) error +type SaveInHouseAppUpdatesFunc func(ctx context.Context, payload *fleet.UpdateSoftwareInstallerPayload) error + +type GetInHouseAppMetadataByTeamAndTitleIDFunc func(ctx context.Context, teamID *uint, titleID uint) (*fleet.SoftwareInstaller, error) + +type RemovePendingInHouseAppInstallsFunc func(ctx context.Context, inHouseAppID uint) error + +type GetSummaryHostInHouseAppInstallsFunc func(ctx context.Context, teamID *uint, inHouseAppID uint) (*fleet.VPPAppStatusSummary, error) + +type DeleteInHouseAppFunc func(ctx context.Context, id uint) error + type CleanupUnusedSoftwareTitleIconsFunc func(ctx context.Context, softwareTitleIconStore fleet.SoftwareTitleIconStore, removeCreatedBefore time.Time) error type BatchSetSoftwareInstallersFunc func(ctx context.Context, tmID *uint, installers []*fleet.UploadSoftwareInstallerPayload) error @@ -2274,6 +2298,24 @@ type DataStore struct { IsVPPAppLabelScopedFunc IsVPPAppLabelScopedFunc IsVPPAppLabelScopedFuncInvoked bool + IsInHouseAppLabelScopedFunc IsInHouseAppLabelScopedFunc + IsInHouseAppLabelScopedFuncInvoked bool + + GetUnverifiedInHouseAppInstallsForHostFunc GetUnverifiedInHouseAppInstallsForHostFunc + GetUnverifiedInHouseAppInstallsForHostFuncInvoked bool + + SetInHouseAppInstallAsVerifiedFunc SetInHouseAppInstallAsVerifiedFunc + SetInHouseAppInstallAsVerifiedFuncInvoked bool + + SetInHouseAppInstallAsFailedFunc SetInHouseAppInstallAsFailedFunc + SetInHouseAppInstallAsFailedFuncInvoked bool + + ReplaceInHouseAppInstallVerificationUUIDFunc ReplaceInHouseAppInstallVerificationUUIDFunc + ReplaceInHouseAppInstallVerificationUUIDFuncInvoked bool + + GetPastActivityDataForInHouseAppInstallFunc GetPastActivityDataForInHouseAppInstallFunc + GetPastActivityDataForInHouseAppInstallFuncInvoked bool + SetHostSoftwareInstallResultFunc SetHostSoftwareInstallResultFunc SetHostSoftwareInstallResultFuncInvoked bool @@ -2292,8 +2334,8 @@ type DataStore struct { GetCategoriesForSoftwareTitlesFunc GetCategoriesForSoftwareTitlesFunc GetCategoriesForSoftwareTitlesFuncInvoked bool - AssociateVPPInstallToVerificationUUIDFunc AssociateVPPInstallToVerificationUUIDFunc - AssociateVPPInstallToVerificationUUIDFuncInvoked bool + AssociateMDMInstallToVerificationUUIDFunc AssociateMDMInstallToVerificationUUIDFunc + AssociateMDMInstallToVerificationUUIDFuncInvoked bool SetVPPInstallAsVerifiedFunc SetVPPInstallAsVerifiedFunc SetVPPInstallAsVerifiedFuncInvoked bool @@ -2301,8 +2343,8 @@ type DataStore struct { ReplaceVPPInstallVerificationUUIDFunc ReplaceVPPInstallVerificationUUIDFunc ReplaceVPPInstallVerificationUUIDFuncInvoked bool - IsHostPendingVPPInstallVerificationFunc IsHostPendingVPPInstallVerificationFunc - IsHostPendingVPPInstallVerificationFuncInvoked bool + IsHostPendingMDMInstallVerificationFunc IsHostPendingMDMInstallVerificationFunc + IsHostPendingMDMInstallVerificationFuncInvoked bool GetUnverifiedVPPInstallsForHostFunc GetUnverifiedVPPInstallsForHostFunc GetUnverifiedVPPInstallsForHostFuncInvoked bool @@ -2310,8 +2352,8 @@ type DataStore struct { SetVPPInstallAsFailedFunc SetVPPInstallAsFailedFunc SetVPPInstallAsFailedFuncInvoked bool - MarkAllPendingVPPInstallsAsFailedFunc MarkAllPendingVPPInstallsAsFailedFunc - MarkAllPendingVPPInstallsAsFailedFuncInvoked bool + MarkAllPendingVPPAndInHouseInstallsAsFailedFunc MarkAllPendingVPPAndInHouseInstallsAsFailedFunc + MarkAllPendingVPPAndInHouseInstallsAsFailedFuncInvoked bool GetHostOperatingSystemFunc GetHostOperatingSystemFunc GetHostOperatingSystemFuncInvoked bool @@ -3426,6 +3468,9 @@ type DataStore struct { GetSoftwareInstallerMetadataByTeamAndTitleIDFunc GetSoftwareInstallerMetadataByTeamAndTitleIDFunc GetSoftwareInstallerMetadataByTeamAndTitleIDFuncInvoked bool + InsertHostInHouseAppInstallFunc InsertHostInHouseAppInstallFunc + InsertHostInHouseAppInstallFuncInvoked bool + GetSoftwareInstallersPendingUninstallScriptPopulationFunc GetSoftwareInstallersPendingUninstallScriptPopulationFunc GetSoftwareInstallersPendingUninstallScriptPopulationFuncInvoked bool @@ -3480,6 +3525,21 @@ type DataStore struct { CleanupUnusedSoftwareInstallersFunc CleanupUnusedSoftwareInstallersFunc CleanupUnusedSoftwareInstallersFuncInvoked bool + SaveInHouseAppUpdatesFunc SaveInHouseAppUpdatesFunc + SaveInHouseAppUpdatesFuncInvoked bool + + GetInHouseAppMetadataByTeamAndTitleIDFunc GetInHouseAppMetadataByTeamAndTitleIDFunc + GetInHouseAppMetadataByTeamAndTitleIDFuncInvoked bool + + RemovePendingInHouseAppInstallsFunc RemovePendingInHouseAppInstallsFunc + RemovePendingInHouseAppInstallsFuncInvoked bool + + GetSummaryHostInHouseAppInstallsFunc GetSummaryHostInHouseAppInstallsFunc + GetSummaryHostInHouseAppInstallsFuncInvoked bool + + DeleteInHouseAppFunc DeleteInHouseAppFunc + DeleteInHouseAppFuncInvoked bool + CleanupUnusedSoftwareTitleIconsFunc CleanupUnusedSoftwareTitleIconsFunc CleanupUnusedSoftwareTitleIconsFuncInvoked bool @@ -5534,6 +5594,48 @@ func (s *DataStore) IsVPPAppLabelScoped(ctx context.Context, vppAppTeamID uint, return s.IsVPPAppLabelScopedFunc(ctx, vppAppTeamID, hostID) } +func (s *DataStore) IsInHouseAppLabelScoped(ctx context.Context, inHouseAppID uint, hostID uint) (bool, error) { + s.mu.Lock() + s.IsInHouseAppLabelScopedFuncInvoked = true + s.mu.Unlock() + return s.IsInHouseAppLabelScopedFunc(ctx, inHouseAppID, hostID) +} + +func (s *DataStore) GetUnverifiedInHouseAppInstallsForHost(ctx context.Context, hostUUID string) ([]*fleet.HostVPPSoftwareInstall, error) { + s.mu.Lock() + s.GetUnverifiedInHouseAppInstallsForHostFuncInvoked = true + s.mu.Unlock() + return s.GetUnverifiedInHouseAppInstallsForHostFunc(ctx, hostUUID) +} + +func (s *DataStore) SetInHouseAppInstallAsVerified(ctx context.Context, hostID uint, installUUID string, verificationUUID string) error { + s.mu.Lock() + s.SetInHouseAppInstallAsVerifiedFuncInvoked = true + s.mu.Unlock() + return s.SetInHouseAppInstallAsVerifiedFunc(ctx, hostID, installUUID, verificationUUID) +} + +func (s *DataStore) SetInHouseAppInstallAsFailed(ctx context.Context, hostID uint, installUUID string, verificationUUID string) error { + s.mu.Lock() + s.SetInHouseAppInstallAsFailedFuncInvoked = true + s.mu.Unlock() + return s.SetInHouseAppInstallAsFailedFunc(ctx, hostID, installUUID, verificationUUID) +} + +func (s *DataStore) ReplaceInHouseAppInstallVerificationUUID(ctx context.Context, oldVerifyUUID string, verifyCommandUUID string) error { + s.mu.Lock() + s.ReplaceInHouseAppInstallVerificationUUIDFuncInvoked = true + s.mu.Unlock() + return s.ReplaceInHouseAppInstallVerificationUUIDFunc(ctx, oldVerifyUUID, verifyCommandUUID) +} + +func (s *DataStore) GetPastActivityDataForInHouseAppInstall(ctx context.Context, commandResults *mdm.CommandResults) (*fleet.User, *fleet.ActivityTypeInstalledSoftware, error) { + s.mu.Lock() + s.GetPastActivityDataForInHouseAppInstallFuncInvoked = true + s.mu.Unlock() + return s.GetPastActivityDataForInHouseAppInstallFunc(ctx, commandResults) +} + func (s *DataStore) SetHostSoftwareInstallResult(ctx context.Context, result *fleet.HostSoftwareInstallResultPayload) (wasCanceled bool, err error) { s.mu.Lock() s.SetHostSoftwareInstallResultFuncInvoked = true @@ -5576,11 +5678,11 @@ func (s *DataStore) GetCategoriesForSoftwareTitles(ctx context.Context, software return s.GetCategoriesForSoftwareTitlesFunc(ctx, softwareTitleIDs, team_id) } -func (s *DataStore) AssociateVPPInstallToVerificationUUID(ctx context.Context, installUUID string, verifyCommandUUID string) error { +func (s *DataStore) AssociateMDMInstallToVerificationUUID(ctx context.Context, installUUID string, verifyCommandUUID string, hostUUID string) error { s.mu.Lock() - s.AssociateVPPInstallToVerificationUUIDFuncInvoked = true + s.AssociateMDMInstallToVerificationUUIDFuncInvoked = true s.mu.Unlock() - return s.AssociateVPPInstallToVerificationUUIDFunc(ctx, installUUID, verifyCommandUUID) + return s.AssociateMDMInstallToVerificationUUIDFunc(ctx, installUUID, verifyCommandUUID, hostUUID) } func (s *DataStore) SetVPPInstallAsVerified(ctx context.Context, hostID uint, installUUID string, verificationUUID string) error { @@ -5597,11 +5699,11 @@ func (s *DataStore) ReplaceVPPInstallVerificationUUID(ctx context.Context, oldVe return s.ReplaceVPPInstallVerificationUUIDFunc(ctx, oldVerifyUUID, verifyCommandUUID) } -func (s *DataStore) IsHostPendingVPPInstallVerification(ctx context.Context, hostUUID string) (bool, error) { +func (s *DataStore) IsHostPendingMDMInstallVerification(ctx context.Context, hostUUID string) (bool, error) { s.mu.Lock() - s.IsHostPendingVPPInstallVerificationFuncInvoked = true + s.IsHostPendingMDMInstallVerificationFuncInvoked = true s.mu.Unlock() - return s.IsHostPendingVPPInstallVerificationFunc(ctx, hostUUID) + return s.IsHostPendingMDMInstallVerificationFunc(ctx, hostUUID) } func (s *DataStore) GetUnverifiedVPPInstallsForHost(ctx context.Context, verificationUUID string) ([]*fleet.HostVPPSoftwareInstall, error) { @@ -5618,11 +5720,11 @@ func (s *DataStore) SetVPPInstallAsFailed(ctx context.Context, hostID uint, inst return s.SetVPPInstallAsFailedFunc(ctx, hostID, installUUID, verificationUUID) } -func (s *DataStore) MarkAllPendingVPPInstallsAsFailed(ctx context.Context, jobName string) error { +func (s *DataStore) MarkAllPendingVPPAndInHouseInstallsAsFailed(ctx context.Context, jobName string) error { s.mu.Lock() - s.MarkAllPendingVPPInstallsAsFailedFuncInvoked = true + s.MarkAllPendingVPPAndInHouseInstallsAsFailedFuncInvoked = true s.mu.Unlock() - return s.MarkAllPendingVPPInstallsAsFailedFunc(ctx, jobName) + return s.MarkAllPendingVPPAndInHouseInstallsAsFailedFunc(ctx, jobName) } func (s *DataStore) GetHostOperatingSystem(ctx context.Context, hostID uint) (*fleet.OperatingSystem, error) { @@ -8222,6 +8324,13 @@ func (s *DataStore) GetSoftwareInstallerMetadataByTeamAndTitleID(ctx context.Con return s.GetSoftwareInstallerMetadataByTeamAndTitleIDFunc(ctx, teamID, titleID, withScriptContents) } +func (s *DataStore) InsertHostInHouseAppInstall(ctx context.Context, hostID uint, inHouseAppID uint, softwareTitleID uint, commandUUID string, opts fleet.HostSoftwareInstallOptions) error { + s.mu.Lock() + s.InsertHostInHouseAppInstallFuncInvoked = true + s.mu.Unlock() + return s.InsertHostInHouseAppInstallFunc(ctx, hostID, inHouseAppID, softwareTitleID, commandUUID, opts) +} + func (s *DataStore) GetSoftwareInstallersPendingUninstallScriptPopulation(ctx context.Context) (map[uint]string, error) { s.mu.Lock() s.GetSoftwareInstallersPendingUninstallScriptPopulationFuncInvoked = true @@ -8348,6 +8457,41 @@ func (s *DataStore) CleanupUnusedSoftwareInstallers(ctx context.Context, softwar return s.CleanupUnusedSoftwareInstallersFunc(ctx, softwareInstallStore, removeCreatedBefore) } +func (s *DataStore) SaveInHouseAppUpdates(ctx context.Context, payload *fleet.UpdateSoftwareInstallerPayload) error { + s.mu.Lock() + s.SaveInHouseAppUpdatesFuncInvoked = true + s.mu.Unlock() + return s.SaveInHouseAppUpdatesFunc(ctx, payload) +} + +func (s *DataStore) GetInHouseAppMetadataByTeamAndTitleID(ctx context.Context, teamID *uint, titleID uint) (*fleet.SoftwareInstaller, error) { + s.mu.Lock() + s.GetInHouseAppMetadataByTeamAndTitleIDFuncInvoked = true + s.mu.Unlock() + return s.GetInHouseAppMetadataByTeamAndTitleIDFunc(ctx, teamID, titleID) +} + +func (s *DataStore) RemovePendingInHouseAppInstalls(ctx context.Context, inHouseAppID uint) error { + s.mu.Lock() + s.RemovePendingInHouseAppInstallsFuncInvoked = true + s.mu.Unlock() + return s.RemovePendingInHouseAppInstallsFunc(ctx, inHouseAppID) +} + +func (s *DataStore) GetSummaryHostInHouseAppInstalls(ctx context.Context, teamID *uint, inHouseAppID uint) (*fleet.VPPAppStatusSummary, error) { + s.mu.Lock() + s.GetSummaryHostInHouseAppInstallsFuncInvoked = true + s.mu.Unlock() + return s.GetSummaryHostInHouseAppInstallsFunc(ctx, teamID, inHouseAppID) +} + +func (s *DataStore) DeleteInHouseApp(ctx context.Context, id uint) error { + s.mu.Lock() + s.DeleteInHouseAppFuncInvoked = true + s.mu.Unlock() + return s.DeleteInHouseAppFunc(ctx, id) +} + func (s *DataStore) CleanupUnusedSoftwareTitleIcons(ctx context.Context, softwareTitleIconStore fleet.SoftwareTitleIconStore, removeCreatedBefore time.Time) error { s.mu.Lock() s.CleanupUnusedSoftwareTitleIconsFuncInvoked = true diff --git a/server/mock/service/service_mock.go b/server/mock/service/service_mock.go index d6bad2364c..e4cb18e21a 100644 --- a/server/mock/service/service_mock.go +++ b/server/mock/service/service_mock.go @@ -451,6 +451,10 @@ type AddAppStoreAppFunc func(ctx context.Context, teamID *uint, appTeam fleet.VP type UpdateAppStoreAppFunc func(ctx context.Context, titleID uint, teamID *uint, selfService bool, labelsIncludeAny []string, labelsExcludeAny []string, categories []string) (*fleet.VPPAppStoreApp, error) +type GetInHouseAppManifestFunc func(ctx context.Context, titleID uint, teamID *uint) ([]byte, error) + +type GetInHouseAppPackageFunc func(ctx context.Context, titleID uint, teamID *uint) (*fleet.DownloadSoftwareInstallerPayload, error) + type MDMAppleProcessOTAEnrollmentFunc func(ctx context.Context, certificates []*x509.Certificate, rootSigner *x509.Certificate, enrollSecret string, idpUUID string, deviceInfo fleet.MDMAppleMachineInfo) ([]byte, error) type ListVulnerabilitiesFunc func(ctx context.Context, opt fleet.VulnListOptions) ([]fleet.VulnerabilityWithMetadata, *fleet.PaginationMetadata, error) @@ -1487,6 +1491,12 @@ type Service struct { UpdateAppStoreAppFunc UpdateAppStoreAppFunc UpdateAppStoreAppFuncInvoked bool + GetInHouseAppManifestFunc GetInHouseAppManifestFunc + GetInHouseAppManifestFuncInvoked bool + + GetInHouseAppPackageFunc GetInHouseAppPackageFunc + GetInHouseAppPackageFuncInvoked bool + MDMAppleProcessOTAEnrollmentFunc MDMAppleProcessOTAEnrollmentFunc MDMAppleProcessOTAEnrollmentFuncInvoked bool @@ -3585,6 +3595,20 @@ func (s *Service) UpdateAppStoreApp(ctx context.Context, titleID uint, teamID *u return s.UpdateAppStoreAppFunc(ctx, titleID, teamID, selfService, labelsIncludeAny, labelsExcludeAny, categories) } +func (s *Service) GetInHouseAppManifest(ctx context.Context, titleID uint, teamID *uint) ([]byte, error) { + s.mu.Lock() + s.GetInHouseAppManifestFuncInvoked = true + s.mu.Unlock() + return s.GetInHouseAppManifestFunc(ctx, titleID, teamID) +} + +func (s *Service) GetInHouseAppPackage(ctx context.Context, titleID uint, teamID *uint) (*fleet.DownloadSoftwareInstallerPayload, error) { + s.mu.Lock() + s.GetInHouseAppPackageFuncInvoked = true + s.mu.Unlock() + return s.GetInHouseAppPackageFunc(ctx, titleID, teamID) +} + func (s *Service) MDMAppleProcessOTAEnrollment(ctx context.Context, certificates []*x509.Certificate, rootSigner *x509.Certificate, enrollSecret string, idpUUID string, deviceInfo fleet.MDMAppleMachineInfo) ([]byte, error) { s.mu.Lock() s.MDMAppleProcessOTAEnrollmentFuncInvoked = true diff --git a/server/ptr/ptr.go b/server/ptr/ptr.go index 9249b56410..4905e65968 100644 --- a/server/ptr/ptr.go +++ b/server/ptr/ptr.go @@ -71,3 +71,8 @@ func Int64(x int64) *int64 { func Duration(x time.Duration) *time.Duration { return &x } + +// T is the generic version to get the pointer of any type. +func T[T any](x T) *T { + return &x +} diff --git a/server/service/apple_mdm.go b/server/service/apple_mdm.go index 064ed76bf2..439afb224f 100644 --- a/server/service/apple_mdm.go +++ b/server/service/apple_mdm.go @@ -3920,7 +3920,7 @@ func (svc *MDMAppleCheckinAndCommandService) CommandAndReportResults(r *mdm.Requ // If the command succeeded, then start the install verification process. if cmdResult.Status == fleet.MDMAppleStatusAcknowledged { // Only send a new InstalledApplicationList command if there's not one in flight - commandsPending, err := svc.ds.IsHostPendingVPPInstallVerification(r.Context, cmdResult.Identifier()) + commandsPending, err := svc.ds.IsHostPendingMDMInstallVerification(r.Context, cmdResult.Identifier()) if err != nil { return nil, ctxerr.Wrap(r.Context, err, "get pending mdm commands by host") } @@ -3931,7 +3931,7 @@ func (svc *MDMAppleCheckinAndCommandService) CommandAndReportResults(r *mdm.Requ } // update the install record - if err := svc.ds.AssociateVPPInstallToVerificationUUID(r.Context, cmdResult.CommandUUID, cmdUUID); err != nil { + if err := svc.ds.AssociateMDMInstallToVerificationUUID(r.Context, cmdResult.CommandUUID, cmdUUID, cmdResult.Identifier()); err != nil { return nil, ctxerr.Wrap(r.Context, err, "update install record") } @@ -4221,13 +4221,18 @@ func NewInstalledApplicationListResultsHandler( installedApps := installedAppResult.AvailableApps() - expectedInstalls, err := ds.GetUnverifiedVPPInstallsForHost(ctx, installedAppResult.HostUUID()) + expectedVPPInstalls, err := ds.GetUnverifiedVPPInstallsForHost(ctx, installedAppResult.HostUUID()) if err != nil { return ctxerr.Wrap(ctx, err, "InstalledApplicationList handler: getting install record") } - if len(expectedInstalls) == 0 { - level.Warn(logger).Log("msg", "no vpp installs found for host", "host_uuid", installedAppResult.HostUUID(), "verification_command_uuid", installedAppResult.UUID()) + expectedInHouseInstalls, err := ds.GetUnverifiedInHouseAppInstallsForHost(ctx, installedAppResult.HostUUID()) + if err != nil { + return ctxerr.Wrap(ctx, err, "InstalledApplicationList handler: get unverified in house installs") + } + + if len(expectedVPPInstalls) == 0 && len(expectedInHouseInstalls) == 0 { + level.Warn(logger).Log("msg", "no apple MDM installs found for host", "host_uuid", installedAppResult.HostUUID(), "verification_command_uuid", installedAppResult.UUID()) return nil } @@ -4236,27 +4241,45 @@ func NewInstalledApplicationListResultsHandler( installsByBundleID[install.BundleIdentifier] = install } - // We've handled the "no installs found" case above, and this is scoped to a single host via the host - // UUID, so this is OK. - hostID := expectedInstalls[0].HostID + // We've handled the "no installs found" case above, + // and installs are scoped to a single host via the host UUID, so this is OK. + var hostID uint + switch { + case len(expectedInHouseInstalls) > 0: + hostID = expectedInHouseInstalls[0].HostID + case len(expectedVPPInstalls) > 0: + hostID = expectedVPPInstalls[0].HostID + } + + type installStatusSetter struct { + // Used to mark the install as verified + verifyFn func(ctx context.Context, hostID uint, installUUID string, verificationUUID string) error + // Used to mark the install as failed + failFn func(ctx context.Context, hostID uint, installUUID string, verificationUUID string) error + // Used to get the activity data for an install + activityFn func(ctx context.Context, results *mdm.CommandResults, fromSetupExp bool) (*fleet.User, fleet.ActivityDetails, error) + } var poll, shouldRefetch bool - for _, expectedInstall := range expectedInstalls { + setStatusForExpectedInstall := func( + expectedInstall *fleet.HostVPPSoftwareInstall, + setter installStatusSetter, + + ) error { // If we don't find the app in the result, then we need to poll for it (within the timeout). - // These are not pointers, so no need to check `ok` here. appFromResult := installsByBundleID[expectedInstall.BundleIdentifier] var terminalStatus string switch { case appFromResult.Installed: - if err := ds.SetVPPInstallAsVerified(ctx, expectedInstall.HostID, expectedInstall.InstallCommandUUID, installedAppResult.UUID()); err != nil { + if err := setter.verifyFn(ctx, expectedInstall.HostID, expectedInstall.InstallCommandUUID, installedAppResult.UUID()); err != nil { return ctxerr.Wrap(ctx, err, "InstalledApplicationList handler: set vpp install verified") } terminalStatus = fleet.MDMAppleStatusAcknowledged shouldRefetch = true case expectedInstall.InstallCommandAckAt != nil && time.Since(*expectedInstall.InstallCommandAckAt) > verifyTimeout: - if err := ds.SetVPPInstallAsFailed(ctx, expectedInstall.HostID, expectedInstall.InstallCommandUUID, installedAppResult.UUID()); err != nil { + if err := setter.failFn(ctx, expectedInstall.HostID, expectedInstall.InstallCommandUUID, installedAppResult.UUID()); err != nil { return ctxerr.Wrap(ctx, err, "InstalledApplicationList handler: set vpp install failed") } @@ -4265,7 +4288,7 @@ func NewInstalledApplicationListResultsHandler( if terminalStatus == "" { poll = true - continue + return nil } // this might be a setup experience VPP install, so we'll try to update setup experience status @@ -4282,20 +4305,55 @@ func NewInstalledApplicationListResultsHandler( } // create an activity for installing only if we're in a terminal state - user, act, err := ds.GetPastActivityDataForVPPAppInstall(ctx, &mdm.CommandResults{CommandUUID: expectedInstall.InstallCommandUUID, Status: terminalStatus}) + user, act, err := setter.activityFn(ctx, &mdm.CommandResults{CommandUUID: expectedInstall.InstallCommandUUID, Status: terminalStatus}, fromSetupExperience) if err != nil { if fleet.IsNotFound(err) { - // Then this isn't a VPP install, so no activity generated + // Then this isn't an MDM-based install, so no activity generated return nil } return ctxerr.Wrap(ctx, err, "fetching data for installed app store app activity") } - act.FromSetupExperience = fromSetupExperience + if err := newActivity(ctx, user, act, ds, logger); err != nil { return ctxerr.Wrap(ctx, err, "creating activity for installed app store app") } + return nil + } + + for _, expectedInstall := range expectedVPPInstalls { + setter := installStatusSetter{ + ds.SetVPPInstallAsVerified, + ds.SetVPPInstallAsFailed, + func(ctx context.Context, results *mdm.CommandResults, fromSetupExp bool) (*fleet.User, fleet.ActivityDetails, error) { + user, act, err := ds.GetPastActivityDataForVPPAppInstall(ctx, results) + if err != nil { + return nil, nil, err + } + + act.FromSetupExperience = fromSetupExp + + return user, act, nil + }, + } + + if err := setStatusForExpectedInstall(expectedInstall, setter); err != nil { + return ctxerr.Wrap(ctx, err, "setting status for vpp installs") + } + } + + for _, expectedInstall := range expectedInHouseInstalls { + setter := installStatusSetter{ + ds.SetInHouseAppInstallAsVerified, + ds.SetInHouseAppInstallAsFailed, + func(ctx context.Context, results *mdm.CommandResults, _ bool) (*fleet.User, fleet.ActivityDetails, error) { + return ds.GetPastActivityDataForInHouseAppInstall(ctx, results) + }, + } + if err := setStatusForExpectedInstall(expectedInstall, setter); err != nil { + return ctxerr.Wrap(ctx, err, "setting status for in-house app installs") + } } if poll { diff --git a/server/service/handler.go b/server/service/handler.go index edf97b0707..9bd7b98b59 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -989,6 +989,9 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC ne.POST("/api/fleet/orbit/enroll", enrollOrbitEndpoint, contract.EnrollOrbitRequest{}) + ne.GET("/api/_version_/fleet/software/titles/{title_id:[0-9]+}/in_house_app", getInHouseAppPackageEndpoint, getInHouseAppPackageRequest{}) + ne.GET("/api/_version_/fleet/software/titles/{title_id:[0-9]+}/in_house_app/manifest", getInHouseAppManifestEndpoint, getInHouseAppManifestRequest{}) + // For some reason osquery does not provide a node key with the block data. // Instead the carve session ID should be verified in the service method. ne.WithAltPaths("/api/v1/osquery/carve/block"). diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go index 3330fa2052..5a7a44bba8 100644 --- a/server/service/integration_mdm_test.go +++ b/server/service/integration_mdm_test.go @@ -752,6 +752,7 @@ func (s *integrationMDMTestSuite) TearDownTest() { _, err := tx.ExecContext(ctx, "DELETE FROM vpp_apps;") return err }) + } func (s *integrationMDMTestSuite) mockDEPResponse(orgName string, handler http.Handler) { diff --git a/server/service/integration_vpp_install_test.go b/server/service/integration_vpp_install_test.go index ce2d9d9780..bf9ebb2db1 100644 --- a/server/service/integration_vpp_install_test.go +++ b/server/service/integration_vpp_install_test.go @@ -1264,3 +1264,158 @@ func (s *integrationMDMTestSuite) TestSoftwareTitleVPPAppSoftwarePackageConflict require.NotNil(t, listSw.SoftwareTitles[1].AppStoreApp) require.Equal(t, "2", listSw.SoftwareTitles[1].AppStoreApp.AppStoreID) } + +func (s *integrationMDMTestSuite) TestInHouseAppInstall() { + t := s.T() + s.setSkipWorkerJobs(t) + ctx := context.Background() + + // Enroll iPhone + iosHost, iosDevice := s.createAppleMobileHostThenEnrollMDM("ios") + s.appleVPPConfigSrvConfig.SerialNumbers = append(s.appleVPPConfigSrvConfig.SerialNumbers, iosDevice.SerialNumber) + + // Create a label + clr := createLabelResponse{} + s.DoJSON("POST", "/api/latest/fleet/labels", createLabelRequest{ + LabelPayload: fleet.LabelPayload{ + Name: "foo", + HostIDs: []uint{iosHost.ID}, + }, + }, http.StatusOK, &clr) + + // Upload in-house app for iOS, with the label as "exclude any" + s.uploadSoftwareInstaller(t, &fleet.UploadSoftwareInstallerPayload{Filename: "ipa_test.ipa", LabelsExcludeAny: []string{"foo"}}, http.StatusOK, "") + + // Get title ID + var titleID uint + mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + return sqlx.GetContext(ctx, q, &titleID, "SELECT title_id FROM in_house_apps WHERE name = 'ipa_test'") + }) + + // TODO: uncomment once this endpoint supports in house apps + // var resp listSoftwareTitlesResponse + // s.DoJSON("GET", "/api/latest/fleet/software/titles", listSoftwareTitlesRequest{}, http.StatusOK, &resp, "team_id", "0") + + // assert.Len(t, resp.SoftwareTitles, 1) + // assert.Equal(t, "ipa_test", resp.SoftwareTitles[0].Name) + // titleID := resp.SoftwareTitles[0].ID + + // Attempt installation on non-scoped app, should fail + var installResp installSoftwareResponse + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", + iosHost.ID, titleID), nil, http.StatusBadRequest, &installResp) + + // Update label to be include any, install should succeed + s.updateSoftwareInstaller(t, &fleet.UpdateSoftwareInstallerPayload{TitleID: titleID, Filename: "ipa_test.ipa", LabelsIncludeAny: []string{"foo"}}, http.StatusOK, "") + + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", + iosHost.ID, titleID), nil, http.StatusAccepted, &installResp) + + var installCmdUUID string + mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + return sqlx.GetContext(ctx, q, &installCmdUUID, "SELECT command_uuid FROM host_in_house_software_installs WHERE host_id = ?", iosHost.ID) + }) + require.NotEmpty(t, installCmdUUID) + + // TODO(JVE): check upcoming activity feed for installation + + // Process the InstallApplication command + s.runWorker() + cmd, err := iosDevice.Idle() + require.NoError(t, err) + + for cmd != nil { + var fullCmd micromdm.CommandPayload + if cmd.Command.RequestType == "InstallApplication" { + require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd)) + assert.Equal(t, installCmdUUID, cmd.CommandUUID) + + // Points at the expected manifest URL + expectedManifestURL := fmt.Sprintf("%s/api/latest/fleet/software/titles/%d/in_house_app/manifest?team_id=%d", s.server.URL, titleID, 0) + assert.Contains(t, string(cmd.Raw), expectedManifestURL) + + cmd, err = iosDevice.Acknowledge(cmd.CommandUUID) + require.NoError(t, err) + } + } + + // Install verification command should be sent + + // Simulate a verification command not finding the app (maybe it takes a little while to install) + s.runWorker() + cmd, err = iosDevice.Idle() + var cmd1 string + require.NoError(t, err) + assert.NotNil(t, cmd) + for cmd != nil { + var fullCmd micromdm.CommandPayload + switch cmd.Command.RequestType { + case "InstalledApplicationList": + require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd)) + cmd1 = cmd.CommandUUID + require.Contains(t, cmd.CommandUUID, fleet.VerifySoftwareInstallVPPPrefix) + cmd, err = iosDevice.AcknowledgeInstalledApplicationList( + iosDevice.UUID, + cmd.CommandUUID, + []fleet.Software{}, + ) + require.NoError(t, err) + default: + require.Fail(t, "unexpected MDM command on client", cmd.Command.RequestType) + } + } + + s.runWorker() + + cmd, err = iosDevice.Idle() + require.NoError(t, err) + assert.NotNil(t, cmd) + var verificationCmdUUID string + for cmd != nil { + var fullCmd micromdm.CommandPayload + switch cmd.Command.RequestType { + case "InstalledApplicationList": + require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd)) + assert.NotEqual(t, cmd1, cmd.CommandUUID) + verificationCmdUUID = cmd.CommandUUID + require.Contains(t, cmd.CommandUUID, fleet.VerifySoftwareInstallVPPPrefix) + cmd, err = iosDevice.AcknowledgeInstalledApplicationList( + iosDevice.UUID, + cmd.CommandUUID, + []fleet.Software{ + { + Name: "test", + BundleIdentifier: "com.ipa-test.ipa-test", + Version: "1.0", + Installed: true, + }, + }, + ) + require.NoError(t, err) + default: + require.Fail(t, "unexpected MDM command on client", cmd.Command.RequestType) + } + } + + mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + var install struct { + CommandUUID string `db:"command_uuid"` + VerificationCmdUUID string `db:"verification_command_uuid"` + VerificationAt *time.Time `db:"verification_at"` + } + err = sqlx.GetContext( + context.Background(), + q, + &install, + "SELECT command_uuid, verification_command_uuid, verification_at FROM host_in_house_software_installs WHERE host_id = ?", + iosHost.ID, + ) + require.NoError(t, err) + assert.Equal(t, installCmdUUID, install.CommandUUID) + assert.Equal(t, verificationCmdUUID, install.VerificationCmdUUID) + assert.NotNil(t, install.VerificationAt) + + return nil + }) + +} diff --git a/server/service/mdm.go b/server/service/mdm.go index 46a1a719ec..c4e1cff3a9 100644 --- a/server/service/mdm.go +++ b/server/service/mdm.go @@ -3310,7 +3310,7 @@ func (svc *Service) DeleteMDMAppleAPNSCert(ctx context.Context) error { // If an install doesn't have a verification_at or verification_failed_at, then // mark it as failed - if err := svc.ds.MarkAllPendingVPPInstallsAsFailed(ctx, worker.AppleSoftwareJobName); err != nil { + if err := svc.ds.MarkAllPendingVPPAndInHouseInstallsAsFailed(ctx, worker.AppleSoftwareJobName); err != nil { return ctxerr.Wrap(ctx, err, "marking all pending vpp installs as failed") } diff --git a/server/service/mdm_test.go b/server/service/mdm_test.go index 0d05e08b83..57f44a724a 100644 --- a/server/service/mdm_test.go +++ b/server/service/mdm_test.go @@ -145,7 +145,7 @@ func TestMDMAppleAuthorization(t *testing.T) { ds.DeleteMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName) error { return nil } - ds.MarkAllPendingVPPInstallsAsFailedFunc = func(ctx context.Context, jobName string) error { return nil } + ds.MarkAllPendingVPPAndInHouseInstallsAsFailedFunc = func(ctx context.Context, jobName string) error { return nil } // use a custom implementation of checkAuthErr as the service call will fail // with a not found error (given that MDM is not really configured) in case diff --git a/server/service/software_installers.go b/server/service/software_installers.go index d48ea10996..84689b083f 100644 --- a/server/service/software_installers.go +++ b/server/service/software_installers.go @@ -906,3 +906,97 @@ func (svc *Service) BatchAssociateVPPApps(ctx context.Context, teamName string, return nil, fleet.ErrMissingLicense } + +type getInHouseAppManifestRequest struct { + TitleID uint `url:"title_id"` + TeamID *uint `query:"team_id"` +} + +type getInHouseAppManifestResponse struct { + // Manifest field is used in HijackRender for the response. + Manifest []byte + + Err error `json:"error,omitempty"` +} + +func (r getInHouseAppManifestResponse) Error() error { return r.Err } + +func (r getInHouseAppManifestResponse) HijackRender(ctx context.Context, w http.ResponseWriter) { + // make the browser download the content to a file + w.Header().Add("Content-Disposition", `attachment; filename="in-house-app-manifest.plist"`) + // explicitly set the content length before the write, so the caller can + // detect short writes (if it fails to send the full content properly) + w.Header().Set("Content-Length", strconv.FormatInt(int64(len(r.Manifest)), 10)) + // this content type will make macos open the profile with the proper application + w.Header().Set("Content-Type", "application/x-apple-aspen-config; charset=utf-8") + // prevent detection of content, obey the provided content-type + w.Header().Set("X-Content-Type-Options", "nosniff") + + if n, err := w.Write(r.Manifest); err != nil { + logging.WithExtras(ctx, "err", err, "written", n) + } +} + +func getInHouseAppManifestEndpoint(ctx context.Context, request any, svc fleet.Service) (fleet.Errorer, error) { + req := request.(*getInHouseAppManifestRequest) + manifest, err := svc.GetInHouseAppManifest(ctx, req.TitleID, req.TeamID) + if err != nil { + return &getInHouseAppManifestResponse{Err: err}, nil + } + + return &getInHouseAppManifestResponse{Manifest: manifest}, nil +} + +func (svc *Service) GetInHouseAppManifest(ctx context.Context, titleID uint, teamID *uint) ([]byte, error) { + // skipauth: No authorization check needed due to implementation returning + // only license error. + svc.authz.SkipAuthorization(ctx) + + return nil, fleet.ErrMissingLicense +} + +type getInHouseAppPackageRequest struct { + TitleID uint `url:"title_id"` + TeamID *uint `query:"team_id"` +} + +type getInHouseAppPackageResponse struct { + payload *fleet.DownloadSoftwareInstallerPayload + + Err error `json:"error,omitempty"` +} + +func (r getInHouseAppPackageResponse) HijackRender(ctx context.Context, w http.ResponseWriter) { + w.Header().Set("Content-Length", strconv.Itoa(int(r.payload.Size))) + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment;filename="%s"`, r.payload.Filename)) + + // OK to just log the error here as writing anything on + // `http.ResponseWriter` sets the status code to 200 (and it can't be + // changed.) Clients should rely on matching content-length with the + // header provided + if n, err := io.Copy(w, r.payload.Installer); err != nil { + logging.WithExtras(ctx, "err", err, "bytes_copied", n) + } + r.payload.Installer.Close() +} + +func (r getInHouseAppPackageResponse) Error() error { return r.Err } + +func getInHouseAppPackageEndpoint(ctx context.Context, request any, svc fleet.Service) (fleet.Errorer, error) { + req := request.(*getInHouseAppPackageRequest) + file, err := svc.GetInHouseAppPackage(ctx, req.TitleID, req.TeamID) + if err != nil { + return &getInHouseAppPackageResponse{Err: err}, nil + } + + return &getInHouseAppPackageResponse{payload: file}, nil +} + +func (svc *Service) GetInHouseAppPackage(ctx context.Context, titleID uint, teamID *uint) (*fleet.DownloadSoftwareInstallerPayload, error) { + // skipauth: No authorization check needed due to implementation returning + // only license error. + svc.authz.SkipAuthorization(ctx) + + return nil, fleet.ErrMissingLicense +} diff --git a/server/service/software_installers_test.go b/server/service/software_installers_test.go index fd696c9d28..867a0da6b4 100644 --- a/server/service/software_installers_test.go +++ b/server/service/software_installers_test.go @@ -80,6 +80,15 @@ func TestSoftwareInstallersAuth(t *testing.T) { ds.GetSoftwareInstallerMetadataByTeamAndTitleIDFunc = func(ctx context.Context, teamID *uint, titleID uint, withScripts bool) (*fleet.SoftwareInstaller, error) { return &fleet.SoftwareInstaller{TeamID: tt.teamID}, nil } + ds.GetVPPAppMetadataByTeamAndTitleIDFunc = func(ctx context.Context, teamID *uint, titleID uint) (*fleet.VPPAppStoreApp, error) { + if tt.teamID == nil { + return &fleet.VPPAppStoreApp{VPPAppsTeamsID: 0}, nil + } + return &fleet.VPPAppStoreApp{VPPAppsTeamsID: *tt.teamID}, nil + } + ds.GetInHouseAppMetadataByTeamAndTitleIDFunc = func(ctx context.Context, teamID *uint, titleID uint) (*fleet.SoftwareInstaller, error) { + return &fleet.SoftwareInstaller{TeamID: tt.teamID}, nil + } ds.DeleteSoftwareInstallerFunc = func(ctx context.Context, installerID uint) error { return nil diff --git a/server/service/testdata/software-installers/ipa_test.ipa b/server/service/testdata/software-installers/ipa_test.ipa new file mode 100644 index 0000000000..177ef455f0 Binary files /dev/null and b/server/service/testdata/software-installers/ipa_test.ipa differ diff --git a/server/service/testing_client.go b/server/service/testing_client.go index 68556a60a6..f79a2fca23 100644 --- a/server/service/testing_client.go +++ b/server/service/testing_client.go @@ -143,6 +143,11 @@ func (ts *withServer) commonTearDownTest(t *testing.T) { // Clean software installers in "No team" (the others are deleted in ts.ds.DeleteTeam above). mysql.ExecAdhocSQL(t, ts.ds, func(q sqlx.ExtContext) error { _, err := q.ExecContext(ctx, `DELETE FROM software_installers WHERE global_or_team_id = 0;`) + if err != nil { + return err + } + + _, err = q.ExecContext(ctx, "DELETE FROM in_house_apps;") return err }) diff --git a/server/test/activities.go b/server/test/activities.go index 05a25e7891..c8c1026903 100644 --- a/server/test/activities.go +++ b/server/test/activities.go @@ -153,6 +153,49 @@ func SetHostVPPAppInstallResult(t *testing.T, ds fleet.Datastore, nanods storage HostID: host.ID, AppStoreID: adamID, CommandUUID: execID, + Status: "Error", + }, []byte(`{}`), time.Now()) + require.NoError(t, err) +} + +// CreateHostInHouseAppInstallUpcomingActivity creates an in-house app install +// request for the provided host. It returns the upcoming activity's execution +// ID. +func CreateHostInHouseAppInstallUpcomingActivity(t *testing.T, ds fleet.Datastore, host *fleet.Host, user *fleet.User) (execID string) { + ctx := context.Background() + ihaID, ihaTitleID, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ + Filename: "inhouse.ipa", + Title: uuid.NewString(), + Source: "ios_apps", + Extension: "ipa", + BundleIdentifier: "com.example.inhouseapp", + UserID: user.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, + }) + require.NoError(t, err) + + execID = uuid.NewString() + err = ds.InsertHostInHouseAppInstall(ctx, host.ID, ihaID, ihaTitleID, execID, fleet.HostSoftwareInstallOptions{}) + require.NoError(t, err) + return execID +} + +func SetHostInHouseAppInstallResult(t *testing.T, ds fleet.Datastore, nanods storage.CommandAndReportResultsStore, host *fleet.Host, execID, status string) { + ctx := context.Background() + ctx = context.WithValue(ctx, fleet.ActivityWebhookContextKey, true) + nanoCtx := &mdm.Request{EnrollID: &mdm.EnrollID{ID: host.UUID}, Context: ctx} + + cmdRes := &mdm.CommandResults{ + CommandUUID: execID, + Status: status, + Raw: []byte(``), + } + err := nanods.StoreCommandReport(nanoCtx, cmdRes) + require.NoError(t, err) + err = ds.NewActivity(ctx, nil, fleet.ActivityTypeInstalledSoftware{ + HostID: host.ID, + CommandUUID: execID, + Status: "Error", }, []byte(`{}`), time.Now()) require.NoError(t, err) } diff --git a/server/worker/vpp_verification.go b/server/worker/vpp_verification.go index 1c912e85ec..631d86af42 100644 --- a/server/worker/vpp_verification.go +++ b/server/worker/vpp_verification.go @@ -59,7 +59,11 @@ func (v *AppleSoftware) verifyVPPInstalls(ctx context.Context, hostUUID, verific } if err := v.Datastore.ReplaceVPPInstallVerificationUUID(ctx, verificationCommandUUID, newListCmdUUID); err != nil { - return ctxerr.Wrap(ctx, err, "update install record") + return ctxerr.Wrap(ctx, err, "update vpp install record") + } + + if err := v.Datastore.ReplaceInHouseAppInstallVerificationUUID(ctx, verificationCommandUUID, newListCmdUUID); err != nil { + return ctxerr.Wrap(ctx, err, "update in-house app install record") } level.Debug(v.Log).Log("msg", "new installed application list command sent", "uuid", newListCmdUUID)