diff --git a/changes/37546-android-certificate-install-activity b/changes/37546-android-certificate-install-activity new file mode 100644 index 00000000000..04ad7135243 --- /dev/null +++ b/changes/37546-android-certificate-install-activity @@ -0,0 +1,2 @@ +* Added activity logging when a certificate is installed or fails to install on an Android host. +* Enabled the host activity card on the Android host details page. diff --git a/frontend/interfaces/activity.ts b/frontend/interfaces/activity.ts index 110432f58a1..8d0c381e08f 100644 --- a/frontend/interfaces/activity.ts +++ b/frontend/interfaces/activity.ts @@ -160,6 +160,7 @@ export enum ActivityType { EditedHostIdpData = "edited_host_idp_data", AddedCertificate = "added_certificate", DeletedCertificate = "deleted_certificate", + InstalledCertificate = "installed_certificate", EditedEnrollSecrets = "edited_enroll_secrets", AddedMicrosoftEntraTenant = "added_microsoft_entra_tenant", DeletedMicrosoftEntraTenant = "deleted_microsoft_entra_tenant", @@ -181,7 +182,8 @@ export type IHostPastActivityType = | ActivityType.CanceledRunScript | ActivityType.CanceledInstallAppStoreApp | ActivityType.CanceledInstallSoftware - | ActivityType.CanceledUninstallSoftware; + | ActivityType.CanceledUninstallSoftware + | ActivityType.InstalledCertificate; /** This is a subset of ActivityType that are shown only for the host upcoming activities */ export type IHostUpcomingActivityType = @@ -289,6 +291,7 @@ export interface IActivityDetails { tenant_id?: string; certificate_name?: string; certificate_template_id?: number; + detail?: string; } // maps activity types to their corresponding label to use when filtering activites via the dropdown @@ -459,5 +462,6 @@ export const ACTIVITY_TYPE_TO_FILTER_LABEL: Record = { [ActivityType.EditedHostIdpData]: "Edited host identity provider (IdP) data", [ActivityType.AddedCertificate]: "Added certificate", [ActivityType.DeletedCertificate]: "Deleted certificate", + [ActivityType.InstalledCertificate]: "Installed certificate", [ActivityType.EditedEnrollSecrets]: "Edited enroll secrets", }; diff --git a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx index dbbbb436c28..e3f777bc167 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx +++ b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx @@ -1151,7 +1151,6 @@ const HostDetailsPage = ({ const showSoftwareLibraryTab = isPremiumTier; const showReportsTab = mdm?.enrollment_status !== "Pending"; - const showActivityCard = !isAndroidHost; const showAgentOptionsCard = !isIosOrIpadosHost && !isAndroidHost; const showLocalUserAccountsCard = !isIosOrIpadosHost && !isAndroidHost; const showCertificatesCard = @@ -1354,64 +1353,61 @@ const HostDetailsPage = ({ toggleLocationModal={toggleLocationModal} toggleMDMStatusModal={toggleMDMStatusModal} /> - {showActivityCard && ( - { - setActivityPage(0); - setShowMDMCommands(true); - }} - onHideMDMCommands={() => { - setActivityPage(0); - setShowMDMCommands(false); - }} - upcomingCount={ - (upcomingActivities?.count || 0) + - (upcomingMDMCommands?.count || 0) - } - onChangeTab={onChangeActivityTab} - onNextPage={() => setActivityPage(activityPage + 1)} - onPreviousPage={() => setActivityPage(activityPage - 1)} - onShowDetails={onShowActivityDetails} - onShowCommandDetails={setMdmCommandDetails} - onCancel={onCancelActivity} - /> - )} + { + setActivityPage(0); + setShowMDMCommands(true); + }} + onHideMDMCommands={() => { + setActivityPage(0); + setShowMDMCommands(false); + }} + upcomingCount={ + (upcomingActivities?.count || 0) + + (upcomingMDMCommands?.count || 0) + } + onChangeTab={onChangeActivityTab} + onNextPage={() => setActivityPage(activityPage + 1)} + onPreviousPage={() => setActivityPage(activityPage - 1)} + onShowDetails={onShowActivityDetails} + onShowCommandDetails={setMdmCommandDetails} + onCancel={onCancelActivity} + /> diff --git a/frontend/pages/hosts/details/cards/Activity/ActivityConfig.tsx b/frontend/pages/hosts/details/cards/Activity/ActivityConfig.tsx index 1c4517d54a3..95b36845e28 100644 --- a/frontend/pages/hosts/details/cards/Activity/ActivityConfig.tsx +++ b/frontend/pages/hosts/details/cards/Activity/ActivityConfig.tsx @@ -22,6 +22,7 @@ import InstalledSoftwareActivityItem from "./ActivityItems/InstalledSoftwareActi import CanceledRunScriptActivityItem from "./ActivityItems/CanceledRunScriptActivityItem"; import CanceledInstallSoftwareActivityItem from "./ActivityItems/CanceledInstallSoftwareActivityItem"; import CanceledUninstallSoftwareActivtyItem from "./ActivityItems/CanceledUninstallSoftwareActivtyItem"; +import InstalledCertificateActivityItem from "./ActivityItems/InstalledCertificateActivityItem"; /** The component props that all host activity items must adhere to */ export interface IHostActivityItemComponentProps { @@ -64,6 +65,7 @@ export const pastActivityComponentMap: Record< [ActivityType.CanceledInstallSoftware]: CanceledInstallSoftwareActivityItem, [ActivityType.CanceledInstallAppStoreApp]: CanceledInstallSoftwareActivityItem, [ActivityType.CanceledUninstallSoftware]: CanceledUninstallSoftwareActivtyItem, + [ActivityType.InstalledCertificate]: InstalledCertificateActivityItem, }; export const upcomingActivityComponentMap: Record< diff --git a/frontend/pages/hosts/details/cards/Activity/ActivityItems/InstalledCertificateActivityItem/InstalledCertificateActivityItem.tsx b/frontend/pages/hosts/details/cards/Activity/ActivityItems/InstalledCertificateActivityItem/InstalledCertificateActivityItem.tsx new file mode 100644 index 00000000000..687306b8530 --- /dev/null +++ b/frontend/pages/hosts/details/cards/Activity/ActivityItems/InstalledCertificateActivityItem/InstalledCertificateActivityItem.tsx @@ -0,0 +1,37 @@ +import React from "react"; + +import ActivityItem from "components/ActivityItem"; + +import { IHostActivityItemComponentProps } from "../../ActivityConfig"; + +const baseClass = "installed-certificate-activity-item"; + +const InstalledCertificateActivityItem = ({ + activity, +}: IHostActivityItemComponentProps) => { + const isFailed = activity.details?.status === "failed_install"; + + return ( + + {isFailed ? ( + <> + Fleet failed to install certificate{" "} + {activity.details?.certificate_name} on this host. + {activity.details?.detail && <> Detail: {activity.details.detail}} + + ) : ( + <> + Fleet installed certificate{" "} + {activity.details?.certificate_name} on this host. + + )} + + ); +}; + +export default InstalledCertificateActivityItem; diff --git a/frontend/pages/hosts/details/cards/Activity/ActivityItems/InstalledCertificateActivityItem/index.ts b/frontend/pages/hosts/details/cards/Activity/ActivityItems/InstalledCertificateActivityItem/index.ts new file mode 100644 index 00000000000..90f291e28a3 --- /dev/null +++ b/frontend/pages/hosts/details/cards/Activity/ActivityItems/InstalledCertificateActivityItem/index.ts @@ -0,0 +1 @@ +export { default } from "./InstalledCertificateActivityItem"; diff --git a/server/fleet/activities.go b/server/fleet/activities.go index 07cfbfc05ca..0643a6fb9ac 100644 --- a/server/fleet/activities.go +++ b/server/fleet/activities.go @@ -167,6 +167,7 @@ var ActivityDetailsList = []ActivityDetails{ ActivityTypeEditedAndroidProfile{}, ActivityTypeEditedAndroidCertificate{}, ActivityTypeResentCertificate{}, + ActivityTypeInstalledCertificate{}, ActivityTypeResentConfigurationProfile{}, ActivityTypeResentConfigurationProfileBatch{}, @@ -1826,3 +1827,28 @@ type ActivityTypeEditedEnrollSecrets struct { func (a ActivityTypeEditedEnrollSecrets) ActivityName() string { return "edited_enroll_secrets" } + +type ActivityTypeInstalledCertificate struct { + HostID uint `json:"host_id"` + HostDisplayName string `json:"host_display_name"` + CertificateTemplateID uint `json:"certificate_template_id"` + CertificateName string `json:"certificate_name"` + Status string `json:"status"` + Detail string `json:"detail,omitempty"` +} + +func (a ActivityTypeInstalledCertificate) ActivityName() string { + return "installed_certificate" +} + +func (a ActivityTypeInstalledCertificate) HostIDs() []uint { + return []uint{a.HostID} +} + +func (a ActivityTypeInstalledCertificate) WasFromAutomation() bool { + return true +} + +func (a ActivityTypeInstalledCertificate) HostOnly() bool { + return true +} diff --git a/server/fleet/certificate_templates.go b/server/fleet/certificate_templates.go index 2b59d4cd066..8d90a46a136 100644 --- a/server/fleet/certificate_templates.go +++ b/server/fleet/certificate_templates.go @@ -55,6 +55,14 @@ var ( CertificateTemplateVerified CertificateTemplateStatus = "verified" ) +// CertificateActivityStatus represents the status of a certificate install activity. +type CertificateActivityStatus string + +const ( + CertificateActivityInstalled CertificateActivityStatus = "installed" + CertificateActivityFailedInstall CertificateActivityStatus = "failed_install" +) + // CertificateTemplateStatusToMDMDeliveryStatus converts a CertificateTemplateStatus to MDMDeliveryStatus. // This is used when converting HostCertificateTemplate to HostMDMProfile for the GetHost endpoint. func CertificateTemplateStatusToMDMDeliveryStatus(s CertificateTemplateStatus) MDMDeliveryStatus { diff --git a/server/service/certificates.go b/server/service/certificates.go index a88aae7ce6b..5b375c283d5 100644 --- a/server/service/certificates.go +++ b/server/service/certificates.go @@ -695,7 +695,41 @@ func (svc *Service) UpdateCertificateStatus(ctx context.Context, update *fleet.C // Fill in HostUUID from context update.HostUUID = host.UUID - return svc.ds.UpsertCertificateStatus(ctx, update) + if err := svc.ds.UpsertCertificateStatus(ctx, update); err != nil { + return err + } + + // Log activity for terminal install statuses only (not removals). + if update.OperationType == fleet.MDMOperationTypeInstall { + var actStatus fleet.CertificateActivityStatus + switch update.Status { + case fleet.MDMDeliveryVerified: + actStatus = fleet.CertificateActivityInstalled + case fleet.MDMDeliveryFailed: + actStatus = fleet.CertificateActivityFailedInstall + } + if actStatus != "" { + detail := "" + if update.Detail != nil { + detail = *update.Detail + } + if err := svc.NewActivity(ctx, nil, fleet.ActivityTypeInstalledCertificate{ + HostID: host.ID, + HostDisplayName: host.DisplayName(), + CertificateTemplateID: update.CertificateTemplateID, + CertificateName: record.Name, + Status: string(actStatus), + Detail: detail, + }); err != nil { + // Log and continue since we don't want the client to retry. + svc.logger.ErrorContext(ctx, "failed to create certificate install activity", "host.id", host.ID, "activity.status", actStatus, + "err", err) + ctxerr.Handle(ctx, err) + } + } + } + + return nil } ////////////////////////////////////////////////////////////////////////////////