From 25d08d1051c8cfb83d91a2600e00a93db885c09c Mon Sep 17 00:00:00 2001 From: Jacob Shandling Date: Tue, 3 Sep 2024 15:45:55 -0700 Subject: [PATCH 01/55] change file --- changes/20320-uninstall-packages | 1 + 1 file changed, 1 insertion(+) create mode 100644 changes/20320-uninstall-packages diff --git a/changes/20320-uninstall-packages b/changes/20320-uninstall-packages new file mode 100644 index 0000000000..89ab892841 --- /dev/null +++ b/changes/20320-uninstall-packages @@ -0,0 +1 @@ +* Implement the ability to use Fleet to uninstall packages from hosts. \ No newline at end of file From 0cfbdc6f5898593364b92399ed25cc44596c43cf Mon Sep 17 00:00:00 2001 From: jacobshandling <61553566+jacobshandling@users.noreply.github.com> Date: Thu, 5 Sep 2024 11:11:14 -0700 Subject: [PATCH 02/55] =?UTF-8?q?UI=20=E2=80=93=C2=A0Implement=20changes?= =?UTF-8?q?=20for=20package=20uninstall=20scripts=20in=20the=20add=20softw?= =?UTF-8?q?are=20modal=20(#21828)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Addresses #21564 – see issue for task list ![Screenshot 2024-09-04 at 5 45 12 PM](https://github.com/user-attachments/assets/546401dd-b56e-4c39-baba-456dc844ee0f) ![Screenshot 2024-09-04 at 5 42 57 PM](https://github.com/user-attachments/assets/810ca450-0ddd-4258-96a5-bddb300ae19d) ![Screenshot 2024-09-04 at 5 45 02 PM](https://github.com/user-attachments/assets/32a19ce6-52c3-4772-ba53-00e50145bc85) ![Screenshot 2024-09-04 at 5 43 23 PM](https://github.com/user-attachments/assets/925843fb-6290-489b-a639-de1cbfba83fa) - [x] Manual QA for all new/changed functionality --------- Co-authored-by: Jacob Shandling --- .../buttons/RevealButton/RevealButton.tsx | 31 ++- frontend/interfaces/package_type.ts | 22 ++ .../AddPackageAdvancedOptions.tsx | 249 ++++++++++++++---- .../AddPackageForm/AddPackageForm.tsx | 39 ++- .../components/AddPackageForm/helpers.ts | 4 +- frontend/styles/var/mixins.scss | 3 + .../utilities/software_install_scripts.ts | 4 +- .../utilities/software_uninstall_scripts.ts | 30 +++ pkg/file/scripts/install_exe.ps1 | 28 +- pkg/file/scripts/uninstall_deb.sh | 4 + pkg/file/scripts/uninstall_exe.ps1 | 17 ++ pkg/file/scripts/uninstall_msi.ps1 | 4 + pkg/file/scripts/uninstall_pkg.sh | 17 ++ 13 files changed, 364 insertions(+), 88 deletions(-) create mode 100644 frontend/interfaces/package_type.ts create mode 100644 frontend/utilities/software_uninstall_scripts.ts create mode 100644 pkg/file/scripts/uninstall_deb.sh create mode 100644 pkg/file/scripts/uninstall_exe.ps1 create mode 100644 pkg/file/scripts/uninstall_msi.ps1 create mode 100644 pkg/file/scripts/uninstall_pkg.sh diff --git a/frontend/components/buttons/RevealButton/RevealButton.tsx b/frontend/components/buttons/RevealButton/RevealButton.tsx index c1db68f9d9..c54d5a0010 100644 --- a/frontend/components/buttons/RevealButton/RevealButton.tsx +++ b/frontend/components/buttons/RevealButton/RevealButton.tsx @@ -13,6 +13,7 @@ export interface IRevealButtonProps { autofocus?: boolean; disabled?: boolean; tooltipContent?: React.ReactNode; + disabledTooltipContent?: React.ReactNode; onClick?: | ((value?: any) => void) | ((evt: React.MouseEvent) => void); @@ -29,6 +30,7 @@ const RevealButton = ({ autofocus, disabled, tooltipContent, + disabledTooltipContent, onClick, }: IRevealButtonProps): JSX.Element => { const classNames = classnames(baseClass, className); @@ -36,11 +38,12 @@ const RevealButton = ({ const buttonContent = () => { const text = isShowing ? hideText : showText; - const buttonText = tooltipContent ? ( - {text} - ) : ( - text - ); + const buttonText = + tooltipContent && !disabled ? ( + {text} + ) : ( + text + ); return ( <> @@ -61,7 +64,7 @@ const RevealButton = ({ ); }; - return ( + const button = ( + + + + ); +}; + +export default SoftwareUninstallDetailsModal; diff --git a/frontend/components/ActivityDetails/InstallDetails/SoftwareUninstallDetailsModal/_styles.scss b/frontend/components/ActivityDetails/InstallDetails/SoftwareUninstallDetailsModal/_styles.scss new file mode 100644 index 0000000000..ca0ad4a2c1 --- /dev/null +++ b/frontend/components/ActivityDetails/InstallDetails/SoftwareUninstallDetailsModal/_styles.scss @@ -0,0 +1,23 @@ +.software-uninstall-details-modal { + &__modal-content { + display: flex; + flex-direction: column; + gap: 2rem; + } + &__status-message { + display: flex; + align-items: center; + gap: $pad-small; + margin: 0; + .icon { + align-self: flex-start; + } + } + &__script-output { + .textarea { + margin-top: $pad-medium; + overflow-wrap: break-word; + font-family: "SourceCodePro", $monospace; + } + } +} diff --git a/frontend/components/ActivityDetails/InstallDetails/SoftwareUninstallDetailsModal/index.ts b/frontend/components/ActivityDetails/InstallDetails/SoftwareUninstallDetailsModal/index.ts new file mode 100644 index 0000000000..c57d50fe8d --- /dev/null +++ b/frontend/components/ActivityDetails/InstallDetails/SoftwareUninstallDetailsModal/index.ts @@ -0,0 +1 @@ +export { default } from "./SoftwareUninstallDetailsModal"; diff --git a/frontend/components/ActivityDetails/InstallDetails/constants.ts b/frontend/components/ActivityDetails/InstallDetails/constants.ts index e717257390..255b12e5fd 100644 --- a/frontend/components/ActivityDetails/InstallDetails/constants.ts +++ b/frontend/components/ActivityDetails/InstallDetails/constants.ts @@ -5,10 +5,9 @@ export const INSTALL_DETAILS_STATUS_ICONS: Record< SoftwareInstallStatus, IconNames > = { - pending: "pending-outline", pending_install: "pending-outline", installed: "success-outline", - failed: "error-outline", + uninstalled: "success-outline", failed_install: "error-outline", pending_uninstall: "pending-outline", failed_uninstall: "error-outline", @@ -18,10 +17,9 @@ const INSTALL_DETAILS_STATUS_PREDICATES: Record< SoftwareInstallStatus, string > = { - pending: "is installing or will install", pending_install: "is installing or will install", installed: "installed", - failed: "failed to install", + uninstalled: "uninstalled", failed_install: "failed to install", pending_uninstall: "is uninstalling or will uninstall", failed_uninstall: "failed to uninstall", diff --git a/frontend/interfaces/software.ts b/frontend/interfaces/software.ts index a071a1237b..2818accc7c 100644 --- a/frontend/interfaces/software.ts +++ b/frontend/interfaces/software.ts @@ -66,8 +66,10 @@ export interface ISoftwarePackage { icon_url: string | null; status: { installed: number; - pending: number; - failed: number; + pending_install: number; + failed_install: number; + pending_uninstall: number; + failed_uninstall: number; }; } @@ -194,42 +196,59 @@ export const formatSoftwareType = ({ /** * This list comprises all possible states of software install operations. */ -export const SOFTWARE_INSTALL_STATUSES = [ - "failed", - "failed_install", - "installed", - "pending", - "pending_install", +export const SOFTWARE_UNINSTALL_STATUSES = [ + "uninstalled", "pending_uninstall", "failed_uninstall", ] as const; +export type SoftwareUninstallStatus = typeof SOFTWARE_UNINSTALL_STATUSES[number]; + +export const SOFTWARE_INSTALL_STATUSES = [ + "installed", + "pending_install", + "failed_install", + ...SOFTWARE_UNINSTALL_STATUSES, +] as const; + /* * SoftwareInstallStatus represents the possible states of software install operations. */ export type SoftwareInstallStatus = typeof SOFTWARE_INSTALL_STATUSES[number]; export const isValidSoftwareInstallStatus = ( - s: string | undefined + s: string | undefined | null ): s is SoftwareInstallStatus => !!s && SOFTWARE_INSTALL_STATUSES.includes(s as SoftwareInstallStatus); +export const isSoftwareUninstallStatus = ( + s: string | undefined | null +): s is SoftwareUninstallStatus => + !!s && SOFTWARE_UNINSTALL_STATUSES.includes(s as SoftwareUninstallStatus); + +// not a typeguard, as above 2 functions are +export const isPendingStatus = (s: string | undefined | null) => + ["pending_install", "pending_uninstall"].includes(s || ""); + /** * ISoftwareInstallResult is the shape of a software install result object * returned by the Fleet API. */ export interface ISoftwareInstallResult { + host_display_name?: string; install_uuid: string; software_title: string; software_title_id: number; software_package: string; host_id: number; - host_display_name: string; status: SoftwareInstallStatus; detail: string; output: string; pre_install_query_output: string; post_install_script_output: string; + created_at: string; + updated_at: string | null; + self_service: boolean; } export interface ISoftwareInstallResults { @@ -280,18 +299,21 @@ export interface IHostSoftware { app_store_app: IHostAppStoreApp | null; source: string; bundle_identifier?: string; - status: SoftwareInstallStatus | null; + status: Exclude | null; installed_versions: ISoftwareInstallVersion[] | null; } export type IDeviceSoftware = IHostSoftware; -const INSTALL_STATUS_PREDICATES: Record = { - failed: "failed to install", - failed_install: "failed to install", +const INSTALL_STATUS_PREDICATES: Record< + SoftwareInstallStatus | "pending", + string +> = { + pending: "pending", installed: "installed", - pending: "told Fleet to install", + uninstalled: "uninstalled", pending_install: "told Fleet to install", + failed_install: "failed to install", pending_uninstall: "told Fleet to uninstall", failed_uninstall: "failed to uninstall", } as const; @@ -306,10 +328,14 @@ export const getInstallStatusPredicate = (status: string | undefined) => { ); }; -export const INSTALL_STATUS_ICONS: Record = { +export const INSTALL_STATUS_ICONS: Record< + SoftwareInstallStatus | "pending" | "failed", + IconNames +> = { pending: "pending-outline", pending_install: "pending-outline", installed: "success-outline", + uninstalled: "success-outline", failed: "error-outline", failed_install: "error-outline", pending_uninstall: "pending-outline", diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityFeed.tsx b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityFeed.tsx index c9e6b08af9..47f08d4430 100644 --- a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityFeed.tsx +++ b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityFeed.tsx @@ -18,6 +18,7 @@ import FleetIcon from "components/icons/FleetIcon"; import { AppInstallDetailsModal } from "components/ActivityDetails/InstallDetails/AppInstallDetails"; import { SoftwareInstallDetailsModal } from "components/ActivityDetails/InstallDetails/SoftwareInstallDetails/SoftwareInstallDetails"; +import SoftwareUninstallDetailsModal from "components/ActivityDetails/InstallDetails/SoftwareUninstallDetailsModal/SoftwareUninstallDetailsModal"; import ActivityItem from "./ActivityItem"; import ScriptDetailsModal from "./components/ScriptDetailsModal/ScriptDetailsModal"; @@ -41,6 +42,10 @@ const ActivityFeed = ({ packageInstallDetails, setPackageInstallDetails, ] = useState(null); + const [ + packageUninstallDetails, + setPackageUninstallDetails, + ] = useState(null); const [ appInstallDetails, setAppInstallDetails, @@ -106,6 +111,9 @@ const ActivityFeed = ({ case ActivityType.InstalledSoftware: setPackageInstallDetails({ ...details }); break; + case ActivityType.UninstalledSoftware: + setPackageUninstallDetails({ ...details }); + break; case ActivityType.InstalledAppStoreApp: setAppInstallDetails({ ...details }); break; @@ -205,6 +213,12 @@ const ActivityFeed = ({ onCancel={() => setPackageInstallDetails(null)} /> )} + {packageUninstallDetails && ( + setPackageUninstallDetails(null)} + /> + )} {appInstallDetails && ( ); }, + uninstalledSoftware: ( + activity: IActivity, + onDetailsClick?: (type: ActivityType, details: IActivityDetails) => void + ) => { + const { details } = activity; + if (!details) { + return TAGGED_TEMPLATES.defaultActivityTemplate(activity); + } + + const { host_display_name: hostName, software_title: title } = details; + const status = + details.status === "failed" ? "failed_uninstall" : details.status; + + const showSoftwarePackage = + !!details.software_package && + activity.type === ActivityType.InstalledSoftware; + + return ( + <> + {" "} + {getInstallStatusPredicate(status)} software {title} + {showSoftwarePackage && ` (${details.software_package})`} from{" "} + {hostName}.{" "} + + + ); + }, enabledVpp: (activity: IActivity) => { return ( <> @@ -1168,6 +1202,9 @@ const getDetail = ( case ActivityType.InstalledSoftware: { return TAGGED_TEMPLATES.installedSoftware(activity, onDetailsClick); } + case ActivityType.UninstalledSoftware: { + return TAGGED_TEMPLATES.uninstalledSoftware(activity, onDetailsClick); + } case ActivityType.AddedAppStoreApp: { return TAGGED_TEMPLATES.addedAppStoreApp(activity); } @@ -1234,6 +1271,7 @@ const ActivityItem = ({ DEFAULT_ACTOR_DISPLAY ); case ActivityType.InstalledSoftware: + case ActivityType.UninstalledSoftware: case ActivityType.InstalledAppStoreApp: return activity.details?.self_service ? ( An end user diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwarePackageCard/SoftwarePackageCard.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwarePackageCard/SoftwarePackageCard.tsx index 4906cb4b55..0229550957 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwarePackageCard/SoftwarePackageCard.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwarePackageCard/SoftwarePackageCard.tsx @@ -86,8 +86,11 @@ interface IStatusDisplayOption { tooltip: React.ReactNode; } +// "pending" and "failed" each encompass both "_install" and "_uninstall" sub-statuses +type SoftwareInstallDisplayStatus = "installed" | "pending" | "failed"; + const STATUS_DISPLAY_OPTIONS: Record< - SoftwareInstallStatus, + SoftwareInstallDisplayStatus, IStatusDisplayOption > = { installed: { @@ -114,16 +117,6 @@ const STATUS_DISPLAY_OPTIONS: Record< ), }, - pending_install: { - displayName: "Pending", - iconName: "pending-outline", - tooltip: "Fleet will install software when these hosts come online.", - }, - pending_uninstall: { - displayName: "Pending", - iconName: "pending-outline", - tooltip: "Fleet will uninstall software when these hosts come online.", - }, failed: { displayName: "Failed", iconName: "error", @@ -135,21 +128,11 @@ const STATUS_DISPLAY_OPTIONS: Record< ), }, - failed_install: { - displayName: "Failed", - iconName: "error", - tooltip: "Fleet failed to install software on these hosts.", - }, - failed_uninstall: { - displayName: "Failed", - iconName: "error", - tooltip: "Fleet failed to uninstall software on these hosts.", - }, }; interface IPackageStatusCountProps { softwareId: number; - status: SoftwareInstallStatus; + status: SoftwareInstallDisplayStatus; count: number; teamId?: number; } diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/helpers.ts b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/helpers.ts index 04bf2d18d4..986497d160 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/helpers.ts +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/helpers.ts @@ -1,10 +1,16 @@ import { IAppStoreApp, + ISoftwarePackage, ISoftwareTitleDetails, isSoftwarePackage, } from "interfaces/software"; import { DEFAULT_EMPTY_CELL_VALUE } from "utilities/constants"; +const mergePackageStatuses = (packageStatuses: ISoftwarePackage["status"]) => ({ + installed: packageStatuses.installed, + pending: packageStatuses.pending_install + packageStatuses.pending_uninstall, + failed: packageStatuses.failed_install + packageStatuses.failed_uninstall, +}); /** * Generates the data needed to render the package card. */ @@ -24,7 +30,9 @@ export const getPackageCardInfo = (softwareTitle: ISoftwareTitleDetails) => { ? packageData.version : packageData.latest_version) || DEFAULT_EMPTY_CELL_VALUE, uploadedAt: isSoftwarePackage(packageData) ? packageData.uploaded_at : "", - status: packageData.status, + status: isSoftwarePackage(packageData) + ? mergePackageStatuses(packageData.status) + : packageData.status, isSelfService: packageData.self_service, }; }; diff --git a/frontend/pages/SoftwarePage/components/AddPackageAdvancedOptions/AddPackageAdvancedOptions.tsx b/frontend/pages/SoftwarePage/components/AddPackageAdvancedOptions/AddPackageAdvancedOptions.tsx index c772d20f03..26476a8e89 100644 --- a/frontend/pages/SoftwarePage/components/AddPackageAdvancedOptions/AddPackageAdvancedOptions.tsx +++ b/frontend/pages/SoftwarePage/components/AddPackageAdvancedOptions/AddPackageAdvancedOptions.tsx @@ -16,8 +16,8 @@ import { IAddPackageFormData } from "../AddPackageForm/AddPackageForm"; const getSupportedScriptTypeText = (pkgType: PackageType) => { return `Currently, ${ - isWindowsPackageType(pkgType) ? "Power" : "" - }Shell scripts are supported.`; + isWindowsPackageType(pkgType) ? "PowerS" : "s" + }hell scripts are supported.`; }; const PKG_TYPE_TO_ID_TEXT = { diff --git a/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx b/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx index 1a03eeeecf..2393f128ed 100644 --- a/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx +++ b/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx @@ -415,7 +415,7 @@ const DeviceUserPage = ({ (null); + const [ + packageUninstallDetails, + setPackageUninstallDetails, + ] = useState(null); const [ appInstallDetails, setAppInstallDetails, @@ -602,6 +607,13 @@ const HostDetailsPage = ({ host?.display_name || details?.host_display_name || "", }); break; + case "uninstalled_software": + setPackageUninstallDetails({ + ...details, + host_display_name: + host?.display_name || details?.host_display_name || "", + }); + break; case "installed_app_store_app": setAppInstallDetails({ ...details, @@ -933,9 +945,7 @@ const HostDetailsPage = ({ id={host.id} platform={host.platform} softwareUpdatedAt={host.software_updated_at} - hostCanInstallSoftware={ - !!host.orbit_version || isIosOrIpadosHost - } + hostCanWriteSoftware={!!host.orbit_version || isIosOrIpadosHost} isSoftwareEnabled={featuresConfig?.enable_software_inventory} router={router} queryParams={parseHostSoftwareQueryParams(location.query)} @@ -1065,6 +1075,12 @@ const HostDetailsPage = ({ onCancel={onCancelSoftwareInstallDetailsModal} /> )} + {packageUninstallDetails && ( + setPackageUninstallDetails(null)} + /> + )} {!!appInstallDetails && ( { const { actor_full_name: actorName, details } = activity; - const { self_service, status, software_title: title } = details; + const { self_service, software_title: title } = details; + const status = + details.status === "failed" ? "failed_uninstall" : details.status; const actorDisplayName = self_service ? ( An end user diff --git a/frontend/pages/hosts/details/cards/HostSummary/HostSummary.tsx b/frontend/pages/hosts/details/cards/HostSummary/HostSummary.tsx index 2a1c13b98b..63caddf9e0 100644 --- a/frontend/pages/hosts/details/cards/HostSummary/HostSummary.tsx +++ b/frontend/pages/hosts/details/cards/HostSummary/HostSummary.tsx @@ -441,7 +441,6 @@ const HostSummary = ({ }; const renderSummary = () => { - console.log(hostMdmProfiles); // for windows hosts we have to manually add a profile for disk encryption // as this is not currently included in the `profiles` value from the API // response for windows hosts. diff --git a/frontend/pages/hosts/details/cards/Software/HostSoftware.tsx b/frontend/pages/hosts/details/cards/Software/HostSoftware.tsx index a8a7c70284..1c56843986 100644 --- a/frontend/pages/hosts/details/cards/Software/HostSoftware.tsx +++ b/frontend/pages/hosts/details/cards/Software/HostSoftware.tsx @@ -37,7 +37,7 @@ interface IHostSoftwareProps { id: number | string; platform?: HostPlatform; softwareUpdatedAt?: string; - hostCanInstallSoftware: boolean; + hostCanWriteSoftware: boolean; router: InjectedRouter; queryParams: ReturnType; pathname: string; @@ -86,7 +86,7 @@ const HostSoftware = ({ id, platform, softwareUpdatedAt, - hostCanInstallSoftware, + hostCanWriteSoftware, router, queryParams, pathname, @@ -105,7 +105,8 @@ const HostSoftware = ({ isTeamMaintainer, } = useContext(AppContext); - const [installingSoftwareId, setInstallingSoftwareId] = useState< + // disables install/uninstall actions after click + const [softwareIdActionPending, setSoftwareIdActionPending] = useState< number | null >(null); @@ -175,13 +176,13 @@ const HostSoftware = ({ [isMyDevicePage, refetchDeviceSoftware, refetchHostSoftware] ); - const userHasSWInstallPermission = Boolean( + const userHasSWWritePermission = Boolean( isGlobalAdmin || isGlobalMaintainer || isTeamAdmin || isTeamMaintainer ); const installHostSoftwarePackage = useCallback( async (softwareId: number) => { - setInstallingSoftwareId(softwareId); + setSoftwareIdActionPending(softwareId); try { await hostAPI.installHostSoftwarePackage(id as number, softwareId); renderFlash( @@ -191,7 +192,28 @@ const HostSoftware = ({ } catch (e) { renderFlash("error", getErrorMessage(e)); } - setInstallingSoftwareId(null); + setSoftwareIdActionPending(null); + refetchSoftware(); + }, + [id, renderFlash, refetchSoftware] + ); + + const uninstallHostSoftwarePackage = useCallback( + async (softwareId: number) => { + setSoftwareIdActionPending(softwareId); + try { + await hostAPI.uninstallHostSoftwarePackage(id as number, softwareId); + renderFlash( + "success", + <> + Software is uninstalling or will uninstall when the host comes + online. To see details, go to Details > Activity. + + ); + } catch (e) { + renderFlash("error", "Couldn't uninstall. Please try again."); + } + setSoftwareIdActionPending(null); refetchSoftware(); }, [id, renderFlash, refetchSoftware] @@ -203,6 +225,9 @@ const HostSoftware = ({ case "install": installHostSoftwarePackage(software.id); break; + case "uninstall": + uninstallHostSoftwarePackage(software.id); + break; case "showDetails": onShowSoftwareDetails?.(software); break; @@ -210,7 +235,11 @@ const HostSoftware = ({ break; } }, - [installHostSoftwarePackage, onShowSoftwareDetails] + [ + installHostSoftwarePackage, + onShowSoftwareDetails, + uninstallHostSoftwarePackage, + ] ); const tableConfig = useMemo(() => { @@ -218,20 +247,20 @@ const HostSoftware = ({ ? generateDeviceSoftwareTableConfig() : generateHostSoftwareTableConfig({ router, - installingSoftwareId, - userHasSWInstallPermission, + softwareIdActionPending, + userHasSWWritePermission, onSelectAction, teamId: hostTeamId, - hostCanInstallSoftware, + hostCanWriteSoftware, }); }, [ isMyDevicePage, router, - installingSoftwareId, - userHasSWInstallPermission, + softwareIdActionPending, + userHasSWWritePermission, onSelectAction, hostTeamId, - hostCanInstallSoftware, + hostCanWriteSoftware, ]); const isLoading = isMyDevicePage diff --git a/frontend/pages/hosts/details/cards/Software/HostSoftwareTableConfig.tsx b/frontend/pages/hosts/details/cards/Software/HostSoftwareTableConfig.tsx index 31c8a03a21..dd4aef833e 100644 --- a/frontend/pages/hosts/details/cards/Software/HostSoftwareTableConfig.tsx +++ b/frontend/pages/hosts/details/cards/Software/HostSoftwareTableConfig.tsx @@ -33,6 +33,7 @@ import InstallStatusCell from "./InstallStatusCell"; const DEFAULT_ACTION_OPTIONS: IDropdownOption[] = [ { value: "showDetails", label: "Show details", disabled: false }, { value: "install", label: "Install", disabled: false }, + { value: "uninstall", label: "Uninstall", disabled: false }, ]; type ISoftwareTableConfig = Column; @@ -50,17 +51,18 @@ type IInstalledVersionsCellProps = CellProps< type IVulnerabilitiesCellProps = IInstalledVersionsCellProps; const generateActions = ({ - userHasSWInstallPermission, - hostCanInstallSoftware, - installingSoftwareId, + userHasSWWritePermission, + // Commenting below in case there is a quick decision to use these conditions after all + // hostCanWriteSoftware, + // software_package, + softwareIdActionPending, softwareId, status, - software_package, app_store_app, }: { - userHasSWInstallPermission: boolean; - hostCanInstallSoftware: boolean; - installingSoftwareId: number | null; + userHasSWWritePermission: boolean; + hostCanWriteSoftware: boolean; + softwareIdActionPending: number | null; softwareId: number; status: SoftwareInstallStatus | null; software_package: IHostSoftwarePackage | null; @@ -76,39 +78,44 @@ const generateActions = ({ // error to fail loudly so that we know to update this function throw new Error("Install action not found in default actions"); } + const indexUninstallAction = actions.findIndex( + (a) => a.value === "uninstall" + ); + if (indexUninstallAction === -1) { + // this should never happen unless the default actions change, but if it does we'll throw an + // error to fail loudly so that we know to update this function + throw new Error("Uninstall action not found in default actions"); + } - const hasSoftwareToInstall = !!software_package || !!app_store_app; - // remove install if there is no package to install or if the software is already installed - if ( - !hasSoftwareToInstall || - !userHasSWInstallPermission || - status === "installed" - ) { + if (!userHasSWWritePermission) { actions.splice(indexInstallAction, 1); - return actions; + actions.splice(indexUninstallAction, 1); + } else { + // user has software write permission for host + const pendingStatuses = ["pending_install", "pending_uninstall"]; + + if ( + // if locally pending (waiting for API response) or pending install/uninstall, disable both + // install and uninstall + softwareId === softwareIdActionPending || + pendingStatuses.includes(status || "") + ) { + actions[indexInstallAction].disabled = true; + actions[indexUninstallAction].disabled = true; + } } - // disable install option if not a fleetd, iPad, or iOS host - if (!hostCanInstallSoftware) { - actions[indexInstallAction].disabled = true; - actions[indexInstallAction].tooltipContent = - "To install software on this host, deploy the fleetd agent with --enable-scripts and refetch host vitals."; - return actions; + if (app_store_app) { + // remove uninstall for VPP apps + actions.splice(indexUninstallAction, 1); } - - // disable install option if software is already installing - if (softwareId === installingSoftwareId || status === "pending") { - actions[indexInstallAction].disabled = true; - return actions; - } - return actions; }; interface ISoftwareTableHeadersProps { - userHasSWInstallPermission: boolean; - hostCanInstallSoftware: boolean; - installingSoftwareId: number | null; + userHasSWWritePermission: boolean; + hostCanWriteSoftware: boolean; + softwareIdActionPending: number | null; router: InjectedRouter; teamId: number; onSelectAction: (software: IHostSoftware, action: string) => void; @@ -117,9 +124,9 @@ interface ISoftwareTableHeadersProps { // NOTE: cellProps come from react-table // more info here https://react-table.tanstack.com/docs/api/useTable#cell-properties export const generateSoftwareTableHeaders = ({ - userHasSWInstallPermission, - hostCanInstallSoftware, - installingSoftwareId, + userHasSWWritePermission, + hostCanWriteSoftware, + softwareIdActionPending, router, teamId, onSelectAction, @@ -209,9 +216,9 @@ export const generateSoftwareTableHeaders = ({ | "selfService", IStatusDisplayConfig > = { installed: { @@ -39,52 +39,42 @@ export const INSTALL_STATUS_DISPLAY_OPTIONS: Record< tooltip: () => "Software is installed (install script finished with exit code 0).", }, - pending: { + pending_install: { iconName: "pending-outline", - displayText: "Pending", + displayText: "Installing (pending)", tooltip: () => "Fleet is installing or will install when the host comes online.", }, - pending_install: { - iconName: "pending-outline", - displayText: "Pending", - tooltip: () => "Fleet will install software when the host comes online.", - }, pending_uninstall: { iconName: "pending-outline", - displayText: "Pending", - tooltip: () => "Fleet will uninstall software when the host comes online.", - }, - failed: { - iconName: "error", - displayText: "Failed", + displayText: "Uninstalling (pending)", tooltip: () => ( <> - The host failed to install software. To view errors, select + Fleet is uninstalling or will uninstall
- Actions > Show details. + software when the host comes online. ), }, failed_install: { iconName: "error", - displayText: "Failed", - tooltip: ({ lastInstalledAt: lastInstall }) => ( + displayText: "Install (failed)", + tooltip: () => ( <> - The host failed to install software. To view errors, select + The host failed to install software.
- Actions > Show details. + Select Actions > Show details view errors. ), }, failed_uninstall: { iconName: "error", - displayText: "Failed", - tooltip: ({ lastInstalledAt: lastInstall }) => ( + displayText: "Uninstall (failed)", + tooltip: () => ( <> - The host failed to install software. To view errors, select + The host failed to uninstall software.
- Actions > Show details. + Select Details > Activity to view errors. ), }, diff --git a/frontend/pages/hosts/details/cards/Software/SelfService/SelfService.tests.tsx b/frontend/pages/hosts/details/cards/Software/SelfService/SelfService.tests.tsx index a7ca5e5cdd..2b96a578c3 100644 --- a/frontend/pages/hosts/details/cards/Software/SelfService/SelfService.tests.tsx +++ b/frontend/pages/hosts/details/cards/Software/SelfService/SelfService.tests.tsx @@ -112,13 +112,13 @@ describe("SelfService", () => { ).toHaveTextContent("Reinstall"); }); - it("renders 'Retry' action button with 'Failed' status", async () => { + it("renders 'Retry' action button with 'failed_install' status", async () => { mockServer.use( customDeviceSoftwareHandler({ software: [ createMockDeviceSoftware({ name: "test-software", - status: "failed", + status: "failed_install", }), ], }) @@ -166,13 +166,13 @@ describe("SelfService", () => { ).toHaveTextContent("Install"); }); - it("renders no action button with 'Pending' status", async () => { + it("renders no action button with 'pending_install' status", async () => { mockServer.use( customDeviceSoftwareHandler({ software: [ createMockDeviceSoftware({ name: "test-software", - status: "pending", + status: "pending_install", }), ], }) diff --git a/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceItem/SelfServiceItem.tsx b/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceItem/SelfServiceItem.tsx index 90a58c8142..f2907e693f 100644 --- a/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceItem/SelfServiceItem.tsx +++ b/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceItem/SelfServiceItem.tsx @@ -21,39 +21,24 @@ import { IStatusDisplayConfig } from "../../InstallStatusCell/InstallStatusCell" const baseClass = "self-service-item"; -const STATUS_CONFIG: Record = { +const STATUS_CONFIG: Record< + Exclude< + SoftwareInstallStatus, + "pending_uninstall" | "failed_uninstall" | "uninstalled" + >, + IStatusDisplayConfig +> = { installed: { iconName: "success", displayText: "Installed", tooltip: ({ lastInstalledAt }) => `Software is installed (${dateAgo(lastInstalledAt as string)}).`, }, - pending: { + pending_install: { iconName: "pending-outline", displayText: "Pending", tooltip: () => "Fleet is installing software.", }, - pending_install: { - iconName: "pending-outline", - displayText: "Install in progress...", - tooltip: () => "Software installation in progress...", - }, - pending_uninstall: { - iconName: "pending-outline", - displayText: "Uninstall in progress...", - tooltip: () => "Software uninstallation in progress...", - }, - failed: { - iconName: "error", - displayText: "Failed", - tooltip: ({ lastInstalledAt = "" }) => ( - <> - Software failed to install{" "} - {lastInstalledAt ? ` (${dateAgo(lastInstalledAt)})` : ""}. Select{" "} - Retry to install again, or contact your IT department. - - ), - }, failed_install: { iconName: "error", displayText: "Failed", @@ -65,17 +50,6 @@ const STATUS_CONFIG: Record = { ), }, - failed_uninstall: { - iconName: "error", - displayText: "Failed", - tooltip: ({ lastInstalledAt = "" }) => ( - <> - Software failed to install - {lastInstalledAt ? ` (${dateAgo(lastInstalledAt)})` : ""}. Select{" "} - Retry to install again, or contact your IT department. - - ), - }, }; interface IInstallerInfoProps { @@ -166,7 +140,7 @@ const getInstallButtonText = (status: SoftwareInstallStatus | null) => { switch (status) { case null: return "Install"; - case "failed": + case "failed_install": return "Retry"; case "installed": return "Reinstall"; @@ -195,7 +169,7 @@ const InstallerStatusAction = ({ // if the localStatus is "failed", we don't want our tooltip to include the old installed_at date so we // set this to null, which tells the tooltip to omit the parenthetical date - const lastInstall = localStatus === "failed" ? null : last_install; + const lastInstall = localStatus === "failed_install" ? null : last_install; const isMountedRef = useRef(false); useEffect(() => { @@ -206,7 +180,7 @@ const InstallerStatusAction = ({ }, []); const onClick = useCallback(async () => { - setLocalStatus("pending"); + setLocalStatus("pending_install"); try { await deviceApi.installSelfServiceSoftware(deviceToken, id); if (isMountedRef.current) { @@ -215,7 +189,7 @@ const InstallerStatusAction = ({ } catch (error) { renderFlash("error", "Couldn't install. Please try again."); if (isMountedRef.current) { - setLocalStatus("failed"); + setLocalStatus("failed_install"); } } }, [deviceToken, id, onInstall, renderFlash]); @@ -232,7 +206,7 @@ const InstallerStatusAction = ({ type="button" className={`${baseClass}__item-action-button`} onClick={onClick} - disabled={localStatus === "pending"} + disabled={localStatus === "pending_install"} > {installButtonText} diff --git a/frontend/services/entities/hosts.ts b/frontend/services/entities/hosts.ts index ba7e0dc7ab..eca209aab4 100644 --- a/frontend/services/entities/hosts.ts +++ b/frontend/services/entities/hosts.ts @@ -590,4 +590,11 @@ export default { HOST_SOFTWARE_PACKAGE_INSTALL(hostId, softwareId) ); }, + uninstallHostSoftwarePackage: (hostId: number, softwareId: number) => { + const { HOST_SOFTWARE_PACKAGE_UNINSTALL } = endpoints; + return sendRequest( + "POST", + HOST_SOFTWARE_PACKAGE_UNINSTALL(hostId, softwareId) + ); + }, }; diff --git a/frontend/services/entities/scripts.ts b/frontend/services/entities/scripts.ts index 8ed35f9a17..6f5792cd25 100644 --- a/frontend/services/entities/scripts.ts +++ b/frontend/services/entities/scripts.ts @@ -39,6 +39,7 @@ export interface IScriptResultResponse { message: string; runtime: number; host_timeout: boolean; + created_at: string; } /** diff --git a/frontend/services/entities/software.ts b/frontend/services/entities/software.ts index 2db7880932..94e1da1cb6 100644 --- a/frontend/services/entities/software.ts +++ b/frontend/services/entities/software.ts @@ -219,6 +219,8 @@ export default { formData.append("software", data.software); formData.append("self_service", data.selfService.toString()); data.installScript && formData.append("install_script", data.installScript); + data.uninstallScript && + formData.append("uninstall_script", data.uninstallScript); data.preInstallQuery && formData.append("pre_install_query", data.preInstallQuery); data.postInstallScript && diff --git a/frontend/utilities/endpoints.ts b/frontend/utilities/endpoints.ts index 2abe094de1..0eadb2d2c8 100644 --- a/frontend/utilities/endpoints.ts +++ b/frontend/utilities/endpoints.ts @@ -52,7 +52,9 @@ export default { `/${API_VERSION}/fleet/hosts/${hostId}/configuration_profiles/resend/${profileUUID}`, HOST_SOFTWARE: (id: number) => `/${API_VERSION}/fleet/hosts/${id}/software`, HOST_SOFTWARE_PACKAGE_INSTALL: (hostId: number, softwareId: number) => - `/${API_VERSION}/fleet/hosts/${hostId}/software/install/${softwareId}`, + `/${API_VERSION}/fleet/hosts/${hostId}/software/${softwareId}/install`, + HOST_SOFTWARE_PACKAGE_UNINSTALL: (hostId: number, softwareId: number) => + `/${API_VERSION}/fleet/hosts/${hostId}/software/${softwareId}/uninstall`, INVITES: `/${API_VERSION}/fleet/invites`, @@ -165,7 +167,7 @@ export default { SOFTWARE_PACKAGE_TOKEN: (id: number) => `/${API_VERSION}/fleet/software/titles/${id}/package/token`, SOFTWARE_INSTALL_RESULTS: (uuid: string) => - `/${API_VERSION}/fleet/software/install/results/${uuid}`, + `/${API_VERSION}/fleet/software/install/${uuid}/results`, SOFTWARE_PACKAGE_INSTALL: (id: number) => `/${API_VERSION}/fleet/software/packages/${id}`, SOFTWARE_AVAILABLE_FOR_INSTALL: (id: number) => From 81e619a2974ada4fadec8e0418d6ab3c571128bc Mon Sep 17 00:00:00 2001 From: Allen Houchins <32207388+allenhouchins@users.noreply.github.com> Date: Thu, 12 Sep 2024 10:02:39 -0700 Subject: [PATCH 31/55] fixed typo in intro image (#22038) Fixed typo in intro image "LEADERSHHIP" -> "LEADERSHIP" --- handbook/company/leadership.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/handbook/company/leadership.md b/handbook/company/leadership.md index a567aeec99..65378b729a 100644 --- a/handbook/company/leadership.md +++ b/handbook/company/leadership.md @@ -2,7 +2,7 @@ This page covers the things managers and other leaders at Fleet need to know about running a great company. -image +image From 1872b6e974614123b85b4f267647ec18a7b3fa2f Mon Sep 17 00:00:00 2001 From: Allen Houchins <32207388+allenhouchins@users.noreply.github.com> Date: Thu, 12 Sep 2024 10:06:17 -0700 Subject: [PATCH 32/55] updated Zoom display name information (#22034) Changed the Zoom display name instructions to put "CEO shadow" before full name. --- handbook/company/leadership.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/handbook/company/leadership.md b/handbook/company/leadership.md index 65378b729a..02eaf66458 100644 --- a/handbook/company/leadership.md +++ b/handbook/company/leadership.md @@ -420,7 +420,7 @@ As a CEO shadow, you will be attending both internal and external meetings regar CEO shadows join all meetings on the [CEO's calendar](https://calendar.google.com/calendar/embed?src=mike%40fleetdm.com&ctz=America%2FChicago) that **do not have** "[no shadows]" appended to the calendar event title. Before beginning your time as a [CEO shadow](https://fleetdm.com/handbook/company/leadership#ceo-shadow-program): 1. Make sure you've read through the [CEO flaws](https://fleetdm.com/handbook/company/leadership#ceo-flaws) to better understand how to communicate with him. -2. Update your Zoom display name to be "[your name] | CEO shadow" (e.g. "Jayne Doo | CEO shadow"). +2. Update your Zoom display name to be "CEO shadow | [your name]" (e.g. "CEO shadow | Jayne Doo"). 3. Know which meetings you're expected to join. **You won't be listed as an attendee on any of the CEO's calendar events** to avoid confusion when scheduling meetings with external participants. You're intentionally marked out of office to avoid scheduling conflicts. > Please **DO NOT** add yourself as an attendee to any of the CEO's meetings. The CEO regularly meets with prospects and customers in the community, and without the context of the CEO shadow program, an unknown name on the calendar event could be mistaken for a sales tactic. From 4c24729df0f91aa74b8402173866dbfe5bd503bf Mon Sep 17 00:00:00 2001 From: Lucas Manuel Rodriguez Date: Thu, 12 Sep 2024 14:23:25 -0300 Subject: [PATCH 33/55] Add policies for "No team" (#21972) #21467 - [X] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/Committing-Changes.md#changes-files) for more information. - [X] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements) - [X] Added/updated tests - [X] If database migrations are included, checked table schema to confirm autoupdate - For database migrations: - [X] Checked schema for all modified table for columns that will auto-update timestamps during migration. - [X] Confirmed that updating the timestamps is acceptable, and will not cause unwanted side effects. - [X] Ensured the correct collation is explicitly set for character columns (`COLLATE utf8mb4_unicode_ci`). - [X] Manual QA for all new/changed functionality --- changes/21467-policies-for-no-team | 1 + cmd/fleetctl/gitops.go | 37 +- cmd/fleetctl/gitops_test.go | 375 ++++++++++++- ...m_software_installer_install_not_found.yml | 15 +- ...e_installer_invalid_self_service_value.yml | 15 +- .../no_team_software_installer_no_url.yml | 15 +- .../no_team_software_installer_not_found.yml | 15 +- ...tware_installer_post_install_not_found.yml | 15 +- ...staller_pre_condition_multiple_queries.yml | 15 +- ...ware_installer_pre_condition_not_found.yml | 15 +- .../no_team_software_installer_too_large.yml | 15 +- ...no_team_software_installer_unsupported.yml | 15 +- .../no_team_software_installer_valid.yml | 15 +- ee/server/service/vpp.go | 4 +- pkg/spec/gitops.go | 78 ++- pkg/spec/gitops_test.go | 64 ++- server/datastore/mysql/hosts.go | 2 +- .../20240905200001_AddPoliciesToNoTeam.go | 78 +++ ...20240905200001_AddPoliciesToNoTeam_test.go | 86 +++ server/datastore/mysql/policies.go | 134 +++-- server/datastore/mysql/policies_test.go | 512 +++++++++++++++++- server/datastore/mysql/schema.sql | 12 +- server/datastore/mysql/vpp.go | 10 +- server/fleet/policies.go | 3 + server/service/client.go | 294 +++++----- server/service/global_policies.go | 2 +- server/service/integration_enterprise_test.go | 138 ++++- server/service/integration_mdm_test.go | 4 +- server/service/osquery.go | 9 +- server/service/team_policies.go | 22 +- 30 files changed, 1626 insertions(+), 389 deletions(-) create mode 100644 changes/21467-policies-for-no-team create mode 100644 server/datastore/mysql/migrations/tables/20240905200001_AddPoliciesToNoTeam.go create mode 100644 server/datastore/mysql/migrations/tables/20240905200001_AddPoliciesToNoTeam_test.go diff --git a/changes/21467-policies-for-no-team b/changes/21467-policies-for-no-team new file mode 100644 index 0000000000..4613cd39ed --- /dev/null +++ b/changes/21467-policies-for-no-team @@ -0,0 +1 @@ +* Added support for policies in "No team" that run on hosts that belong to "No team". diff --git a/cmd/fleetctl/gitops.go b/cmd/fleetctl/gitops.go index b593ebf929..fc9e3c7a83 100644 --- a/cmd/fleetctl/gitops.go +++ b/cmd/fleetctl/gitops.go @@ -77,6 +77,23 @@ func gitopsCommand() *cli.Command { if appConfig.License == nil { return errors.New("no license struct found in app config") } + logf := func(format string, a ...interface{}) { + _, _ = fmt.Fprintf(c.App.Writer, format, a...) + } + + // We need to extract the controls from no-team.yml to be able to apply them when applying the global app config. + var noTeamControls spec.Controls + for _, flFilename := range flFilenames.Value() { + if filepath.Base(flFilename) == "no-team.yml" { + baseDir := filepath.Dir(flFilename) + config, err := spec.GitOpsFromFile(flFilename, baseDir, appConfig, logf) + if err != nil { + return err + } + noTeamControls = config.Controls + break + } + } var originalABMConfig []any var originalVPPConfig []any @@ -92,7 +109,7 @@ func gitopsCommand() *cli.Command { secrets := make(map[string]struct{}) for _, flFilename := range flFilenames.Value() { baseDir := filepath.Dir(flFilename) - config, err := spec.GitOpsFromFile(flFilename, baseDir, appConfig) + config, err := spec.GitOpsFromFile(flFilename, baseDir, appConfig, logf) if err != nil { return err } @@ -109,6 +126,21 @@ func gitopsCommand() *cli.Command { firstFileMustBeGlobal = ptr.Bool(false) } + if isGlobalConfig { + if noTeamControls.Set() && config.Controls.Set() { + return errors.New("'controls' cannot be set on both global config and on no-team.yml") + } + if !noTeamControls.Defined && !config.Controls.Defined { + if appConfig.License.IsPremium() { + return errors.New("'controls' must be set on global config or no-team.yml") + } + return errors.New("'controls' must be set on global config") + } + if !config.Controls.Set() { + config.Controls = noTeamControls + } + } + // Special handling for tokens is required because they link to teams (by // name.) Because teams can be created/deleted during the same gitops run, we // grab some information to help us determine allowed/restricted actions and @@ -160,9 +192,6 @@ func gitopsCommand() *cli.Command { } } } - logf := func(format string, a ...interface{}) { - _, _ = fmt.Fprintf(c.App.Writer, format, a...) - } if flDryRun { incomingSecrets := fleetClient.GetGitOpsSecrets(config) for _, secret := range incomingSecrets { diff --git a/cmd/fleetctl/gitops_test.go b/cmd/fleetctl/gitops_test.go index 58fb94b1c6..a0153097f0 100644 --- a/cmd/fleetctl/gitops_test.go +++ b/cmd/fleetctl/gitops_test.go @@ -13,6 +13,7 @@ import ( "testing" "time" + "github.com/fleetdm/fleet/v4/pkg/file" "github.com/fleetdm/fleet/v4/server/config" "github.com/fleetdm/fleet/v4/server/datastore/mysql" "github.com/fleetdm/fleet/v4/server/fleet" @@ -141,6 +142,28 @@ org_settings: require.Error(t, err) assert.Contains(t, err.Error(), "organization name must be present") + // Missing controls. + tmpFile2, err := os.CreateTemp(t.TempDir(), "*.yml") + require.NoError(t, err) + _, err = tmpFile2.WriteString( + ` +queries: +policies: +agent_options: +org_settings: + server_settings: + server_url: https://example.com + org_info: + contact_url: https://example.com/contact + org_name: Foobar + secrets: +`, + ) + require.NoError(t, err) + _, err = runAppNoChecks([]string{"gitops", "-f", tmpFile2.Name()}) + require.Error(t, err) + assert.Equal(t, `'controls' must be set on global config`, err.Error()) + // Dry run t.Setenv("ORG_NAME", orgName) _ = runAppForTest(t, []string{"gitops", "-f", tmpFile.Name(), "--dry-run"}) @@ -398,16 +421,15 @@ software: require.Error(t, err) assert.Contains(t, err.Error(), "'name' is required") - // reserved team name; should error in both dry run and real + // Invalid name for "No team" file (dry and real). t.Setenv("TEST_TEAM_NAME", "no TEam") _, err = runAppNoChecks([]string{"gitops", "-f", tmpFile.Name(), "--dry-run"}) require.Error(t, err) - assert.Contains(t, err.Error(), `"No team" is a reserved team name`) - + assert.Contains(t, err.Error(), fmt.Sprintf("file %q for 'No team' must be named 'no-team.yml'", tmpFile.Name())) t.Setenv("TEST_TEAM_NAME", "no TEam") _, err = runAppNoChecks([]string{"gitops", "-f", tmpFile.Name()}) require.Error(t, err) - assert.Contains(t, err.Error(), `"No team" is a reserved team name`) + assert.Contains(t, err.Error(), fmt.Sprintf("file %q for 'No team' must be named 'no-team.yml'", tmpFile.Name())) t.Setenv("TEST_TEAM_NAME", "All teams") _, err = runAppNoChecks([]string{"gitops", "-f", tmpFile.Name(), "--dry-run"}) @@ -1164,6 +1186,336 @@ software: assert.True(t, ds.DeleteTeamFuncInvoked) } +func TestGitOpsBasicGlobalAndNoTeam(t *testing.T) { + // Cannot run t.Parallel() because runServerWithMockedDS sets the FLEET_SERVER_ADDRESS + // environment variable. + + license := &fleet.LicenseInfo{Tier: fleet.TierPremium, Expiration: time.Now().Add(24 * time.Hour)} + _, ds := runServerWithMockedDS( + t, &service.TestServerOpts{ + License: license, + }, + ) + // Mock appConfig + savedAppConfig := &fleet.AppConfig{} + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return &fleet.AppConfig{}, nil + } + ds.SaveAppConfigFunc = func(ctx context.Context, config *fleet.AppConfig) error { + savedAppConfig = config + return nil + } + ds.SetTeamVPPAppsFunc = func(ctx context.Context, teamID *uint, adamIDs []fleet.VPPAppTeam) error { + return nil + } + ds.BatchInsertVPPAppsFunc = func(ctx context.Context, apps []*fleet.VPPApp) error { + return nil + } + + const ( + fleetServerURL = "https://fleet.example.com" + orgName = "GitOps Test" + secret = "TestSecret" + ) + var enrolledSecrets []*fleet.EnrollSecret + var enrolledTeamSecrets []*fleet.EnrollSecret + var savedTeam *fleet.Team + team := &fleet.Team{ + ID: 1, + CreatedAt: time.Now(), + Name: teamName, + } + + ds.IsEnrollSecretAvailableFunc = func(ctx context.Context, secret string, new bool, teamID *uint) (bool, error) { + return true, nil + } + ds.ApplyEnrollSecretsFunc = func(ctx context.Context, teamID *uint, secrets []*fleet.EnrollSecret) error { + if teamID == nil { + enrolledSecrets = secrets + } else { + enrolledTeamSecrets = secrets + } + return nil + } + ds.BatchSetMDMProfilesFunc = func( + ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, + macDecls []*fleet.MDMAppleDeclaration, + ) (updates fleet.MDMProfilesUpdates, err error) { + assert.Empty(t, macProfiles) + assert.Empty(t, winProfiles) + return fleet.MDMProfilesUpdates{}, nil + } + ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) error { + assert.Empty(t, scripts) + return nil + } + ds.BulkSetPendingMDMHostProfilesFunc = func( + ctx context.Context, hostIDs []uint, teamIDs []uint, profileUUIDs []string, hostUUIDs []string, + ) (updates fleet.MDMProfilesUpdates, err error) { + assert.Empty(t, profileUUIDs) + return fleet.MDMProfilesUpdates{}, nil + } + ds.DeleteMDMAppleDeclarationByNameFunc = func(ctx context.Context, teamID *uint, name string) error { + return nil + } + ds.LabelIDsByNameFunc = func(ctx context.Context, labels []string) (map[string]uint, error) { + require.ElementsMatch(t, labels, []string{fleet.BuiltinLabelMacOS14Plus}) + return map[string]uint{fleet.BuiltinLabelMacOS14Plus: 1}, nil + } + ds.ListGlobalPoliciesFunc = func(ctx context.Context, opts fleet.ListOptions) ([]*fleet.Policy, error) { return nil, nil } + ds.ListTeamPoliciesFunc = func( + ctx context.Context, teamID uint, opts fleet.ListOptions, iopts fleet.ListOptions, + ) (teamPolicies []*fleet.Policy, inheritedPolicies []*fleet.Policy, err error) { + return nil, nil, nil + } + ds.ListTeamsFunc = func(ctx context.Context, filter fleet.TeamFilter, opt fleet.ListOptions) ([]*fleet.Team, error) { + return nil, nil + } + ds.ListQueriesFunc = func(ctx context.Context, opts fleet.ListQueryOptions) ([]*fleet.Query, error) { return nil, nil } + ds.NewActivityFunc = func( + ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time, + ) error { + return nil + } + ds.NewJobFunc = func(ctx context.Context, job *fleet.Job) (*fleet.Job, error) { + job.ID = 1 + return job, nil + } + ds.TeamFunc = func(ctx context.Context, tid uint) (*fleet.Team, error) { + if tid == team.ID { + return savedTeam, nil + } + return nil, nil + } + ds.TeamByNameFunc = func(ctx context.Context, name string) (*fleet.Team, error) { + if name == teamName && savedTeam != nil { + return savedTeam, nil + } + return nil, ¬FoundError{} + } + ds.TeamByFilenameFunc = func(ctx context.Context, filename string) (*fleet.Team, error) { + if savedTeam != nil && *savedTeam.Filename == filename { + return savedTeam, nil + } + return nil, ¬FoundError{} + } + ds.NewTeamFunc = func(ctx context.Context, newTeam *fleet.Team) (*fleet.Team, error) { + newTeam.ID = team.ID + savedTeam = newTeam + enrolledTeamSecrets = newTeam.Secrets + return newTeam, nil + } + ds.SaveTeamFunc = func(ctx context.Context, team *fleet.Team) (*fleet.Team, error) { + savedTeam = team + return team, nil + } + ds.BatchSetSoftwareInstallersFunc = func(ctx context.Context, teamID *uint, installers []*fleet.UploadSoftwareInstallerPayload) ([]fleet.SoftwareInstaller, error) { + return nil, nil + } + ds.ListSoftwareTitlesFunc = func(ctx context.Context, opt fleet.SoftwareTitleListOptions, tmFilter fleet.TeamFilter) ([]fleet.SoftwareTitleListResult, int, *fleet.PaginationMetadata, error) { + return nil, 0, nil, nil + } + + globalFileBasic, err := os.CreateTemp(t.TempDir(), "*.yml") + require.NoError(t, err) + + _, err = globalFileBasic.WriteString(fmt.Sprintf( + ` +controls: +queries: +policies: +agent_options: +org_settings: + server_settings: + server_url: %s + org_info: + contact_url: https://example.com/contact + org_logo_url: "" + org_logo_url_light_background: "" + org_name: %s + secrets: [{"secret":"globalSecret"}] +software: +`, fleetServerURL, orgName), + ) + require.NoError(t, err) + + globalFileWithSoftware, err := os.CreateTemp(t.TempDir(), "*.yml") + require.NoError(t, err) + _, err = globalFileWithSoftware.WriteString(fmt.Sprintf( + ` +controls: +queries: +policies: +agent_options: +org_settings: + server_settings: + server_url: %s + org_info: + contact_url: https://example.com/contact + org_logo_url: "" + org_logo_url_light_background: "" + org_name: %s + secrets: [{"secret":"globalSecret"}] +software: + packages: + - url: https://example.com +`, fleetServerURL, orgName), + ) + require.NoError(t, err) + + globalFileWithControls, err := os.CreateTemp(t.TempDir(), "*.yml") + require.NoError(t, err) + _, err = globalFileWithControls.WriteString(fmt.Sprintf( + ` +controls: + ios_updates: + deadline: "2022-02-02" + minimum_version: "17.6" +queries: +policies: +agent_options: +org_settings: + server_settings: + server_url: %s + org_info: + contact_url: https://example.com/contact + org_logo_url: "" + org_logo_url_light_background: "" + org_name: %s + secrets: [{"secret":"globalSecret"}] +software: +`, fleetServerURL, orgName), + ) + require.NoError(t, err) + + globalFileWithoutControlsAndSoftwareKeys, err := os.CreateTemp(t.TempDir(), "*.yml") + require.NoError(t, err) + _, err = globalFileWithoutControlsAndSoftwareKeys.WriteString(fmt.Sprintf( + ` +queries: +policies: +agent_options: +org_settings: + server_settings: + server_url: %s + org_info: + contact_url: https://example.com/contact + org_logo_url: "" + org_logo_url_light_background: "" + org_name: %s + secrets: [{"secret":"globalSecret"}] +`, fleetServerURL, orgName), + ) + require.NoError(t, err) + + teamFile, err := os.CreateTemp(t.TempDir(), "*.yml") + require.NoError(t, err) + _, err = teamFile.WriteString(fmt.Sprintf(` +controls: +queries: +policies: +agent_options: +name: %s +team_settings: + secrets: [{"secret":"%s"}] +software: +`, teamName, secret), + ) + require.NoError(t, err) + + noTeamFilePath := filepath.Join(t.TempDir(), "no-team.yml") + noTeamFile, err := os.Create(noTeamFilePath) + require.NoError(t, err) + _, err = noTeamFile.WriteString(` +controls: +policies: +name: No team +software: +`) + require.NoError(t, err) + + noTeamFilePathWithControls := filepath.Join(t.TempDir(), "no-team.yml") + noTeamFileWithControls, err := os.Create(noTeamFilePathWithControls) + require.NoError(t, err) + _, err = noTeamFileWithControls.WriteString(` +controls: + ipados_updates: + deadline: "2023-03-03" + minimum_version: "18.0" +policies: +name: No team +software: +`) + require.NoError(t, err) + + noTeamFilePathWithoutControls := filepath.Join(t.TempDir(), "no-team.yml") + noTeamFileWithoutControls, err := os.Create(noTeamFilePathWithoutControls) + require.NoError(t, err) + _, err = noTeamFileWithoutControls.WriteString(` +policies: +name: No team +software: +`) + require.NoError(t, err) + + // Dry run, global defines software, should fail. + _, err = runAppNoChecks([]string{"gitops", "-f", globalFileWithSoftware.Name(), "-f", teamFile.Name(), "-f", noTeamFile.Name(), "--dry-run"}) + require.Error(t, err) + assert.True(t, strings.Contains(err.Error(), "'software' cannot be set on global file")) + // Real run, global defines software, should fail. + _, err = runAppNoChecks([]string{"gitops", "-f", globalFileWithSoftware.Name(), "-f", teamFile.Name(), "-f", noTeamFile.Name()}) + require.Error(t, err) + assert.True(t, strings.Contains(err.Error(), "'software' cannot be set on global file")) + + // Dry run, both global and no-team.yml define controls. + _, err = runAppNoChecks([]string{"gitops", "-f", globalFileWithControls.Name(), "-f", teamFile.Name(), "-f", noTeamFileWithControls.Name(), "--dry-run"}) + require.Error(t, err) + assert.True(t, strings.Contains(err.Error(), "'controls' cannot be set on both global config and on no-team.yml")) + // Real run, both global and no-team.yml define controls. + _, err = runAppNoChecks([]string{"gitops", "-f", globalFileWithControls.Name(), "-f", teamFile.Name(), "-f", noTeamFileWithControls.Name(), "--dry-run"}) + require.Error(t, err) + assert.True(t, strings.Contains(err.Error(), "'controls' cannot be set on both global config and on no-team.yml")) + + // Dry run, controls should be defined somewhere, either in no-team.yml or global. + _, err = runAppNoChecks([]string{"gitops", "-f", globalFileWithoutControlsAndSoftwareKeys.Name(), "-f", teamFile.Name(), "-f", noTeamFileWithoutControls.Name(), "--dry-run"}) + require.Error(t, err) + assert.True(t, strings.Contains(err.Error(), "'controls' must be set on global config or no-team.yml")) + // Real run, both global and no-team.yml define controls. + _, err = runAppNoChecks([]string{"gitops", "-f", globalFileWithoutControlsAndSoftwareKeys.Name(), "-f", teamFile.Name(), "-f", noTeamFileWithoutControls.Name(), "--dry-run"}) + require.Error(t, err) + assert.True(t, strings.Contains(err.Error(), "'controls' must be set on global config or no-team.yml")) + + // Dry run, global file without controls and software keys. + _ = runAppForTest(t, []string{"gitops", "-f", globalFileWithoutControlsAndSoftwareKeys.Name(), "-f", teamFile.Name(), "-f", noTeamFile.Name(), "--dry-run"}) + assert.Equal(t, fleet.AppConfig{}, *savedAppConfig, "AppConfig should be empty") + + // Real run, global file without controls and software keys. + _ = runAppForTest(t, []string{"gitops", "-f", globalFileWithoutControlsAndSoftwareKeys.Name(), "-f", teamFile.Name(), "-f", noTeamFile.Name()}) + assert.Equal(t, orgName, savedAppConfig.OrgInfo.OrgName) + assert.Equal(t, fleetServerURL, savedAppConfig.ServerSettings.ServerURL) + assert.Len(t, enrolledSecrets, 1) + require.NotNil(t, savedTeam) + assert.Equal(t, teamName, savedTeam.Name) + require.Len(t, enrolledTeamSecrets, 1) + assert.Equal(t, secret, enrolledTeamSecrets[0].Secret) + + // Restore to test below. + savedAppConfig = &fleet.AppConfig{} + + // Dry run + _ = runAppForTest(t, []string{"gitops", "-f", globalFileBasic.Name(), "-f", teamFile.Name(), "-f", noTeamFile.Name(), "--dry-run"}) + assert.Equal(t, fleet.AppConfig{}, *savedAppConfig, "AppConfig should be empty") + // Real run + _ = runAppForTest(t, []string{"gitops", "-f", globalFileBasic.Name(), "-f", teamFile.Name(), "-f", noTeamFile.Name()}) + assert.Equal(t, orgName, savedAppConfig.OrgInfo.OrgName) + assert.Equal(t, fleetServerURL, savedAppConfig.ServerSettings.ServerURL) + assert.Len(t, enrolledSecrets, 1) + require.NotNil(t, savedTeam) + assert.Equal(t, teamName, savedTeam.Name) + require.Len(t, enrolledTeamSecrets, 1) + assert.Equal(t, secret, enrolledTeamSecrets[0].Secret) +} + func TestGitOpsFullGlobalAndTeam(t *testing.T) { // Cannot run t.Parallel() because it sets environment variables // mdm test configuration must be set so that activating windows MDM works. @@ -1299,8 +1651,8 @@ func TestGitOpsNoTeamSoftwareInstallers(t *testing.T) { startSoftwareInstallerServer(t) cases := []struct { - file string - wantErr string + noTeamFile string + wantErr string }{ {"testdata/gitops/no_team_software_installer_not_found.yml", "Please make sure that URLs are publicy accessible to the internet."}, {"testdata/gitops/no_team_software_installer_unsupported.yml", "The file should be .pkg, .msi, .exe or .deb."}, @@ -1314,11 +1666,18 @@ func TestGitOpsNoTeamSoftwareInstallers(t *testing.T) { {"testdata/gitops/no_team_software_installer_invalid_self_service_value.yml", "\"packages.self_service\" must be a bool, found string"}, } for _, c := range cases { - t.Run(filepath.Base(c.file), func(t *testing.T) { + t.Run(filepath.Base(c.noTeamFile), func(t *testing.T) { setupFullGitOpsPremiumServer(t) t.Setenv("APPLE_BM_DEFAULT_TEAM", "") - _, err := runAppNoChecks([]string{"gitops", "-f", c.file}) + globalFile := "./testdata/gitops/global_config_no_paths.yml" + dstPath := filepath.Join(filepath.Dir(c.noTeamFile), "no-team.yml") + t.Cleanup(func() { + os.Remove(dstPath) + }) + err := file.Copy(c.noTeamFile, dstPath, 0o755) + require.NoError(t, err) + _, err = runAppNoChecks([]string{"gitops", "-f", globalFile, "-f", dstPath}) if c.wantErr == "" { require.NoError(t, err) } else { diff --git a/cmd/fleetctl/testdata/gitops/no_team_software_installer_install_not_found.yml b/cmd/fleetctl/testdata/gitops/no_team_software_installer_install_not_found.yml index d3bcada54e..58bae27ae9 100644 --- a/cmd/fleetctl/testdata/gitops/no_team_software_installer_install_not_found.yml +++ b/cmd/fleetctl/testdata/gitops/no_team_software_installer_install_not_found.yml @@ -1,19 +1,8 @@ -# Test config +name: No team controls: -queries: policies: -agent_options: -org_settings: - server_settings: - server_url: $FLEET_SERVER_URL - org_info: - contact_url: https://example.com/contact - org_logo_url: "" - org_logo_url_light_background: "" - org_name: ${ORG_NAME} - secrets: [{"secret":"globalSecret"}] software: packages: - url: ${SOFTWARE_INSTALLER_URL}/ruby.deb install_script: - path: lib/notfound.sh \ No newline at end of file + path: lib/notfound.sh diff --git a/cmd/fleetctl/testdata/gitops/no_team_software_installer_invalid_self_service_value.yml b/cmd/fleetctl/testdata/gitops/no_team_software_installer_invalid_self_service_value.yml index acee06d683..b333e7816e 100644 --- a/cmd/fleetctl/testdata/gitops/no_team_software_installer_invalid_self_service_value.yml +++ b/cmd/fleetctl/testdata/gitops/no_team_software_installer_invalid_self_service_value.yml @@ -1,18 +1,7 @@ -# Test config +name: No team controls: -queries: policies: -agent_options: -org_settings: - server_settings: - server_url: $FLEET_SERVER_URL - org_info: - contact_url: https://example.com/contact - org_logo_url: "" - org_logo_url_light_background: "" - org_name: ${ORG_NAME} - secrets: [{"secret":"globalSecret"}] software: packages: - url: ${SOFTWARE_INSTALLER_URL}/invalidtype.txt - self_service: "not a boolean" \ No newline at end of file + self_service: "not a boolean" diff --git a/cmd/fleetctl/testdata/gitops/no_team_software_installer_no_url.yml b/cmd/fleetctl/testdata/gitops/no_team_software_installer_no_url.yml index 6d83a9daed..d897af7b43 100644 --- a/cmd/fleetctl/testdata/gitops/no_team_software_installer_no_url.yml +++ b/cmd/fleetctl/testdata/gitops/no_team_software_installer_no_url.yml @@ -1,17 +1,6 @@ -# Test config +name: No TEAM controls: -queries: policies: -agent_options: -org_settings: - server_settings: - server_url: $FLEET_SERVER_URL - org_info: - contact_url: https://example.com/contact - org_logo_url: "" - org_logo_url_light_background: "" - org_name: ${ORG_NAME} - secrets: [{"secret":"globalSecret"}] software: packages: - install_script: @@ -19,4 +8,4 @@ software: pre_install_query: path: lib/query_ruby.yml post_install_script: - path: lib/post_install_ruby.sh \ No newline at end of file + path: lib/post_install_ruby.sh diff --git a/cmd/fleetctl/testdata/gitops/no_team_software_installer_not_found.yml b/cmd/fleetctl/testdata/gitops/no_team_software_installer_not_found.yml index cd7332f91e..590458e78b 100644 --- a/cmd/fleetctl/testdata/gitops/no_team_software_installer_not_found.yml +++ b/cmd/fleetctl/testdata/gitops/no_team_software_installer_not_found.yml @@ -1,17 +1,6 @@ -# Test config +name: No team controls: -queries: policies: -agent_options: -org_settings: - server_settings: - server_url: $FLEET_SERVER_URL - org_info: - contact_url: https://example.com/contact - org_logo_url: "" - org_logo_url_light_background: "" - org_name: ${ORG_NAME} - secrets: [{"secret":"globalSecret"}] software: packages: - - url: ${SOFTWARE_INSTALLER_URL}/notfound.deb \ No newline at end of file + - url: ${SOFTWARE_INSTALLER_URL}/notfound.deb diff --git a/cmd/fleetctl/testdata/gitops/no_team_software_installer_post_install_not_found.yml b/cmd/fleetctl/testdata/gitops/no_team_software_installer_post_install_not_found.yml index ac0a436360..12b2598d59 100644 --- a/cmd/fleetctl/testdata/gitops/no_team_software_installer_post_install_not_found.yml +++ b/cmd/fleetctl/testdata/gitops/no_team_software_installer_post_install_not_found.yml @@ -1,21 +1,10 @@ -# Test config +name: No team controls: -queries: policies: -agent_options: -org_settings: - server_settings: - server_url: $FLEET_SERVER_URL - org_info: - contact_url: https://example.com/contact - org_logo_url: "" - org_logo_url_light_background: "" - org_name: ${ORG_NAME} - secrets: [{"secret":"globalSecret"}] software: packages: - url: ${SOFTWARE_INSTALLER_URL}/ruby.deb install_script: path: lib/install_ruby.sh post_install_script: - path: lib/notfound.sh \ No newline at end of file + path: lib/notfound.sh diff --git a/cmd/fleetctl/testdata/gitops/no_team_software_installer_pre_condition_multiple_queries.yml b/cmd/fleetctl/testdata/gitops/no_team_software_installer_pre_condition_multiple_queries.yml index a2b5419c05..15ddcb438c 100644 --- a/cmd/fleetctl/testdata/gitops/no_team_software_installer_pre_condition_multiple_queries.yml +++ b/cmd/fleetctl/testdata/gitops/no_team_software_installer_pre_condition_multiple_queries.yml @@ -1,17 +1,6 @@ -# Test config +name: No team controls: -queries: policies: -agent_options: -org_settings: - server_settings: - server_url: $FLEET_SERVER_URL - org_info: - contact_url: https://example.com/contact - org_logo_url: "" - org_logo_url_light_background: "" - org_name: ${ORG_NAME} - secrets: [{"secret":"globalSecret"}] software: packages: - url: ${SOFTWARE_INSTALLER_URL}/ruby.deb @@ -20,4 +9,4 @@ software: pre_install_query: path: lib/query_multiple.yml post_install_script: - path: lib/post_install_ruby.sh \ No newline at end of file + path: lib/post_install_ruby.sh diff --git a/cmd/fleetctl/testdata/gitops/no_team_software_installer_pre_condition_not_found.yml b/cmd/fleetctl/testdata/gitops/no_team_software_installer_pre_condition_not_found.yml index bafde42691..48e6ff42e5 100644 --- a/cmd/fleetctl/testdata/gitops/no_team_software_installer_pre_condition_not_found.yml +++ b/cmd/fleetctl/testdata/gitops/no_team_software_installer_pre_condition_not_found.yml @@ -1,21 +1,10 @@ -# Test config +name: No team controls: -queries: policies: -agent_options: -org_settings: - server_settings: - server_url: $FLEET_SERVER_URL - org_info: - contact_url: https://example.com/contact - org_logo_url: "" - org_logo_url_light_background: "" - org_name: ${ORG_NAME} - secrets: [{"secret":"globalSecret"}] software: packages: - url: ${SOFTWARE_INSTALLER_URL}/ruby.deb install_script: path: lib/install_ruby.sh pre_install_query: - path: lib/notfound.yml \ No newline at end of file + path: lib/notfound.yml diff --git a/cmd/fleetctl/testdata/gitops/no_team_software_installer_too_large.yml b/cmd/fleetctl/testdata/gitops/no_team_software_installer_too_large.yml index db4ffd3211..23ba8dbe80 100644 --- a/cmd/fleetctl/testdata/gitops/no_team_software_installer_too_large.yml +++ b/cmd/fleetctl/testdata/gitops/no_team_software_installer_too_large.yml @@ -1,17 +1,6 @@ -# Test config +name: No team controls: -queries: policies: -agent_options: -org_settings: - server_settings: - server_url: $FLEET_SERVER_URL - org_info: - contact_url: https://example.com/contact - org_logo_url: "" - org_logo_url_light_background: "" - org_name: ${ORG_NAME} - secrets: [{"secret":"globalSecret"}] software: packages: - - url: ${SOFTWARE_INSTALLER_URL}/toolarge.deb \ No newline at end of file + - url: ${SOFTWARE_INSTALLER_URL}/toolarge.deb diff --git a/cmd/fleetctl/testdata/gitops/no_team_software_installer_unsupported.yml b/cmd/fleetctl/testdata/gitops/no_team_software_installer_unsupported.yml index 2bc609b931..ace876a8d5 100644 --- a/cmd/fleetctl/testdata/gitops/no_team_software_installer_unsupported.yml +++ b/cmd/fleetctl/testdata/gitops/no_team_software_installer_unsupported.yml @@ -1,17 +1,6 @@ -# Test config +name: "No team" controls: -queries: policies: -agent_options: -org_settings: - server_settings: - server_url: $FLEET_SERVER_URL - org_info: - contact_url: https://example.com/contact - org_logo_url: "" - org_logo_url_light_background: "" - org_name: ${ORG_NAME} - secrets: [{"secret":"globalSecret"}] software: packages: - - url: ${SOFTWARE_INSTALLER_URL}/invalidtype.txt \ No newline at end of file + - url: ${SOFTWARE_INSTALLER_URL}/invalidtype.txt diff --git a/cmd/fleetctl/testdata/gitops/no_team_software_installer_valid.yml b/cmd/fleetctl/testdata/gitops/no_team_software_installer_valid.yml index e0fcaa490e..db8043baf9 100644 --- a/cmd/fleetctl/testdata/gitops/no_team_software_installer_valid.yml +++ b/cmd/fleetctl/testdata/gitops/no_team_software_installer_valid.yml @@ -1,17 +1,6 @@ -# Test config +name: No team controls: -queries: policies: -agent_options: -org_settings: - server_settings: - server_url: $FLEET_SERVER_URL - org_info: - contact_url: https://example.com/contact - org_logo_url: "" - org_logo_url_light_background: "" - org_name: ${ORG_NAME} - secrets: [{"secret":"globalSecret"}] software: packages: - url: ${SOFTWARE_INSTALLER_URL}/ruby.deb @@ -22,4 +11,4 @@ software: post_install_script: path: lib/post_install_ruby.sh - url: ${SOFTWARE_INSTALLER_URL}/other.deb - self_service: true \ No newline at end of file + self_service: true diff --git a/ee/server/service/vpp.go b/ee/server/service/vpp.go index b03291ff46..d1f13bd555 100644 --- a/ee/server/service/vpp.go +++ b/ee/server/service/vpp.go @@ -149,8 +149,8 @@ func (svc *Service) BatchAssociateVPPApps(ctx context.Context, teamName string, vppAppTeams = append(vppAppTeams, app.VPPAppTeam) } } - } + } if err := svc.ds.SetTeamVPPApps(ctx, &team.ID, vppAppTeams); err != nil { if errors.Is(err, sql.ErrNoRows) { return fleet.NewUserMessageError(ctxerr.Wrap(ctx, err, "no vpp token to set team vpp assets"), http.StatusUnprocessableEntity) @@ -375,7 +375,7 @@ func getVPPAppsMetadata(ctx context.Context, ids []fleet.VPPAppTeam) ([]*fleet.V var apps []*fleet.VPPApp // Map of adamID to platform, then to whether it's available as self-service. - var adamIDMap = make(map[string]map[fleet.AppleDevicePlatform]bool) + adamIDMap := make(map[string]map[fleet.AppleDevicePlatform]bool) for _, id := range ids { if _, ok := adamIDMap[id.AdamID]; !ok { adamIDMap[id.AdamID] = make(map[fleet.AppleDevicePlatform]bool, 1) diff --git a/pkg/spec/gitops.go b/pkg/spec/gitops.go index 558d7a1f06..0d15687da7 100644 --- a/pkg/spec/gitops.go +++ b/pkg/spec/gitops.go @@ -7,6 +7,7 @@ import ( "os" "path/filepath" "slices" + "strings" "unicode" "github.com/fleetdm/fleet/v4/server/fleet" @@ -36,6 +37,16 @@ type Controls struct { EnableDiskEncryption interface{} `json:"enable_disk_encryption"` Scripts []BaseItem `json:"scripts"` + + Defined bool +} + +func (c Controls) Set() bool { + return c.MacOSUpdates != nil || c.IOSUpdates != nil || + c.IPadOSUpdates != nil || c.MacOSSettings != nil || + c.MacOSSetup != nil || c.MacOSMigration != nil || + c.WindowsUpdates != nil || c.WindowsSettings != nil || c.WindowsEnabledAndConfigured != nil || + c.EnableDiskEncryption != nil || len(c.Scripts) > 0 } type Policy struct { @@ -88,8 +99,10 @@ type GitOpsSoftware struct { AppStoreApps []*fleet.TeamSpecAppStoreApp } +type Logf func(format string, a ...interface{}) + // GitOpsFromFile parses a GitOps yaml file. -func GitOpsFromFile(filePath, baseDir string, appConfig *fleet.EnrichedAppConfig) (*GitOps, error) { +func GitOpsFromFile(filePath, baseDir string, appConfig *fleet.EnrichedAppConfig, logFn Logf) (*GitOps, error) { b, err := os.ReadFile(filePath) if err != nil { return nil, fmt.Errorf("failed to read file: %s: %w", filePath, err) @@ -126,17 +139,30 @@ func GitOpsFromFile(filePath, baseDir string, appConfig *fleet.EnrichedAppConfig } else { multiError = parseOrgSettings(orgSettingsRaw, result, baseDir, multiError) } - } else if teamOk && teamSettingsOk { + } else if teamOk { multiError = parseName(teamRaw, result, multiError) - multiError = parseTeamSettings(teamSettingsRaw, result, baseDir, multiError) + if result.IsNoTeam() { + if teamSettingsOk { + multiError = multierror.Append(multiError, fmt.Errorf("cannot set 'team_settings' on 'No team' file: %q", filePath)) + } + if filepath.Base(filePath) != "no-team.yml" { + multiError = multierror.Append(multiError, fmt.Errorf("file %q for 'No team' must be named 'no-team.yml'", filePath)) + } + } else { + if !teamSettingsOk { + multiError = multierror.Append(multiError, errors.New("'team_settings' is required when 'name' is provided")) + } else { + multiError = parseTeamSettings(teamSettingsRaw, result, baseDir, multiError) + } + } } else { multiError = multierror.Append(multiError, errors.New("either 'org_settings' or 'name' and 'team_settings' is required")) } // Validate the required top level options multiError = parseControls(top, result, baseDir, multiError) - multiError = parseAgentOptions(top, result, baseDir, multiError) - multiError = parseQueries(top, result, baseDir, multiError) + multiError = parseAgentOptions(top, result, baseDir, logFn, multiError) + multiError = parseQueries(top, result, baseDir, logFn, multiError) if appConfig != nil && appConfig.License.IsPremium() { multiError = parseSoftware(top, result, baseDir, multiError) @@ -161,6 +187,20 @@ func parseName(raw json.RawMessage, result *GitOps, multiError *multierror.Error return multiError } +func (g *GitOps) global() bool { + return g.TeamName == nil || *g.TeamName == "" +} + +func (g *GitOps) IsNoTeam() bool { + return g.TeamName != nil && isNoTeam(*g.TeamName) +} + +func isNoTeam(teamName string) bool { + return strings.ToLower(teamName) == strings.ToLower(noTeam) +} + +const noTeam = "No team" + func parseOrgSettings(raw json.RawMessage, result *GitOps, baseDir string, multiError *multierror.Error) *multierror.Error { var orgSettingsTop BaseItem if err := json.Unmarshal(raw, &orgSettingsTop); err != nil { @@ -314,9 +354,14 @@ func parseSecrets(result *GitOps, multiError *multierror.Error) *multierror.Erro return multiError } -func parseAgentOptions(top map[string]json.RawMessage, result *GitOps, baseDir string, multiError *multierror.Error) *multierror.Error { +func parseAgentOptions(top map[string]json.RawMessage, result *GitOps, baseDir string, logFn Logf, multiError *multierror.Error) *multierror.Error { agentOptionsRaw, ok := top["agent_options"] - if !ok { + if result.IsNoTeam() { + if ok { + logFn("[!] 'agent_options' is not supported for \"No team\". This key will be ignored.") + } + return multiError + } else if !ok { return multierror.Append(multiError, errors.New("'agent_options' is required")) } var agentOptionsTop BaseItem @@ -366,12 +411,14 @@ func parseAgentOptions(top map[string]json.RawMessage, result *GitOps, baseDir s func parseControls(top map[string]json.RawMessage, result *GitOps, baseDir string, multiError *multierror.Error) *multierror.Error { controlsRaw, ok := top["controls"] if !ok { - return multierror.Append(multiError, errors.New("'controls' is required")) + // Nothing to do, return. + return multiError } var controlsTop Controls if err := json.Unmarshal(controlsRaw, &controlsTop); err != nil { return multierror.Append(multiError, fmt.Errorf("failed to unmarshal controls: %v", err)) } + controlsTop.Defined = true if controlsTop.Path == nil { result.Controls = controlsTop } else { @@ -516,9 +563,14 @@ func parsePolicyInstallSoftware(baseDir string, teamName *string, policy *Policy return nil } -func parseQueries(top map[string]json.RawMessage, result *GitOps, baseDir string, multiError *multierror.Error) *multierror.Error { +func parseQueries(top map[string]json.RawMessage, result *GitOps, baseDir string, logFn Logf, multiError *multierror.Error) *multierror.Error { queriesRaw, ok := top["queries"] - if !ok { + if result.IsNoTeam() { + if ok { + logFn("[!] 'queries' is not supported for \"No team\". This key will be ignored.") + } + return multiError + } else if !ok { return multierror.Append(multiError, errors.New("'queries' key is required")) } var queries []Query @@ -593,7 +645,11 @@ func parseQueries(top map[string]json.RawMessage, result *GitOps, baseDir string func parseSoftware(top map[string]json.RawMessage, result *GitOps, baseDir string, multiError *multierror.Error) *multierror.Error { softwareRaw, ok := top["software"] - if !ok { + if result.global() { + if ok && string(softwareRaw) != "null" { + return multierror.Append(multiError, errors.New("'software' cannot be set on global file")) + } + } else if !ok { return multierror.Append(multiError, errors.New("'software' is required")) } var software Software diff --git a/pkg/spec/gitops_test.go b/pkg/spec/gitops_test.go index ea01fcf1dc..1fa9699102 100644 --- a/pkg/spec/gitops_test.go +++ b/pkg/spec/gitops_test.go @@ -53,9 +53,22 @@ func createTempFile(t *testing.T, pattern, contents string) (filePath string, ba return tmpFile.Name(), filepath.Dir(tmpFile.Name()) } +func createNamedFileOnTempDir(t *testing.T, name string, contents string) (filePath string, baseDir string) { + tmpFilePath := filepath.Join(t.TempDir(), name) + tmpFile, err := os.Create(tmpFilePath) + require.NoError(t, err) + _, err = tmpFile.WriteString(contents) + require.NoError(t, err) + require.NoError(t, tmpFile.Close()) + return tmpFile.Name(), filepath.Dir(tmpFile.Name()) +} + func gitOpsFromString(t *testing.T, s string) (*GitOps, error) { path, basePath := createTempFile(t, "", s) - return GitOpsFromFile(path, basePath, nil) + return GitOpsFromFile(path, basePath, nil, nopLogf) +} + +func nopLogf(_ string, _ ...interface{}) { } func TestValidGitOpsYaml(t *testing.T) { @@ -118,7 +131,7 @@ func TestValidGitOpsYaml(t *testing.T) { } } - gitops, err := GitOpsFromFile(test.filePath, "./testdata", appConfig) + gitops, err := GitOpsFromFile(test.filePath, "./testdata", appConfig, nopLogf) require.NoError(t, err) if test.isTeam { @@ -443,14 +456,44 @@ func TestInvalidGitOpsYaml(t *testing.T) { _, err = gitOpsFromString(t, config) assert.ErrorContains(t, err, "must have a 'secret' key") + // Missing team_settings. + config = getConfig([]string{"team_settings"}) + _, err = gitOpsFromString(t, config) + assert.ErrorContains(t, err, "'team_settings' is required when 'name' is provided") + + // team_settings set on a "no-team.yml". + config = getConfig([]string{"name"}) + config += "name: No team\n" + noTeamPath1, noTeamBasePath1 := createNamedFileOnTempDir(t, "no-team.yml", config) + _, err = GitOpsFromFile(noTeamPath1, noTeamBasePath1, nil, nopLogf) + assert.ErrorContains(t, err, fmt.Sprintf("cannot set 'team_settings' on 'No team' file: %q", noTeamPath1)) + + // 'No team' file with invalid name. + config = getConfig([]string{"name", "team_settings"}) + config += "name: No team\n" + noTeamPath2, noTeamBasePath2 := createNamedFileOnTempDir(t, "foobar.yml", config) + _, err = GitOpsFromFile(noTeamPath2, noTeamBasePath2, nil, nopLogf) + assert.ErrorContains(t, err, fmt.Sprintf("file %q for 'No team' must be named 'no-team.yml'", noTeamPath2)) + // Missing secrets config = getConfig([]string{"team_settings"}) config += "team_settings:\n" _, err = gitOpsFromString(t, config) assert.ErrorContains(t, err, "'team_settings.secrets' is required") } else { + // 'software' is not allowed in global config + config := getConfig(nil) + config += "software:\n packages:\n - url: https://example.com\n" + path1, basePath1 := createTempFile(t, "", config) + appConfig := fleet.EnrichedAppConfig{} + appConfig.License = &fleet.LicenseInfo{ + Tier: fleet.TierPremium, + } + _, err = GitOpsFromFile(path1, basePath1, &appConfig, nopLogf) + assert.ErrorContains(t, err, "'software' cannot be set on global file") + // Invalid org_settings - config := getConfig([]string{"org_settings"}) + config = getConfig([]string{"org_settings"}) config += "org_settings:\n path: [2]\n" _, err = gitOpsFromString(t, config) assert.ErrorContains(t, err, "failed to unmarshal org_settings") @@ -595,9 +638,6 @@ func TestTopLevelGitOpsValidation(t *testing.T) { "missing_all": { optsToExclude: []string{"controls", "queries", "policies", "agent_options", "org_settings"}, }, - "missing_controls": { - optsToExclude: []string{"controls"}, - }, "missing_queries": { optsToExclude: []string{"queries"}, }, @@ -724,7 +764,7 @@ func TestGitOpsPaths(t *testing.T) { err = os.WriteFile(mainTmpFile.Name(), []byte(config), 0o644) require.NoError(t, err) - _, err = GitOpsFromFile(mainTmpFile.Name(), dir, nil) + _, err = GitOpsFromFile(mainTmpFile.Name(), dir, nil, nopLogf) assert.NoError(t, err) // Test a bad path @@ -737,7 +777,7 @@ func TestGitOpsPaths(t *testing.T) { err = os.WriteFile(mainTmpFile.Name(), []byte(config), 0o644) require.NoError(t, err) - _, err = GitOpsFromFile(mainTmpFile.Name(), dir, nil) + _, err = GitOpsFromFile(mainTmpFile.Name(), dir, nil, nopLogf) assert.ErrorContains(t, err, "no such file or directory") // Test a bad file -- cannot be unmarshalled @@ -772,7 +812,7 @@ func TestGitOpsPaths(t *testing.T) { } err = os.WriteFile(mainTmpFile.Name(), []byte(config), 0o644) require.NoError(t, err) - _, err = GitOpsFromFile(mainTmpFile.Name(), dir, nil) + _, err = GitOpsFromFile(mainTmpFile.Name(), dir, nil, nopLogf) assert.ErrorContains(t, err, "nested paths are not supported") }, ) @@ -830,7 +870,7 @@ software: Tier: fleet.TierPremium, } path, basePath := createTempFile(t, "", config) - _, err = GitOpsFromFile(path, basePath, &appConfig) + _, err = GitOpsFromFile(path, basePath, &appConfig, nopLogf) assert.ErrorContains(t, err, fmt.Sprintf("software URL \"%s\" is too long, must be less than 256 characters", tooBigURL)) // Policy references a software installer not present in the team. @@ -857,7 +897,7 @@ software: 0o755, ) require.NoError(t, err) - _, err = GitOpsFromFile(path, basePath, &appConfig) + _, err = GitOpsFromFile(path, basePath, &appConfig, nopLogf) assert.ErrorContains(t, err, "install_software.package_path URL https://statics.teams.cdn.office.net/production-osx/enterprise/webview2/lkg/MicrosoftTeams.pkg not found on team", ) @@ -889,7 +929,7 @@ software: appConfig.License = &fleet.LicenseInfo{ Tier: fleet.TierPremium, } - _, err = GitOpsFromFile(path, basePath, &appConfig) + _, err = GitOpsFromFile(path, basePath, &appConfig, nopLogf) assert.ErrorContains(t, err, "failed to unmarshal install_software.package_path file") } diff --git a/server/datastore/mysql/hosts.go b/server/datastore/mysql/hosts.go index 73f19ead9c..0b3a0e4983 100644 --- a/server/datastore/mysql/hosts.go +++ b/server/datastore/mysql/hosts.go @@ -2974,7 +2974,7 @@ func (ds *Datastore) ListPoliciesForHost(ctx context.Context, host *fleet.Host) FROM policies p LEFT JOIN policy_membership pm ON (p.id=pm.policy_id AND host_id=?) LEFT JOIN users u ON p.author_id = u.id - WHERE (p.team_id IS NULL OR p.team_id = (select team_id from hosts WHERE id = ?)) + WHERE (p.team_id IS NULL OR p.team_id = COALESCE((SELECT team_id FROM hosts WHERE id = ?), 0)) AND (p.platforms IS NULL OR p.platforms = '' OR FIND_IN_SET(?, p.platforms) != 0) ORDER BY FIELD(response, 'fail', '', 'pass'), p.name` diff --git a/server/datastore/mysql/migrations/tables/20240905200001_AddPoliciesToNoTeam.go b/server/datastore/mysql/migrations/tables/20240905200001_AddPoliciesToNoTeam.go new file mode 100644 index 0000000000..d24592b9f0 --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20240905200001_AddPoliciesToNoTeam.go @@ -0,0 +1,78 @@ +package tables + +import ( + "database/sql" + "fmt" + + "github.com/pkg/errors" +) + +func init() { + MigrationClient.AddMigration(Up_20240905200001, Down_20240905200001) +} + +func Up_20240905200001(tx *sql.Tx) error { + // + // Changes in `policies` and `policy_stats` to support policies for "No team". + // "No team" here means policies that run on hosts that belong to no team (hosts.team_id = NULL) + // + // `policies`: + // - team_id = NULL means the policy is a "Global policy" (aka "All teams" policy). + // - team_id > 0 means the policy is a team policy. + // - team_id = 0 means the policy is a "No team" policy. + // + // `policy_stats`: + // - For "Global policies": + // - inherited_team_id_char = 'global', inherited_team_id = NULL are the stats for the policy's global domain. + // - inherited_team_id_char = '', inherited_team_id = are the stats of the policy on a specific team domain. + // - inherited_team_id_car = '0', inherited_team_id = 0 are the stats of the policy on the "No team" domain. + // - For "Team policies" (for team policies there's always just one row in this table): + // - inherited_team_id_char = 'global', inherited_team_id = NULL are the stats for the team policy. + // + + // Drop foreign key on policies table to teams to allow for team_id = 0 to represent "No team". + referencedTables := map[string]struct{}{"teams": {}} + table := "policies" + constraints, err := constraintsForTable(tx, table, referencedTables) + if err != nil { + return err + } + if len(constraints) != 1 { + return errors.New("policies foreign key to teams not found") + } + if _, err := tx.Exec(fmt.Sprintf(` + ALTER TABLE policies + DROP FOREIGN KEY %s; + `, constraints[0])); err != nil { + return fmt.Errorf("failed to drop policies foreign key to teams: %w", err) + } + + // Allow `inherited_team_id` to be NULL to represent global policy stats on the global domain, and `inherited_team_id = 0` + // to represent global policy stats on the "No team" domain. + // Add `inherited_team_id_char` as generated column to add uniqueness constraint to the table for policies on each domain. + if _, err := tx.Exec(` + ALTER TABLE policy_stats + DROP INDEX policy_team_unique, + MODIFY inherited_team_id INT UNSIGNED NULL, + ADD COLUMN inherited_team_id_char char(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci + GENERATED ALWAYS AS (IF(inherited_team_id IS NULL, 'global', CONVERT(inherited_team_id, CHAR))), + ADD UNIQUE KEY (policy_id, inherited_team_id_char); + `); err != nil { + return fmt.Errorf("failed to modify inherited_team_id in policy_stats: %w", err) + } + + // Update inherited_team_id from `0` to `NULL` to allow storing stats for the "No team" domain as `inherited_team_id = 0`. + if _, err := tx.Exec(` + UPDATE policy_stats + SET inherited_team_id = NULL + WHERE inherited_team_id = 0; + `); err != nil { + return fmt.Errorf("failed to update policy_stats: %w", err) + } + + return nil +} + +func Down_20240905200001(tx *sql.Tx) error { + return nil +} diff --git a/server/datastore/mysql/migrations/tables/20240905200001_AddPoliciesToNoTeam_test.go b/server/datastore/mysql/migrations/tables/20240905200001_AddPoliciesToNoTeam_test.go new file mode 100644 index 0000000000..90a2495ea5 --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20240905200001_AddPoliciesToNoTeam_test.go @@ -0,0 +1,86 @@ +package tables + +import ( + "strconv" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestUp_20240905200001(t *testing.T) { + db := applyUpToPrev(t) + + team1ID := uint(execNoErrLastID(t, db, `INSERT INTO teams (name) VALUES ('team1');`)) + globalPolicy0 := uint(execNoErrLastID(t, db, + `INSERT INTO policies (name, query, description, checksum) VALUES + ('globalPolicy0', 'SELECT 0', 'Description', 'checksum');`, + )) + policy1Team1 := uint(execNoErrLastID(t, db, + `INSERT INTO policies (name, query, description, team_id, checksum) + VALUES ('policy1Team1', 'SELECT 1', 'Description', ?, 'checksum2');`, + team1ID, + )) + + // Insert policy stats for a global policy. + execNoErr(t, db, + `INSERT INTO policy_stats + (policy_id, inherited_team_id, passing_host_count, failing_host_count) + VALUES + (?, ?, 1, 2), (?, ?, 3, 4);`, + globalPolicy0, + 0, + globalPolicy0, + policy1Team1, + ) + // Insert policy stats for a team policy. + execNoErr(t, db, + `INSERT INTO policy_stats (policy_id, inherited_team_id, passing_host_count, failing_host_count) + VALUES (?, ?, 5, 6);`, + policy1Team1, + 0, + ) + + applyNext(t, db) + + // Check the policy_stats for global have been migrated correctly. + var results []struct { + PolicyID uint `db:"policy_id"` + InheritedTeamID *uint `db:"inherited_team_id"` + InheritedTeamIDChar string `db:"inherited_team_id_char"` + PassingHostCount uint `db:"passing_host_count"` + FailingHostCount uint `db:"failing_host_count"` + } + err := db.Select(&results, + `SELECT policy_id, inherited_team_id, inherited_team_id_char, passing_host_count, failing_host_count + FROM policy_stats ORDER BY policy_id ASC;`, + ) + require.NoError(t, err) + require.Len(t, results, 3) + + require.Equal(t, globalPolicy0, results[0].PolicyID) + require.Nil(t, results[0].InheritedTeamID) + require.Equal(t, "global", results[0].InheritedTeamIDChar) + require.Equal(t, uint(1), results[0].PassingHostCount) + require.Equal(t, uint(2), results[0].FailingHostCount) + + require.Equal(t, globalPolicy0, results[1].PolicyID) + require.NotNil(t, results[1].InheritedTeamID) + require.Equal(t, policy1Team1, *results[1].InheritedTeamID) + require.Equal(t, strconv.FormatUint(uint64(policy1Team1), 10), results[1].InheritedTeamIDChar) + require.Equal(t, uint(3), results[1].PassingHostCount) + require.Equal(t, uint(4), results[1].FailingHostCount) + + require.Equal(t, policy1Team1, results[2].PolicyID) + require.Nil(t, results[2].InheritedTeamID) + require.Equal(t, "global", results[2].InheritedTeamIDChar) + require.Equal(t, uint(5), results[2].PassingHostCount) + require.Equal(t, uint(6), results[2].FailingHostCount) + + // The team can be deleted, and the policy won't be automatically deleted. + execNoErr(t, db, + `DELETE FROM teams;`, + ) + var ok bool + err = db.Get(&ok, `SELECT 1 FROM policies WHERE id = ?;`, policy1Team1) + require.NoError(t, err) +} diff --git a/server/datastore/mysql/policies.go b/server/datastore/mysql/policies.go index 1c3cc02411..f96f99289d 100644 --- a/server/datastore/mysql/policies.go +++ b/server/datastore/mysql/policies.go @@ -12,9 +12,9 @@ import ( "golang.org/x/text/unicode/norm" - "github.com/doug-martin/goqu/v9" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/ptr" kitlog "github.com/go-kit/log" "github.com/go-kit/log/level" "github.com/jmoiron/sqlx" @@ -103,8 +103,7 @@ func policyDB(ctx context.Context, q sqlx.QueryerContext, id uint, teamID *uint) FROM policies p LEFT JOIN users u ON p.author_id = u.id LEFT JOIN policy_stats ps ON p.id = ps.policy_id - AND ((p.team_id IS NULL AND ps.inherited_team_id = 0) - OR (p.team_id IS NOT NULL AND ps.inherited_team_id = p.team_id)) + AND ((p.team_id IS NULL AND ps.inherited_team_id IS NULL) OR (p.team_id IS NOT NULL)) WHERE p.id=? AND %s`, policyCols, teamWhere), args...) if err != nil { @@ -381,7 +380,7 @@ func listPoliciesDB(ctx context.Context, q sqlx.QueryerContext, teamID *uint, op COALESCE(ps.failing_host_count, 0) AS failing_host_count FROM policies p LEFT JOIN users u ON p.author_id = u.id - LEFT JOIN policy_stats ps ON p.id = ps.policy_id AND ps.inherited_team_id = 0 + LEFT JOIN policy_stats ps ON p.id = ps.policy_id AND ps.inherited_team_id IS NULL ` if teamID != nil { @@ -498,8 +497,7 @@ func (ds *Datastore) PoliciesByID(ctx context.Context, ids []uint) (map[uint]*fl FROM policies p LEFT JOIN users u ON p.author_id = u.id LEFT JOIN policy_stats ps ON p.id = ps.policy_id - AND ((p.team_id IS NULL AND ps.inherited_team_id = 0) - OR (p.team_id IS NOT NULL AND ps.inherited_team_id = p.team_id)) + AND ((p.team_id IS NULL AND ps.inherited_team_id IS NULL) OR (p.team_id IS NOT NULL)) WHERE p.id IN (?)` query, args, err := sqlx.In(sql, ids) if err != nil { @@ -556,38 +554,25 @@ func deletePolicyDB(ctx context.Context, q sqlx.ExtContext, ids []uint, teamID * // PolicyQueriesForHost returns the policy queries that are to be executed on the given host. func (ds *Datastore) PolicyQueriesForHost(ctx context.Context, host *fleet.Host) (map[string]string, error) { - var rows []struct { - ID string `db:"id"` - Query string `db:"query"` - } if host.FleetPlatform() == "" { // We log to help troubleshooting in case this happens, as the host // won't be receiving any policies targeted for specific platforms. level.Error(ds.logger).Log("err", "unrecognized platform", "hostID", host.ID, "platform", host.Platform) //nolint:errcheck } - q := dialect.From("policies").Select( - goqu.I("id"), - goqu.I("query"), - ).Where( - goqu.And( - goqu.Or( - goqu.I("platforms").Eq(""), - goqu.L("FIND_IN_SET(?, ?)", - host.FleetPlatform(), - goqu.I("platforms"), - ).Neq(0), - ), - goqu.Or( - goqu.I("team_id").IsNull(), // global policies - goqu.I("team_id").Eq(host.TeamID), // team policies - ), - ), - ) - sql, args, err := q.ToSQL() - if err != nil { - return nil, ctxerr.Wrap(ctx, err, "selecting policies sql build") + const stmt = ` + SELECT id, query + FROM policies + WHERE + -- team_id == NULL are global policies that apply to all hosts + -- team_id == 0 are policies that apply to hosts in "No team" + -- team_id > 0 are policies that apply to hosts in teams + (team_id IS NULL OR team_id = COALESCE(?, 0)) AND + (platforms = '' OR FIND_IN_SET(?, platforms))` + var rows []struct { + ID string `db:"id"` + Query string `db:"query"` } - if err := sqlx.SelectContext(ctx, ds.reader(ctx), &rows, sql, args...); err != nil { + if err := sqlx.SelectContext(ctx, ds.reader(ctx), &rows, stmt, host.TeamID, host.FleetPlatform()); err != nil { return nil, ctxerr.Wrap(ctx, err, "selecting policies for host") } results := make(map[string]string) @@ -607,6 +592,18 @@ func (ds *Datastore) NewTeamPolicy(ctx context.Context, teamID uint, authorID *u args.Query = q.Query args.Description = q.Description } + // Check team exists. + if teamID > 0 { + var ok bool + err := ds.writer(ctx).GetContext(ctx, &ok, `SELECT COUNT(*) = 1 FROM teams WHERE id = ?`, teamID) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "get team id") + } + if !ok { + return nil, ctxerr.Wrap(ctx, notFound("Team").WithID(teamID), "get team id") + } + + } // We must normalize the name for full Unicode support (Unicode equivalence). nameUnicode := norm.NFC.String(args.Name) res, err := ds.writer(ctx).ExecContext(ctx, @@ -659,7 +656,7 @@ func (ds *Datastore) ListMergedTeamPolicies(ctx context.Context, teamID uint, op FROM policies p LEFT JOIN users u ON p.author_id = u.id LEFT JOIN policy_stats ps ON p.id = ps.policy_id - AND ps.inherited_team_id = IF(p.team_id IS NULL, ?, 0) + AND (p.team_id IS NOT NULL OR ps.inherited_team_id = ?) WHERE (p.team_id = ? OR p.team_id IS NULL) ` @@ -699,9 +696,9 @@ func (ds *Datastore) ApplyPolicySpecs(ctx context.Context, authorID uint, specs queryerContext := ds.writer(ctx) // Preprocess specs and group them by team - teamNameToID := make(map[string]uint, 1) - teamIDToPolicies := make(map[uint][]*fleet.PolicySpec, 1) - softwareInstallerIDs := make(map[uint]map[uint]*uint) // teamID -> titleID -> softwareInstallerID + teamNameToID := make(map[string]*uint, 1) + teamIDToPolicies := make(map[*uint][]*fleet.PolicySpec, 1) + softwareInstallerIDs := make(map[*uint]map[uint]*uint) // teamID -> titleID -> softwareInstallerID // Get the team IDs for _, spec := range specs { @@ -711,13 +708,18 @@ func (ds *Datastore) ApplyPolicySpecs(ctx context.Context, authorID uint, specs teamID, ok := teamNameToID[spec.Team] if !ok { if spec.Team != "" { - // if team name is not empty, it must have a team ID; otherwise teamID defaults to 0 value - err := sqlx.GetContext(ctx, queryerContext, &teamID, `SELECT id FROM teams WHERE name = ?`, spec.Team) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return ctxerr.Wrap(ctx, notFound("Team").WithName(spec.Team), "get team id") + if spec.Team == "No team" { + teamID = ptr.Uint(0) + } else { + var tmID uint + err := sqlx.GetContext(ctx, queryerContext, &tmID, `SELECT id FROM teams WHERE name = ?`, spec.Team) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return ctxerr.Wrap(ctx, notFound("Team").WithName(spec.Team), "get team id") + } + return ctxerr.Wrap(ctx, err, "get team id") } - return ctxerr.Wrap(ctx, err, "get team id") + teamID = &tmID } } teamNameToID[spec.Team] = teamID @@ -755,7 +757,7 @@ func (ds *Datastore) ApplyPolicySpecs(ctx context.Context, authorID uint, specs Query string `db:"query"` Platforms string `db:"platforms"` } - teamIDToPoliciesByName := make(map[uint]map[string]policyLite, len(teamIDToPolicies)) + teamIDToPoliciesByName := make(map[*uint]map[string]policyLite, len(teamIDToPolicies)) for teamID, teamPolicySpecs := range teamIDToPolicies { teamIDToPoliciesByName[teamID] = make(map[string]policyLite, len(teamPolicySpecs)) policyNames := make([]string, 0, len(teamPolicySpecs)) @@ -766,11 +768,11 @@ func (ds *Datastore) ApplyPolicySpecs(ctx context.Context, authorID uint, specs var query string var args []interface{} var err error - if teamID == 0 { + if teamID == nil { query, args, err = sqlx.In("SELECT name, query, platforms FROM policies WHERE team_id IS NULL AND name IN (?)", policyNames) } else { query, args, err = sqlx.In( - "SELECT name, query, platforms FROM policies WHERE team_id = ? AND name IN (?)", &teamID, policyNames, + "SELECT name, query, platforms FROM policies WHERE team_id = ? AND name IN (?)", *teamID, policyNames, ) } if err != nil { @@ -814,10 +816,6 @@ func (ds *Datastore) ApplyPolicySpecs(ctx context.Context, authorID uint, specs `, policiesChecksumComputedColumn(), ) for teamID, teamPolicySpecs := range teamIDToPolicies { - var teamIDPtr *uint - if teamID != 0 { - teamIDPtr = &teamID - } for _, spec := range teamPolicySpecs { var softwareInstallerID *uint if spec.SoftwareTitleID != nil { @@ -825,7 +823,7 @@ func (ds *Datastore) ApplyPolicySpecs(ctx context.Context, authorID uint, specs } res, err := tx.ExecContext( ctx, - query, spec.Name, spec.Query, spec.Description, authorID, spec.Resolution, teamIDPtr, spec.Platform, spec.Critical, + query, spec.Name, spec.Query, spec.Description, authorID, spec.Resolution, teamID, spec.Platform, spec.Critical, spec.CalendarEventsEnabled, softwareInstallerID, ) if err != nil { @@ -1408,7 +1406,7 @@ func (ds *Datastore) UpdateHostPolicyCounts(ctx context.Context) error { WHERE p.team_id IS NULL AND p.id = ? GROUP BY t.id, p.id` err = sqlx.SelectContext(ctx, db, &policyStats, selectStmt, policy.ID) - if err != nil && !errors.Is(err, sql.ErrNoRows) { + if err != nil { if errors.Is(err, sql.ErrNoRows) { // Policy or team was deleted by a parallel process. We proceed. level.Error(ds.logger).Log( @@ -1418,6 +1416,38 @@ func (ds *Datastore) UpdateHostPolicyCounts(ctx context.Context) error { } return ctxerr.Wrap(ctx, err, "select policy counts for inherited global policies") } + + noTeamStmt := `SELECT + p.id as policy_id, + 0 AS inherited_team_id, -- 0 means "No team" + ( + SELECT COUNT(*) + FROM policy_membership pm + INNER JOIN hosts h ON pm.host_id = h.id + WHERE pm.policy_id = p.id AND pm.passes = true AND h.team_id IS NULL + ) AS passing_host_count, + ( + SELECT COUNT(*) + FROM policy_membership pm + INNER JOIN hosts h ON pm.host_id = h.id + WHERE pm.policy_id = p.id AND pm.passes = false AND h.team_id IS NULL + ) AS failing_host_count + FROM policies p + WHERE p.team_id IS NULL AND p.id = ?` + var noTeamPolicyStats []policyStat + err = sqlx.SelectContext(ctx, db, &noTeamPolicyStats, noTeamStmt, policy.ID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + // Policy was deleted by a parallel process. We proceed. + level.Error(ds.logger).Log( + "msg", "'No team' policy not found for inherited global policies. Was policy deleted?", "policy_id", policy.ID, + ) + continue + } + return ctxerr.Wrap(ctx, err, "select policy counts for inherited global policies for 'no team' policies") + } + policyStats = append(policyStats, noTeamPolicyStats...) + insertStmt := `INSERT INTO policy_stats (policy_id, inherited_team_id, passing_host_count, failing_host_count) VALUES (:policy_id, :inherited_team_id, :passing_host_count, :failing_host_count) ON DUPLICATE KEY UPDATE @@ -1441,7 +1471,7 @@ func (ds *Datastore) UpdateHostPolicyCounts(ctx context.Context) error { INSERT INTO policy_stats (policy_id, inherited_team_id, passing_host_count, failing_host_count) SELECT p.id, - 0 AS inherited_team_id, -- using 0 to represent global scope + NULL AS inherited_team_id, -- using NULL to represent global scope COALESCE(SUM(IF(pm.passes IS NULL, 0, pm.passes = 1)), 0), COALESCE(SUM(IF(pm.passes IS NULL, 0, pm.passes = 0)), 0) FROM policies p diff --git a/server/datastore/mysql/policies_test.go b/server/datastore/mysql/policies_test.go index aee58797ff..c800eeee1c 100644 --- a/server/datastore/mysql/policies_test.go +++ b/server/datastore/mysql/policies_test.go @@ -67,6 +67,7 @@ func TestPolicies(t *testing.T) { {"TestPoliciesNewGlobalPolicyWithInstaller", testNewGlobalPolicyWithInstaller}, {"TestPoliciesTeamPoliciesWithInstaller", testTeamPoliciesWithInstaller}, {"ApplyPolicySpecWithInstallers", testApplyPolicySpecWithInstallers}, + {"TeamPoliciesNoTeam", testTeamPoliciesNoTeam}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -1413,6 +1414,14 @@ func testApplyPolicySpec(t *testing.T, ds *Datastore) { Team: "team1", Platform: "windows,linux", }, + { + Name: "query4", + Query: "select 4;", + Description: "query4 desc", + Resolution: "some other good resolution 2", + Team: "No team", + Platform: "", + }, })) policies, err := ds.ListGlobalPolicies(ctx, fleet.ListOptions{}) @@ -1450,6 +1459,21 @@ func testApplyPolicySpec(t *testing.T, ds *Datastore) { assert.Equal(t, "windows,linux", teamPolicies[1].Platform) assert.False(t, teamPolicies[1].CalendarEventsEnabled) + noTeamPolicies, _, err := ds.ListTeamPolicies(ctx, fleet.PolicyNoTeamID, fleet.ListOptions{}, fleet.ListOptions{}) + require.NoError(t, err) + require.Len(t, noTeamPolicies, 1) + assert.Equal(t, "query4", noTeamPolicies[0].Name) + assert.Equal(t, "select 4;", noTeamPolicies[0].Query) + assert.Equal(t, "query4 desc", noTeamPolicies[0].Description) + require.NotNil(t, noTeamPolicies[0].AuthorID) + assert.Equal(t, user1.ID, *noTeamPolicies[0].AuthorID) + require.NotNil(t, noTeamPolicies[0].Resolution) + assert.Equal(t, "some other good resolution 2", *noTeamPolicies[0].Resolution) + assert.Equal(t, "", noTeamPolicies[0].Platform) + assert.False(t, noTeamPolicies[0].CalendarEventsEnabled) + assert.NotNil(t, noTeamPolicies[0].TeamID) + assert.Zero(t, *noTeamPolicies[0].TeamID) + // Make sure apply is idempotent require.NoError(t, ds.ApplyPolicySpecs(ctx, user1.ID, []*fleet.PolicySpec{ { @@ -1477,6 +1501,14 @@ func testApplyPolicySpec(t *testing.T, ds *Datastore) { Team: "team1", Platform: "windows,linux", }, + { + Name: "query4", + Query: "select 4;", + Description: "query4 desc", + Resolution: "some other good resolution 2", + Team: "No team", + Platform: "", + }, })) policies, err = ds.ListGlobalPolicies(ctx, fleet.ListOptions{}) @@ -1485,6 +1517,9 @@ func testApplyPolicySpec(t *testing.T, ds *Datastore) { teamPolicies, _, err = ds.ListTeamPolicies(ctx, team1.ID, fleet.ListOptions{}, fleet.ListOptions{}) require.NoError(t, err) require.Len(t, teamPolicies, 2) + noTeamPolicies, _, err = ds.ListTeamPolicies(ctx, fleet.PolicyNoTeamID, fleet.ListOptions{}, fleet.ListOptions{}) + require.NoError(t, err) + require.Len(t, noTeamPolicies, 1) // Test policy updating. require.NoError(t, ds.ApplyPolicySpecs(ctx, user1.ID, []*fleet.PolicySpec{ @@ -3964,7 +3999,19 @@ func testTeamPoliciesWithInstaller(t *testing.T, ds *Datastore) { require.NotNil(t, p2.SoftwareInstallerID) require.Equal(t, installerID, *p2.SoftwareInstallerID) - policiesWithInstallers, err := ds.GetPoliciesWithAssociatedInstaller(ctx, team1.ID, []uint{}) + // Policy p4 in "No team" with associated installer. + p4, err := ds.NewTeamPolicy(ctx, fleet.PolicyNoTeamID, &user1.ID, fleet.PolicyPayload{ + Name: "p4", + Query: "SELECT 4;", + SoftwareInstallerID: ptr.Uint(installerID), + }) + require.NoError(t, err) + policiesWithInstallers, err := ds.GetPoliciesWithAssociatedInstaller(ctx, fleet.PolicyNoTeamID, []uint{p4.ID}) + require.NoError(t, err) + require.Len(t, policiesWithInstallers, 1) + require.Equal(t, p4.ID, policiesWithInstallers[0].ID) + + policiesWithInstallers, err = ds.GetPoliciesWithAssociatedInstaller(ctx, team1.ID, []uint{}) require.NoError(t, err) require.Empty(t, policiesWithInstallers) @@ -4014,6 +4061,7 @@ func testApplyPolicySpecWithInstallers(t *testing.T, ds *Datastore) { require.NoError(t, err) team2, err := ds.NewTeam(ctx, &fleet.Team{Name: "team2"}) require.NoError(t, err) + installer1ID, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ InstallScript: "hello", PreInstallQuery: "SELECT 1;", @@ -4048,6 +4096,23 @@ func testApplyPolicySpecWithInstallers(t *testing.T, ds *Datastore) { installer2, err := ds.GetSoftwareInstallerMetadataByID(ctx, installer2ID) require.NoError(t, err) require.NotNil(t, installer2.TitleID) + installer3ID, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "hello3", + PreInstallQuery: "SELECT 3;", + PostInstallScript: "world3", + InstallerFile: bytes.NewReader([]byte("hello3")), + StorageID: "storage3", + Filename: "file3", + Title: "file3", + Version: "1.0", + Source: "rpm_packages", + UserID: user1.ID, + TeamID: nil, + }) + require.NoError(t, err) + installer3, err := ds.GetSoftwareInstallerMetadataByID(ctx, installer3ID) + require.NoError(t, err) + require.NotNil(t, installer3.TitleID) // Installers cannot be assigned to global policies. err = ds.ApplyPolicySpecs(ctx, user1.ID, []*fleet.PolicySpec{ @@ -4064,7 +4129,7 @@ func testApplyPolicySpecWithInstallers(t *testing.T, ds *Datastore) { require.Error(t, err) require.ErrorIs(t, err, errSoftwareTitleIDOnGlobalPolicy) - // Apply two team policies associated to two installers. + // Apply two team policies associated to two installers and a "No team" policy associated to an installer. err = ds.ApplyPolicySpecs(ctx, user1.ID, []*fleet.PolicySpec{ { Name: "Team policy 1", @@ -4084,6 +4149,15 @@ func testApplyPolicySpecWithInstallers(t *testing.T, ds *Datastore) { Platform: "linux", SoftwareTitleID: installer2.TitleID, }, + { + Name: "No team policy 3", + Query: "SELECT 3;", + Description: "Description 3", + Resolution: "Resolution 3", + Team: "No team", + Platform: "linux", + SoftwareTitleID: installer3.TitleID, + }, }) require.NoError(t, err) team1Policies, _, err := ds.ListTeamPolicies(ctx, team1.ID, fleet.ListOptions{}, fleet.ListOptions{}) @@ -4096,6 +4170,11 @@ func testApplyPolicySpecWithInstallers(t *testing.T, ds *Datastore) { require.Len(t, team2Policies, 1) require.NotNil(t, team2Policies[0].SoftwareInstallerID) require.Equal(t, installer2.InstallerID, *team2Policies[0].SoftwareInstallerID) + noTeamPolicies, _, err := ds.ListTeamPolicies(ctx, fleet.PolicyNoTeamID, fleet.ListOptions{}, fleet.ListOptions{}) + require.NoError(t, err) + require.Len(t, noTeamPolicies, 1) + require.NotNil(t, noTeamPolicies[0].SoftwareInstallerID) + require.Equal(t, installer3.InstallerID, *noTeamPolicies[0].SoftwareInstallerID) // Unset software installer from "Team policy 1". err = ds.ApplyPolicySpecs(ctx, user1.ID, []*fleet.PolicySpec{ @@ -4115,7 +4194,7 @@ func testApplyPolicySpecWithInstallers(t *testing.T, ds *Datastore) { require.Len(t, team1Policies, 1) require.Nil(t, team1Policies[0].SoftwareInstallerID) - // Set software installer "Team policy 1" to a software installer on team2. + // Set "Team policy 1" to a software installer on team2. err = ds.ApplyPolicySpecs(ctx, user1.ID, []*fleet.PolicySpec{ { Name: "Team policy 1", @@ -4131,7 +4210,22 @@ func testApplyPolicySpecWithInstallers(t *testing.T, ds *Datastore) { var notFoundErr *notFoundError require.ErrorAs(t, err, ¬FoundErr) - // Set software installer "Team policy 1" to a software title that doesn't exist. + // Set "No team policy 3" to a software installer on team2. + err = ds.ApplyPolicySpecs(ctx, user1.ID, []*fleet.PolicySpec{ + { + Name: "No team policy 3", + Query: "SELECT 3;", + Description: "Description 3", + Resolution: "Resolution 3", + Team: "No team", + Platform: "darwin", + SoftwareTitleID: installer2.TitleID, + }, + }) + require.Error(t, err) + require.ErrorAs(t, err, ¬FoundErr) + + // Set "Team policy 1" to a software title that doesn't exist. err = ds.ApplyPolicySpecs(ctx, user1.ID, []*fleet.PolicySpec{ { Name: "Team policy 1", @@ -4146,6 +4240,21 @@ func testApplyPolicySpecWithInstallers(t *testing.T, ds *Datastore) { require.Error(t, err) require.ErrorAs(t, err, ¬FoundErr) + // Set "No team policy 3" to a software title that doesn't exist. + err = ds.ApplyPolicySpecs(ctx, user1.ID, []*fleet.PolicySpec{ + { + Name: "No team policy 3", + Query: "SELECT 3;", + Description: "Description 3", + Resolution: "Resolution 3", + Team: "No team", + Platform: "darwin", + SoftwareTitleID: ptr.Uint(999_999), + }, + }) + require.Error(t, err) + require.ErrorAs(t, err, ¬FoundErr) + // Unset software installer from "Team policy 2" using 0. err = ds.ApplyPolicySpecs(ctx, user1.ID, []*fleet.PolicySpec{ { @@ -4165,7 +4274,7 @@ func testApplyPolicySpecWithInstallers(t *testing.T, ds *Datastore) { require.Nil(t, team2Policies[0].SoftwareInstallerID) // Apply team policies associated to two installers (again, with two installers with the same title). - installer3ID, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ + installer4ID, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ InstallScript: "hello3", PreInstallQuery: "SELECT 3;", PostInstallScript: "world3", @@ -4179,7 +4288,7 @@ func testApplyPolicySpecWithInstallers(t *testing.T, ds *Datastore) { TeamID: &team2.ID, }) require.NoError(t, err) - installer3, err := ds.GetSoftwareInstallerMetadataByID(ctx, installer3ID) + installer4, err := ds.GetSoftwareInstallerMetadataByID(ctx, installer4ID) require.NoError(t, err) require.NotNil(t, installer2.TitleID) err = ds.ApplyPolicySpecs(ctx, user1.ID, []*fleet.PolicySpec{ @@ -4199,7 +4308,7 @@ func testApplyPolicySpecWithInstallers(t *testing.T, ds *Datastore) { Resolution: "Resolution 2", Team: "team2", Platform: "linux", - SoftwareTitleID: installer3.TitleID, + SoftwareTitleID: installer4.TitleID, }, }) require.NoError(t, err) @@ -4212,5 +4321,392 @@ func testApplyPolicySpecWithInstallers(t *testing.T, ds *Datastore) { require.NoError(t, err) require.Len(t, team2Policies, 1) require.NotNil(t, team2Policies[0].SoftwareInstallerID) - require.Equal(t, installer3.InstallerID, *team2Policies[0].SoftwareInstallerID) + require.Equal(t, installer4.InstallerID, *team2Policies[0].SoftwareInstallerID) +} + +func testTeamPoliciesNoTeam(t *testing.T, ds *Datastore) { + ctx := context.Background() + + user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true) + team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1"}) + require.NoError(t, err) + team2, err := ds.NewTeam(ctx, &fleet.Team{Name: "team2"}) + require.NoError(t, err) + + newHost := func(name string, teamID *uint, platform string) *fleet.Host { + h, err := ds.NewHost(ctx, &fleet.Host{ + OsqueryHostID: ptr.String(uuid.New().String()), + DetailUpdatedAt: time.Now(), + LabelUpdatedAt: time.Now(), + PolicyUpdatedAt: time.Now(), + SeenTime: time.Now(), + NodeKey: ptr.String(uuid.New().String()), + UUID: uuid.New().String(), + Hostname: name, + TeamID: teamID, + Platform: platform, + }) + require.NoError(t, err) + return h + } + + host0NoTeam := newHost("host0NoTeam", nil, "darwin") + host1Team1 := newHost("host1Team1", &team1.ID, "darwin") + host2Team1 := newHost("host2Team1", &team1.ID, "linux") + host3Team2 := newHost("host1Team1", &team2.ID, "windows") + host5NoTeam := newHost("host5NoTeam", nil, "windows") + + policy0NoTeam, err := ds.NewTeamPolicy(ctx, fleet.PolicyNoTeamID, &user1.ID, fleet.PolicyPayload{ + Name: "policy0NoTeam", + Query: "SELECT 0;", + }) + require.NoError(t, err) + require.NotNil(t, policy0NoTeam.TeamID) + require.Equal(t, fleet.PolicyNoTeamID, *policy0NoTeam.TeamID) + tp, err := ds.TeamPolicy(ctx, fleet.PolicyNoTeamID, policy0NoTeam.ID) + require.NoError(t, err) + require.Equal(t, tp, policy0NoTeam) + + policy1Team1, err := ds.NewTeamPolicy(ctx, team1.ID, &user1.ID, fleet.PolicyPayload{ + Name: "policy1Team1", + Query: "SELECT 1;", + }) + require.NoError(t, err) + policy2Team2, err := ds.NewTeamPolicy(ctx, team2.ID, &user1.ID, fleet.PolicyPayload{ + Name: "policy2Team2", + Query: "SELECT 2;", + }) + require.NoError(t, err) + policy3NoTeam, err := ds.NewTeamPolicy(ctx, fleet.PolicyNoTeamID, &user1.ID, fleet.PolicyPayload{ + Name: "policy3NoTeam", + Query: "SELECT 3;", + }) + require.NoError(t, err) + policy4Team2, err := ds.NewTeamPolicy(ctx, team2.ID, &user1.ID, fleet.PolicyPayload{ + Name: "policy4Team2", + Query: "SELECT 4;", + }) + require.NoError(t, err) + + globalPolicy1, err := ds.NewGlobalPolicy(ctx, &user1.ID, fleet.PolicyPayload{ + Name: "globalPolicy1", + Query: "SELECT gp1;", + }) + require.NoError(t, err) + globalPolicy2, err := ds.NewGlobalPolicy(ctx, &user1.ID, fleet.PolicyPayload{ + Name: "globalPolicy2", + Query: "SELECT gp2;", + }) + require.NoError(t, err) + + // Results for host0NoTeam + err = ds.RecordPolicyQueryExecutions(ctx, host0NoTeam, map[uint]*bool{ + globalPolicy1.ID: ptr.Bool(false), + globalPolicy2.ID: ptr.Bool(false), + policy0NoTeam.ID: ptr.Bool(true), + policy3NoTeam.ID: ptr.Bool(false), + }, time.Now(), false) + require.NoError(t, err) + + // Results for host1Team1 + err = ds.RecordPolicyQueryExecutions(ctx, host1Team1, map[uint]*bool{ + globalPolicy1.ID: ptr.Bool(true), + globalPolicy2.ID: nil, // failed to execute, e.g. typo on SQL. + policy1Team1.ID: ptr.Bool(true), + }, time.Now(), false) + require.NoError(t, err) + + // Results for host2Team1 + err = ds.RecordPolicyQueryExecutions(ctx, host2Team1, map[uint]*bool{ + globalPolicy1.ID: ptr.Bool(false), + globalPolicy2.ID: ptr.Bool(true), + policy1Team1.ID: ptr.Bool(false), + }, time.Now(), false) + require.NoError(t, err) + + // Results for host3Team2 + err = ds.RecordPolicyQueryExecutions(ctx, host3Team2, map[uint]*bool{ + globalPolicy1.ID: ptr.Bool(true), + policy2Team2.ID: ptr.Bool(true), + policy4Team2.ID: ptr.Bool(false), + }, time.Now(), false) + require.NoError(t, err) + + // Results for host5NoTeam + err = ds.RecordPolicyQueryExecutions(ctx, host5NoTeam, map[uint]*bool{ + globalPolicy1.ID: ptr.Bool(true), + globalPolicy2.ID: ptr.Bool(false), + policy0NoTeam.ID: ptr.Bool(false), + policy3NoTeam.ID: ptr.Bool(false), + }, time.Now(), false) + require.NoError(t, err) + + err = ds.UpdateHostPolicyCounts(ctx) + require.NoError(t, err) + + // Tests on global domain. + globalPolicies, err := ds.ListGlobalPolicies(ctx, fleet.ListOptions{}) + require.NoError(t, err) + require.Len(t, globalPolicies, 2) + require.Equal(t, globalPolicy1.ID, globalPolicies[0].ID) + require.Equal(t, uint(2), globalPolicies[0].FailingHostCount) + require.Equal(t, uint(3), globalPolicies[0].PassingHostCount) + require.Equal(t, globalPolicy2.ID, globalPolicies[1].ID) + require.Equal(t, uint(2), globalPolicies[1].FailingHostCount) + require.Equal(t, uint(1), globalPolicies[1].PassingHostCount) + ids := make([]uint, 0, len(globalPolicies)) + for _, globalPolicy := range globalPolicies { + p, err := ds.Policy(ctx, globalPolicy.ID) + require.NoError(t, err) + require.Equal(t, p, globalPolicy) + ids = append(ids, globalPolicy.ID) + } + c, err := ds.CountPolicies(ctx, nil, "") + require.NoError(t, err) + require.Equal(t, 2, c) + globalPoliciesByID, err := ds.PoliciesByID(ctx, ids) + require.NoError(t, err) + require.Len(t, globalPoliciesByID, 2) + require.Equal(t, globalPoliciesByID[globalPolicies[0].ID], globalPolicies[0]) + require.Equal(t, globalPoliciesByID[globalPolicies[1].ID], globalPolicies[1]) + + // Tests on team1 domain. + teamPolicies, inheritedPolicies, err := ds.ListTeamPolicies(ctx, team1.ID, fleet.ListOptions{}, fleet.ListOptions{}) + require.NoError(t, err) + require.Len(t, teamPolicies, 1) + require.Equal(t, policy1Team1.ID, teamPolicies[0].ID) + require.Equal(t, uint(1), teamPolicies[0].FailingHostCount) + require.Equal(t, uint(1), teamPolicies[0].PassingHostCount) + require.Len(t, inheritedPolicies, 2) + require.Equal(t, globalPolicy1.ID, inheritedPolicies[0].ID) + require.Equal(t, uint(1), inheritedPolicies[0].FailingHostCount) + require.Equal(t, uint(1), inheritedPolicies[0].PassingHostCount) + require.Equal(t, globalPolicy2.ID, inheritedPolicies[1].ID) + require.Equal(t, uint(0), inheritedPolicies[1].FailingHostCount) + require.Equal(t, uint(1), inheritedPolicies[1].PassingHostCount) + ids = make([]uint, 0, len(teamPolicies)) + for _, teamPolicy := range teamPolicies { + p, err := ds.Policy(ctx, teamPolicy.ID) + require.NoError(t, err) + require.Equal(t, p, teamPolicy) + ids = append(ids, teamPolicy.ID) + } + teamPoliciesByID, err := ds.PoliciesByID(ctx, ids) + require.NoError(t, err) + require.Len(t, teamPoliciesByID, 1) + require.Equal(t, teamPoliciesByID[teamPolicies[0].ID], teamPolicies[0]) + c, err = ds.CountMergedTeamPolicies(ctx, team1.ID, "") + require.NoError(t, err) + require.Equal(t, 3, c) + c, err = ds.CountPolicies(ctx, &team1.ID, "") + require.NoError(t, err) + require.Equal(t, 1, c) + mergedTeamPolicies, err := ds.ListMergedTeamPolicies(ctx, team1.ID, fleet.ListOptions{}) + require.NoError(t, err) + require.Len(t, mergedTeamPolicies, 3) + require.Equal(t, policy1Team1.ID, mergedTeamPolicies[0].ID) + require.Equal(t, uint(1), mergedTeamPolicies[0].FailingHostCount) + require.Equal(t, uint(1), mergedTeamPolicies[0].PassingHostCount) + require.Equal(t, globalPolicy1.ID, mergedTeamPolicies[1].ID) + require.Equal(t, uint(1), mergedTeamPolicies[1].FailingHostCount) + require.Equal(t, uint(1), mergedTeamPolicies[1].PassingHostCount) + require.Equal(t, globalPolicy2.ID, mergedTeamPolicies[2].ID) + require.Equal(t, uint(0), mergedTeamPolicies[2].FailingHostCount) + require.Equal(t, uint(1), mergedTeamPolicies[2].PassingHostCount) + + // Tests on team2 domain. + teamPolicies, inheritedPolicies, err = ds.ListTeamPolicies(ctx, team2.ID, fleet.ListOptions{}, fleet.ListOptions{}) + require.NoError(t, err) + require.Len(t, teamPolicies, 2) + require.Equal(t, policy2Team2.ID, teamPolicies[0].ID) + require.Equal(t, uint(0), teamPolicies[0].FailingHostCount) + require.Equal(t, uint(1), teamPolicies[0].PassingHostCount) + require.Equal(t, policy4Team2.ID, teamPolicies[1].ID) + require.Equal(t, uint(1), teamPolicies[1].FailingHostCount) + require.Equal(t, uint(0), teamPolicies[1].PassingHostCount) + require.Len(t, inheritedPolicies, 2) + require.Equal(t, globalPolicy1.ID, inheritedPolicies[0].ID) + require.Equal(t, uint(0), inheritedPolicies[0].FailingHostCount) + require.Equal(t, uint(1), inheritedPolicies[0].PassingHostCount) + require.Equal(t, globalPolicy2.ID, inheritedPolicies[1].ID) + require.Equal(t, uint(0), inheritedPolicies[1].FailingHostCount) + require.Equal(t, uint(0), inheritedPolicies[1].PassingHostCount) + ids = make([]uint, 0, len(teamPolicies)) + for _, teamPolicy := range teamPolicies { + p, err := ds.Policy(ctx, teamPolicy.ID) + require.NoError(t, err) + require.Equal(t, p, teamPolicy) + ids = append(ids, teamPolicy.ID) + } + teamPoliciesByID, err = ds.PoliciesByID(ctx, ids) + require.NoError(t, err) + require.Len(t, teamPoliciesByID, 2) + require.Equal(t, teamPoliciesByID[teamPolicies[0].ID], teamPolicies[0]) + require.Equal(t, teamPoliciesByID[teamPolicies[1].ID], teamPolicies[1]) + c, err = ds.CountMergedTeamPolicies(ctx, team2.ID, "") + require.NoError(t, err) + require.Equal(t, 4, c) + c, err = ds.CountPolicies(ctx, &team2.ID, "") + require.NoError(t, err) + require.Equal(t, 2, c) + mergedTeamPolicies, err = ds.ListMergedTeamPolicies(ctx, team2.ID, fleet.ListOptions{}) + require.NoError(t, err) + require.Len(t, mergedTeamPolicies, 4) + require.Equal(t, policy2Team2.ID, mergedTeamPolicies[0].ID) + require.Equal(t, uint(0), mergedTeamPolicies[0].FailingHostCount) + require.Equal(t, uint(1), mergedTeamPolicies[0].PassingHostCount) + require.Equal(t, policy4Team2.ID, mergedTeamPolicies[1].ID) + require.Equal(t, uint(1), mergedTeamPolicies[1].FailingHostCount) + require.Equal(t, uint(0), mergedTeamPolicies[1].PassingHostCount) + require.Equal(t, globalPolicy1.ID, mergedTeamPolicies[2].ID) + require.Equal(t, uint(0), mergedTeamPolicies[2].FailingHostCount) + require.Equal(t, uint(1), mergedTeamPolicies[2].PassingHostCount) + require.Equal(t, globalPolicy2.ID, mergedTeamPolicies[3].ID) + require.Equal(t, uint(0), mergedTeamPolicies[3].FailingHostCount) + require.Equal(t, uint(0), mergedTeamPolicies[3].PassingHostCount) + + // Tests on "No team" domain. + teamPolicies, inheritedPolicies, err = ds.ListTeamPolicies(ctx, fleet.PolicyNoTeamID, fleet.ListOptions{}, fleet.ListOptions{}) + require.NoError(t, err) + require.Len(t, teamPolicies, 2) + require.Equal(t, policy0NoTeam.ID, teamPolicies[0].ID) + require.Equal(t, uint(1), teamPolicies[0].FailingHostCount) + require.Equal(t, uint(1), teamPolicies[0].PassingHostCount) + require.Equal(t, policy3NoTeam.ID, teamPolicies[1].ID) + require.Equal(t, uint(2), teamPolicies[1].FailingHostCount) + require.Equal(t, uint(0), teamPolicies[1].PassingHostCount) + require.Len(t, inheritedPolicies, 2) + require.Equal(t, globalPolicy1.ID, inheritedPolicies[0].ID) + require.Equal(t, uint(1), inheritedPolicies[0].FailingHostCount) + require.Equal(t, uint(1), inheritedPolicies[0].PassingHostCount) + require.Equal(t, globalPolicy2.ID, inheritedPolicies[1].ID) + require.Equal(t, uint(2), inheritedPolicies[1].FailingHostCount) + require.Equal(t, uint(0), inheritedPolicies[1].PassingHostCount) + ids = make([]uint, 0, len(teamPolicies)) + for _, teamPolicy := range teamPolicies { + p, err := ds.Policy(ctx, teamPolicy.ID) + require.NoError(t, err) + require.Equal(t, p, teamPolicy) + ids = append(ids, teamPolicy.ID) + } + teamPoliciesByID, err = ds.PoliciesByID(ctx, ids) + require.NoError(t, err) + require.Len(t, teamPoliciesByID, 2) + require.Equal(t, teamPoliciesByID[teamPolicies[0].ID], teamPolicies[0]) + require.Equal(t, teamPoliciesByID[teamPolicies[1].ID], teamPolicies[1]) + c, err = ds.CountMergedTeamPolicies(ctx, fleet.PolicyNoTeamID, "") + require.NoError(t, err) + require.Equal(t, 4, c) + c, err = ds.CountPolicies(ctx, ptr.Uint(fleet.PolicyNoTeamID), "") + require.NoError(t, err) + require.Equal(t, 2, c) + mergedTeamPolicies, err = ds.ListMergedTeamPolicies(ctx, fleet.PolicyNoTeamID, fleet.ListOptions{}) + require.NoError(t, err) + require.Len(t, mergedTeamPolicies, 4) + require.Equal(t, policy0NoTeam.ID, mergedTeamPolicies[0].ID) + require.Equal(t, uint(1), mergedTeamPolicies[0].FailingHostCount) + require.Equal(t, uint(1), mergedTeamPolicies[0].PassingHostCount) + require.Equal(t, policy3NoTeam.ID, mergedTeamPolicies[1].ID) + require.Equal(t, uint(2), mergedTeamPolicies[1].FailingHostCount) + require.Equal(t, uint(0), mergedTeamPolicies[1].PassingHostCount) + require.Equal(t, globalPolicy1.ID, mergedTeamPolicies[2].ID) + require.Equal(t, uint(1), mergedTeamPolicies[2].FailingHostCount) + require.Equal(t, uint(1), mergedTeamPolicies[2].PassingHostCount) + require.Equal(t, globalPolicy2.ID, mergedTeamPolicies[3].ID) + require.Equal(t, uint(2), mergedTeamPolicies[3].FailingHostCount) + require.Equal(t, uint(0), mergedTeamPolicies[3].PassingHostCount) + + // Test ListPoliciesForHost and PolicyQueriesForHost for host0NoTeam. + host0Policies, err := ds.ListPoliciesForHost(ctx, host0NoTeam) + require.NoError(t, err) + require.Len(t, host0Policies, 4) + require.Equal(t, globalPolicy1.ID, host0Policies[0].ID) + require.Equal(t, "fail", host0Policies[0].Response) + require.Equal(t, globalPolicy2.ID, host0Policies[1].ID) + require.Equal(t, "fail", host0Policies[1].Response) + require.Equal(t, policy3NoTeam.ID, host0Policies[2].ID) + require.Equal(t, "fail", host0Policies[2].Response) + require.Equal(t, policy0NoTeam.ID, host0Policies[3].ID) + require.Equal(t, "pass", host0Policies[3].Response) + host0PolicyQueries, err := ds.PolicyQueriesForHost(ctx, host0NoTeam) + require.NoError(t, err) + require.Len(t, host0PolicyQueries, 4) + require.Equal(t, "SELECT gp1;", host0PolicyQueries[strconv.FormatUint(uint64(globalPolicy1.ID), 10)]) + require.Equal(t, "SELECT gp2;", host0PolicyQueries[strconv.FormatUint(uint64(globalPolicy2.ID), 10)]) + require.Equal(t, "SELECT 0;", host0PolicyQueries[strconv.FormatUint(uint64(policy0NoTeam.ID), 10)]) + require.Equal(t, "SELECT 3;", host0PolicyQueries[strconv.FormatUint(uint64(policy3NoTeam.ID), 10)]) + + // Test ListPoliciesForHost and PolicyQueriesForHost for host1Team1. + host1Policies, err := ds.ListPoliciesForHost(ctx, host1Team1) + require.NoError(t, err) + require.Len(t, host1Policies, 3) + require.Equal(t, globalPolicy2.ID, host1Policies[0].ID) + require.Equal(t, "", host1Policies[0].Response) + require.Equal(t, globalPolicy1.ID, host1Policies[1].ID) + require.Equal(t, "pass", host1Policies[1].Response) + require.Equal(t, policy1Team1.ID, host1Policies[2].ID) + require.Equal(t, "pass", host1Policies[2].Response) + host1PolicyQueries, err := ds.PolicyQueriesForHost(ctx, host1Team1) + require.NoError(t, err) + require.Len(t, host1PolicyQueries, 3) + require.Equal(t, "SELECT gp1;", host1PolicyQueries[strconv.FormatUint(uint64(globalPolicy1.ID), 10)]) + require.Equal(t, "SELECT gp2;", host1PolicyQueries[strconv.FormatUint(uint64(globalPolicy2.ID), 10)]) + require.Equal(t, "SELECT 1;", host1PolicyQueries[strconv.FormatUint(uint64(policy1Team1.ID), 10)]) + + // Test ListPoliciesForHost and PolicyQueriesForHost for host2Team1. + host2Policies, err := ds.ListPoliciesForHost(ctx, host2Team1) + require.NoError(t, err) + require.Len(t, host2Policies, 3) + require.Equal(t, globalPolicy1.ID, host2Policies[0].ID) + require.Equal(t, "fail", host2Policies[0].Response) + require.Equal(t, policy1Team1.ID, host2Policies[1].ID) + require.Equal(t, "fail", host2Policies[1].Response) + require.Equal(t, globalPolicy2.ID, host2Policies[2].ID) + require.Equal(t, "pass", host2Policies[2].Response) + host2PolicyQueries, err := ds.PolicyQueriesForHost(ctx, host2Team1) + require.NoError(t, err) + require.Len(t, host2PolicyQueries, 3) + require.Equal(t, "SELECT gp1;", host2PolicyQueries[strconv.FormatUint(uint64(globalPolicy1.ID), 10)]) + require.Equal(t, "SELECT gp2;", host2PolicyQueries[strconv.FormatUint(uint64(globalPolicy2.ID), 10)]) + require.Equal(t, "SELECT 1;", host2PolicyQueries[strconv.FormatUint(uint64(policy1Team1.ID), 10)]) + + // Test ListPoliciesForHost and PolicyQueriesForHost for host3Team2. + host3Policies, err := ds.ListPoliciesForHost(ctx, host3Team2) + require.NoError(t, err) + require.Len(t, host3Policies, 4) + require.Equal(t, policy4Team2.ID, host3Policies[0].ID) + require.Equal(t, "fail", host3Policies[0].Response) + require.Equal(t, globalPolicy2.ID, host3Policies[1].ID) + require.Equal(t, "", host3Policies[1].Response) + require.Equal(t, globalPolicy1.ID, host3Policies[2].ID) + require.Equal(t, "pass", host3Policies[2].Response) + require.Equal(t, policy2Team2.ID, host3Policies[3].ID) + require.Equal(t, "pass", host3Policies[3].Response) + host3PolicyQueries, err := ds.PolicyQueriesForHost(ctx, host3Team2) + require.NoError(t, err) + require.Len(t, host3PolicyQueries, 4) + require.Equal(t, "SELECT gp1;", host3PolicyQueries[strconv.FormatUint(uint64(globalPolicy1.ID), 10)]) + require.Equal(t, "SELECT gp2;", host3PolicyQueries[strconv.FormatUint(uint64(globalPolicy2.ID), 10)]) + require.Equal(t, "SELECT 2;", host3PolicyQueries[strconv.FormatUint(uint64(policy2Team2.ID), 10)]) + require.Equal(t, "SELECT 4;", host3PolicyQueries[strconv.FormatUint(uint64(policy4Team2.ID), 10)]) + + // Test ListPoliciesForHost and PolicyQueriesForHost for host5NoTeam. + host5Policies, err := ds.ListPoliciesForHost(ctx, host5NoTeam) + require.NoError(t, err) + require.Len(t, host5Policies, 4) + require.Equal(t, globalPolicy2.ID, host5Policies[0].ID) + require.Equal(t, "fail", host5Policies[0].Response) + require.Equal(t, policy0NoTeam.ID, host5Policies[1].ID) + require.Equal(t, "fail", host5Policies[1].Response) + require.Equal(t, policy3NoTeam.ID, host5Policies[2].ID) + require.Equal(t, "fail", host5Policies[2].Response) + require.Equal(t, globalPolicy1.ID, host5Policies[3].ID) + require.Equal(t, "pass", host5Policies[3].Response) + host5PolicyQueries, err := ds.PolicyQueriesForHost(ctx, host5NoTeam) + require.NoError(t, err) + require.Len(t, host5PolicyQueries, 4) + require.Equal(t, "SELECT gp1;", host5PolicyQueries[strconv.FormatUint(uint64(globalPolicy1.ID), 10)]) + require.Equal(t, "SELECT gp2;", host5PolicyQueries[strconv.FormatUint(uint64(globalPolicy2.ID), 10)]) + require.Equal(t, "SELECT 0;", host5PolicyQueries[strconv.FormatUint(uint64(policy0NoTeam.ID), 10)]) + require.Equal(t, "SELECT 3;", host5PolicyQueries[strconv.FormatUint(uint64(policy3NoTeam.ID), 10)]) } diff --git a/server/datastore/mysql/schema.sql b/server/datastore/mysql/schema.sql index d30b3f12ef..290ecaf577 100644 --- a/server/datastore/mysql/schema.sql +++ b/server/datastore/mysql/schema.sql @@ -1038,9 +1038,9 @@ CREATE TABLE `migration_status_tables` ( `tstamp` timestamp NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `id` (`id`) -) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB AUTO_INCREMENT=312 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB AUTO_INCREMENT=313 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'); +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'); /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `mobile_device_management_solutions` ( @@ -1394,7 +1394,6 @@ CREATE TABLE `policies` ( KEY `idx_policies_author_id` (`author_id`), KEY `idx_policies_team_id` (`team_id`), KEY `fk_policies_software_installer_id` (`software_installer_id`), - CONSTRAINT `policies_ibfk_2` FOREIGN KEY (`team_id`) REFERENCES `teams` (`id`) ON DELETE CASCADE ON UPDATE CASCADE, CONSTRAINT `policies_ibfk_3` FOREIGN KEY (`software_installer_id`) REFERENCES `software_installers` (`id`), CONSTRAINT `policies_queries_ibfk_1` FOREIGN KEY (`author_id`) REFERENCES `users` (`id`) ON DELETE SET NULL ) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; @@ -1429,15 +1428,16 @@ CREATE TABLE `policy_membership` ( CREATE TABLE `policy_stats` ( `id` int unsigned NOT NULL AUTO_INCREMENT, `policy_id` int unsigned NOT NULL, - `inherited_team_id` int unsigned NOT NULL DEFAULT '0', + `inherited_team_id` int unsigned DEFAULT NULL, `passing_host_count` mediumint unsigned NOT NULL DEFAULT '0', `failing_host_count` mediumint unsigned NOT NULL DEFAULT '0', `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `inherited_team_id_char` char(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci GENERATED ALWAYS AS (if((`inherited_team_id` is null),_utf8mb4'global',cast(`inherited_team_id` as char charset utf8mb4))) VIRTUAL, PRIMARY KEY (`id`), - UNIQUE KEY `policy_team_unique` (`policy_id`,`inherited_team_id`), + UNIQUE KEY `policy_id` (`policy_id`,`inherited_team_id_char`), CONSTRAINT `policy_stats_ibfk_1` FOREIGN KEY (`policy_id`) REFERENCES `policies` (`id`) ON DELETE CASCADE -) 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/vpp.go b/server/datastore/mysql/vpp.go index d40b41be81..1127497f88 100644 --- a/server/datastore/mysql/vpp.go +++ b/server/datastore/mysql/vpp.go @@ -191,9 +191,12 @@ func (ds *Datastore) SetTeamVPPApps(ctx context.Context, teamID *uint, appFleets } } - vppToken, err := ds.GetVPPTokenByTeamID(ctx, teamID) - if err != nil { - return ctxerr.Wrap(ctx, err, "SetTeamVPPApps retrieve VPP token ID") + var vppToken *fleet.VPPTokenDB + if len(appFleets) > 0 { + vppToken, err = ds.GetVPPTokenByTeamID(ctx, teamID) + if err != nil { + return ctxerr.Wrap(ctx, err, "SetTeamVPPApps retrieve VPP token ID") + } } return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { @@ -858,7 +861,6 @@ func (ds *Datastore) UpdateVPPTokenTeams(ctx context.Context, id uint, teams []u return nil }) - if err != nil { var mysqlErr *mysql.MySQLError // https://dev.mysql.com/doc/mysql-errors/8.4/en/server-error-reference.html#error_er_dup_entry diff --git a/server/fleet/policies.go b/server/fleet/policies.go index a66ccc00a4..53849a6227 100644 --- a/server/fleet/policies.go +++ b/server/fleet/policies.go @@ -77,6 +77,9 @@ var ( errPolicyInvalidPlatform = errors.New("invalid policy platform") ) +// PolicyNoTeamID is the team ID of "No team" policies. +const PolicyNoTeamID = uint(0) + // Verify verifies the policy payload is valid. func (p PolicyPayload) Verify() error { if p.QueryID != nil { diff --git a/server/service/client.go b/server/service/client.go index 9840fb6586..8d40d6d939 100644 --- a/server/service/client.go +++ b/server/service/client.go @@ -687,7 +687,7 @@ func (c *Client) ApplyGroup( for tmName, software := range tmSoftwarePackagesPayloads { // For non-dry run, currentTeamName and tmName are the same currentTeamName := getTeamName(tmName) - logfn("[+] applying software installers for team %s\n", tmName) + logfn("[+] applying %d software packages for team %s\n", len(software), tmName) installers, err := c.ApplyTeamSoftwareInstallers(currentTeamName, software, opts.ApplySpecOptions) if err != nil { return nil, nil, fmt.Errorf("applying software installers for team %q: %w", tmName, err) @@ -1283,9 +1283,7 @@ func (c *Client) DoGitOps( } } group.AppConfig.(map[string]interface{})["scripts"] = scripts - - group.Software = config.Software.Packages - } else { + } else if !config.IsNoTeam() { team = make(map[string]interface{}) team["name"] = *config.TeamName team["agent_options"] = config.AgentOptions @@ -1339,111 +1337,115 @@ func (c *Client) DoGitOps( team["mdm"] = map[string]interface{}{} mdmAppConfig = team["mdm"].(map[string]interface{}) } - // Common controls settings between org and team settings - // Put in default values for macos_settings - if config.Controls.MacOSSettings != nil { - mdmAppConfig["macos_settings"] = config.Controls.MacOSSettings - } else { - mdmAppConfig["macos_settings"] = map[string]interface{}{} - } - macOSSettings := mdmAppConfig["macos_settings"].(map[string]interface{}) - if customSettings, ok := macOSSettings["custom_settings"]; !ok || customSettings == nil { - macOSSettings["custom_settings"] = []interface{}{} - } - // Put in default values for macos_updates - if config.Controls.MacOSUpdates != nil { - mdmAppConfig["macos_updates"] = config.Controls.MacOSUpdates - } else { - mdmAppConfig["macos_updates"] = map[string]interface{}{} - } - macOSUpdates := mdmAppConfig["macos_updates"].(map[string]interface{}) - if minimumVersion, ok := macOSUpdates["minimum_version"]; !ok || minimumVersion == nil { - macOSUpdates["minimum_version"] = "" - } - if deadline, ok := macOSUpdates["deadline"]; !ok || deadline == nil { - macOSUpdates["deadline"] = "" - } - // Put in default values for ios_updates - if config.Controls.IOSUpdates != nil { - mdmAppConfig["ios_updates"] = config.Controls.IOSUpdates - } else { - mdmAppConfig["ios_updates"] = map[string]interface{}{} - } - iOSUpdates := mdmAppConfig["ios_updates"].(map[string]interface{}) - if minimumVersion, ok := iOSUpdates["minimum_version"]; !ok || minimumVersion == nil { - iOSUpdates["minimum_version"] = "" - } - if deadline, ok := iOSUpdates["deadline"]; !ok || deadline == nil { - iOSUpdates["deadline"] = "" - } - // Put in default values for ipados_updates - if config.Controls.IPadOSUpdates != nil { - mdmAppConfig["ipados_updates"] = config.Controls.IPadOSUpdates - } else { - mdmAppConfig["ipados_updates"] = map[string]interface{}{} - } - iPadOSUpdates := mdmAppConfig["ipados_updates"].(map[string]interface{}) - if minimumVersion, ok := iPadOSUpdates["minimum_version"]; !ok || minimumVersion == nil { - iPadOSUpdates["minimum_version"] = "" - } - if deadline, ok := iPadOSUpdates["deadline"]; !ok || deadline == nil { - iPadOSUpdates["deadline"] = "" - } - // Put in default values for macos_setup - if config.Controls.MacOSSetup != nil { - mdmAppConfig["macos_setup"] = config.Controls.MacOSSetup - } else { - mdmAppConfig["macos_setup"] = map[string]interface{}{} - } - macOSSetup := mdmAppConfig["macos_setup"].(map[string]interface{}) - if bootstrapPackage, ok := macOSSetup["bootstrap_package"]; !ok || bootstrapPackage == nil { - macOSSetup["bootstrap_package"] = "" - } - if enableEndUserAuthentication, ok := macOSSetup["enable_end_user_authentication"]; !ok || enableEndUserAuthentication == nil { - macOSSetup["enable_end_user_authentication"] = false - } - if macOSSetupAssistant, ok := macOSSetup["macos_setup_assistant"]; !ok || macOSSetupAssistant == nil { - macOSSetup["macos_setup_assistant"] = "" - } - // Put in default values for windows_settings - if config.Controls.WindowsSettings != nil { - mdmAppConfig["windows_settings"] = config.Controls.WindowsSettings - } else { - mdmAppConfig["windows_settings"] = map[string]interface{}{} - } - windowsSettings := mdmAppConfig["windows_settings"].(map[string]interface{}) - if customSettings, ok := windowsSettings["custom_settings"]; !ok || customSettings == nil { - windowsSettings["custom_settings"] = []interface{}{} - } - // Put in default values for windows_updates - if config.Controls.WindowsUpdates != nil { - mdmAppConfig["windows_updates"] = config.Controls.WindowsUpdates - } else { - mdmAppConfig["windows_updates"] = map[string]interface{}{} - } - if appConfig.License.IsPremium() { - windowsUpdates := mdmAppConfig["windows_updates"].(map[string]interface{}) - if deadlineDays, ok := windowsUpdates["deadline_days"]; !ok || deadlineDays == nil { - windowsUpdates["deadline_days"] = nil + + if !config.IsNoTeam() { + // Common controls settings between org and team settings + // Put in default values for macos_settings + if config.Controls.MacOSSettings != nil { + mdmAppConfig["macos_settings"] = config.Controls.MacOSSettings + } else { + mdmAppConfig["macos_settings"] = map[string]interface{}{} } - if gracePeriodDays, ok := windowsUpdates["grace_period_days"]; !ok || gracePeriodDays == nil { - windowsUpdates["grace_period_days"] = nil + macOSSettings := mdmAppConfig["macos_settings"].(map[string]interface{}) + if customSettings, ok := macOSSettings["custom_settings"]; !ok || customSettings == nil { + macOSSettings["custom_settings"] = []interface{}{} } - } - // Put in default value for enable_disk_encryption - if config.Controls.EnableDiskEncryption != nil { - mdmAppConfig["enable_disk_encryption"] = config.Controls.EnableDiskEncryption - } else { - mdmAppConfig["enable_disk_encryption"] = false - } - if config.TeamName != nil { - team["gitops_filename"] = filename - rawTeam, err := json.Marshal(team) - if err != nil { - return nil, fmt.Errorf("error marshalling team spec: %w", err) + // Put in default values for macos_updates + if config.Controls.MacOSUpdates != nil { + mdmAppConfig["macos_updates"] = config.Controls.MacOSUpdates + } else { + mdmAppConfig["macos_updates"] = map[string]interface{}{} + } + macOSUpdates := mdmAppConfig["macos_updates"].(map[string]interface{}) + if minimumVersion, ok := macOSUpdates["minimum_version"]; !ok || minimumVersion == nil { + macOSUpdates["minimum_version"] = "" + } + if deadline, ok := macOSUpdates["deadline"]; !ok || deadline == nil { + macOSUpdates["deadline"] = "" + } + // Put in default values for ios_updates + if config.Controls.IOSUpdates != nil { + mdmAppConfig["ios_updates"] = config.Controls.IOSUpdates + } else { + mdmAppConfig["ios_updates"] = map[string]interface{}{} + } + iOSUpdates := mdmAppConfig["ios_updates"].(map[string]interface{}) + if minimumVersion, ok := iOSUpdates["minimum_version"]; !ok || minimumVersion == nil { + iOSUpdates["minimum_version"] = "" + } + if deadline, ok := iOSUpdates["deadline"]; !ok || deadline == nil { + iOSUpdates["deadline"] = "" + } + // Put in default values for ipados_updates + if config.Controls.IPadOSUpdates != nil { + mdmAppConfig["ipados_updates"] = config.Controls.IPadOSUpdates + } else { + mdmAppConfig["ipados_updates"] = map[string]interface{}{} + } + iPadOSUpdates := mdmAppConfig["ipados_updates"].(map[string]interface{}) + if minimumVersion, ok := iPadOSUpdates["minimum_version"]; !ok || minimumVersion == nil { + iPadOSUpdates["minimum_version"] = "" + } + if deadline, ok := iPadOSUpdates["deadline"]; !ok || deadline == nil { + iPadOSUpdates["deadline"] = "" + } + // Put in default values for macos_setup + if config.Controls.MacOSSetup != nil { + mdmAppConfig["macos_setup"] = config.Controls.MacOSSetup + } else { + mdmAppConfig["macos_setup"] = map[string]interface{}{} + } + macOSSetup := mdmAppConfig["macos_setup"].(map[string]interface{}) + if bootstrapPackage, ok := macOSSetup["bootstrap_package"]; !ok || bootstrapPackage == nil { + macOSSetup["bootstrap_package"] = "" + } + if enableEndUserAuthentication, ok := macOSSetup["enable_end_user_authentication"]; !ok || enableEndUserAuthentication == nil { + macOSSetup["enable_end_user_authentication"] = false + } + if macOSSetupAssistant, ok := macOSSetup["macos_setup_assistant"]; !ok || macOSSetupAssistant == nil { + macOSSetup["macos_setup_assistant"] = "" + } + // Put in default values for windows_settings + if config.Controls.WindowsSettings != nil { + mdmAppConfig["windows_settings"] = config.Controls.WindowsSettings + } else { + mdmAppConfig["windows_settings"] = map[string]interface{}{} + } + windowsSettings := mdmAppConfig["windows_settings"].(map[string]interface{}) + if customSettings, ok := windowsSettings["custom_settings"]; !ok || customSettings == nil { + windowsSettings["custom_settings"] = []interface{}{} + } + // Put in default values for windows_updates + if config.Controls.WindowsUpdates != nil { + mdmAppConfig["windows_updates"] = config.Controls.WindowsUpdates + } else { + mdmAppConfig["windows_updates"] = map[string]interface{}{} + } + if appConfig.License.IsPremium() { + windowsUpdates := mdmAppConfig["windows_updates"].(map[string]interface{}) + if deadlineDays, ok := windowsUpdates["deadline_days"]; !ok || deadlineDays == nil { + windowsUpdates["deadline_days"] = nil + } + if gracePeriodDays, ok := windowsUpdates["grace_period_days"]; !ok || gracePeriodDays == nil { + windowsUpdates["grace_period_days"] = nil + } + } + // Put in default value for enable_disk_encryption + if config.Controls.EnableDiskEncryption != nil { + mdmAppConfig["enable_disk_encryption"] = config.Controls.EnableDiskEncryption + } else { + mdmAppConfig["enable_disk_encryption"] = false + } + + if config.TeamName != nil { + team["gitops_filename"] = filename + rawTeam, err := json.Marshal(team) + if err != nil { + return nil, fmt.Errorf("error marshalling team spec: %w", err) + } + group.Teams = []json.RawMessage{rawTeam} + group.TeamsDryRunAssumptions = teamDryRunAssumptions } - group.Teams = []json.RawMessage{rawTeam} - group.TeamsDryRunAssumptions = teamDryRunAssumptions } // Apply org settings, scripts, enroll secrets, team entities (software, scripts, etc.), and controls. @@ -1456,27 +1458,32 @@ func (c *Client) DoGitOps( if err != nil { return nil, err } + var teamSoftwareInstallers []fleet.SoftwareInstaller if config.TeamName != nil { - if len(teamIDsByName) != 1 { - return nil, fmt.Errorf("expected 1 team spec to be applied, got %d", len(teamIDsByName)) - } - teamID, ok := teamIDsByName[*config.TeamName] - if ok && teamID == 0 { - if dryRun { - logFn("[+] would've added any policies/queries to new team %s\n", *config.TeamName) - return nil, nil + if !config.IsNoTeam() { + if len(teamIDsByName) != 1 { + return nil, fmt.Errorf("expected 1 team spec to be applied, got %d", len(teamIDsByName)) } - return nil, fmt.Errorf("team %s not created", *config.TeamName) + teamID, ok := teamIDsByName[*config.TeamName] + if ok && teamID == 0 { + if dryRun { + logFn("[+] would've added any policies/queries to new team %s\n", *config.TeamName) + return nil, nil + } + return nil, fmt.Errorf("team %s not created", *config.TeamName) + } + for _, teamID = range teamIDsByName { + config.TeamID = &teamID + } + teamSoftwareInstallers = teamsSoftwareInstallers[*config.TeamName] + } else { + noTeamSoftwareInstallers, err := c.doGitOpsNoTeamSoftware(config, baseDir, appConfig, logFn, dryRun) + if err != nil { + return nil, err + } + teamSoftwareInstallers = noTeamSoftwareInstallers } - for _, teamID = range teamIDsByName { - config.TeamID = &teamID - } - teamSoftwareInstallers = teamsSoftwareInstallers[*config.TeamName] - } - - if _, err = c.doGitOpsNoTeamSoftware(group, baseDir, appConfig, logFn, dryRun); err != nil { - return nil, err } err = c.doGitOpsPolicies(config, teamSoftwareInstallers, logFn, dryRun) @@ -1492,11 +1499,11 @@ func (c *Client) DoGitOps( return teamAssumptions, nil } -func (c *Client) doGitOpsNoTeamSoftware(specs spec.Group, baseDir string, appconfig *fleet.EnrichedAppConfig, logFn func(format string, args ...interface{}), dryRun bool) ([]fleet.SoftwareInstaller, error) { +func (c *Client) doGitOpsNoTeamSoftware(config *spec.GitOps, baseDir string, appconfig *fleet.EnrichedAppConfig, logFn func(format string, args ...interface{}), dryRun bool) ([]fleet.SoftwareInstaller, error) { var softwareInstallers []fleet.SoftwareInstaller - if len(specs.Teams) == 0 && appconfig != nil && appconfig.License.IsPremium() { - packages := make([]fleet.SoftwarePackageSpec, 0, len(specs.Software)) - for _, software := range specs.Software { + if config.IsNoTeam() && appconfig != nil && appconfig.License.IsPremium() { + packages := make([]fleet.SoftwarePackageSpec, 0, len(config.Software.Packages)) + for _, software := range config.Software.Packages { if software != nil { packages = append(packages, *software) } @@ -1505,23 +1512,31 @@ func (c *Client) doGitOpsNoTeamSoftware(specs spec.Group, baseDir string, appcon if err != nil { return nil, fmt.Errorf("applying software installers: %w", err) } + logFn("[+] applying %d software packages for 'No team'\n", len(payload)) softwareInstallers, err = c.ApplyNoTeamSoftwareInstallers(payload, fleet.ApplySpecOptions{DryRun: dryRun}) if err != nil { return nil, fmt.Errorf("applying software installers: %w", err) } if dryRun { - logFn("[+] would've applied 'No Team' software installers\n") + logFn("[+] would've applied 'No Team' software packages\n") } else { - logFn("[+] applied 'No Team' software installers\n") + logFn("[+] applied 'No Team' software packages\n") } } return softwareInstallers, nil } func (c *Client) doGitOpsPolicies(config *spec.GitOps, teamSoftwareInstallers []fleet.SoftwareInstaller, logFn func(format string, args ...interface{}), dryRun bool) error { + var teamID *uint // Global policies (nil) + switch { + case config.TeamID != nil: // Team policies + teamID = config.TeamID + case config.IsNoTeam(): // "No team" policies + teamID = ptr.Uint(0) + } // Get software titles of packages for the team. - if config.TeamID != nil { + if teamID != nil { softwareTitleURLs := make(map[string]uint) for _, softwareInstaller := range teamSoftwareInstallers { if softwareInstaller.URL == "" { @@ -1555,7 +1570,7 @@ func (c *Client) doGitOpsPolicies(config *spec.GitOps, teamSoftwareInstallers [] } // Get the ids and names of current policies to figure out which ones to delete - policies, err := c.GetPolicies(config.TeamID) + policies, err := c.GetPolicies(teamID) if err != nil { return fmt.Errorf("error getting current policies: %w", err) } @@ -1595,7 +1610,11 @@ func (c *Client) doGitOpsPolicies(config *spec.GitOps, teamSoftwareInstallers [] } if !found { policiesToDelete = append(policiesToDelete, oldItem.ID) - fmt.Printf("[-] deleting policy %s\n", oldItem.Name) + if !dryRun { + logFn("[-] deleting policy %s\n", oldItem.Name) + } else { + logFn("[-] would've deleted policy %s\n", oldItem.Name) + } } } if len(policiesToDelete) > 0 { @@ -1608,7 +1627,16 @@ func (c *Client) doGitOpsPolicies(config *spec.GitOps, teamSoftwareInstallers [] end = len(policiesToDelete) } totalDeleted += end - i - if err := c.DeletePolicies(config.TeamID, policiesToDelete[i:end]); err != nil { + var teamID *uint + switch { + case config.TeamID != nil: // Team policies + teamID = config.TeamID + case config.IsNoTeam(): // No team policies + teamID = ptr.Uint(fleet.PolicyNoTeamID) + default: // Global policies + teamID = nil + } + if err := c.DeletePolicies(teamID, policiesToDelete[i:end]); err != nil { return fmt.Errorf("error deleting policies: %w", err) } logFn("[-] deleted %d policies\n", totalDeleted) diff --git a/server/service/global_policies.go b/server/service/global_policies.go index ed0ef22013..87c1d67152 100644 --- a/server/service/global_policies.go +++ b/server/service/global_policies.go @@ -487,7 +487,7 @@ func applyPolicySpecsEndpoint(ctx context.Context, request interface{}, svc flee func (svc *Service) checkPolicySpecAuthorization(ctx context.Context, policies []*fleet.PolicySpec) error { checkGlobalPolicyAuth := false for _, policy := range policies { - if policy.Team != "" { + if policy.Team != "" && policy.Team != "No team" { team, err := svc.ds.TeamByName(ctx, policy.Team) if err != nil { // This is so that the proper HTTP status code is returned diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index d739a0649f..1757d006bb 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -938,6 +938,141 @@ func (s *integrationEnterpriseTestSuite) TestTeamPolicies() { require.Len(t, ts.Policies, 0) } +func (s *integrationEnterpriseTestSuite) TestNoTeamPolicies() { + t := s.T() + ctx := context.Background() + + // + // Test a global admin can read and write "No team" policies. + // + + // List "No team" policies. + ts := listTeamPoliciesResponse{} + s.DoJSON("GET", "/api/latest/fleet/teams/0/policies", nil, http.StatusOK, &ts) + require.Len(t, ts.Policies, 0) + require.Len(t, ts.InheritedPolicies, 0) + // Create a placeholder global policy. + _, err := s.ds.NewGlobalPolicy(ctx, nil, fleet.PolicyPayload{ + Name: "globalPolicy1", + Query: "SELECT 0;", + }) + require.NoError(t, err) + // Create a "No team" policy. + tpParams := teamPolicyRequest{ + Name: "noTeamPolicy1", + Query: "SELECT 1;", + } + r := teamPolicyResponse{} + s.DoJSON("POST", "/api/latest/fleet/teams/0/policies", tpParams, http.StatusOK, &r) + require.NotNil(t, r.Policy.TeamID) + require.Zero(t, *r.Policy.TeamID) + // Test that we can't create a policy with the same name under "No team" domain. + s.DoJSON("POST", "/api/latest/fleet/teams/0/policies", tpParams, http.StatusConflict, &r) + // Create a second "No team" policy. + tpParams = teamPolicyRequest{ + Name: "noTeamPolicy2", + Query: "SELECT 2;", + } + r = teamPolicyResponse{} + s.DoJSON("POST", "/api/latest/fleet/teams/0/policies", tpParams, http.StatusOK, &r) + require.NotNil(t, r.Policy.TeamID) + require.Zero(t, *r.Policy.TeamID) + // List "No team" policies. + ts = listTeamPoliciesResponse{} + s.DoJSON("GET", "/api/latest/fleet/teams/0/policies", nil, http.StatusOK, &ts) + require.Len(t, ts.Policies, 2) + assert.Equal(t, "noTeamPolicy1", ts.Policies[0].Name) + assert.Equal(t, "SELECT 1;", ts.Policies[0].Query) + require.NotNil(t, ts.Policies[0].TeamID) + require.Zero(t, *ts.Policies[0].TeamID) + assert.Equal(t, "noTeamPolicy2", ts.Policies[1].Name) + assert.Equal(t, "SELECT 2;", ts.Policies[1].Query) + require.NotNil(t, ts.Policies[1].TeamID) + require.Zero(t, *ts.Policies[1].TeamID) + require.Len(t, ts.InheritedPolicies, 1) + assert.Equal(t, "globalPolicy1", ts.InheritedPolicies[0].Name) + assert.Equal(t, "SELECT 0;", ts.InheritedPolicies[0].Query) + assert.Nil(t, ts.InheritedPolicies[0].TeamID) + // Test policy count for "No team" policies. + tc := countTeamPoliciesResponse{} + s.DoJSON("GET", "/api/latest/fleet/teams/0/policies/count", nil, http.StatusOK, &tc) + require.Equal(t, 2, tc.Count) + // Test merge inherited for "No team" policies. + ts = listTeamPoliciesResponse{} + s.DoJSON("GET", "/api/latest/fleet/teams/0/policies", nil, http.StatusOK, &ts, "merge_inherited", "true", "order_key", "team_id", "order_direction", "desc") + require.Len(t, ts.Policies, 3) + require.Nil(t, ts.InheritedPolicies) + assert.Equal(t, "noTeamPolicy1", ts.Policies[0].Name) + assert.Equal(t, "SELECT 1;", ts.Policies[0].Query) + assert.Equal(t, "noTeamPolicy2", ts.Policies[1].Name) + assert.Equal(t, "SELECT 2;", ts.Policies[1].Query) + assert.Equal(t, "globalPolicy1", ts.Policies[2].Name) + assert.Equal(t, "SELECT 0;", ts.Policies[2].Query) + // Test merge inherited count for "No team" policies. + countResp := countTeamPoliciesResponse{} + s.DoJSON("GET", "/api/latest/fleet/teams/0/policies/count", nil, http.StatusOK, &countResp, "merge_inherited", "true") + require.Nil(t, countResp.Err) + require.Equal(t, 3, countResp.Count) + // Test deleting "No team" policies. + deletePolicyParams := deleteTeamPoliciesRequest{ + IDs: []uint{ts.Policies[0].ID}, + } + deletePolicyResp := deleteTeamPoliciesResponse{} + s.DoJSON("POST", "/api/latest/fleet/teams/0/policies/delete", deletePolicyParams, http.StatusOK, &deletePolicyResp) + ts = listTeamPoliciesResponse{} + s.DoJSON("GET", "/api/latest/fleet/teams/0/policies", nil, http.StatusOK, &ts) + require.Len(t, ts.Policies, 1) + assert.Equal(t, "noTeamPolicy2", ts.Policies[0].Name) + assert.Equal(t, "SELECT 2;", ts.Policies[0].Query) + noTeamPolicy2 := ts.Policies[0] + + // + // Test that a team admin is not allowed to access "No team" policies. + // + + team1, err := s.ds.NewTeam(context.Background(), &fleet.Team{ + Name: "team1", + }) + require.NoError(t, err) + oldToken := s.token + t.Cleanup(func() { + s.token = oldToken + }) + password := test.GoodPassword + email := "testteam@user.com" + team1Admin := &fleet.User{ + Name: "test team user", + Email: email, + GlobalRole: nil, + Teams: []fleet.UserTeam{ + { + Team: *team1, + Role: fleet.RoleAdmin, + }, + }, + } + require.NoError(t, team1Admin.SetPassword(password, 10, 10)) + _, err = s.ds.NewUser(context.Background(), team1Admin) + require.NoError(t, err) + + s.token = s.getTestToken(email, password) + + ts = listTeamPoliciesResponse{} + s.DoJSON("GET", "/api/latest/fleet/teams/0/policies", nil, http.StatusForbidden, &ts) + tpParams = teamPolicyRequest{ + Name: "noTeamPolicy1", + Query: "SELECT 1;", + } + r = teamPolicyResponse{} + s.DoJSON("POST", "/api/latest/fleet/teams/0/policies", tpParams, http.StatusForbidden, &r) + tc = countTeamPoliciesResponse{} + s.DoJSON("GET", "/api/latest/fleet/teams/0/policies/count", nil, http.StatusForbidden, &tc) + deletePolicyParams = deleteTeamPoliciesRequest{ + IDs: []uint{noTeamPolicy2.ID}, + } + s.DoJSON("POST", "/api/latest/fleet/teams/0/policies/delete", deletePolicyParams, http.StatusForbidden, &deleteTeamPoliciesResponse{}) +} + func (s *integrationEnterpriseTestSuite) TestTeamQueries() { t := s.T() @@ -13064,14 +13199,13 @@ func (s *integrationEnterpriseTestSuite) TestVPPAppsWithoutMDM() { func (s *integrationEnterpriseTestSuite) TestPolicyAutomationsSoftwareInstallers() { t := s.T() ctx := context.Background() + test.CreateInsertGlobalVPPToken(t, s.ds) team1, err := s.ds.NewTeam(ctx, &fleet.Team{Name: t.Name() + "team1"}) require.NoError(t, err) team2, err := s.ds.NewTeam(ctx, &fleet.Team{Name: t.Name() + "team2"}) require.NoError(t, err) - test.CreateInsertGlobalVPPToken(t, s.ds) - newHost := func(name string, teamID *uint, platform string) *fleet.Host { h, err := s.ds.NewHost(ctx, &fleet.Host{ DetailUpdatedAt: time.Now(), diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go index 09876a25ff..f8b3fb6790 100644 --- a/server/service/integration_mdm_test.go +++ b/server/service/integration_mdm_test.go @@ -9966,8 +9966,8 @@ func (s *integrationMDMTestSuite) TestBatchAssociateAppStoreApps() { }) require.NoError(t, err) - // No vpp token set, no association. - s.Do("POST", batchURL, batchAssociateAppStoreAppsRequest{}, http.StatusUnprocessableEntity, "team_name", tmGood.Name) + // No vpp token set, but request is empty so it succeeds (clears VPP apps for the team). + s.Do("POST", batchURL, batchAssociateAppStoreAppsRequest{}, http.StatusNoContent, "team_name", tmGood.Name) // No vpp token set, try association // FIXME diff --git a/server/service/osquery.go b/server/service/osquery.go index ec90de1027..d3fb9c6920 100644 --- a/server/service/osquery.go +++ b/server/service/osquery.go @@ -1623,9 +1623,12 @@ func (svc *Service) processSoftwareForNewlyFailingPolicies( // We do not want to queue software installations on vanilla osquery hosts. return nil } + + var policyTeamID uint if hostTeamID == nil { - // TODO(lucas): Support hosts in "No team". - return nil + policyTeamID = fleet.PolicyNoTeamID + } else { + policyTeamID = *hostTeamID } // Filter out results that are not failures (we are only interested on failing policies, @@ -1643,7 +1646,7 @@ func (svc *Service) processSoftwareForNewlyFailingPolicies( } // Get policies with associated installers for the team. - policiesWithInstaller, err := svc.ds.GetPoliciesWithAssociatedInstaller(ctx, *hostTeamID, incomingFailingPoliciesIDs) + policiesWithInstaller, err := svc.ds.GetPoliciesWithAssociatedInstaller(ctx, policyTeamID, incomingFailingPoliciesIDs) if err != nil { return ctxerr.Wrap(ctx, err, "failed to get policies with installer") } diff --git a/server/service/team_policies.go b/server/service/team_policies.go index 8f68ecddf1..74c22fe4e5 100644 --- a/server/service/team_policies.go +++ b/server/service/team_policies.go @@ -187,8 +187,10 @@ func (svc *Service) ListTeamPolicies(ctx context.Context, teamID uint, opts flee return nil, nil, err } - if _, err := svc.ds.Team(ctx, teamID); err != nil { - return nil, nil, ctxerr.Wrapf(ctx, err, "loading team %d", teamID) + if teamID > 0 { + if _, err := svc.ds.Team(ctx, teamID); err != nil { + return nil, nil, ctxerr.Wrapf(ctx, err, "loading team %d", teamID) + } } if mergeInherited { @@ -250,8 +252,10 @@ func (svc *Service) CountTeamPolicies(ctx context.Context, teamID uint, matchQue return 0, err } - if _, err := svc.ds.Team(ctx, teamID); err != nil { - return 0, ctxerr.Wrapf(ctx, err, "loading team %d", teamID) + if teamID > 0 { + if _, err := svc.ds.Team(ctx, teamID); err != nil { + return 0, ctxerr.Wrapf(ctx, err, "loading team %d", teamID) + } } if mergeInherited { @@ -341,8 +345,10 @@ func (svc Service) DeleteTeamPolicies(ctx context.Context, teamID uint, ids []ui return nil, err } - if _, err := svc.ds.Team(ctx, teamID); err != nil { - return nil, ctxerr.Wrapf(ctx, err, "loading team %d", teamID) + if teamID > 0 { + if _, err := svc.ds.Team(ctx, teamID); err != nil { + return nil, ctxerr.Wrapf(ctx, err, "loading team %d", teamID) + } } if len(ids) == 0 { @@ -553,10 +559,6 @@ func (svc *Service) deduceSoftwareInstallerIDFromTitleID(ctx context.Context, te }) } - // - // TODO(lucas): Support "No team" (softwareTitle.SoftwarePackage.TeamID == nil). - // - // At this point we assume *softwareTitle.SoftwarePackage.TeamID == *teamID, // because SoftwareTitleByID above receives the teamID. return ptr.Uint(softwareTitle.SoftwarePackage.InstallerID), nil From 3599c2b49adeeaf967247a1a5f8f2dc7f879a714 Mon Sep 17 00:00:00 2001 From: Gabriel Hernandez Date: Thu, 12 Sep 2024 18:28:12 +0100 Subject: [PATCH 34/55] show errors for restricted team names, "all teams" and "no team" (#22043) relates to #21971 Show error messages when trying to create a team called "All teams" and "no team" **all teams error:** ![image](https://github.com/user-attachments/assets/de00754c-176c-4362-9243-ce97719dff74) **no team error:** ![image](https://github.com/user-attachments/assets/7b41e760-1779-4b53-8ba9-8ca12d84493a) - [x] Manual QA for all new/changed functionality --- .../pages/admin/TeamManagementPage/TeamManagementPage.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/pages/admin/TeamManagementPage/TeamManagementPage.tsx b/frontend/pages/admin/TeamManagementPage/TeamManagementPage.tsx index 7243f01eed..e50128dc8e 100644 --- a/frontend/pages/admin/TeamManagementPage/TeamManagementPage.tsx +++ b/frontend/pages/admin/TeamManagementPage/TeamManagementPage.tsx @@ -116,11 +116,11 @@ const TeamManagementPage = (): JSX.Element => { setBackendValidators({ name: "A team with this name already exists", }); - } else if (createError.data.errors[0].reason.includes("all teams")) { + } else if (createError.data.errors[0].reason.includes("All teams")) { setBackendValidators({ name: `"All teams" is a reserved team name. Please try another name.`, }); - } else if (createError.data.errors[0].reason.includes("no team")) { + } else if (createError.data.errors[0].reason.includes("No team")) { setBackendValidators({ name: `"No team" is a reserved team name. Please try another name.`, }); From 0074a5f964436c5a7792513f043c1134b1f72e88 Mon Sep 17 00:00:00 2001 From: Dante Catalfamo <43040593+dantecatalfamo@users.noreply.github.com> Date: Thu, 12 Sep 2024 13:36:19 -0400 Subject: [PATCH 35/55] Validate orbit access to installer package before returning it (#21337) --- changes/hosts-can-access-any-software | 1 + ee/server/service/software_installers.go | 11 +++- server/datastore/mysql/software_installers.go | 23 +++++++ server/fleet/datastore.go | 5 ++ server/mock/datastore_mock.go | 12 ++++ server/service/integration_enterprise_test.go | 62 +++++++++++++++++-- 6 files changed, 107 insertions(+), 7 deletions(-) create mode 100644 changes/hosts-can-access-any-software diff --git a/changes/hosts-can-access-any-software b/changes/hosts-can-access-any-software new file mode 100644 index 0000000000..0fbcae035a --- /dev/null +++ b/changes/hosts-can-access-any-software @@ -0,0 +1 @@ +- Hosts can no longer access installers that aren't directly assigned to it diff --git a/ee/server/service/software_installers.go b/ee/server/service/software_installers.go index abdaaf8aa0..0c65884f83 100644 --- a/ee/server/service/software_installers.go +++ b/ee/server/service/software_installers.go @@ -330,11 +330,20 @@ func (svc *Service) OrbitDownloadSoftwareInstaller(ctx context.Context, installe // this is not a user-authenticated endpoint svc.authz.SkipAuthorization(ctx) - _, ok := hostctx.FromContext(ctx) + host, ok := hostctx.FromContext(ctx) if !ok { return nil, fleet.OrbitError{Message: "internal error: missing host from request context"} } + access, err := svc.ds.ValidateOrbitSoftwareInstallerAccess(ctx, host.ID, installerID) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "check software installer access") + } + + if !access { + return nil, fleet.NewUserMessageError(errors.New("Host doesn't have access to this installer"), http.StatusForbidden) + } + // get the installer's metadata meta, err := svc.ds.GetSoftwareInstallerMetadataByID(ctx, installerID) if err != nil { diff --git a/server/datastore/mysql/software_installers.go b/server/datastore/mysql/software_installers.go index aa5e751240..f893a818fa 100644 --- a/server/datastore/mysql/software_installers.go +++ b/server/datastore/mysql/software_installers.go @@ -210,6 +210,29 @@ func (ds *Datastore) addSoftwareTitleToMatchingSoftware(ctx context.Context, tit return ctxerr.Wrap(ctx, err, "adding fk reference in software to software_titles") } +func (ds *Datastore) ValidateOrbitSoftwareInstallerAccess(ctx context.Context, hostID uint, installerID uint) (bool, error) { + query := ` + SELECT 1 + FROM + host_software_installs + WHERE + software_installer_id = ? + AND + host_id = ? + AND + install_script_exit_code IS NULL +` + var access bool + err := sqlx.GetContext(ctx, ds.reader(ctx), &access, query, installerID, hostID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + return false, ctxerr.Wrap(ctx, err, "check software installer association to host") + } + return true, nil +} + func (ds *Datastore) GetSoftwareInstallerMetadataByID(ctx context.Context, id uint) (*fleet.SoftwareInstaller, error) { query := ` SELECT diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index 921ec9d71a..80aede54b6 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -1651,6 +1651,11 @@ type Datastore interface { // GetSoftwareInstallerMetadataByID returns the software installer corresponding to the installer id. GetSoftwareInstallerMetadataByID(ctx context.Context, id uint) (*SoftwareInstaller, error) + // ValidateSoftwareInstallerAccess checks if a host has access to + // an installer. Access is granted if there is currently an unfinished + // install request present in host_software_installs + ValidateOrbitSoftwareInstallerAccess(ctx context.Context, hostID uint, installerID uint) (bool, error) + // GetSoftwareInstallerMetadataByTeamAndTitleID returns the software // installer corresponding to the specified team and title ids. If // withScriptContents is true, also returns the contents of the install and diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index 33efcf575b..8634e6662a 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -1040,6 +1040,8 @@ type MatchOrCreateSoftwareInstallerFunc func(ctx context.Context, payload *fleet type GetSoftwareInstallerMetadataByIDFunc func(ctx context.Context, id uint) (*fleet.SoftwareInstaller, error) +type ValidateOrbitSoftwareInstallerAccessFunc func(ctx context.Context, hostID uint, installerID uint) (bool, error) + type GetSoftwareInstallerMetadataByTeamAndTitleIDFunc func(ctx context.Context, teamID *uint, titleID uint, withScriptContents bool) (*fleet.SoftwareInstaller, error) type GetVPPAppByTeamAndTitleIDFunc func(ctx context.Context, teamID *uint, titleID uint) (*fleet.VPPApp, error) @@ -2607,6 +2609,9 @@ type DataStore struct { GetSoftwareInstallerMetadataByIDFunc GetSoftwareInstallerMetadataByIDFunc GetSoftwareInstallerMetadataByIDFuncInvoked bool + ValidateOrbitSoftwareInstallerAccessFunc ValidateOrbitSoftwareInstallerAccessFunc + ValidateOrbitSoftwareInstallerAccessFuncInvoked bool + GetSoftwareInstallerMetadataByTeamAndTitleIDFunc GetSoftwareInstallerMetadataByTeamAndTitleIDFunc GetSoftwareInstallerMetadataByTeamAndTitleIDFuncInvoked bool @@ -6234,6 +6239,13 @@ func (s *DataStore) GetSoftwareInstallerMetadataByID(ctx context.Context, id uin return s.GetSoftwareInstallerMetadataByIDFunc(ctx, id) } +func (s *DataStore) ValidateOrbitSoftwareInstallerAccess(ctx context.Context, hostID uint, installerID uint) (bool, error) { + s.mu.Lock() + s.ValidateOrbitSoftwareInstallerAccessFuncInvoked = true + s.mu.Unlock() + return s.ValidateOrbitSoftwareInstallerAccessFunc(ctx, hostID, installerID) +} + func (s *DataStore) GetSoftwareInstallerMetadataByTeamAndTitleID(ctx context.Context, teamID *uint, titleID uint, withScriptContents bool) (*fleet.SoftwareInstaller, error) { s.mu.Lock() s.GetSoftwareInstallerMetadataByTeamAndTitleIDFuncInvoked = true diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index 1757d006bb..97acc15219 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -10390,16 +10390,19 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndD // create an orbit host that is not in the team hostNotInTeam := createOrbitEnrolledHost(t, "windows", "orbit-host-no-team", s.ds) - // downloading installer still works because we allow it explicitly + // downloading installer doesn't work if the host doesn't have a pending install request s.Do("POST", "/api/fleet/orbit/software_install/package?alt=media", orbitDownloadSoftwareInstallerRequest{ InstallerID: installerID, OrbitNodeKey: *hostNotInTeam.OrbitNodeKey, - }, http.StatusOK) + }, http.StatusForbidden) // create an orbit host, assign to team - hostInTeam := createOrbitEnrolledHost(t, "windows", "orbit-host-team", s.ds) + hostInTeam := createOrbitEnrolledHost(t, "linux", "orbit-host-team", s.ds) require.NoError(t, s.ds.AddHostsToTeam(context.Background(), &createTeamResp.Team.ID, []uint{hostInTeam.ID})) + // Create a software installation request + s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/install/%d", hostInTeam.ID, titleID), installSoftwareRequest{}, http.StatusAccepted) + // requesting download with alt != media fails r = s.Do("POST", "/api/fleet/orbit/software_install/package?alt=FOOBAR", orbitDownloadSoftwareInstallerRequest{ InstallerID: installerID, @@ -10415,6 +10418,28 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndD }, http.StatusOK) checkDownloadResponse(t, r, payload.Filename) + // Get execution ID, normally comes from orbit config + var installUUID string + mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + return sqlx.GetContext(context.Background(), q, &installUUID, "SELECT execution_id FROM host_software_installs WHERE host_id = ? AND install_script_exit_code IS NULL", hostInTeam.ID) + }) + + // Installation complete, host no longer has access to software + s.Do("POST", "/api/fleet/orbit/software_install/result", orbitPostSoftwareInstallResultRequest{ + OrbitNodeKey: *hostInTeam.OrbitNodeKey, + HostSoftwareInstallResultPayload: &fleet.HostSoftwareInstallResultPayload{ + HostID: hostInTeam.ID, + InstallUUID: installUUID, + InstallScriptExitCode: ptr.Int(0), + InstallScriptOutput: ptr.String("done"), + }, + }, http.StatusNoContent) + + r = s.Do("POST", "/api/fleet/orbit/software_install/package?alt=media", orbitDownloadSoftwareInstallerRequest{ + InstallerID: installerID, + OrbitNodeKey: *hostInTeam.OrbitNodeKey, + }, http.StatusForbidden) + // delete the installer s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/software/titles/%d/available_for_install", titleID), nil, http.StatusNoContent, "team_id", fmt.Sprintf("%d", *payload.TeamID)) @@ -10457,14 +10482,14 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndD // create an orbit host that is not in the team hostNotInTeam := createOrbitEnrolledHost(t, "windows", "orbit-host-no-team", s.ds) - // downloading installer still works because we allow it explicitly + // downloading installer fails because there's no install request s.Do("POST", "/api/fleet/orbit/software_install/package?alt=media", orbitDownloadSoftwareInstallerRequest{ InstallerID: installerID, OrbitNodeKey: *hostNotInTeam.OrbitNodeKey, - }, http.StatusOK) + }, http.StatusForbidden) // create an orbit host, assign to team - hostInTeam := createOrbitEnrolledHost(t, "windows", "orbit-host-team", s.ds) + hostInTeam := createOrbitEnrolledHost(t, "linux", "orbit-host-team", s.ds) // requesting download with alt != media fails r = s.Do("POST", "/api/fleet/orbit/software_install/package?alt=FOOBAR", orbitDownloadSoftwareInstallerRequest{ @@ -10474,6 +10499,9 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndD errMsg := extractServerErrorText(r.Body) require.Contains(t, errMsg, "only alt=media is supported") + // Create a software installation request + s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/install/%d", hostInTeam.ID, titleID), installSoftwareRequest{}, http.StatusAccepted) + // valid download r = s.Do("POST", "/api/fleet/orbit/software_install/package?alt=media", orbitDownloadSoftwareInstallerRequest{ InstallerID: installerID, @@ -10481,6 +10509,28 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndD }, http.StatusOK) checkDownloadResponse(t, r, payload.Filename) + // Get execution ID, normally comes from orbit config + var installUUID string + mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + return sqlx.GetContext(context.Background(), q, &installUUID, "SELECT execution_id FROM host_software_installs WHERE host_id = ? AND install_script_exit_code IS NULL", hostInTeam.ID) + }) + + // Installation complete, host no longer has access to software + s.Do("POST", "/api/fleet/orbit/software_install/result", orbitPostSoftwareInstallResultRequest{ + OrbitNodeKey: *hostInTeam.OrbitNodeKey, + HostSoftwareInstallResultPayload: &fleet.HostSoftwareInstallResultPayload{ + HostID: hostInTeam.ID, + InstallUUID: installUUID, + InstallScriptExitCode: ptr.Int(0), + InstallScriptOutput: ptr.String("done"), + }, + }, http.StatusNoContent) + + r = s.Do("POST", "/api/fleet/orbit/software_install/package?alt=media", orbitDownloadSoftwareInstallerRequest{ + InstallerID: installerID, + OrbitNodeKey: *hostInTeam.OrbitNodeKey, + }, http.StatusForbidden) + // delete the installer s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/software/titles/%d/available_for_install", titleID), nil, http.StatusNoContent, "team_id", "0") From b569ea82531bed8f8d2734efed24ac542927a213 Mon Sep 17 00:00:00 2001 From: Sam Pfluger <108141731+Sampfluger88@users.noreply.github.com> Date: Thu, 12 Sep 2024 12:52:57 -0500 Subject: [PATCH 36/55] Add walkthrough looms to responsibilities (#22047) --- handbook/digital-experience/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/handbook/digital-experience/README.md b/handbook/digital-experience/README.md index d319862bf0..d9cc6dbcfe 100644 --- a/handbook/digital-experience/README.md +++ b/handbook/digital-experience/README.md @@ -88,7 +88,7 @@ Once you have the above follow these steps: ### Check production dependencies of fleetdm.com -Every week, we run `npm audit --only=prod` to check for vulnerabilities on the production dependencies of fleetdm.com. Once we have a solution to configure GitHub's Dependabot to ignore devDependencies, this manual process can be replaced with Dependabot. +Every week, we run `npm audit --only=prod` to check for vulnerabilities on the production dependencies of fleetdm.com. Once we have a solution to configure GitHub's Dependabot to ignore devDependencies, this [manual process](https://www.loom.com/share/153613cc1c5347478d3a9545e438cc97?sid=5102dafc-7e27-43cb-8c62-70c8789e5559) can be replaced with Dependabot. ### Respond to a 5xx error on fleetdm.com @@ -104,7 +104,7 @@ Production systems can fail for various reasons, and it can be frustrating to us ### Check browser compatibility for fleetdm.com -A browser compatibility check of [fleetdm.com](https://fleetdm.com/) should be carried out monthly to verify that the website looks and functions as expected across all [supported browsers](https://fleetdm.com/docs/using-fleet/supported-browsers). +A [browser compatibility check](https://www.loom.com/share/4b1945ccffa14b7daca8ab9546b8fbb9?sid=eaa4d27a-236b-426d-a7cb-9c3bdb2c8cdc) of [fleetdm.com](https://fleetdm.com/) should be carried out monthly to verify that the website looks and functions as expected across all [supported browsers](https://fleetdm.com/docs/using-fleet/supported-browsers). - We use [BrowserStack](https://www.browserstack.com/users/sign_in) (logins can be found in [1Password](https://start.1password.com/open/i?a=N3F7LHAKQ5G3JPFPX234EC4ZDQ&v=3ycqkai6naxhqsylmsos6vairu&i=nwnxrrbpcwkuzaazh3rywzoh6e&h=fleetdevicemanagement.1password.com)) for our cross-browser checks. - Check for issues against the latest version of Google Chrome (macOS). We use this as our baseline for quality assurance. From b60ebbc63eb8fe13faf084059472098899cae8f8 Mon Sep 17 00:00:00 2001 From: Victor Lyuboslavsky Date: Thu, 12 Sep 2024 13:25:40 -0500 Subject: [PATCH 37/55] Added GitOps support for uninstall script. (#21969) `fleetctl gitops` subtask for #20320 # Checklist for submitter - [x] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements) - [x] Added/updated tests - [x] Manual QA for all new/changed functionality --- cmd/fleetctl/gitops_test.go | 24 +++++++++++++------ .../testdata/gitops/lib/uninstall_ruby.sh | 1 + ...software_installer_uninstall_not_found.yml | 19 +++++++++++++++ .../no_team_software_installer_valid.yml | 2 ++ .../testdata/gitops/team_config_no_paths.yml | 2 ++ ...software_installer_uninstall_not_found.yml | 19 +++++++++++++++ .../gitops/team_software_installer_valid.yml | 2 ++ ee/server/service/software_installers.go | 4 ++++ pkg/spec/gitops_test.go | 10 ++++++-- .../testdata/microsoft-teams.pkg.software.yml | 2 ++ server/datastore/mysql/software_installers.go | 6 +++-- server/fleet/scripts.go | 1 + server/fleet/software_installer.go | 1 + server/service/client.go | 11 +++++++++ 14 files changed, 93 insertions(+), 11 deletions(-) create mode 100644 cmd/fleetctl/testdata/gitops/lib/uninstall_ruby.sh create mode 100644 cmd/fleetctl/testdata/gitops/no_team_software_installer_uninstall_not_found.yml create mode 100644 cmd/fleetctl/testdata/gitops/team_software_installer_uninstall_not_found.yml diff --git a/cmd/fleetctl/gitops_test.go b/cmd/fleetctl/gitops_test.go index a0153097f0..8a6d1aeeb8 100644 --- a/cmd/fleetctl/gitops_test.go +++ b/cmd/fleetctl/gitops_test.go @@ -803,7 +803,9 @@ func TestGitOpsFullTeam(t *testing.T) { appliedQueries = queries return nil } + var appliedSoftwareInstallers []*fleet.UploadSoftwareInstallerPayload ds.BatchSetSoftwareInstallersFunc = func(ctx context.Context, teamID *uint, installers []*fleet.UploadSoftwareInstallerPayload) ([]fleet.SoftwareInstaller, error) { + appliedSoftwareInstallers = installers return nil, nil } ds.SetTeamVPPAppsFunc = func(ctx context.Context, teamID *uint, adamIDs []fleet.VPPAppTeam) error { @@ -826,8 +828,8 @@ func TestGitOpsFullTeam(t *testing.T) { // Dry run const baseFilename = "team_config_no_paths.yml" - file := "./testdata/gitops/" + baseFilename - _ = runAppForTest(t, []string{"gitops", "-f", file, "--dry-run"}) + gitopsFile := "./testdata/gitops/" + baseFilename + _ = runAppForTest(t, []string{"gitops", "-f", gitopsFile, "--dry-run"}) assert.Nil(t, savedTeam) assert.Len(t, enrolledSecrets, 0) assert.Len(t, appliedPolicySpecs, 0) @@ -835,13 +837,14 @@ func TestGitOpsFullTeam(t *testing.T) { assert.Len(t, appliedScripts, 0) assert.Len(t, appliedMacProfiles, 0) assert.Len(t, appliedWinProfiles, 0) + assert.Empty(t, appliedSoftwareInstallers) // Real run // Setting global calendar config appConfig.Integrations = fleet.Integrations{ GoogleCalendar: []*fleet.GoogleCalendarIntegration{{}}, } - _ = runAppForTest(t, []string{"gitops", "-f", file}) + _ = runAppForTest(t, []string{"gitops", "-f", gitopsFile}) require.NotNil(t, savedTeam) assert.Equal(t, teamName, savedTeam.Name) assert.Contains(t, string(*savedTeam.Config.AgentOptions), "distributed_denylist_duration") @@ -861,21 +864,26 @@ func TestGitOpsFullTeam(t *testing.T) { require.NotNil(t, savedTeam.Config.Integrations.GoogleCalendar) assert.True(t, savedTeam.Config.Integrations.GoogleCalendar.Enable) assert.Equal(t, baseFilename, *savedTeam.Filename) + require.Len(t, appliedSoftwareInstallers, 2) + packageID := `"ruby"` + uninstallScriptProcessed := strings.ReplaceAll(file.GetUninstallScript("deb"), "$PACKAGE_ID", packageID) + assert.ElementsMatch(t, []string{fmt.Sprintf("echo 'uninstall' %s\n", packageID), uninstallScriptProcessed}, + []string{appliedSoftwareInstallers[0].UninstallScript, appliedSoftwareInstallers[1].UninstallScript}) // Change team name newTeamName := "New Team Name" t.Setenv("TEST_TEAM_NAME", newTeamName) - _ = runAppForTest(t, []string{"gitops", "-f", file, "--dry-run"}) - _ = runAppForTest(t, []string{"gitops", "-f", file}) + _ = runAppForTest(t, []string{"gitops", "-f", gitopsFile, "--dry-run"}) + _ = runAppForTest(t, []string{"gitops", "-f", gitopsFile}) require.NotNil(t, savedTeam) assert.Equal(t, newTeamName, savedTeam.Name) assert.Equal(t, baseFilename, *savedTeam.Filename) // Try to change team name again, but this time the new name conflicts with an existing team t.Setenv("TEST_TEAM_NAME", "Conflict") - _, err = runAppNoChecks([]string{"gitops", "-f", file, "--dry-run"}) + _, err = runAppNoChecks([]string{"gitops", "-f", gitopsFile, "--dry-run"}) assert.ErrorContains(t, err, "team name already exists") - _, err = runAppNoChecks([]string{"gitops", "-f", file}) + _, err = runAppNoChecks([]string{"gitops", "-f", gitopsFile}) assert.ErrorContains(t, err, "team name already exists") // Now clear the settings @@ -1612,6 +1620,7 @@ func TestGitOpsTeamSofwareInstallers(t *testing.T) { {"testdata/gitops/team_software_installer_pre_condition_multiple_queries_apply.yml", "should have only one query."}, {"testdata/gitops/team_software_installer_pre_condition_not_found.yml", "no such file or directory"}, {"testdata/gitops/team_software_installer_install_not_found.yml", "no such file or directory"}, + {"testdata/gitops/team_software_installer_uninstall_not_found.yml", "no such file or directory"}, {"testdata/gitops/team_software_installer_post_install_not_found.yml", "no such file or directory"}, {"testdata/gitops/team_software_installer_no_url.yml", "software URL is required"}, {"testdata/gitops/team_software_installer_invalid_self_service_value.yml", "\"packages.self_service\" must be a bool, found string"}, @@ -1661,6 +1670,7 @@ func TestGitOpsNoTeamSoftwareInstallers(t *testing.T) { {"testdata/gitops/no_team_software_installer_pre_condition_multiple_queries.yml", "should have only one query."}, {"testdata/gitops/no_team_software_installer_pre_condition_not_found.yml", "no such file or directory"}, {"testdata/gitops/no_team_software_installer_install_not_found.yml", "no such file or directory"}, + {"testdata/gitops/no_team_software_installer_uninstall_not_found.yml", "no such file or directory"}, {"testdata/gitops/no_team_software_installer_post_install_not_found.yml", "no such file or directory"}, {"testdata/gitops/no_team_software_installer_no_url.yml", "software URL is required"}, {"testdata/gitops/no_team_software_installer_invalid_self_service_value.yml", "\"packages.self_service\" must be a bool, found string"}, diff --git a/cmd/fleetctl/testdata/gitops/lib/uninstall_ruby.sh b/cmd/fleetctl/testdata/gitops/lib/uninstall_ruby.sh new file mode 100644 index 0000000000..c6c41b5e01 --- /dev/null +++ b/cmd/fleetctl/testdata/gitops/lib/uninstall_ruby.sh @@ -0,0 +1 @@ +echo 'uninstall' ${PACKAGE_ID} diff --git a/cmd/fleetctl/testdata/gitops/no_team_software_installer_uninstall_not_found.yml b/cmd/fleetctl/testdata/gitops/no_team_software_installer_uninstall_not_found.yml new file mode 100644 index 0000000000..812c05339f --- /dev/null +++ b/cmd/fleetctl/testdata/gitops/no_team_software_installer_uninstall_not_found.yml @@ -0,0 +1,19 @@ +# Test config +controls: +queries: +policies: +agent_options: +org_settings: + server_settings: + server_url: $FLEET_SERVER_URL + org_info: + contact_url: https://example.com/contact + org_logo_url: "" + org_logo_url_light_background: "" + org_name: ${ORG_NAME} + secrets: [{"secret":"globalSecret"}] +software: + packages: + - url: ${SOFTWARE_INSTALLER_URL}/ruby.deb + uninstall_script: + path: lib/notfound.sh \ No newline at end of file diff --git a/cmd/fleetctl/testdata/gitops/no_team_software_installer_valid.yml b/cmd/fleetctl/testdata/gitops/no_team_software_installer_valid.yml index db8043baf9..4599698d1d 100644 --- a/cmd/fleetctl/testdata/gitops/no_team_software_installer_valid.yml +++ b/cmd/fleetctl/testdata/gitops/no_team_software_installer_valid.yml @@ -10,5 +10,7 @@ software: path: lib/query_ruby.yml post_install_script: path: lib/post_install_ruby.sh + uninstall_script: + path: lib/uninstall_ruby.sh - url: ${SOFTWARE_INSTALLER_URL}/other.deb self_service: true diff --git a/cmd/fleetctl/testdata/gitops/team_config_no_paths.yml b/cmd/fleetctl/testdata/gitops/team_config_no_paths.yml index 785ba5d215..e671d17d29 100644 --- a/cmd/fleetctl/testdata/gitops/team_config_no_paths.yml +++ b/cmd/fleetctl/testdata/gitops/team_config_no_paths.yml @@ -124,5 +124,7 @@ software: path: lib/query_ruby.yml post_install_script: path: lib/post_install_ruby.sh + uninstall_script: + path: lib/uninstall_ruby.sh - url: ${SOFTWARE_INSTALLER_URL}/other.deb self_service: true diff --git a/cmd/fleetctl/testdata/gitops/team_software_installer_uninstall_not_found.yml b/cmd/fleetctl/testdata/gitops/team_software_installer_uninstall_not_found.yml new file mode 100644 index 0000000000..1fc9903d6b --- /dev/null +++ b/cmd/fleetctl/testdata/gitops/team_software_installer_uninstall_not_found.yml @@ -0,0 +1,19 @@ +name: "${TEST_TEAM_NAME}" +team_settings: + secrets: + - secret: "ABC" + features: + enable_host_users: true + enable_software_inventory: true + host_expiry_settings: + host_expiry_enabled: true + host_expiry_window: 30 +agent_options: +controls: +policies: +queries: +software: + packages: + - url: ${SOFTWARE_INSTALLER_URL}/ruby.deb + uninstall_script: + path: lib/notfound.sh diff --git a/cmd/fleetctl/testdata/gitops/team_software_installer_valid.yml b/cmd/fleetctl/testdata/gitops/team_software_installer_valid.yml index e894112249..0733758ced 100644 --- a/cmd/fleetctl/testdata/gitops/team_software_installer_valid.yml +++ b/cmd/fleetctl/testdata/gitops/team_software_installer_valid.yml @@ -21,5 +21,7 @@ software: path: lib/query_ruby.yml post_install_script: path: lib/post_install_ruby.sh + uninstall_script: + path: lib/uninstall_ruby.sh - url: ${SOFTWARE_INSTALLER_URL}/other.deb self_service: true diff --git a/ee/server/service/software_installers.go b/ee/server/service/software_installers.go index 0c65884f83..5c090c6353 100644 --- a/ee/server/service/software_installers.go +++ b/ee/server/service/software_installers.go @@ -1001,6 +1001,7 @@ func (svc *Service) BatchSetSoftwareInstallers( InstallScript: p.InstallScript, PreInstallQuery: p.PreInstallQuery, PostInstallScript: p.PostInstallScript, + UninstallScript: p.UninstallScript, InstallerFile: bytes.NewReader(bodyBytes), SelfService: p.SelfService, UserID: vc.UserID(), @@ -1023,6 +1024,9 @@ func (svc *Service) BatchSetSoftwareInstallers( return err } + // Update $PACKAGE_ID in uninstall script + preProcessUninstallScript(installer) + // if filename was empty, try to extract it from the URL with the // now-known extension if filename == "" { diff --git a/pkg/spec/gitops_test.go b/pkg/spec/gitops_test.go index 1fa9699102..65a73ab7c2 100644 --- a/pkg/spec/gitops_test.go +++ b/pkg/spec/gitops_test.go @@ -119,8 +119,6 @@ func TestValidGitOpsYaml(t *testing.T) { os.Unsetenv(k) } }) - } else { - t.Parallel() } var appConfig *fleet.EnrichedAppConfig @@ -155,6 +153,14 @@ func TestValidGitOpsYaml(t *testing.T) { require.Len(t, secrets.([]*fleet.EnrollSecret), 2) assert.Equal(t, "SampleSecret123", secrets.([]*fleet.EnrollSecret)[0].Secret) assert.Equal(t, "ABC", secrets.([]*fleet.EnrollSecret)[1].Secret) + require.Len(t, gitops.Software.Packages, 2) + for _, pkg := range gitops.Software.Packages { + if strings.Contains(pkg.URL, "MicrosoftTeams") { + assert.Equal(t, "uninstall.sh", pkg.UninstallScript.Path) + } else { + assert.Empty(t, pkg.UninstallScript.Path) + } + } } else { // Check org settings serverSettings, ok := gitops.OrgSettings["server_settings"] diff --git a/pkg/spec/testdata/microsoft-teams.pkg.software.yml b/pkg/spec/testdata/microsoft-teams.pkg.software.yml index 1aa50514d3..664068cb94 100644 --- a/pkg/spec/testdata/microsoft-teams.pkg.software.yml +++ b/pkg/spec/testdata/microsoft-teams.pkg.software.yml @@ -1,2 +1,4 @@ url: https://statics.teams.cdn.office.net/production-osx/enterprise/webview2/lkg/MicrosoftTeams.pkg self_service: false +uninstall_script: + path: uninstall.sh diff --git a/server/datastore/mysql/software_installers.go b/server/datastore/mysql/software_installers.go index f893a818fa..67e847f666 100644 --- a/server/datastore/mysql/software_installers.go +++ b/server/datastore/mysql/software_installers.go @@ -720,11 +720,12 @@ INSERT INTO software_installers ( user_id, user_name, user_email, - url + url, + package_ids ) VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, (SELECT id FROM software_titles WHERE name = ? AND source = ? AND browser = ''), - ?, (SELECT name FROM users WHERE id = ?), (SELECT email FROM users WHERE id = ?), ? + ?, (SELECT name FROM users WHERE id = ?), (SELECT email FROM users WHERE id = ?), ?, ? ) ON DUPLICATE KEY UPDATE install_script_content_id = VALUES(install_script_content_id), @@ -857,6 +858,7 @@ WHERE global_or_team_id = ? installer.UserID, installer.UserID, installer.URL, + strings.Join(installer.PackageIDs, ","), } if _, err := tx.ExecContext(ctx, insertNewOrEditedInstaller, args...); err != nil { diff --git a/server/fleet/scripts.go b/server/fleet/scripts.go index 95e585aaf3..c6aeeaa934 100644 --- a/server/fleet/scripts.go +++ b/server/fleet/scripts.go @@ -372,6 +372,7 @@ type SoftwareInstallerPayload struct { URL string `json:"url"` PreInstallQuery string `json:"pre_install_query"` InstallScript string `json:"install_script"` + UninstallScript string `json:"uninstall_script"` PostInstallScript string `json:"post_install_script"` SelfService bool `json:"self_service"` } diff --git a/server/fleet/software_installer.go b/server/fleet/software_installer.go index 3893c63cf2..c9899fd124 100644 --- a/server/fleet/software_installer.go +++ b/server/fleet/software_installer.go @@ -383,6 +383,7 @@ type SoftwarePackageSpec struct { PreInstallQuery TeamSpecSoftwareAsset `json:"pre_install_query"` InstallScript TeamSpecSoftwareAsset `json:"install_script"` PostInstallScript TeamSpecSoftwareAsset `json:"post_install_script"` + UninstallScript TeamSpecSoftwareAsset `json:"uninstall_script"` } type SoftwareSpec struct { diff --git a/server/service/client.go b/server/service/client.go index 8d40d6d939..2818da432f 100644 --- a/server/service/client.go +++ b/server/service/client.go @@ -825,12 +825,23 @@ func buildSoftwarePackagesPayload(baseDir string, specs []fleet.SoftwarePackageS } } + var us []byte + if si.UninstallScript.Path != "" { + uninstallScriptFile := resolveApplyRelativePath(baseDir, si.UninstallScript.Path) + us, err = os.ReadFile(uninstallScriptFile) + if err != nil { + return nil, fmt.Errorf("Couldn't edit software (%s). Unable to read uninstall script file %s: %w", si.URL, + si.UninstallScript.Path, err) + } + } + softwarePayloads[i] = fleet.SoftwareInstallerPayload{ URL: si.URL, SelfService: si.SelfService, PreInstallQuery: qc, InstallScript: string(ic), PostInstallScript: string(pc), + UninstallScript: string(us), } } From 92c4c529c758566b86889fce5db9c7db9e12b3d5 Mon Sep 17 00:00:00 2001 From: Lucas Manuel Rodriguez Date: Thu, 12 Sep 2024 16:33:44 -0300 Subject: [PATCH 38/55] Fix breaking changes tests (#22054) Related to #21467 and #20320 --- ...eam_software_installer_uninstall_not_found.yml | 15 ++------------- server/service/integration_enterprise_test.go | 4 ++-- 2 files changed, 4 insertions(+), 15 deletions(-) diff --git a/cmd/fleetctl/testdata/gitops/no_team_software_installer_uninstall_not_found.yml b/cmd/fleetctl/testdata/gitops/no_team_software_installer_uninstall_not_found.yml index 812c05339f..c5c8838267 100644 --- a/cmd/fleetctl/testdata/gitops/no_team_software_installer_uninstall_not_found.yml +++ b/cmd/fleetctl/testdata/gitops/no_team_software_installer_uninstall_not_found.yml @@ -1,19 +1,8 @@ -# Test config +name: No team controls: -queries: policies: -agent_options: -org_settings: - server_settings: - server_url: $FLEET_SERVER_URL - org_info: - contact_url: https://example.com/contact - org_logo_url: "" - org_logo_url_light_background: "" - org_name: ${ORG_NAME} - secrets: [{"secret":"globalSecret"}] software: packages: - url: ${SOFTWARE_INSTALLER_URL}/ruby.deb uninstall_script: - path: lib/notfound.sh \ No newline at end of file + path: lib/notfound.sh diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index 97acc15219..24d0a3c1ca 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -10401,7 +10401,7 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndD require.NoError(t, s.ds.AddHostsToTeam(context.Background(), &createTeamResp.Team.ID, []uint{hostInTeam.ID})) // Create a software installation request - s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/install/%d", hostInTeam.ID, titleID), installSoftwareRequest{}, http.StatusAccepted) + s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", hostInTeam.ID, titleID), installSoftwareRequest{}, http.StatusAccepted) // requesting download with alt != media fails r = s.Do("POST", "/api/fleet/orbit/software_install/package?alt=FOOBAR", orbitDownloadSoftwareInstallerRequest{ @@ -10500,7 +10500,7 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndD require.Contains(t, errMsg, "only alt=media is supported") // Create a software installation request - s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/install/%d", hostInTeam.ID, titleID), installSoftwareRequest{}, http.StatusAccepted) + s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", hostInTeam.ID, titleID), installSoftwareRequest{}, http.StatusAccepted) // valid download r = s.Do("POST", "/api/fleet/orbit/software_install/package?alt=media", orbitDownloadSoftwareInstallerRequest{ From 3541ad6fa7f1ea039e72384a1210b9e83dc54e5e Mon Sep 17 00:00:00 2001 From: Eric Date: Thu, 12 Sep 2024 14:35:34 -0500 Subject: [PATCH 39/55] Website: update docsearch styles (#22056) Closes: https://github.com/fleetdm/fleet/issues/22055 Changes: - Updated docsearch.less to hide the duplicate placeholder text in the search modal. --- website/assets/styles/docsearch.less | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/website/assets/styles/docsearch.less b/website/assets/styles/docsearch.less index 9e831cbe1b..111bd4b235 100644 --- a/website/assets/styles/docsearch.less +++ b/website/assets/styles/docsearch.less @@ -149,6 +149,10 @@ justify-content: center; } +.DocSearch-VisuallyHiddenForAccessibility { + display: none; +} + .DocSearch-Container--Stalled .DocSearch-MagnifierLabel { display: none; } From 169d9de24c5d5b8c37b41768bd74c22a95032780 Mon Sep 17 00:00:00 2001 From: Lucas Manuel Rodriguez Date: Thu, 12 Sep 2024 16:56:12 -0300 Subject: [PATCH 40/55] Clear policy results and stats when setting or changing an installer (#22053) Follow up PR for #21428. After some discussions with Noah we want to clear policy results when a user sets for the first time or changes an installer on a policy. --- server/datastore/mysql/policies.go | 22 +++- server/datastore/mysql/policies_test.go | 118 ++++++++++++++++++ server/service/integration_enterprise_test.go | 106 +++++++++++++++- server/service/team_policies.go | 7 ++ 4 files changed, 242 insertions(+), 11 deletions(-) diff --git a/server/datastore/mysql/policies.go b/server/datastore/mysql/policies.go index f96f99289d..7ca49d3564 100644 --- a/server/datastore/mysql/policies.go +++ b/server/datastore/mysql/policies.go @@ -753,9 +753,10 @@ func (ds *Datastore) ApplyPolicySpecs(ctx context.Context, authorID uint, specs // Get the query and platforms of the current policies so that we can check if query or platform changed later, if needed type policyLite struct { - Name string `db:"name"` - Query string `db:"query"` - Platforms string `db:"platforms"` + Name string `db:"name"` + Query string `db:"query"` + Platforms string `db:"platforms"` + SoftwareInstallerID *uint `db:"software_installer_id"` } teamIDToPoliciesByName := make(map[*uint]map[string]policyLite, len(teamIDToPolicies)) for teamID, teamPolicySpecs := range teamIDToPolicies { @@ -769,10 +770,10 @@ func (ds *Datastore) ApplyPolicySpecs(ctx context.Context, authorID uint, specs var args []interface{} var err error if teamID == nil { - query, args, err = sqlx.In("SELECT name, query, platforms FROM policies WHERE team_id IS NULL AND name IN (?)", policyNames) + query, args, err = sqlx.In("SELECT name, query, platforms, software_installer_id FROM policies WHERE team_id IS NULL AND name IN (?)", policyNames) } else { query, args, err = sqlx.In( - "SELECT name, query, platforms FROM policies WHERE team_id = ? AND name IN (?)", *teamID, policyNames, + "SELECT name, query, platforms, software_installer_id FROM policies WHERE team_id = ? AND name IN (?)", *teamID, policyNames, ) } if err != nil { @@ -838,12 +839,21 @@ func (ds *Datastore) ApplyPolicySpecs(ctx context.Context, authorID uint, specs shouldRemoveAllPolicyMemberships bool removePolicyStats bool ) - // Figure out if the query or platform changed + // Figure out if the query, platform or software installer changed. + var softwareInstallerID *uint + if spec.SoftwareTitleID != nil { + softwareInstallerID = softwareInstallerIDs[teamID][*spec.SoftwareTitleID] + } if prev, ok := teamIDToPoliciesByName[teamID][spec.Name]; ok { switch { case prev.Query != spec.Query: shouldRemoveAllPolicyMemberships = true removePolicyStats = true + case teamID != nil && + ((prev.SoftwareInstallerID == nil && spec.SoftwareTitleID != nil) || + (prev.SoftwareInstallerID != nil && softwareInstallerID != nil && *prev.SoftwareInstallerID != *softwareInstallerID)): + shouldRemoveAllPolicyMemberships = true + removePolicyStats = true case prev.Platforms != spec.Platform: removePolicyStats = true } diff --git a/server/datastore/mysql/policies_test.go b/server/datastore/mysql/policies_test.go index c800eeee1c..96392e494f 100644 --- a/server/datastore/mysql/policies_test.go +++ b/server/datastore/mysql/policies_test.go @@ -4061,6 +4061,24 @@ func testApplyPolicySpecWithInstallers(t *testing.T, ds *Datastore) { require.NoError(t, err) team2, err := ds.NewTeam(ctx, &fleet.Team{Name: "team2"}) require.NoError(t, err) + newHost := func(name string, teamID *uint, platform string) *fleet.Host { + h, err := ds.NewHost(ctx, &fleet.Host{ + OsqueryHostID: ptr.String(uuid.New().String()), + DetailUpdatedAt: time.Now(), + LabelUpdatedAt: time.Now(), + PolicyUpdatedAt: time.Now(), + SeenTime: time.Now(), + NodeKey: ptr.String(uuid.New().String()), + UUID: uuid.New().String(), + Hostname: name, + TeamID: teamID, + Platform: platform, + }) + require.NoError(t, err) + return h + } + + host1Team1 := newHost("host1Team1", &team1.ID, "darwin") installer1ID, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ InstallScript: "hello", @@ -4113,6 +4131,24 @@ func testApplyPolicySpecWithInstallers(t *testing.T, ds *Datastore) { installer3, err := ds.GetSoftwareInstallerMetadataByID(ctx, installer3ID) require.NoError(t, err) require.NotNil(t, installer3.TitleID) + // Another installer on team1 to test changing installers. + installer5ID, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "hello5", + PreInstallQuery: "SELECT 5;", + PostInstallScript: "world5", + InstallerFile: bytes.NewReader([]byte("hello5")), + StorageID: "storage5", + Filename: "file5", + Title: "file5", + Version: "1.0", + Source: "programs", + UserID: user1.ID, + TeamID: &team1.ID, + }) + require.NoError(t, err) + installer5, err := ds.GetSoftwareInstallerMetadataByID(ctx, installer5ID) + require.NoError(t, err) + require.NotNil(t, installer5.TitleID) // Installers cannot be assigned to global policies. err = ds.ApplyPolicySpecs(ctx, user1.ID, []*fleet.PolicySpec{ @@ -4164,6 +4200,7 @@ func testApplyPolicySpecWithInstallers(t *testing.T, ds *Datastore) { require.NoError(t, err) require.Len(t, team1Policies, 1) require.NotNil(t, team1Policies[0].SoftwareInstallerID) + policy1Team1 := team1Policies[0] require.Equal(t, installer1.InstallerID, *team1Policies[0].SoftwareInstallerID) team2Policies, _, err := ds.ListTeamPolicies(ctx, team2.ID, fleet.ListOptions{}, fleet.ListOptions{}) require.NoError(t, err) @@ -4176,6 +4213,14 @@ func testApplyPolicySpecWithInstallers(t *testing.T, ds *Datastore) { require.NotNil(t, noTeamPolicies[0].SoftwareInstallerID) require.Equal(t, installer3.InstallerID, *noTeamPolicies[0].SoftwareInstallerID) + // Record policy execution on policy1Team1. + err = ds.RecordPolicyQueryExecutions(ctx, host1Team1, map[uint]*bool{ + policy1Team1.ID: ptr.Bool(false), + }, time.Now(), false) + require.NoError(t, err) + err = ds.UpdateHostPolicyCounts(ctx) + require.NoError(t, err) + // Unset software installer from "Team policy 1". err = ds.ApplyPolicySpecs(ctx, user1.ID, []*fleet.PolicySpec{ { @@ -4193,6 +4238,8 @@ func testApplyPolicySpecWithInstallers(t *testing.T, ds *Datastore) { require.NoError(t, err) require.Len(t, team1Policies, 1) require.Nil(t, team1Policies[0].SoftwareInstallerID) + // Should not clear results because we've cleared not changed/set-new installer. + require.Equal(t, uint(1), team1Policies[0].FailingHostCount) // Set "Team policy 1" to a software installer on team2. err = ds.ApplyPolicySpecs(ctx, user1.ID, []*fleet.PolicySpec{ @@ -4317,11 +4364,82 @@ func testApplyPolicySpecWithInstallers(t *testing.T, ds *Datastore) { require.Len(t, team1Policies, 1) require.NotNil(t, team1Policies[0].SoftwareInstallerID) require.Equal(t, installer1.InstallerID, *team1Policies[0].SoftwareInstallerID) + // Should clear results because we've are setting an installer. + require.Equal(t, uint(0), team1Policies[0].FailingHostCount) + countBiggerThanZero := true + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + return sqlx.GetContext(ctx, q, + &countBiggerThanZero, + `SELECT COUNT(*) > 0 FROM policy_membership WHERE policy_id = ?`, + team1Policies[0].ID, + ) + }) + require.False(t, countBiggerThanZero) team2Policies, _, err = ds.ListTeamPolicies(ctx, team2.ID, fleet.ListOptions{}, fleet.ListOptions{}) require.NoError(t, err) require.Len(t, team2Policies, 1) require.NotNil(t, team2Policies[0].SoftwareInstallerID) require.Equal(t, installer4.InstallerID, *team2Policies[0].SoftwareInstallerID) + + // Record policy execution on policy1Team1 to test that setting the same installer won't clear results. + err = ds.RecordPolicyQueryExecutions(ctx, host1Team1, map[uint]*bool{ + policy1Team1.ID: ptr.Bool(false), + }, time.Now(), false) + require.NoError(t, err) + err = ds.UpdateHostPolicyCounts(ctx) + require.NoError(t, err) + err = ds.ApplyPolicySpecs(ctx, user1.ID, []*fleet.PolicySpec{ + { + Name: "Team policy 1", + Query: "SELECT 1;", + Description: "Description 1", + Resolution: "Resolution 1", + Team: "team1", + Platform: "darwin", + SoftwareTitleID: installer1.TitleID, + }, + }) + require.NoError(t, err) + team1Policies, _, err = ds.ListTeamPolicies(ctx, team1.ID, fleet.ListOptions{}, fleet.ListOptions{}) + require.NoError(t, err) + require.Len(t, team1Policies, 1) + require.Equal(t, uint(1), team1Policies[0].FailingHostCount) + countBiggerThanZero = false + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + return sqlx.GetContext(ctx, q, + &countBiggerThanZero, + `SELECT COUNT(*) > 0 FROM policy_membership WHERE policy_id = ?`, + team1Policies[0].ID, + ) + }) + require.True(t, countBiggerThanZero) + + // Now change the installer, should clear results. + err = ds.ApplyPolicySpecs(ctx, user1.ID, []*fleet.PolicySpec{ + { + Name: "Team policy 1", + Query: "SELECT 1;", + Description: "Description 1", + Resolution: "Resolution 1", + Team: "team1", + Platform: "darwin", + SoftwareTitleID: installer5.TitleID, + }, + }) + require.NoError(t, err) + team1Policies, _, err = ds.ListTeamPolicies(ctx, team1.ID, fleet.ListOptions{}, fleet.ListOptions{}) + require.NoError(t, err) + require.Len(t, team1Policies, 1) + require.Equal(t, uint(0), team1Policies[0].FailingHostCount) + countBiggerThanZero = true + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + return sqlx.GetContext(ctx, q, + &countBiggerThanZero, + `SELECT COUNT(*) > 0 FROM policy_membership WHERE policy_id = ?`, + team1Policies[0].ID, + ) + }) + require.False(t, countBiggerThanZero) } func testTeamPoliciesNoTeam(t *testing.T, ds *Datastore) { diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index 24d0a3c1ca..21f1ba2b78 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -13553,6 +13553,35 @@ func (s *integrationEnterpriseTestSuite) TestPolicyAutomationsSoftwareInstallers policy1Team1, err = s.ds.Policy(ctx, policy1Team1.ID) require.NoError(t, err) require.Nil(t, policy1Team1.SoftwareInstallerID) + + host1LastInstall, err := s.ds.GetHostLastInstallData(ctx, host1Team1.ID, dummyInstallerPkgInstallerID) + require.NoError(t, err) + require.Nil(t, host1LastInstall) + + // Add some results and stats that should be cleared after setting an installer again. + distributedResp := submitDistributedQueryResultsResponse{} + s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults( + host1Team1, + map[uint]*bool{ + policy1Team1.ID: ptr.Bool(false), + }, + ), http.StatusOK, &distributedResp) + err = s.ds.UpdateHostPolicyCounts(ctx) + require.NoError(t, err) + policy1Team1, err = s.ds.Policy(ctx, policy1Team1.ID) + require.NoError(t, err) + require.Equal(t, uint(0), policy1Team1.PassingHostCount) + require.Equal(t, uint(1), policy1Team1.FailingHostCount) + passes := true + mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + return sqlx.GetContext(ctx, q, + &passes, + `SELECT passes FROM policy_membership WHERE policy_id = ? AND host_id = ?`, + policy1Team1.ID, host1Team1.ID, + ) + }) + require.False(t, passes) + // Back to associating dummy_installer.pkg to policy1Team1. mtplr = modifyTeamPolicyResponse{} s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team1.ID, policy1Team1.ID), modifyTeamPolicyRequest{ @@ -13564,6 +13593,77 @@ func (s *integrationEnterpriseTestSuite) TestPolicyAutomationsSoftwareInstallers require.NoError(t, err) require.NotNil(t, policy1Team1.SoftwareInstallerID) require.Equal(t, dummyInstallerPkgInstallerID, *policy1Team1.SoftwareInstallerID) + // Policy stats and membership should be cleared from policy1Team1. + require.Equal(t, uint(0), policy1Team1.PassingHostCount) + require.Equal(t, uint(0), policy1Team1.FailingHostCount) + countBiggerThanZero := true + mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + return sqlx.GetContext(ctx, q, + &countBiggerThanZero, + `SELECT COUNT(*) > 0 FROM policy_membership WHERE policy_id = ?`, + policy1Team1.ID, + ) + }) + require.False(t, countBiggerThanZero) + + // Add (again) some results and stats that should be cleared after changing an existing installer. + distributedResp = submitDistributedQueryResultsResponse{} + s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults( + host1Team1, + map[uint]*bool{ + policy1Team1.ID: ptr.Bool(false), + }, + ), http.StatusOK, &distributedResp) + err = s.ds.UpdateHostPolicyCounts(ctx) + require.NoError(t, err) + policy1Team1, err = s.ds.Policy(ctx, policy1Team1.ID) + require.NoError(t, err) + require.Equal(t, uint(0), policy1Team1.PassingHostCount) + require.Equal(t, uint(1), policy1Team1.FailingHostCount) + passes = true + mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + return sqlx.GetContext(ctx, q, + &passes, + `SELECT passes FROM policy_membership WHERE policy_id = ? AND host_id = ?`, + policy1Team1.ID, host1Team1.ID, + ) + }) + require.False(t, passes) + + // Change the installer (temporarily to test that changing an installer will clear results) + // Associate ruby.deb to policy1Team1. + mtplr = modifyTeamPolicyResponse{} + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team1.ID, policy1Team1.ID), modifyTeamPolicyRequest{ + ModifyPolicyPayload: fleet.ModifyPolicyPayload{ + SoftwareTitleID: &rubyDebTitleID, + }, + }, http.StatusOK, &mtplr) + + // After changing the installer, membership and stats should be cleared. + policy1Team1, err = s.ds.Policy(ctx, policy1Team1.ID) + require.NoError(t, err) + require.NotNil(t, policy1Team1.SoftwareInstallerID) + require.Equal(t, rubyDebInstallerID, *policy1Team1.SoftwareInstallerID) + // Policy stats and membership should be cleared from policy1Team1. + require.Equal(t, uint(0), policy1Team1.PassingHostCount) + require.Equal(t, uint(0), policy1Team1.FailingHostCount) + countBiggerThanZero = true + mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + return sqlx.GetContext(ctx, q, + &countBiggerThanZero, + `SELECT COUNT(*) > 0 FROM policy_membership WHERE policy_id = ?`, + policy1Team1.ID, + ) + }) + require.False(t, countBiggerThanZero) + + // Back to (again) associating dummy_installer.pkg to policy1Team1. + mtplr = modifyTeamPolicyResponse{} + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team1.ID, policy1Team1.ID), modifyTeamPolicyRequest{ + ModifyPolicyPayload: fleet.ModifyPolicyPayload{ + SoftwareTitleID: &dummyInstallerPkgTitleID, + }, + }, http.StatusOK, &mtplr) // Associate ruby.deb to policy2Team1. mtplr = modifyTeamPolicyResponse{} @@ -13573,10 +13673,6 @@ func (s *integrationEnterpriseTestSuite) TestPolicyAutomationsSoftwareInstallers }, }, http.StatusOK, &mtplr) - host1LastInstall, err := s.ds.GetHostLastInstallData(ctx, host1Team1.ID, dummyInstallerPkgInstallerID) - require.NoError(t, err) - require.Nil(t, host1LastInstall) - // We use DoJSONWithoutAuth for distributed/write because we want the requests to not have the // current user's "Authorization: Bearer " header. @@ -13584,7 +13680,7 @@ func (s *integrationEnterpriseTestSuite) TestPolicyAutomationsSoftwareInstallers // Failing policy1Team1 means an install request must be generated. // Failing policy2Team1 should not trigger a install request because it has a .deb attached to it (does not apply to macOS hosts). // Failing policy3Team1 should do nothing because it doesn't have any installers associated to it. - distributedResp := submitDistributedQueryResultsResponse{} + distributedResp = submitDistributedQueryResultsResponse{} s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults( host1Team1, map[uint]*bool{ diff --git a/server/service/team_policies.go b/server/service/team_policies.go index 74c22fe4e5..14cea750e1 100644 --- a/server/service/team_policies.go +++ b/server/service/team_policies.go @@ -493,6 +493,13 @@ func (svc *Service) modifyPolicy(ctx context.Context, teamID *uint, id uint, p f if err != nil { return nil, err } + // If the associated installer is changed (or it's set and the policy didn't have an associated installer) + // then we clear the results of the policy so that automation can be triggered upon failure + // (automation is currently triggered on the first failure or when it goes from passing to failure). + if softwareInstallerID != nil && (policy.SoftwareInstallerID == nil || *policy.SoftwareInstallerID != *softwareInstallerID) { + removeAllMemberships = true + removeStats = true + } policy.SoftwareInstallerID = softwareInstallerID } From 0e689745338ee78578d3b77a9295792ac65d1f51 Mon Sep 17 00:00:00 2001 From: JD Date: Thu, 12 Sep 2024 14:11:03 -0600 Subject: [PATCH 41/55] Article: Guide default teams (#22045) Article: Guide: Configuring default teams. https://github.com/fleetdm/confidential/issues/8004 --- ...ring-default-teams-for-devices-in-fleet.md | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 articles/configuring-default-teams-for-devices-in-fleet.md diff --git a/articles/configuring-default-teams-for-devices-in-fleet.md b/articles/configuring-default-teams-for-devices-in-fleet.md new file mode 100644 index 0000000000..1b22d16424 --- /dev/null +++ b/articles/configuring-default-teams-for-devices-in-fleet.md @@ -0,0 +1,46 @@ +# Configuring default teams for macOS, iOS, and iPadOS devices in Fleet + +Fleet allows you to configure default teams for macOS, iOS, and iPadOS devices as they automatically enroll in your instance. This ensures that devices are assigned to the correct teams and receive the appropriate apps and configuration profiles at enrollment. + +## Why configure default teams? + +The ability to assign default teams during device enrollment helps streamline the deployment process. Each device is automatically placed in its correct group, ensuring it receives the necessary configuration profiles and apps without requiring manual assignment. + +### Configuring default teams in Fleet + +Follow these steps to assign default teams to your devices: + +1. **Navigate to automatic enrollment settings**: + + - Go to **Settings > Integrations > Mobile device management (MDM)**, and locate the **Automatic enrollment** section. + +2. **Edit the ABM token**: + + - Click **Edit** next to the ABM token for which you want to configure default teams. + +3. **Assign default teams**: + + - In the modal, use the dropdowns to select the appropriate default team for each platform (macOS, iOS, and iPadOS). + +4. **Save your changes**: + + - After selecting the teams, click **Save** to apply the changes. New devices will be automatically assigned to the selected teams upon enrollment. + +## Benefits of configuring default teams + +1. **Streamlined deployment**: Devices are configured and ready for use immediately after enrollment, reducing manual setup time. + +2. **Reduced errors**: Automating team assignments helps avoid misconfigurations and ensures that the right profiles and apps are installed on the correct devices. + +## Conclusion + +Configuring default teams in Fleet simplifies the enrollment and management of Apple devices, ensuring that each device is assigned to the correct team immediately upon enrollment. This feature reduces manual setup tasks for IT teams by automating the assignment of configuration profiles and apps based on team specifications. By streamlining the deployment process and minimizing errors, configuring default teams ensures that devices are ready to use right out of the box, helping organizations save time and maintain consistency across their device fleet. + +For organizations managing a large number of macOS, iOS, or iPadOS devices, this feature plays a crucial role in automating routine tasks, increasing efficiency, and improving the overall deployment experience. It enables teams to focus on more critical tasks and be confident that newly enrolled devices are correctly configured. For more information on using Fleet, please refer to the [Fleet documentation](https://fleetdm.com/docs) and [guides](https://fleetdm.com/guides). + + + + + + + From eaa016b40cb519620e45156ad7f54d3ff6f27011 Mon Sep 17 00:00:00 2001 From: Robert Fairburn <8029478+rfairburn@users.noreply.github.com> Date: Thu, 12 Sep 2024 15:29:06 -0500 Subject: [PATCH 42/55] saml-auth-proxy saves alb logs and outputs sec grp. (#22030) --- terraform/addons/saml-auth-proxy/README.md | 2 ++ terraform/addons/saml-auth-proxy/main.tf | 2 +- terraform/addons/saml-auth-proxy/outputs.tf | 4 ++++ terraform/addons/saml-auth-proxy/variables.tf | 5 +++++ 4 files changed, 12 insertions(+), 1 deletion(-) diff --git a/terraform/addons/saml-auth-proxy/README.md b/terraform/addons/saml-auth-proxy/README.md index cae388b2b3..baaa39bac4 100644 --- a/terraform/addons/saml-auth-proxy/README.md +++ b/terraform/addons/saml-auth-proxy/README.md @@ -32,6 +32,7 @@ No requirements. | Name | Description | Type | Default | Required | |------|-------------|------|---------|:--------:| +|
[alb\_access\_logs](#input\_alb\_access\_logs) | n/a | `map(string)` | `{}` | no | | [alb\_target\_group\_arn](#input\_alb\_target\_group\_arn) | n/a | `string` | n/a | yes | | [base\_url](#input\_base\_url) | n/a | `string` | n/a | yes | | [cookie\_max\_age](#input\_cookie\_max\_age) | n/a | `string` | `"1h"` | no | @@ -53,6 +54,7 @@ No requirements. |------|-------------| | [fleet\_extra\_execution\_policies](#output\_fleet\_extra\_execution\_policies) | n/a | | [lb](#output\_lb) | n/a | +| [lb\_security\_group](#output\_lb\_security\_group) | n/a | | [lb\_target\_group\_arn](#output\_lb\_target\_group\_arn) | Keep for legacy support for now | | [name](#output\_name) | n/a | | [secretsmanager\_secret\_id](#output\_secretsmanager\_secret\_id) | n/a | diff --git a/terraform/addons/saml-auth-proxy/main.tf b/terraform/addons/saml-auth-proxy/main.tf index 2148e41c4f..6daa975d44 100644 --- a/terraform/addons/saml-auth-proxy/main.tf +++ b/terraform/addons/saml-auth-proxy/main.tf @@ -82,7 +82,7 @@ module "saml_auth_proxy_alb" { subnets = var.subnets security_groups = [aws_security_group.saml_auth_proxy_alb.id] # FIXME: Get this working eventually. - # access_logs = var.alb_config.access_logs + access_logs = var.alb_access_logs internal = true target_groups = [ diff --git a/terraform/addons/saml-auth-proxy/outputs.tf b/terraform/addons/saml-auth-proxy/outputs.tf index cea09cf5b3..afc268f9c8 100644 --- a/terraform/addons/saml-auth-proxy/outputs.tf +++ b/terraform/addons/saml-auth-proxy/outputs.tf @@ -17,6 +17,10 @@ output "lb" { value = module.saml_auth_proxy_alb } +output "lb_security_group" { + value = aws_security_group.saml_auth_proxy_alb.id +} + output "secretsmanager_secret_id" { value = aws_secretsmanager_secret.saml_auth_proxy_cert.id } diff --git a/terraform/addons/saml-auth-proxy/variables.tf b/terraform/addons/saml-auth-proxy/variables.tf index 66aa6677d7..f441c643e3 100644 --- a/terraform/addons/saml-auth-proxy/variables.tf +++ b/terraform/addons/saml-auth-proxy/variables.tf @@ -7,6 +7,11 @@ variable "alb_target_group_arn" { type = string } +variable "alb_access_logs" { + type = map(string) + default = {} +} + # variable "public_alb_security_group_id" { # type = string # } From 199dad272b3c8bad74c25ad8402e22875b68c8d1 Mon Sep 17 00:00:00 2001 From: Ian Littman Date: Thu, 12 Sep 2024 16:22:35 -0500 Subject: [PATCH 43/55] Add software installer extension column to database (#22017) #22044 This is distinct from the filename extension due to being based on package introspection. # Checklist for submitter - [x] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements) - [x] Added/updated tests - [x] If database migrations are included, checked table schema to confirm autoupdate - For database migrations: - [x] Checked schema for all modified table for columns that will auto-update timestamps during migration. - [x] Confirmed that updating the timestamps is acceptable, and will not cause unwanted side effects. - [x] Ensured the correct collation is explicitly set for character columns (`COLLATE utf8mb4_unicode_ci`). - [x] Manual QA for all new/changed functionality --- .../tables/20240905200000_UninstallPackages.go | 8 ++++++++ .../tables/20240905200000_UninstallPackages_test.go | 5 +++++ server/datastore/mysql/schema.sql | 2 ++ server/datastore/mysql/software_installers.go | 13 ++++++++++--- server/datastore/mysql/software_test.go | 12 ++++++------ server/fleet/software_installer.go | 2 ++ server/service/integration_enterprise_test.go | 6 +++--- 7 files changed, 36 insertions(+), 12 deletions(-) diff --git a/server/datastore/mysql/migrations/tables/20240905200000_UninstallPackages.go b/server/datastore/mysql/migrations/tables/20240905200000_UninstallPackages.go index 8bb4f0c008..e9596f5359 100644 --- a/server/datastore/mysql/migrations/tables/20240905200000_UninstallPackages.go +++ b/server/datastore/mysql/migrations/tables/20240905200000_UninstallPackages.go @@ -19,7 +19,9 @@ func Up_20240905200000(tx *sql.Tx) error { if _, err := tx.Exec(` ALTER TABLE software_installers ADD COLUMN package_ids TEXT COLLATE utf8mb4_unicode_ci NOT NULL, +ADD COLUMN extension VARCHAR(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', ADD COLUMN uninstall_script_content_id int unsigned NOT NULL, +ADD COLUMN updated_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), MODIFY COLUMN uploaded_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) `); err != nil { return fmt.Errorf("failed to alter software_installers: %w", err) @@ -59,6 +61,12 @@ MODIFY COLUMN uploaded_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) } } + // Add best-guess installer extensions if needed -- these will be updated later by a cron job to file contents based types + // Also set existing updated_at timestamps to uploaded_at since installers were previously immutable + if _, err := tx.Exec(`UPDATE software_installers SET extension = SUBSTRING_INDEX(filename,'.',-1), updated_at = uploaded_at`); err != nil { + return fmt.Errorf("failed to backfill best-guess installer extensions: %w", err) + } + // Add foreign key if _, err := tx.Exec(` ALTER TABLE software_installers diff --git a/server/datastore/mysql/migrations/tables/20240905200000_UninstallPackages_test.go b/server/datastore/mysql/migrations/tables/20240905200000_UninstallPackages_test.go index e4402341b7..0a9af607bc 100644 --- a/server/datastore/mysql/migrations/tables/20240905200000_UninstallPackages_test.go +++ b/server/datastore/mysql/migrations/tables/20240905200000_UninstallPackages_test.go @@ -93,6 +93,11 @@ func TestUp_20240905200000(t *testing.T) { require.NoError(t, err) assert.Equal(t, placeholderUninstallScriptWindows, windowsScript) + var extension string + err = db.Get(&extension, `SELECT extension FROM software_installers si WHERE si.id = 3 AND updated_at = uploaded_at`) + require.NoError(t, err) + assert.Equal(t, "exe", extension) + var status string err = db.Get(&status, "SELECT status FROM host_software_installs WHERE id = ?", hsi1) require.NoError(t, err) diff --git a/server/datastore/mysql/schema.sql b/server/datastore/mysql/schema.sql index 290ecaf577..8fb66675fb 100644 --- a/server/datastore/mysql/schema.sql +++ b/server/datastore/mysql/schema.sql @@ -1683,7 +1683,9 @@ CREATE TABLE `software_installers` ( `user_email` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', `url` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', `package_ids` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `extension` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', `uninstall_script_content_id` int unsigned NOT NULL, + `updated_at` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), PRIMARY KEY (`id`), UNIQUE KEY `idx_software_installers_team_id_title_id` (`global_or_team_id`,`title_id`), KEY `fk_software_installers_title` (`title_id`), diff --git a/server/datastore/mysql/software_installers.go b/server/datastore/mysql/software_installers.go index 67e847f666..774134935d 100644 --- a/server/datastore/mysql/software_installers.go +++ b/server/datastore/mysql/software_installers.go @@ -115,6 +115,7 @@ INSERT INTO software_installers ( title_id, storage_id, filename, + extension, version, package_ids, install_script_content_id, @@ -126,7 +127,7 @@ INSERT INTO software_installers ( user_id, user_name, user_email -) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, (SELECT name FROM users WHERE id = ?), (SELECT email FROM users WHERE id = ?))` +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, (SELECT name FROM users WHERE id = ?), (SELECT email FROM users WHERE id = ?))` args := []interface{}{ tid, @@ -134,6 +135,7 @@ INSERT INTO software_installers ( titleID, payload.StorageID, payload.Filename, + payload.Extension, payload.Version, strings.Join(payload.PackageIDs, ","), installScriptID, @@ -241,6 +243,7 @@ SELECT si.title_id, si.storage_id, si.filename, + si.extension, si.version, si.install_script_content_id, si.pre_install_query, @@ -283,6 +286,7 @@ SELECT si.title_id, si.storage_id, si.filename, + si.extension, si.version, si.install_script_content_id, si.pre_install_query, @@ -708,7 +712,8 @@ INSERT INTO software_installers ( team_id, global_or_team_id, storage_id, - filename, + filename, + extension, version, install_script_content_id, uninstall_script_content_id, @@ -723,7 +728,7 @@ INSERT INTO software_installers ( url, package_ids ) VALUES ( - ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, (SELECT id FROM software_titles WHERE name = ? AND source = ? AND browser = ''), ?, (SELECT name FROM users WHERE id = ?), (SELECT email FROM users WHERE id = ?), ?, ? ) @@ -733,6 +738,7 @@ ON DUPLICATE KEY UPDATE post_install_script_content_id = VALUES(post_install_script_content_id), storage_id = VALUES(storage_id), filename = VALUES(filename), + extension = VALUES(extension), version = VALUES(version), pre_install_query = VALUES(pre_install_query), platform = VALUES(platform), @@ -845,6 +851,7 @@ WHERE global_or_team_id = ? globalOrTeamID, installer.StorageID, installer.Filename, + installer.Extension, installer.Version, installScriptID, uninstallScriptID, diff --git a/server/datastore/mysql/software_test.go b/server/datastore/mysql/software_test.go index 3596281355..8bb804f566 100644 --- a/server/datastore/mysql/software_test.go +++ b/server/datastore/mysql/software_test.go @@ -3480,10 +3480,10 @@ func testListHostSoftware(t *testing.T, ds *Datastore) { } res, err := q.ExecContext(ctx, ` INSERT INTO software_installers - (team_id, global_or_team_id, title_id, filename, version, install_script_content_id, uninstall_script_content_id, storage_id, platform, self_service) + (team_id, global_or_team_id, title_id, filename, extension, version, install_script_content_id, uninstall_script_content_id, storage_id, platform, self_service) VALUES - (?, ?, ?, ?, ?, ?, ?, unhex(?), ?, ?)`, - teamID, globalOrTeamID, titleID, fmt.Sprintf("installer-%d.pkg", i), fmt.Sprintf("v%d.0.0", i), scriptContentID, + (?, ?, ?, ?, ?, ?, ?, ?, unhex(?), ?, ?)`, + teamID, globalOrTeamID, titleID, fmt.Sprintf("installer-%d.pkg", i), "pkg", fmt.Sprintf("v%d.0.0", i), scriptContentID, uninstallScriptContentID, hex.EncodeToString([]byte("test")), "darwin", i < 2) if err != nil { @@ -4354,10 +4354,10 @@ func testSetHostSoftwareInstallResult(t *testing.T, ds *Datastore) { res, err = q.ExecContext(ctx, ` INSERT INTO software_installers - (title_id, filename, version, install_script_content_id, uninstall_script_content_id, storage_id) + (title_id, filename, extension, version, install_script_content_id, uninstall_script_content_id, storage_id) VALUES - (?, ?, ?, ?, ?, unhex(?))`, - titleID, "installer.pkg", "v1.0.0", scriptContentID, uninstallScriptContentID, hex.EncodeToString([]byte("test"))) + (?, ?, ?, ?, ?, ?, unhex(?))`, + titleID, "installer.pkg", "pkg", "v1.0.0", scriptContentID, uninstallScriptContentID, hex.EncodeToString([]byte("test"))) if err != nil { return err } diff --git a/server/fleet/software_installer.go b/server/fleet/software_installer.go index c9899fd124..fc7249bafc 100644 --- a/server/fleet/software_installer.go +++ b/server/fleet/software_installer.go @@ -77,6 +77,8 @@ type SoftwareInstaller struct { TitleID *uint `json:"title_id" db:"title_id"` // Name is the name of the software package. Name string `json:"name" db:"filename"` + // Extension is the file extension of the software package, inferred from package contents. + Extension string `json:"-" db:"extension"` // Version is the version of the software package. Version string `json:"version" db:"version"` // Platform can be "darwin" (for pkgs), "windows" (for exes/msis) or "linux" (for debs). diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index 21f1ba2b78..40f7fb970c 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -11068,10 +11068,10 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerNewInstallRequestP _, err = q.ExecContext(ctx, ` INSERT INTO software_installers - (title_id, filename, version, install_script_content_id, uninstall_script_content_id, storage_id, team_id, global_or_team_id, pre_install_query) + (title_id, filename, extension, version, install_script_content_id, uninstall_script_content_id, storage_id, team_id, global_or_team_id, pre_install_query) VALUES - (?, ?, ?, ?, ?, unhex(?), ?, ?, ?)`, - titleID, fmt.Sprintf("installer.%s", kind), "v1.0.0", scriptContentID, uninstallScriptContentID, + (?, ?, ?, ?, ?, ?, unhex(?), ?, ?, ?)`, + titleID, fmt.Sprintf("installer.%s", kind), kind, "v1.0.0", scriptContentID, uninstallScriptContentID, hex.EncodeToString([]byte("test")), tm.ID, tm.ID, "foo") return err }) From b03b67723f14252e34b2fac388015a9908a693ef Mon Sep 17 00:00:00 2001 From: Sam Pfluger <108141731+Sampfluger88@users.noreply.github.com> Date: Thu, 12 Sep 2024 16:24:36 -0500 Subject: [PATCH 44/55] Add "Track an objection" to Sales README (#22061) --- handbook/sales/README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/handbook/sales/README.md b/handbook/sales/README.md index 8213a86777..126a67b8d5 100644 --- a/handbook/sales/README.md +++ b/handbook/sales/README.md @@ -25,6 +25,13 @@ This handbook page details processes specific to working [with](#contact-us) and The Sales department is directly responsible for attaining the revenue goals of Fleet and helping to deliver upon our customers' objectives. +### Track an objection + +We often hear objections to using Fleet that are important to track, understand, and solve for. To track an objection: +1. Navigate to the ["Understanding objections document" (Confidential Google Doc)](https://docs.google.com/document/d/1UFjHaIBdoSGDiqNqwgxRdwRz9Wn9SqP7h-g2OM8Runk/edit). +2. Copy the template at the top of the page and paste it at the top of the "Objections" section completing all TODOs. + + ### Onboard a new sales team member Once the standard Fleetie onboarding issue is complete, create a new ["Sales team onboarding"](https://github.com/fleetdm/confidential/issues/new?assignees=&labels=%23g-sales&projects=&template=sales-team-onboarding.md&title=Sales%20onboarding%3A_____________) issue and complete it. From a46450562ea93849dfb4b2d8abf98a9366f8d844 Mon Sep 17 00:00:00 2001 From: Zay Hanlon <114112018+zayhanlon@users.noreply.github.com> Date: Thu, 12 Sep 2024 17:49:43 -0400 Subject: [PATCH 45/55] Requestor to provide Gong snippet when available (#22062) Updating the feature request template to note that the Fleet requestor should provide a Gong snippet where a customer or prospect discussed a feature when available --- .github/ISSUE_TEMPLATE/feature-request.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/ISSUE_TEMPLATE/feature-request.md b/.github/ISSUE_TEMPLATE/feature-request.md index 45439ae92c..7955082fc7 100644 --- a/.github/ISSUE_TEMPLATE/feature-request.md +++ b/.github/ISSUE_TEMPLATE/feature-request.md @@ -11,6 +11,8 @@ assignees: '' Thanks for filing an issue! Please use the prompts below to provide as much context as you can about your use case and motivations. --> +Gong snippet: TODO + ## Problem -- [ ] UI changes: TODO -- [ ] CLI (fleetctl) usage changes: TODO -- [ ] YAML changes: TODO -- [ ] REST API changes: TODO -- [ ] Fleet's agent (fleetd) changes: TODO -- [ ] Permissions changes: TODO +- [ ] UI changes: TODO +- [ ] CLI (fleetctl) usage changes: TODO +- [ ] YAML changes: TODO +- [ ] REST API changes: TODO +- [ ] Fleet's agent (fleetd) changes: TODO +- [ ] Activity changes: TODO +- [ ] Permissions changes: TODO - [ ] Changes to paid features or tiers: TODO ### Engineering From cc8134af7660bf1a0fc73406af01efb495c9bc6d Mon Sep 17 00:00:00 2001 From: Rebecca Cowart Date: Thu, 12 Sep 2024 18:12:36 -0400 Subject: [PATCH 47/55] updated hyperlink capitalization (#22050) Changed the word "minor" in the "minor planets page" hyperlink to lowercase, because there is no need for it to be capitalized. --- handbook/customer-success/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/handbook/customer-success/README.md b/handbook/customer-success/README.md index ccac764339..0aeba57401 100644 --- a/handbook/customer-success/README.md +++ b/handbook/customer-success/README.md @@ -29,7 +29,7 @@ The customer success department is directly responsible for ensuring that custom Occasionally, we will need to track public issues for customers and prospects who wish to remain anonymous on our public issue tracker. To do this: -1. The team member creating the issue will choose an appropriate minor planet name from this [Minor planets page](https://minorplanetcenter.net//iau/lists/MPNames.html) (alphabetical). +1. The team member creating the issue will choose an appropriate minor planet name from this [minor planets page](https://minorplanetcenter.net//iau/lists/MPNames.html) (alphabetical). 2. Create a label in the fleetdm/fleet and fleetdm/confidential repos which can be attached to current and future issues for the customer or prospect. As part of the label description in the fleetdm/confidential repo, add the customer or prospect name. This way, we maintain a confidential mapping of codename to customer or prospect. From c8149fa5e2cb3b7952511878b3d7e454bd81dc7e Mon Sep 17 00:00:00 2001 From: Eric Date: Thu, 12 Sep 2024 17:17:52 -0500 Subject: [PATCH 48/55] Website: update contacts when users subscribe to Fleet Premium (#22004) Closes: #21921 Changes: - Updated `save-biling-info-and-subscribe` to update CRM records in the background when users purchase a self-service license. --- .../customers/save-billing-info-and-subscribe.js | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/website/api/controllers/customers/save-billing-info-and-subscribe.js b/website/api/controllers/customers/save-billing-info-and-subscribe.js index 31f6e7df7e..6369ebb06c 100644 --- a/website/api/controllers/customers/save-billing-info-and-subscribe.js +++ b/website/api/controllers/customers/save-billing-info-and-subscribe.js @@ -162,6 +162,22 @@ module.exports = { } }); + let todayOn = new Date(); + let isoTimestampForDescription = todayOn.toISOString(); + sails.helpers.salesforce.updateOrCreateContactAndAccount.with({ + emailAddress: this.req.me.emailAddress, + firstName: this.req.me.firstName, + lastName: this.req.me.lastName, + organization: this.req.me.organization, + description: `Purchased a self-service Fleet Premium license on ${isoTimestampForDescription.split('T')[0]} for ${quoteRecord.numberOfHosts} host${quoteRecord.numberOfHosts > 1 ? 's' : ''}.` + }).exec((err)=>{ + if(err){ + sails.log.warn(`Background task failed: When a user (email: ${this.req.me.emailAddress} purchased a self-service Fleet premium subscription, a Contact and Account record could not be created/updated in the CRM.`, err); + } + return; + }); + + } From 867029e9c0ecbad57dcdde390224b5b6ba7fe155 Mon Sep 17 00:00:00 2001 From: Eric Date: Thu, 12 Sep 2024 17:37:48 -0500 Subject: [PATCH 49/55] Website: Add Fleet Premium trial to get started questionnaire. (#21922) Related to: #18869 Changes: - Updated the /start questionnaire to generate a 30 day, 10 host trial for Fleet Premium when users submit the step before the "Is it any good?" step (Where the user is directed to try `fleetctl preview`) and to save the details (license key and expiration timestamp) of the trial to their user record. - Added two new attributes to the User model: - `fleetPremiumTrialLicenseKey`: A Fleet Premium license key that was generated for the user when they progressed through the get started questionnaire. - `fleetPremiumTrialLicenseKeyExpiresAt`: A JS timestamp of when the user's Fleet Premium trial license key expires. - Updated the try-fleet page to have copyable terminal commands, and to add the `--license_key` flag with the users license key to the command to run `fleetctl preview` --- .../save-questionnaire-progress.js | 15 +++ .../api/controllers/view-fleetctl-preview.js | 19 +++- website/api/models/User.js | 10 ++ website/assets/images/icon-copy-16x16@2x.png | Bin 0 -> 723 bytes .../icon-copy-clicked-checkmark-32x32@2x.png | Bin 0 -> 894 bytes .../assets/js/pages/fleetctl-preview.page.js | 38 +++++++- website/assets/resources/install-fleetctl.sh | 2 +- .../assets/styles/pages/fleetctl-preview.less | 85 ++++++++++++++++- website/views/pages/fleetctl-preview.ejs | 88 ++++++++++++++++-- 9 files changed, 239 insertions(+), 18 deletions(-) create mode 100644 website/assets/images/icon-copy-16x16@2x.png create mode 100644 website/assets/images/icon-copy-clicked-checkmark-32x32@2x.png diff --git a/website/api/controllers/save-questionnaire-progress.js b/website/api/controllers/save-questionnaire-progress.js index 5cf77ba3cf..4e3beddf0e 100644 --- a/website/api/controllers/save-questionnaire-progress.js +++ b/website/api/controllers/save-questionnaire-progress.js @@ -142,6 +142,21 @@ module.exports = { } else {// Otherwise, they have a use case and will be set to stage 4. psychologicalStage = '4 - Has use case'; } + // When the user submits the step before the "Is it any good?" step, we will generate them a 30 day Trial key for Fleet Premium that they can use with fleetctl preview + if(!userRecord.fleetPremiumTrialLicenseKey) { + let thirtyDaysFromNowAt = Date.now() + (1000 * 60 * 60 * 24 * 30); + let trialLicenseKeyForThisUser = await sails.helpers.createLicenseKey.with({ + numberOfHosts: 10, + organization: this.req.me.organization, + expiresAt: thirtyDaysFromNowAt, + }); + // Save the trial license key to the DB record for this user. + await User.updateOne({id: this.req.me.id}) + .set({ + fleetPremiumTrialLicenseKey: trialLicenseKeyForThisUser, + fleetPremiumTrialLicenseKeyExpiresAt: thirtyDaysFromNowAt, + }); + } } else if(currentStep === 'is-it-any-good') { if(currentSelectedBuyingSituation === 'mdm') { // Since the mdm use case question is the only buying situation-specific question where a use case can't diff --git a/website/api/controllers/view-fleetctl-preview.js b/website/api/controllers/view-fleetctl-preview.js index 0712b57acb..aa5d0c5600 100644 --- a/website/api/controllers/view-fleetctl-preview.js +++ b/website/api/controllers/view-fleetctl-preview.js @@ -30,8 +30,25 @@ module.exports = { fn: async function ({start}) { + let trialLicenseKey; + // Check to see if this user has a Fleet premium trial license key. + let userHasTrialLicense = this.req.me.fleetPremiumTrialLicenseKey; + let userHasExpiredTrialLicense = false; + if(userHasTrialLicense) { + if(this.req.me.fleetPremiumTrialLicenseKeyExpiresAt < Date.now()) { + userHasExpiredTrialLicense = true; + } + trialLicenseKey = this.req.me.fleetPremiumTrialLicenseKey; + } else { + trialLicenseKey = ''; + } + // Respond with view. - return {hideNextStepsButtons: start}; + return { + hideNextStepsButtons: start, + trialLicenseKey, + userHasExpiredTrialLicense, + }; } diff --git a/website/api/models/User.js b/website/api/models/User.js index 53be23f9c6..7f325e3ffa 100644 --- a/website/api/models/User.js +++ b/website/api/models/User.js @@ -261,6 +261,16 @@ without necessarily having a billing card.` description: 'A JS timestamp of when the stage 5 nurture email was sent to the user, or 1 if the user is unsubscribed from automated emails.', }, + fleetPremiumTrialLicenseKey: { + type: 'string', + description: 'A Fleet Premium license key that was generated for this user when they progressed through the get started questionnaire.', + }, + + fleetPremiumTrialLicenseKeyExpiresAt: { + type: 'number', + description: 'A JS timestamp of when this user\'s Fleet Premium trial license key expires.', + }, + // ╔═╗╔╦╗╔╗ ╔═╗╔╦╗╔═╗ // ║╣ ║║║╠╩╗║╣ ║║╚═╗ // ╚═╝╩ ╩╚═╝╚═╝═╩╝╚═╝ diff --git a/website/assets/images/icon-copy-16x16@2x.png b/website/assets/images/icon-copy-16x16@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..7c2c83b4e8c888bba3596bf99238efbf899b1b5c GIT binary patch literal 723 zcmV;^0xbQBP)o^ z+AtJ-67H($jBikKf^vhpCuqBG0G}YJ6VmA_u%tiG2L-_5 zCB*A=9YzeO532b!SHNfeeP|H36Y}X5qE{c4KgB&U(BmGhbdq#K2ha9p{uDOcQgMG5 zazO)%^TrAH+^gpV3Q}w z!lz^rpddc!Vf_hYhvpxqA}D)~^VX85wDtCRbEw^ioWW6Yq*@*F&^K%NByWxsh`h8T z1V+1t8l#4|m-Vtpx>JCCu@ud4>!6fx8_(S!;9>D7;88&30t9N$tzKBE08aU7eZeq@ zg31JNLCYyW?Q_aMR{q361z23=1|}m9IpuTv0X%>(wo1Fo1kgP*rCsRH5bg8Ur{gO} zU?5AY2W)gzb8Yb}`Hdy*&{Ho^0#5Z=JL`!_-m|iXc|iX?xxI`>Zq2>FH-SS&B@a9^bN`zz=T*(2T+FsrG=pd6^c5* zLWl&Niqt4jmBg`+`_4*89H$LcTwi?tEw!(Xl>h&}&-cZ?kD*V55TlOc-js(E04oZJ zygVL-9#j}uS$@kkdAw-b)+}SJ($5FkUKi}_h`g2Y?n(phKp(+-B85G4ZEHHm$e3NP z3rEd(!<&+S?_r3MkuhW2e7aXegc?Az&Mj{NewZf!R`zT@85RvZIPkk(FD6SoAt%)F z7V1)4@IM_9ki3}>(j5sc11RU~j`uOS;+wE?*5>D%_k>mo&aJCb%pWi!c-+ zwX<5iLHR#4KXL|4g$xid7!Jcp4S?aK2EcGq17JAD8<2s9+8Gcgo}d(n0<8?_CH}_s%aP;}Xso)~x?(7tvTi{mJsJ+(%OZ z$N14Bhm{FI;u23p_EeaJKAnBhWkQfR#S@V}7N@+Qf3>VqAdI-h69@8MfNq4r)j$XF z#DTpS`C9spjg3upTqum;#Mp7@K>B6~hLaiq!$}Q*;iLw@aL9nZ8VeFih5MZrrozfx z2=@~Vg+Z)wkMY+qm&N8*S=uOV+*Y})Z5`vZZ(cw=f?-fo6=_em%hO3N$I3Da9>5St zYqM=8ZJB8e%K^{jlSp)3;5SXR_YiiJ!juXQ+*`>s!Pge-9ZVZ1!!D;tpgc*!jgd5v zmR>!?eKk*mJNG%`w=q1Z+D6!!nSD1;2ccC$^%n{6n#HVhy0*`|vb;U?AMNvh0SVNd U(^43kegFUf07*qoM6N<$f^u4n6#xJL literal 0 HcmV?d00001 diff --git a/website/assets/js/pages/fleetctl-preview.page.js b/website/assets/js/pages/fleetctl-preview.page.js index 48dd281a4b..33938abf58 100644 --- a/website/assets/js/pages/fleetctl-preview.page.js +++ b/website/assets/js/pages/fleetctl-preview.page.js @@ -3,7 +3,20 @@ parasails.registerPage('fleetctl-preview', { // ║║║║║ ║ ║╠═╣║ ╚═╗ ║ ╠═╣ ║ ║╣ // ╩╝╚╝╩ ╩ ╩╩ ╩╩═╝ ╚═╝ ╩ ╩ ╩ ╩ ╚═╝ data: { - selectedPlatform: 'macos' + selectedPlatform: 'macos', + installCommands: { + macos: 'curl -sSL https://fleetdm.com/resources/install-fleetctl.sh | bash', + linux: 'curl -sSL https://fleetdm.com/resources/install-fleetctl.sh | bash', + windows: `for /f "tokens=1,* delims=:" %a in ('curl -s https://api.github.com/repos/fleetdm/fleet/releases/latest ^| findstr "browser_download_url" ^| findstr "_windows.zip"') do (curl -kOL %b) && if not exist "%USERPROFILE%\\.fleetctl" mkdir "%USERPROFILE%\\.fleetctl" && for /f "delims=" %a in ('dir /b fleetctl_*_windows.zip') do tar -xf "%a" --strip-components=1 -C "%USERPROFILE%\\.fleetctl" && del "%a"`, + npm: 'npm install fleetctl -g', + }, + fleetctlPreviewTerminalCommand: { + macos: '~/.fleetctl/fleetctl preview', + linux: '~/.fleetctl/fleetctl preview', + windows: `%USERPROFILE%\\.fleetctl\\fleetctl preview`, + npm: 'fleetctl preview', + } + }, // ╦ ╦╔═╗╔═╗╔═╗╦ ╦╔═╗╦ ╔═╗ @@ -20,6 +33,27 @@ parasails.registerPage('fleetctl-preview', { // ║║║║ ║ ║╣ ╠╦╝╠═╣║ ║ ║║ ║║║║╚═╗ // ╩╝╚╝ ╩ ╚═╝╩╚═╩ ╩╚═╝ ╩ ╩╚═╝╝╚╝╚═╝ methods: { - //… + clickCopyInstallCommand: async function(platform) { + let commandToInstallFleetctl = this.installCommands[platform]; + // https://caniuse.com/mdn-api_clipboard_writetext + $('[purpose="install-copy-button"]').addClass('copied'); + await setTimeout(()=>{ + $('[purpose="install-copy-button"]').removeClass('copied'); + }, 2000); + navigator.clipboard.writeText(commandToInstallFleetctl); + }, + + clickCopyTerminalCommand: async function(platform) { + let commandToRunFleetPreview = this.fleetctlPreviewTerminalCommand[platform]; + if(this.trialLicenseKey && !this.userHasExpiredTrialLicense){ + commandToRunFleetPreview += ' --license-key '+this.trialLicenseKey; + } + $('[purpose="command-copy-button"]').addClass('copied'); + await setTimeout(()=>{ + $('[purpose="command-copy-button"]').removeClass('copied'); + }, 2000); + // https://caniuse.com/mdn-api_clipboard_writetext + navigator.clipboard.writeText(commandToRunFleetPreview); + }, } }); diff --git a/website/assets/resources/install-fleetctl.sh b/website/assets/resources/install-fleetctl.sh index 4c21a19b11..08fb727385 100644 --- a/website/assets/resources/install-fleetctl.sh +++ b/website/assets/resources/install-fleetctl.sh @@ -48,7 +48,7 @@ echo echo "To start the local demo:" echo echo "1. Start Docker Desktop" -echo "2. Run ~/.fleetctl/fleetctl preview" +echo "2. To access your Fleet Premium trial, head to fleetdm.com/try-fleet and run the command in step 2." # Verify if the binary is executable if [[ ! -x "${FLEETCTL_INSTALL_DIR}/fleetctl" ]]; then diff --git a/website/assets/styles/pages/fleetctl-preview.less b/website/assets/styles/pages/fleetctl-preview.less index 443db87c12..60b055eed9 100644 --- a/website/assets/styles/pages/fleetctl-preview.less +++ b/website/assets/styles/pages/fleetctl-preview.less @@ -104,18 +104,27 @@ } } [purpose='terminal-commands'] { - padding: 16px 24px; + padding: 16px 60px 16px 24px; border: 1px solid @core-fleet-black-25; border-radius: 4px; margin: 16px 0px 0px; background: @ui-off-white; width: 100%; - overflow-x: scroll; + overflow: auto; scrollbar-width: none; + position: relative; &::-webkit-scrollbar { display: none; } - p { + [purpose='command-container'] { + overflow-x: scroll; + scrollbar-width: none; + position: relative; + &::-webkit-scrollbar { + display: none; + } + } + code { white-space: nowrap; color: @core-fleet-black-75; font-family: @code-font; @@ -123,9 +132,77 @@ font-size: 14px; line-height: @text-lineheight; margin-bottom: 0px; - padding-right: 24px; + padding: 0px; + border: none; + } + + [purpose='install-copy-button'], [purpose='command-copy-button'] { + display: none; + background: url('/images/icon-copy-16x16@2x.png'); + font-size: 32px; + position: absolute; + top: 14px; + right: 14px; + color: green; + width: 32px; + padding: 9px; + height: 32px; + background-size: 14px 14px; + background-position: center; + border-radius: 8px; + background-repeat: no-repeat; + cursor: pointer; + &.copied { + display: inline-block; + background: url('/images/icon-copy-clicked-checkmark-32x32@2x.png'); + background-size: 32px 32px; + background-repeat: no-repeat; + background-position: center; + } + + } + &:hover { + [purpose='install-copy-button'], [purpose='command-copy-button'] { + display: inline-block; + &:hover { + background-color: #F2F2F5; + } + } } } + [purpose='tip'] { + margin: 16px 0 32px; + background: #F4F4FF; + padding: 16px; + border-radius: 8px; + display: flex; + img { + display: flex; + margin: 4px 12px 0 0; + height: 16px; + width: 16px; + padding: 0px; + } + p { + display: block; + margin-bottom: 16px; + line-height: 24px; + font-size: 16px; + } + p:last-child { + margin-bottom: 0px; + } + ul { + padding-left: 16px; + } + ul:last-child { + margin-bottom: 0px; + } + li:last-child { + padding-bottom: 0px; + } + } + [purpose='docs-button'] { diff --git a/website/views/pages/fleetctl-preview.ejs b/website/views/pages/fleetctl-preview.ejs index 3eb350198a..068a3d3d4d 100644 --- a/website/views/pages/fleetctl-preview.ejs +++ b/website/views/pages/fleetctl-preview.ejs @@ -2,7 +2,7 @@

Try Fleet

-

The quickest way to try Fleet is to run a local demo with Docker.

+

The quickest way to try Fleet Premium is to run a local demo with Docker.

Follow the instructions below to test Fleet on your macOS, Windows, and Linux device.

@@ -27,11 +27,28 @@

Install the fleetctl command line tool:

-

curl -sSL https://fleetdm.com/resources/install-fleetctl.sh | bash

+
+
+ {{installCommands[selectedPlatform]}} +
+ +

Run a local demo of the Fleet server:

-

~/.fleetctl/fleetctl preview

+
+
+ {{fleetctlPreviewTerminalCommand[selectedPlatform]}} --license-key {{trialLicenseKey}} + {{fleetctlPreviewTerminalCommand[selectedPlatform]}} +
+ +
+
+ An icon indicating that this section has important information +
+

Your Fleet Premium trial license has expired. You can still run the free version of Fleet locally.

+
+

The Fleet UI is now available at http://localhost:1337. Use the credentials below to login:

@@ -44,12 +61,29 @@
-

Install the fleetctl command line tool:

-

curl -sSL https://fleetdm.com/resources/install-fleetctl.sh | bash

+

Install the fleetctl command line tool:

+
+
+ {{installCommands[selectedPlatform]}} +
+ +

Run a local demo of the Fleet server:

-

~/.fleetctl/fleetctl preview

+
+
+ {{fleetctlPreviewTerminalCommand[selectedPlatform]}} --license-key {{trialLicenseKey}} + {{fleetctlPreviewTerminalCommand[selectedPlatform]}} +
+ +
+
+ An icon indicating that this section has important information +
+

Your Fleet Premium trial license has expired. You can still run the free version of Fleet locally.

+
+

The Fleet UI is now available at http://localhost:1337. Use the credentials below to login:

@@ -63,11 +97,28 @@

Install the fleetctl command line tool:

-

for /f "tokens=1,* delims=:" %a in ('curl -s https://api.github.com/repos/fleetdm/fleet/releases/latest ^| findstr "browser_download_url" ^| findstr "_windows.zip"') do (curl -kOL %b) && if not exist "%USERPROFILE%\.fleetctl" mkdir "%USERPROFILE%\.fleetctl" && for /f "delims=" %a in ('dir /b fleetctl_*_windows.zip') do tar -xf "%a" --strip-components=1 -C "%USERPROFILE%\.fleetctl" && del "%a"

+
+
+ {{installCommands[selectedPlatform]}} +
+ +

Run a local demo of the Fleet server:

-

%USERPROFILE%\.fleetctl\fleetctl preview

+
+
+ {{fleetctlPreviewTerminalCommand[selectedPlatform]}} --license-key {{trialLicenseKey}} + {{fleetctlPreviewTerminalCommand[selectedPlatform]}} +
+ +
+
+ An icon indicating that this section has important information +
+

Your Fleet Premium trial license has expired. You can still run the free version of Fleet locally.

+
+

The Fleet UI is now available at http://localhost:1337. Use the credentials below to login:

@@ -88,11 +139,28 @@

Install the fleetctl command line tool:

-

npm install fleetctl -g

+
+
+ {{installCommands[selectedPlatform]}} +
+ +

Run a local demo of the Fleet server:

-

fleetctl preview

+
+
+ {{fleetctlPreviewTerminalCommand[selectedPlatform]}} --license-key {{trialLicenseKey}} + {{fleetctlPreviewTerminalCommand[selectedPlatform]}} +
+ +
+
+ An icon indicating that this section has important information +
+

Your Fleet Premium trial license has expired. You can still run the free version of Fleet locally.

+
+

The Fleet UI is now available at http://localhost:1337. Use the credentials below to login:

From f71d399b132548f575696f6b98d39cea5ca4600f Mon Sep 17 00:00:00 2001 From: Robert Fairburn <8029478+rfairburn@users.noreply.github.com> Date: Thu, 12 Sep 2024 19:45:17 -0500 Subject: [PATCH 50/55] Update mdmproxy module to force redeployment on secret change (#22065) --- terraform/addons/mdmproxy/main.tf | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/terraform/addons/mdmproxy/main.tf b/terraform/addons/mdmproxy/main.tf index 4e57a0dcfe..5d4a7ca51a 100644 --- a/terraform/addons/mdmproxy/main.tf +++ b/terraform/addons/mdmproxy/main.tf @@ -140,6 +140,11 @@ resource "aws_ecs_service" "mdmproxy" { desired_count = var.config.desired_count deployment_minimum_healthy_percent = 100 deployment_maximum_percent = 200 + force_new_deployment = true + + triggers = { + redeployment = md5(jsonencode(aws_secretsmanager_secret_version.mdmproxy.secret_string)) + } load_balancer { target_group_arn = module.alb.target_group_arns[0] From 3eccbb1bd09a13af7fbed79f345d3865505b0e85 Mon Sep 17 00:00:00 2001 From: Victor Lyuboslavsky Date: Thu, 12 Sep 2024 20:07:56 -0500 Subject: [PATCH 51/55] Uninstall migration cron job (#22036) --- cmd/fleet/cron.go | 26 +++++ cmd/fleet/serve.go | 10 ++ ee/server/service/software_installers.go | 66 +++++++++++ server/datastore/mysql/software_installers.go | 41 +++++++ server/fleet/cron_schedules.go | 1 + server/fleet/datastore.go | 6 + server/mock/datastore_mock.go | 24 ++++ server/service/integration_enterprise_test.go | 103 +++++++++++++++++- server/service/schedule/schedule.go | 12 ++ 9 files changed, 287 insertions(+), 2 deletions(-) diff --git a/cmd/fleet/cron.go b/cmd/fleet/cron.go index 96e7f7998f..83b50d4eef 100644 --- a/cmd/fleet/cron.go +++ b/cmd/fleet/cron.go @@ -11,6 +11,7 @@ import ( "strings" "time" + eeservice "github.com/fleetdm/fleet/v4/ee/server/service" eewebhooks "github.com/fleetdm/fleet/v4/ee/server/webhooks" "github.com/fleetdm/fleet/v4/server" "github.com/fleetdm/fleet/v4/server/config" @@ -1394,3 +1395,28 @@ func newIPhoneIPadRefetcher( return s, nil } + +// cronUninstallSoftwareMigration will update uninstall scripts for software. +// Once all customers are using on Fleet 4.57 or later, this job can be removed. +func cronUninstallSoftwareMigration( + ctx context.Context, + instanceID string, + ds fleet.Datastore, + softwareInstallStore fleet.SoftwareInstallerStore, + logger kitlog.Logger, +) (*schedule.Schedule, error) { + const ( + name = string(fleet.CronUninstallSoftwareMigration) + defaultInterval = 24 * time.Hour + ) + logger = kitlog.With(logger, "cron", name, "component", name) + s := schedule.New( + ctx, name, instanceID, defaultInterval, ds, ds, + schedule.WithLogger(logger), + schedule.WithRunOnce(true), + schedule.WithJob(name, func(ctx context.Context) error { + return eeservice.UninstallSoftwareMigration(ctx, ds, softwareInstallStore, logger) + }), + ) + return s, nil +} diff --git a/cmd/fleet/serve.go b/cmd/fleet/serve.go index 19dfd798aa..eabe158c70 100644 --- a/cmd/fleet/serve.go +++ b/cmd/fleet/serve.go @@ -829,6 +829,16 @@ the way that the Fleet server works. } }() + if softwareInstallStore != nil { + if err := cronSchedules.StartCronSchedule( + func() (fleet.CronSchedule, error) { + return cronUninstallSoftwareMigration(ctx, instanceID, ds, softwareInstallStore, logger) + }, + ); err != nil { + initFatal(err, fmt.Sprintf("failed to register %s", fleet.CronUninstallSoftwareMigration)) + } + } + if config.Server.FrequentCleanupsEnabled { if err := cronSchedules.StartCronSchedule( func() (fleet.CronSchedule, error) { diff --git a/ee/server/service/software_installers.go b/ee/server/service/software_installers.go index 5c090c6353..5826f488a1 100644 --- a/ee/server/service/software_installers.go +++ b/ee/server/service/software_installers.go @@ -24,6 +24,7 @@ import ( "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/mdm/apple/vpp" "github.com/fleetdm/fleet/v4/server/ptr" + kitlog "github.com/go-kit/log" "github.com/go-kit/log/level" "github.com/google/uuid" "golang.org/x/sync/errgroup" @@ -1165,3 +1166,68 @@ func packageExtensionToPlatform(ext string) string { return requiredPlatform } + +func UninstallSoftwareMigration( + ctx context.Context, + ds fleet.Datastore, + softwareInstallStore fleet.SoftwareInstallerStore, + logger kitlog.Logger, +) error { + // Find software installers without package_id + idMap, err := ds.GetSoftwareInstallersWithoutPackageIDs(ctx) + if err != nil { + return ctxerr.Wrap(ctx, err, "getting software installers without package_id") + } + if len(idMap) == 0 { + return nil + } + + // Download each package and parse it + for id, storageID := range idMap { + // check if the installer exists in the store + exists, err := softwareInstallStore.Exists(ctx, storageID) + if err != nil { + return ctxerr.Wrap(ctx, err, "checking if installer exists") + } + if !exists { + level.Warn(logger).Log("msg", "software installer not found in store", "software_installer_id", id, "storage_id", storageID) + continue + } + + // get the installer from the store + installer, _, err := softwareInstallStore.Get(ctx, storageID) + if err != nil { + return ctxerr.Wrap(ctx, err, "getting installer from store") + } + + meta, err := file.ExtractInstallerMetadata(installer) + if err != nil { + level.Warn(logger).Log("msg", "extracting metadata from installer", "software_installer_id", id, "storage_id", storageID, "err", + err) + continue + } + if len(meta.PackageIDs) == 0 { + level.Warn(logger).Log("msg", "no package_id found in metadata", "software_installer_id", id, "storage_id", storageID) + continue + } + if meta.Extension == "" { + level.Warn(logger).Log("msg", "no extension found in metadata", "software_installer_id", id, "storage_id", storageID) + continue + } + payload := fleet.UploadSoftwareInstallerPayload{ + PackageIDs: meta.PackageIDs, + Extension: meta.Extension, + } + payload.UninstallScript = file.GetUninstallScript(payload.Extension) + + // Update $PACKAGE_ID in uninstall script + preProcessUninstallScript(&payload) + + // Update the package_id in the software installer and the uninstall script + if err := ds.UpdateSoftwareInstallerWithoutPackageIDs(ctx, id, payload); err != nil { + return ctxerr.Wrap(ctx, err, "updating package_id in software installer") + } + } + + return nil +} diff --git a/server/datastore/mysql/software_installers.go b/server/datastore/mysql/software_installers.go index 774134935d..ee314da7d2 100644 --- a/server/datastore/mysql/software_installers.go +++ b/server/datastore/mysql/software_installers.go @@ -927,3 +927,44 @@ func (ds *Datastore) GetSoftwareTitleNameFromExecutionID(ctx context.Context, ex } return name, nil } + +func (ds *Datastore) GetSoftwareInstallersWithoutPackageIDs(ctx context.Context) (map[uint]string, error) { + query := ` + SELECT id, storage_id FROM software_installers WHERE package_ids = '' + ` + type result struct { + ID uint `db:"id"` + StorageID string `db:"storage_id"` + } + + var results []result + if err := sqlx.SelectContext(ctx, ds.reader(ctx), &results, query); err != nil { + return nil, ctxerr.Wrap(ctx, err, "get software installers without package ID") + } + if len(results) == 0 { + return nil, nil + } + idMap := make(map[uint]string, len(results)) + for _, r := range results { + idMap[r.ID] = r.StorageID + } + return idMap, nil +} + +func (ds *Datastore) UpdateSoftwareInstallerWithoutPackageIDs(ctx context.Context, id uint, + payload fleet.UploadSoftwareInstallerPayload) error { + uninstallScriptID, err := ds.getOrGenerateScriptContentsID(ctx, payload.UninstallScript) + if err != nil { + return ctxerr.Wrap(ctx, err, "get or generate uninstall script contents ID") + } + query := ` + UPDATE software_installers + SET package_ids = ?, uninstall_script_content_id = ? + WHERE id = ? + ` + _, err = ds.writer(ctx).ExecContext(ctx, query, strings.Join(payload.PackageIDs, ","), uninstallScriptID, id) + if err != nil { + return ctxerr.Wrap(ctx, err, "update software installer without package ID") + } + return nil +} diff --git a/server/fleet/cron_schedules.go b/server/fleet/cron_schedules.go index 250a8dc3b5..937fb85a51 100644 --- a/server/fleet/cron_schedules.go +++ b/server/fleet/cron_schedules.go @@ -24,6 +24,7 @@ const ( CronAppleMDMIPhoneIPadRefetcher CronScheduleName = "apple_mdm_iphone_ipad_refetcher" CronAppleMDMAPNsPusher CronScheduleName = "apple_mdm_apns_pusher" CronCalendar CronScheduleName = "calendar" + CronUninstallSoftwareMigration CronScheduleName = "uninstall_software_migration" ) type CronSchedulesService interface { diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index 80aede54b6..8184ca5015 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -1662,6 +1662,12 @@ 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) + // GetSoftwareInstallersWithoutPackageIDs returns a map of software installers to storage ids that do not have a package ID. + GetSoftwareInstallersWithoutPackageIDs(ctx context.Context) (map[uint]string, error) + + // UpdateSoftwareInstallerWithoutPackageIDs updates the software installer corresponding to the id. Used to add uninstall scripts. + UpdateSoftwareInstallerWithoutPackageIDs(ctx context.Context, id uint, payload UploadSoftwareInstallerPayload) error + GetVPPAppByTeamAndTitleID(ctx context.Context, teamID *uint, titleID uint) (*VPPApp, error) // GetVPPAppMetadataByTeamAndTitleID returns the VPP app corresponding to the // specified team and title ids. diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index 8634e6662a..323144afd7 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -1044,6 +1044,10 @@ type ValidateOrbitSoftwareInstallerAccessFunc func(ctx context.Context, hostID u type GetSoftwareInstallerMetadataByTeamAndTitleIDFunc func(ctx context.Context, teamID *uint, titleID uint, withScriptContents bool) (*fleet.SoftwareInstaller, error) +type GetSoftwareInstallersWithoutPackageIDsFunc func(ctx context.Context) (map[uint]string, error) + +type UpdateSoftwareInstallerWithoutPackageIDsFunc func(ctx context.Context, id uint, payload fleet.UploadSoftwareInstallerPayload) error + type GetVPPAppByTeamAndTitleIDFunc func(ctx context.Context, teamID *uint, titleID uint) (*fleet.VPPApp, error) type GetVPPAppMetadataByTeamAndTitleIDFunc func(ctx context.Context, teamID *uint, titleID uint) (*fleet.VPPAppStoreApp, error) @@ -2615,6 +2619,12 @@ type DataStore struct { GetSoftwareInstallerMetadataByTeamAndTitleIDFunc GetSoftwareInstallerMetadataByTeamAndTitleIDFunc GetSoftwareInstallerMetadataByTeamAndTitleIDFuncInvoked bool + GetSoftwareInstallersWithoutPackageIDsFunc GetSoftwareInstallersWithoutPackageIDsFunc + GetSoftwareInstallersWithoutPackageIDsFuncInvoked bool + + UpdateSoftwareInstallerWithoutPackageIDsFunc UpdateSoftwareInstallerWithoutPackageIDsFunc + UpdateSoftwareInstallerWithoutPackageIDsFuncInvoked bool + GetVPPAppByTeamAndTitleIDFunc GetVPPAppByTeamAndTitleIDFunc GetVPPAppByTeamAndTitleIDFuncInvoked bool @@ -6253,6 +6263,20 @@ func (s *DataStore) GetSoftwareInstallerMetadataByTeamAndTitleID(ctx context.Con return s.GetSoftwareInstallerMetadataByTeamAndTitleIDFunc(ctx, teamID, titleID, withScriptContents) } +func (s *DataStore) GetSoftwareInstallersWithoutPackageIDs(ctx context.Context) (map[uint]string, error) { + s.mu.Lock() + s.GetSoftwareInstallersWithoutPackageIDsFuncInvoked = true + s.mu.Unlock() + return s.GetSoftwareInstallersWithoutPackageIDsFunc(ctx) +} + +func (s *DataStore) UpdateSoftwareInstallerWithoutPackageIDs(ctx context.Context, id uint, payload fleet.UploadSoftwareInstallerPayload) error { + s.mu.Lock() + s.UpdateSoftwareInstallerWithoutPackageIDsFuncInvoked = true + s.mu.Unlock() + return s.UpdateSoftwareInstallerWithoutPackageIDsFunc(ctx, id, payload) +} + func (s *DataStore) GetVPPAppByTeamAndTitleID(ctx context.Context, teamID *uint, titleID uint) (*fleet.VPPApp, error) { s.mu.Lock() s.GetVPPAppByTeamAndTitleIDFuncInvoked = true diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index 40f7fb970c..d871544aeb 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -24,11 +24,14 @@ import ( "time" "github.com/fleetdm/fleet/v4/ee/server/calendar" + eeservice "github.com/fleetdm/fleet/v4/ee/server/service" + "github.com/fleetdm/fleet/v4/pkg/file" "github.com/fleetdm/fleet/v4/pkg/optjson" "github.com/fleetdm/fleet/v4/pkg/scripts" "github.com/fleetdm/fleet/v4/server/config" "github.com/fleetdm/fleet/v4/server/contexts/license" "github.com/fleetdm/fleet/v4/server/cron" + "github.com/fleetdm/fleet/v4/server/datastore/filesystem" "github.com/fleetdm/fleet/v4/server/datastore/mysql" "github.com/fleetdm/fleet/v4/server/datastore/redis/redistest" "github.com/fleetdm/fleet/v4/server/fleet" @@ -60,8 +63,9 @@ func TestIntegrationsEnterprise(t *testing.T) { type integrationEnterpriseTestSuite struct { withServer suite.Suite - redisPool fleet.RedisPool - calendarSchedule *schedule.Schedule + redisPool fleet.RedisPool + calendarSchedule *schedule.Schedule + softwareInstallStore fleet.SoftwareInstallerStore lq *live_query_mock.MockLiveQuery } @@ -72,6 +76,13 @@ func (s *integrationEnterpriseTestSuite) SetupSuite() { s.redisPool = redistest.SetupRedis(s.T(), "integration_enterprise", false, false, false) s.lq = live_query_mock.New(s.T()) var calendarSchedule *schedule.Schedule + + // Create a software install store + dir := s.T().TempDir() + softwareInstallStore, err := filesystem.NewSoftwareInstallerStore(dir) + require.NoError(s.T(), err) + s.softwareInstallStore = softwareInstallStore + config := TestServerOpts{ License: &fleet.LicenseInfo{ Tier: fleet.TierPremium, @@ -98,6 +109,7 @@ func (s *integrationEnterpriseTestSuite) SetupSuite() { } }, }, + SoftwareInstallStore: softwareInstallStore, } if os.Getenv("FLEET_INTEGRATION_TESTS_DISABLE_LOG") != "" { config.Logger = kitlog.NewNopLogger() @@ -10540,6 +10552,93 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndD // download the installer, not found anymore s.Do("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d/package?alt=media", titleID), nil, http.StatusNotFound, "team_id", fmt.Sprintf("%d", 0)) }) + + t.Run("uninstall migration for software installer", func(t *testing.T) { + var createTeamResp teamResponse + s.DoJSON("POST", "/api/latest/fleet/teams", &fleet.Team{ + Name: t.Name(), + }, http.StatusOK, &createTeamResp) + require.NotZero(t, createTeamResp.Team.ID) + + payload := &fleet.UploadSoftwareInstallerPayload{ + TeamID: &createTeamResp.Team.ID, + InstallScript: "another install script", + UninstallScript: "exit 1", + Filename: "ruby.deb", + // additional fields below are pre-populated so we can re-use the payload later for the test assertions + Title: "ruby", + Version: "1:2.5.1", + Source: "deb_packages", + StorageID: "df06d9ce9e2090d9cb2e8cd1f4d7754a803dc452bf93e3204e3acd3b95508628", + Platform: "linux", + } + s.uploadSoftwareInstaller(payload, http.StatusOK, "") + + logger := kitlog.NewLogfmtLogger(os.Stderr) + + // Run the migration when nothing is to be done + err = eeservice.UninstallSoftwareMigration(context.Background(), s.ds, s.softwareInstallStore, logger) + require.NoError(t, err) + + // check the software installer + installerID, titleID := checkSoftwareInstaller(t, payload) + + var origPackageIDs string + // Update DB by clearing package id + mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + if err := sqlx.GetContext(context.Background(), q, &origPackageIDs, `SELECT package_ids FROM software_installers WHERE id = ?`, + installerID); err != nil { + return err + } + require.NotEmpty(t, origPackageIDs) + if _, err = q.ExecContext(context.Background(), `UPDATE software_installers SET package_ids = '' WHERE id = ?`, + installerID); err != nil { + return err + } + return nil + }) + + // Check title to make it works without package id + respTitle := getSoftwareTitleResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", titleID), nil, http.StatusOK, &respTitle, "team_id", + fmt.Sprintf("%d", createTeamResp.Team.ID)) + require.NotNil(t, respTitle.SoftwareTitle.SoftwarePackage) + assert.Equal(t, "another install script", respTitle.SoftwareTitle.SoftwarePackage.InstallScript) + assert.Equal(t, "exit 1", respTitle.SoftwareTitle.SoftwarePackage.UninstallScript) + + // Run the migration + err = eeservice.UninstallSoftwareMigration(context.Background(), s.ds, s.softwareInstallStore, logger) + require.NoError(t, err) + + // Check package ID + mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + var packageIDs string + if err := sqlx.GetContext(context.Background(), q, &packageIDs, `SELECT package_ids FROM software_installers WHERE id = ?`, + installerID); err != nil { + return err + } + assert.Equal(t, origPackageIDs, packageIDs) + return nil + }) + + // Check uninstall script + uninstallScript := file.GetUninstallScript("deb") + uninstallScript = strings.ReplaceAll(uninstallScript, "$PACKAGE_ID", "\"ruby\"") + respTitle = getSoftwareTitleResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", titleID), nil, http.StatusOK, &respTitle, "team_id", + fmt.Sprintf("%d", createTeamResp.Team.ID)) + require.NotNil(t, respTitle.SoftwareTitle.SoftwarePackage) + assert.Equal(t, "another install script", respTitle.SoftwareTitle.SoftwarePackage.InstallScript) + assert.Equal(t, uninstallScript, respTitle.SoftwareTitle.SoftwarePackage.UninstallScript) + + // Running the migration again causes no issues. + err = eeservice.UninstallSoftwareMigration(context.Background(), s.ds, s.softwareInstallStore, logger) + require.NoError(t, err) + + // delete the installer + s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/software/titles/%d/available_for_install", titleID), nil, http.StatusNoContent, + "team_id", fmt.Sprintf("%d", *payload.TeamID)) + }) } func (s *integrationEnterpriseTestSuite) TestApplyTeamsSoftwareConfig() { diff --git a/server/service/schedule/schedule.go b/server/service/schedule/schedule.go index 7ca865416a..be6377c91b 100644 --- a/server/service/schedule/schedule.go +++ b/server/service/schedule/schedule.go @@ -47,6 +47,8 @@ type Schedule struct { jobs []Job statsStore CronStatsStore + + runOnce bool } // JobFn is the signature of a Job. @@ -120,6 +122,13 @@ func WithJob(id string, fn JobFn) Option { } } +// WithRunOnce sets the Schedule to run only once. +func WithRunOnce(once bool) Option { + return func(s *Schedule) { + s.runOnce = once + } +} + // New creates and returns a Schedule. // Jobs are added with the WithJob Option. // @@ -172,6 +181,9 @@ func (s *Schedule) Start() { startedAt := prevScheduledRun.CreatedAt if startedAt.IsZero() { startedAt = time.Now() + } else if s.runOnce && prevScheduledRun.Status == fleet.CronStatsStatusCompleted { + // If job is set to run once, and it already ran, then nothing to do + return } s.setIntervalStartedAt(startedAt) From 22fdd45832a1b1c7610c73e5fb965e0dced53021 Mon Sep 17 00:00:00 2001 From: Mike McNeil Date: Fri, 13 Sep 2024 01:21:02 -0500 Subject: [PATCH 52/55] Add finance department (#22067) Co-authored-by: Sampfluger88 --- CODEOWNERS | 14 +- articles/tales-from-fleet-security-soc2.md | 2 +- handbook/business-operations/README.md | 556 ------------------ handbook/company/README.md | 24 +- handbook/company/communications.md | 75 ++- handbook/company/handbook.md | 2 +- handbook/company/leadership.md | 45 +- handbook/company/why-this-way.md | 10 +- handbook/digital-experience/README.md | 225 ++++++- .../application-security.md} | 16 +- .../digital-experience.rituals.yml | 44 +- .../security-audits.md | 0 .../security-policies.md | 8 +- .../security.md | 12 +- .../vendor-questionnaires.md | 8 +- handbook/engineering/README.md | 4 +- handbook/engineering/engineering.rituals.yml | 2 +- handbook/finance/README.md | 345 +++++++++++ .../finance.rituals.yml} | 104 ++-- handbook/sales/README.md | 14 +- .../2022-05-security-awareness-slides.md | 4 +- website/config/custom.js | 2 +- website/config/routes.js | 10 +- 23 files changed, 744 insertions(+), 782 deletions(-) delete mode 100644 handbook/business-operations/README.md rename handbook/{business-operations/Application-security.md => digital-experience/application-security.md} (77%) rename handbook/{business-operations => digital-experience}/security-audits.md (100%) rename handbook/{business-operations => digital-experience}/security-policies.md (99%) rename handbook/{business-operations => digital-experience}/security.md (99%) rename handbook/{business-operations => digital-experience}/vendor-questionnaires.md (95%) create mode 100644 handbook/finance/README.md rename handbook/{business-operations/business-operations.rituals.yml => finance/finance.rituals.yml} (60%) diff --git a/CODEOWNERS b/CODEOWNERS index fae91d00d0..5f1c7e9bca 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -95,13 +95,13 @@ go.mod @fleetdm/go /handbook/README.md @mikermcneil /handbook/company/open-positions.yml @sampfluger88 /handbook/company/product-groups.md @lukeheath -/handbook/business-operations/README.md @sampfluger88 -/handbook/business-operations/business-operations.rituals.yml @sampfluger88 -/handbook/business-operations/Application-security.md @lukeheath -/handbook/business-operations/security-audits.md @lukeheath -/handbook/business-operations/security-policies.md @lukeheath -/handbook/business-operations/security.md @lukeheath -/handbook/business-operations/vendor-questionnaires.md @lukeheath +/handbook/finance/README.md @sampfluger88 +/handbook/finance/finance.rituals.yml @sampfluger88 +/handbook/digital-experience/application-security.md @lukeheath +/handbook/digital-experience/security-audits.md @lukeheath +/handbook/digital-experience/security-policies.md @lukeheath +/handbook/digital-experience/security.md @lukeheath +/handbook/digital-experience/vendor-questionnaires.md @lukeheath /handbook/digital-experience @sampfluger88 /handbook/customer-success @sampfluger88 /handbook/demand @sampfluger88 diff --git a/articles/tales-from-fleet-security-soc2.md b/articles/tales-from-fleet-security-soc2.md index c5b6d8aaaa..641583270a 100644 --- a/articles/tales-from-fleet-security-soc2.md +++ b/articles/tales-from-fleet-security-soc2.md @@ -43,7 +43,7 @@ One of the essential things about SOC 2 is having the right security policies. T Writing policies from scratch can seem daunting. Many compliance automation products have templates you can use to get started, but there are excellent free and open resources online. -As you can see, our policies are in our [handbook](https://fleetdm.com/handbook/business-operations/security-policies#information-security-policy-and-acceptable-use-policy), and we created most of them using this [free set of templates](https://github.com/JupiterOne/security-policy-templates) published by JupiterOne under Creative Commons licensing. +As you can see, our policies are in our [handbook](https://fleetdm.com/handbook/digital-experience/security-policies#information-security-policy-and-acceptable-use-policy), and we created most of them using this [free set of templates](https://github.com/JupiterOne/security-policy-templates) published by JupiterOne under Creative Commons licensing. We kept our policies as basic as possible to make sure everything in them is valuable and achievable. Having policies that state you must do the impossible is a surefire way of getting in trouble! The templates we used contained many processes and procedures as well. We used the policies and will eventually document more of our procedures in our handbook. diff --git a/handbook/business-operations/README.md b/handbook/business-operations/README.md deleted file mode 100644 index 74fa608680..0000000000 --- a/handbook/business-operations/README.md +++ /dev/null @@ -1,556 +0,0 @@ -# Business Operations -This handbook page details processes specific to working [with](#contact-us) and [within](#responsibilities) this department. - -## Team -| Role | Contributor(s) | -|:------------------------------|:-----------------------------------------------------------------------------------------------------------| -| Head of Business Operations | [Joanne Stableford](https://www.linkedin.com/in/joanne-stableford/) _([@jostableford](https://github.com/JoStableford))_ -| Business Operations Engineer | [Nathan Holliday](https://www.linkedin.com/in/nathanael-holliday/) _([@hollidayn](https://github.com/hollidayn))_
[Isabell Reedy](https://www.linkedin.com/in/isabell-reedy-202aa3123/) _([@ireedy](https://github.com/ireedy))_ - -## Contact us -- To **make a request** of this department, [create an issue](https://github.com/fleetdm/confidential/issues/new?assignees=&labels=%23g-business-operations&projects=&template=custom-request.md&title=Request%3A+_______________________) and a team member will get back to you within one business day (If urgent, mention a [team member](#team) in [#g-business-operations](https://fleetdm.slack.com/archives/C047N5L6EGH). - - Please **use issue comments and GitHub mentions** to communicate follow-ups or answer questions related to your request. - - Any Fleet team member can [view the kanban board](https://app.zenhub.com/workspaces/-g-business-operations-63f3dc3cc931f6247fcf55a9/board?sprints=none) for this department, including pending tasks and the status of new requests. - - -## Responsibilities -The Business Operations department is directly responsible for people operations, finance + invoicing, tax, compliance, and legal + deal desk. - - -### Run payroll -Many of these processes are automated, but it's vital to check Gusto and Plane manually for accuracy. - - Salaried fleeties are automated in Gusto and Plane. - - Hourly fleeties and consultants are a manual process each month in Gusto and Plane. - -| Payroll type | What to use | DRI | -|:-----------------------------|:-----------------------------|:-----------------------------| -| [Commissions and ramp](https://fleetdm.com/handbook/business-operations#run-us-commission-payroll) | "Off-cycle - Commission" payroll | Head of Business Operations -| Sign-on bonus | "Bonus" payroll | Head of Business Operations -| Performance bonus | "Bonus" payroll | Head of Business Operations -| Accelerations (quarterly) | "Off-cycle - Commission" payroll | Head of Business Operations -| [US contractor payroll](https://fleetdm.com/handbook/business-operations#run-us-contractor-payroll) | "Off-cycle" payroll | Head of Business Operations - -### Reconcile monthly recurring expenses -Recurring monthly or annual expenses, such as the tools we use throughout Fleet, are tracked as recurring, non-personnel expenses in ["🧮 The Numbers"](https://docs.google.com/spreadsheets/d/1X-brkmUK7_Rgp7aq42drNcUg8ZipzEiS153uKZSabWc/edit#gid=2112277278) _(¶confidential Google Sheet)_, along with their payment source. Reconciliation of recurring expenses happens monthly. - -> Use this spreadsheet as the source of truth. Always make changes to it first before adding or removing a recurring expense. Only track significant expenses. (Other things besides amount can make a payment significant; like it being an individualized expense, for example.) - - -### Access a background check -All Fleet team members undergo a background check provided through [Vetty](https://vetty.co/). Only the most recent background checks appear on the home page of Vetty's dashboard. To access a complete list of background checks run in Vetty, scroll down to the bottom of the candidates page and click "View Historical". - - -### Register Fleet as an employer with a new state -Fleet must register as an employer in any state where we hire new teammates. To do this, complete the following steps in Gusto: -1. After a new teammate completes their Gusto profile, the Business Operations department will be prompted to approve it for payroll. Sign in to your Gusto admin account and begin the approval process. -2. Select "yes" when prompted to file a new hire report and complete the approval process. -3. Once the profile is approved, navigate to Tax setup and select the state you’d like to register Fleet in. -4. Select “Have us register for you” and then “Start registration.” -5. Verify, add, and amend any company information to ensure accuracy. -6. Select “Send registration” and authorize payment for the specified amount. CorpNet will then send an email with next steps, which vary by state. -7. Update the [list of states that Fleet is currently registered with as an employer](https://fleetdm.com/handbook/business-operations#review-state-employment-tax-filings-for-the-previous-quarter). - - -### Process an email from a state agency -From time to time, you may get notices via email (or in the mail) from state agencies regarding Fleet's withholding and/or unemployment tax accounts. You can resolve some of these notices on your own by verifying and/or updating the settings in your Gusto account. - -If the notice is regarding an upcoming change to your deposit schedule or unemployment tax rate, make the required change in Gusto, such as: -- Update your unemployment tax rate. -- Update your federal deposit schedule. -- Update your state deposit schedule. - -In Gusto, you can click **How to review your notice** to help you understand what kind of notice you received and what additional action you can take to help speed up the time it takes to resolve the issue. - -> **Note:** Many agencies do not send notices to Gusto directly, so it’s important that you read and take action before any listed deadlines or effective dates of requested changes, in case you have to do something. If you can't resolve the notice on your own, are unsure what the notice is in reference to, or the tax notice has a missing payment or balance owed, follow the steps in the Report and upload a tax notice in Gusto. - -Every quarter, payroll and tax filings are due for each state. Gusto can handle these automatically if Third-party authorization (TPA) is enabled. Each state is unique and Gusto has a library of [State registration and resources](https://support.gusto.com/hub/Employers-and-admins/Taxes-forms-and-compliance/State-registration-and-resources) available to review. You will need to grant Third-party authorization (TPA) per state and this should be checked quarterly before the filing due dates to ensure that Gusto can file on time. --> - - -### Review state employment tax filings for the previous quarter - -Every quarter, payroll and tax filings are due for each state. Gusto automates this process, however there are often delays or quirks between Gusto's submission and the state receiving the filings. -To mitigate the risk of penalties and to ensure filings occur as expected, follow these steps in the first month of the new quarter, verifying past quarter submission: -1. Create an issue to "Review state filings for the previous quarter". -2. Copy this text block into the issue to track progress by state: - - -``` -States checked: -- [ ] California -- [ ] Colorado -- [ ] Connecticut -- [ ] Florida -- [ ] Georgia -- [ ] Hawaii -- [ ] Illinois -- [ ] Kansas -- [ ] Maryland -- [ ] Massachusetts -- [ ] New York -- [ ] Ohio -- [ ] Oregon -- [ ] Pennsylvania -- [ ] Rhode Island -- [ ] Tennessee -- [ ] Texas -- [ ] Utah -- [ ] Virginia -- [ ] Washington -- [ ] Washington, DC -- [ ] West Virginia -- [ ] Wisconsin -``` - - -3. Login to Gusto and navigate to "Taxes and compliance", then "Tax documents". -4. Login to each State portal (using the details saved in 1Password) and verify that the portal has received the automated submission from Gusto. -5. Check off states that are correct, and use comments to explain any quirks or remediation that's needed. - - -### Inform managers about hours worked - -Every Friday at 2:00 PM CT, we collect hours worked for all hourly employees at Fleet, including core team members and consultants, regardless of their location. - -Here's how: - -1. Consultants submit their hours through Gusto (US consultants) or Plane.com (international consultants) and require DRI approval (generally their manager) for hours worked. Find the DRI using the [Business Operations KPIs](https://docs.google.com/spreadsheets/d/1Hso0LxqwrRVINCyW_n436bNHmoqhoLhC8bcbvLPOs9A/edit#gid=0). -2. Send the teammate's DRI a direct message in Slack with a screenshot of the HRIS portal, showing hours logged since last Saturday at midnight, and ask them to confirm the hours are expected. Ensure the screenshot does not include compensation information. - - For international teammates, they cannot enter hours weekly in Plane.com, so you will need to request the hours worked from them in order to have the DRI approve them. -3. The following Monday, check for updates to logged hours and ensure the KPI sheet aligns with HRIS records. - - If there are discrepancies between what was previously reported, reconfirm logged hours with the teammate's DRI and update the KPI sheet to reflect the correct amount. - - -### Change the DRI of a consultant - -1. In the [KPIs](https://docs.google.com/spreadsheets/d/1Hso0LxqwrRVINCyW_n436bNHmoqhoLhC8bcbvLPOs9A/edit#gid=0) sheet, find the consultant's column. -2. Change the DRI documented there to the new DRI who will receive information about the consultant's hours. - -### Run US contractor payroll -For Fleet's US contractors, running payroll is a manual process: -1. Add the amount to be paid to the "Gross" line. -2. Review hours _("Time tools > Time tracking")_ -3. Adjust time frame to match current payroll period (the 27th through 26th of the month) -4. Sync hours and run contractor payroll. - -### Create an invoice -To create a new invoice for a Fleet customer, follow these steps: -1. Go to the [invoice folder in google drive](https://drive.google.com/drive/folders/11limC_KQYNYQPApPoXN0CplHo_5Qgi2b?usp=drive_link). -2. Create a copy of the invoice template, and title the copy `[invoice number] Fleet invoice - [customer name]`. - - The invoice number follows the format of `YYMMDD[daily issued invoice number]`, where the daily issued invoice number should equal `01` if it's the first invoice issued that day, `02` if it's the second, etc. -3. Edit the new invoice to reflect details from the signed subscription agreement (and PO if required). - - Enter the invoice number (and PO number if required) into the top right section of the invoice. - - Update the date of the invoice to reflect the current date. - - Make sure the payment terms match the signed subscription agreement. - - Copy the customer address from the signed subscription agreement and input it in the "Bill to" section of the invoice. - - Copy the "Billing contact" email from the signed subscription agreement and add it to the last line of the "Bill to" address. - - Make sure the start and end dates of the contract and amount match the subscription agreement. - - If professional services are included in the subscription agreement, include as a separate line in the invoice, and ensure the amounts total correctly. - - Ensure the "Notes" section has wiring instructions for payment via SVB. -4. Download the completed invoice as a PDF. -5. Send the PDF to the billing contact from the "Bill to" section of the invoice and cc [Fleet's billing email address](https://fleetdm.com/handbook/company/communications#email-relays). Use the following template for the email: - -``` -Subject: Invoice for Fleet Device Management [invoice number] -Hello, - -I've attached the invoice for [customer name]'s purchase of Fleet Device Management's premium subscription. -For payment instructions please refer to your invoice, and reach out to [insert Fleet's billing address] with any questions. - -Thanks, -[name] -``` - -6. Update the opportunity and the opportunity billing cycle in Salesforce to include the "Invoice date" as the day the invoice was sent. -8. Notify the AE/CSM that the invoice has been sent. - -> Certain vendors require invoices submitted via a payment portal (such as Coupa). Once you've generated the invoice using the steps above, upload it to the relevant payment portal and email the billing contact to let them know you've submitted the invoice. - - -### Communicate the status of customer financial actions -This reporting is performed to update the status of open or upcoming customer actions regarding the financial health of the opportunity. To complete the report: -1. Check [SVB](https://connect.svb.com/#/) and [Brex](https://accounts.brex.com/login) for any recently received payments from customers and record them in SFDC. -2. Go to this [report folder](https://fleetdm.lightning.force.com/lightning/r/Folder/00lUG000000DstpYAC/view?queryScope=userFolders) in SFDC. The three reports will provide the data used in the report. -3. Copy the template below and paste it into the [#g-sales slack channel](https://fleetdm.slack.com/archives/C030A767HQV) and complete all "todos" using the data from Salesforce before sending. - -``` -Weekly revenue report - [@`todo: CRO` and @`todo: CEO`] -- Number accounts with outstanding balances = `todo` -- Number of customers awaiting invoices = `todo` -- Number of past-due renewals = `todo` -``` - -4. Send payment reminders via email to all outstanding accounts by responding to the invoice email initially sent to the customer. - -``` -Hello, -This is a reminder that you have an outstanding balance due for your Fleet Device Management premium subscription. -We have included the invoice here for your convenience. -For payment instructions please refer to your invoice, and reach out to [Fleet's billing contact] with any questions. - -Thanks, -[name] -``` -5. If any accounts will become overdue within a week, reply in thread to the slack post, mention the opportunity owner of the account, and ask them to notify their contact that Fleet is still awaiting payment. -5. Review the [billing cycles](https://fleetdm.lightning.force.com/lightning/r/Report/00OUG000000yGjR2AU/view) report in SFDC for customers on multiyear deals. For any customers due for invoicing within the next week, create an issue on the Business Operations board. - - -### Run US commission payroll -1. Update individual teammates commission calculators (linked from [main commission calculator](https://docs.google.com/spreadsheets/d/1PuqUbfPGos87TfcHWgUd05TRJgQLlBmhyz1euj79m2A/edit?usp=sharing)) with new revenue from any deals that are closed-won (have a subscription agreement signed by both parties) and have a **close date** within the previous month. - - Verify closed-won deal numbers with CRO to ensure any agreed upon exceptions are captured (eg: CRO approves an AE to receive commission on a renewal deal due to cross-sell). -2. In the "Monthly commission payroll party" meeting, present the commission calculations for Fleeties receiving commission for approval. - - If there are any quarterly accelerators due for the teammate receiving commission, ensure the individual total includes both the monthly and the quarterly amount. -3. After the amounts are approved in the meeting, process the commission payroll. - - Use the off-cycle payroll option in Gusto. Be sure to classify the payment as "Commission" in the "other earnings" field and not the generic "Bonus." -4. Once commission payroll has been run, update the [main commission calculator](https://docs.google.com/spreadsheets/d/1PuqUbfPGos87TfcHWgUd05TRJgQLlBmhyz1euj79m2A/edit?usp=sharing) to mark the commission as paid. - -### Run international commission payroll -1. Follow the steps in [run US commission payroll](https://fleetdm.com/handbook/business-operations#run-us-commission-payroll) to have the commission amounts approved by the CRO. -2. After the amounts are approved in the "Monthly commission payroll party", navigate to Help > Ask a question in Plane to request a commission payment for the teammate. -3. Send a message using the following template - - ``` - Hello, - I’d like to run an off-cycle commission payment for [teammate’s full name] for the period of [commission period]. - The amount of [USD amount] should be paid with their next payroll. - Please let me know if you need any additional information to process this request. - - Thanks, - [name] - ``` - -4. Once Plane confirms the payroll change has been actioned, update the [main commission calculator](https://docs.google.com/spreadsheets/d/1PuqUbfPGos87TfcHWgUd05TRJgQLlBmhyz1euj79m2A/edit#gid=928324236) to mark the commission as paid. - - -### Run quarterly or annual employee bonus payroll -1. Update individual teammate bonus calculator (linked from [main commission calculator](https://docs.google.com/spreadsheets/d/1PuqUbfPGos87TfcHWgUd05TRJgQLlBmhyz1euj79m2A/edit?usp=sharing)) with relevant metrics. - - Bonus plans will have details specified on how to measure success, with most drawing from the [KPI spreadsheet](https://docs.google.com/spreadsheets/d/1Hso0LxqwrRVINCyW_n436bNHmoqhoLhC8bcbvLPOs9A/edit?usp=sharing) or from linked SFDC reports. If unsure where to pull achievement metrics from, contact teammate's manager to clarify. -2. In the "Monthly commission payroll party" meeting, present the bonus calculations for Fleeties receiving bonus for approval. -3. After the amounts are approved in the meeting, process the bonus payroll. - - Use the off-cycle payroll option in Gusto and be sure to classify the payment as "Bonus". - - For international teammates, you may need to use the "Help" function, or email support to notify Plane of the amount needing to be paid. -4. Once bonus payroll has been run, update the [main commission calculator](https://docs.google.com/spreadsheets/d/1PuqUbfPGos87TfcHWgUd05TRJgQLlBmhyz1euj79m2A/edit?usp=sharing) to mark the bonus as paid. - - -### Convert a Fleetie to a consultant -If a Fleetie decides they want to move to being a [consultant](https://fleetdm.com/handbook/company/leadership#consultants), either the Fleetie or their manager need to create a [custom issue for the BizOps team](https://github.com/fleetdm/confidential/issues/new?assignees=&labels=%23g-business-operations&projects=&template=custom-request.md&title=Request%3A+_______________________) to notify them of the change. -Once notified, BizOps takes the following steps: -1. Confirm the following details with the Fleetie: - - Date of change - - Term of consultancy (time period) - - Hours/capacity expected (hours per week or month) - - Confirm hourly rate -2. Once details are confirmed, use the information given to create the consulting agreement for the Fleetie (either in docusign (US-based) or via Plane (international)), and send to their personal email for signature. Once signed, save in Fleetie's [employee file](https://drive.google.com/drive/folders/1UL7o3BzkTKnpvIS4hm_RtbOilSABo3oG?usp=drive_link). -3. Schedule the Fleetie's final day in HRIS (Gusto or Plane). -4. Update final day in ["🧑‍🚀 Fleeties"](https://docs.google.com/spreadsheets/d/1OSLn-ZCbGSjPusHPiR5dwQhheH1K8-xqyZdsOe9y7qc/edit#gid=0) spreadsheet. -5. Create an [offboarding issue](https://github.com/fleetdm/classified/blob/main/.github/ISSUE_TEMPLATE/%F0%9F%9A%AA-offboarding-____________.md) for the Fleetie converting to a consultant, and confirm with their manager if there is a need to retain any tools or access while they are a consultant (default to removing all access from Fleet email, and migrating to personal email for Slack and other tools unless there is a business case to retain the Fleet email and associated tool access). -6. Follow the offboarding issue for next steps, including communicating to teammates and updating equity plan. - - -### Update personnel details -When a Fleetie, consultant or advisor requests an update to their personnel details (name, location, phone, etc), follow these steps to ensure accurate representation across systems. -1. Team member submits a [custom issue](https://github.com/fleetdm/confidential/issues/new?assignees=&labels=%23g-business-operations&projects=&template=custom-request.md&title=Request%3A+_______________________) to update their personnel details (or BizOps team creates if the request comes via email or is sensitive and needs a classified issue). - - If change is for a primary identification or contact method, ask for evidence of change and capture in [employee's personnel file](https://drive.google.com/drive/folders/1UL7o3BzkTKnpvIS4hm_RtbOilSABo3oG?usp=drive_link). -2. BizOps makes change to HRIS (Gusto or Plane) to reflect change. - - Note: if making the change requires follow up steps, resolve those steps to action the change. -3. Once change is effected in HRIS, BizOps makes changes to ["🧑‍🚀 Fleeties"](https://docs.google.com/spreadsheets/d/1OSLn-ZCbGSjPusHPiR5dwQhheH1K8-xqyZdsOe9y7qc/edit#gid=0) spreadsheet. -4. If required, BizOps makes any relevant changes to [Fleet's equity plan](https://docs.google.com/spreadsheets/d/1_GJlqnWWIQBiZFOoyl9YbTr72bg5qdSSp4O3kuKm1Jc/edit#gid=0). -5. If required, BizOps makes any relevant changes to the ["🗺️ Geographical factors"](https://docs.google.com/spreadsheets/d/1rCVCs-eOo-VSEG7fPLgdq5l7oSaActl5bewaWP7PnSE/edit#gid=1533353559) spreadsheet and follows through on any action items involving tax implications (i.e. registering with a new state for employer taxes). -6. If required, BizOps also makes changes to other core systems (e.g: creating a new email alias in google workspace; updating details in Carta; etc). -7. The change is now actioned, notify the team member and close the issue. - -> Note: if the Fleetie is US based and has a qualifying life event that impacts benefit coverage, they can [follow the Gusto steps](https://support.gusto.com/article/100895878100000/Change-your-benefits-with-a-qualifying-life-event) to update their coverage elections. - - -### Change a Fleetie's job title -When BizOps receives notification of a Fleetie's job title changing, follow these steps to ensure accurate recording of the change across our systems. -1. Update ["🧑‍🚀 Fleeties"](https://docs.google.com/spreadsheets/d/1OSLn-ZCbGSjPusHPiR5dwQhheH1K8-xqyZdsOe9y7qc/edit#gid=0): - - Search the spreadsheet for the Fleetie in need of a job title change. - - Input the new job title in the Fleetie's row in the "Job title" cell. - - Navigate to the "Org chart" tab of the spreadsheet, and verify that the Fleetie's title appears correctly in the org chart. -2. Update the departmental handbook page with the change of job title -3. [Prepare salary benchmarking information](https://fleetdm.com/handbook/business-operations#prepare-salary-benchmarking-information) to determine whether the teammate's current compensation aligns with the benchmarks of the new role. - - If the benchmark is significantly different, take the steps to [update a team member's compensation](https://fleetdm.com/handbook/business-operations#prepare-salary-benchmarking-information). -4. Update the relevant payroll/HRIS system. - - For updating Gusto (US-based Fleeties): - - Login to Gusto and navigate to "People > Team members". - - Find the Fleetie and select them to see their profile page. - - Under the "Compensation" heading, select edit and update the "Job title" and input the specific date the change happened. Save the changes. - - For updating Plane (non-US Fleeties): - - Login to Plane and navigate to "People > Team". - - Find the Fleetie and select them to see their profile page. - - Use the "Help" function, or email support@plane.com to notify Plane of the need to change the job title for the Fleetie. Include the Fleetie's name, current title, new title, and effective date. - - Take any relevant steps as directed by Plane in order to make the required changes to the Fleetie's profile. - - -### Change a Fleetie's manager -When BizOps receives notification of a Fleetie's manager changing, follow these steps to ensure correct recording in our systems. -1. Update [🧑‍🚀 Fleeties](https://docs.google.com/spreadsheets/d/1OSLn-ZCbGSjPusHPiR5dwQhheH1K8-xqyZdsOe9y7qc/edit#gid=0): - - Search for the Fleetie's new manager, and copy the new manager's unique ID from the far left "Unique ID" column. - - Search for the Fleetie whose manager is changing, and paste (without formatting) their new manager's unique ID in the "Reports to: (manager unique ID)" cell in the Fleetie's row. - - Verify that the "Reports to (auto: manager name and job title)" cell in the Fleetie's row reflects the new manager's details. - - Verify that in the new manager's row, the "# direct reports" cell reflect the correct number. - - Navigate to the "Org chart" tab in the spreadsheet, and verify that the Fleetie now appears in the correct place in the org chart. -2. If the person's department is changing, then update both departmental handbook pages to move the person to their new department: - - Remove the person from the "Team" section of the old department and add them to the "Team" section of the new department. -3. If the person's level of confidential access will change along with the change to their manager, then update that level of access: - - Update Google Workspace to make sure this person lives in the correct Google Group, removing them from the old and/or adding them to the new. - - Update 1password to remove this person from old vaults and/or add them to new vaults. - - For a team member moving from "classified" to "confidential" access, check Gusto, Plane, and other systems to remove their access. - -> **Note:** The Fleeties spreadsheet is the source of truth for who everyone's manager is and their job titles. - -### Recognize employee workiversaries - -At Fleet, everyone is recognized on their [workiversary](https://fleetdm.com/handbook/company/communications#workiversaries). To ensure this happens, take the following steps: - -1. Bimonthly, use [Fleeties (private google doc)](https://docs.google.com/spreadsheets/d/1OSLn-ZCbGSjPusHPiR5dwQhheH1K8-xqyZdsOe9y7qc/edit#gid=0) to determine who is celebrating their workiversary in the following two months. -2. Post in the #help-classifed Slack channel and cc the Head of Business Operations. Use the following template: - - - ``` - [Month] - [workiversary date (DD-MMM)] - [teammate name] - [number of years at Fleet] - ``` - - - The Apprentice to the CEO will also use this post to update the [All hands](https://fleetdm.com/handbook/company/communications#all-hands) deck. -3. On the day prior to a workiversary, send the teammate’s manager a DM on Slack: - - - ``` - Hey! Just a heads up, tomorrow is [teammate’s name] [number of years at Fleet] workiversary at Fleet. - BizOps were planning on posting something in the #random channel to recognize them, but I was wondering if you would like to instead? - ``` - - - > If a manager elects to post and hasn't done so by 2pm ET on the day of the workiversary, send them a friendly reminder and offer to post instead. - -4. If the manager has deferred to BizOps, schedule a Slack post for the following day to recognize the teammate's contributions at Fleet. If you’re unsure about what to post, take a look at what’s been [posted previously](https://docs.google.com/document/d/1Va4TYAs9Tb0soDQPeoeMr-qHxk0Xrlf-DUlBe4jn29Q/edit). - - - -### Prepare salary benchmarking information -1. Use the relevant template text in the README section of the [¶¶ 💌 Compensation decisions document](https://docs.google.com/document/d/1NQ-IjcOTbyFluCWqsFLMfP4SvnopoXDcX0civ-STS5c/edit?usp=sharing) for a current Fleetie, a new role, a prospective hire, or other benchmarking use case. -2. Copy the template text and paste at the end of the document. -3. Fill in details as required, pulling from [🧑‍🚀 Fleeties spreadsheet](https://docs.google.com/spreadsheets/d/1OSLn-ZCbGSjPusHPiR5dwQhheH1K8-xqyZdsOe9y7qc/edit#gid=0) and [equity spreadsheet](https://docs.google.com/spreadsheets/d/1_GJlqnWWIQBiZFOoyl9YbTr72bg5qdSSp4O3kuKm1Jc/edit?usp=sharing) as required. -4. Use the teammate's information to benchmark in [Pave](https://www.pave.com/) (login details in 1Password). You can pattern match from previous benchmarking entries, and include all company assumtions. Add the direct link to the Pave benchmark. - - -### Update a team member's compensation -To [change a teammate's compensation](https://fleetdm.com/handbook/company/communications#compensation-changes), follow these steps: -1. Create a copy of the ["Values assessment" template](https://docs.google.com/spreadsheets/d/1P5TyRV2v-YN0aR_X8vd8GksKcr3uHfUDdshqpVzamV8/edit?usp=drive_link) and move it to the teammate's [personnel folder in Google Drive](https://drive.google.com/drive/folders/1UL7o3BzkTKnpvIS4hm_RtbOilSABo3oG?usp=drive_link). -2. Share the values assessment document with the manager and ask them to perform the values assessment. -3. Once the values assessment is complete, [prepare salary benchmarking information](#prepare-salary-benchmarking-information) and notify the Head of Business Operations so the compensation change can be added to the e-group agenda for discussion amongst Fleet leadership. - - If the teammate's manager is not part of the e-group, the Head of Business Operations will ensure they're included in the discussion at e-group as well. -4. Once compensation decisions have been finalized, the Head of Business Operations will post in slack to `#help-classified` to confirm the decisions have been recorded in ["¶¶ 💌 Compensation decisions (offer math)"](https://docs.google.com/document/d/1NQ-IjcOTbyFluCWqsFLMfP4SvnopoXDcX0civ-STS5c/edit#heading=h.slomq4whmyas). -5. Send the teammates manager a Slack DM to determine who will communicate the decision to the teammate. -6. Update the respective payroll platform (Gusto or Plane) by navigating to the personnel page, selecting salary field, and updating with an effective date that makes the next payroll. -7. Update the [equity spreadsheet](https://docs.google.com/spreadsheets/d/1_GJlqnWWIQBiZFOoyl9YbTr72bg5qdSSp4O3kuKm1Jc/edit?usp=sharing) (internal doc) by copying existing OTE to the bottom of the "Notes" cell, updating the OTE column with the new compensation information, and updating the "Last compensation change" column with the effective date from payroll platform. -8. Calculate the monthly burn rate increase percentage and notify the CEO via a Slack DM. - -> If the company decides on an additional equity grant as part of a compensation change, note the previous equity and new situation in detail in the "Notes" column of the equity plan. Update the "Grant started?" column to "todo" which adds it to the queue for the next time grants are processed (quarterly). - -### Review Fleet's US company benefits - -Annually, around mid-year, Fleet will be prompted by Gusto to review company benefits. The goal is to keep changes minimal. Follow these steps: -1. Log in to your [Gusto admin account](https://gusto.com/). -2. Navigate to "Benefits" and select "Renewal survey". -3. Complete the survey questions, aiming for minimal changes. -4. Approximately 2-3 months after survery completion, Gusto will suggest plans based on Fleet's responses. Choose plans with minimal changes. -5. Gusto will offer these plans to employees during open enrollment, with new coverage starting 3-4 weeks afterward. - - -### Process monthly accounting -Create a [new montly accounting issue](https://github.com/fleetdm/confidential/issues/new/choose) for the current month and year named "Closing out YYYY-MM" in GitHub and complete all of the tasks in the issue. (This uses the [monthly accounting issue template](https://github.com/fleetdm/confidential/blob/main/.github/ISSUE_TEMPLATE/5-monthly-accounting.md). - -- **SLA:** The monthly accounting issue should be completed and closed before the 7th of the month. -- The close date is tracked each month in [KPIs](https://docs.google.com/spreadsheets/d/1Hso0LxqwrRVINCyW_n436bNHmoqhoLhC8bcbvLPOs9A/edit). -- **When is the issue created?** We create and close the monthly accounting issue for the previous month within the first 7 days of the following month. For example, the monthly accounting issue to close out the month of January is created promptly in February and closed before the end of the day, Feb 7th. A convenient trick is to create the issue on the first Friday of the month and close it ASAP. - - -### Respond to low credit alert -Fleet admins will receive an email alert when the usage of company cards for the month is aproaching the company credit limit. To avoid the limit being exceeded, a Brex admin will follow these steps: -1. Sign in to Fleet's Brex account. -2. On the landing page, use the "Move money" button to "Add funds to your Brex business accounts". -3. Select "Transfer from a connected account" and select the primary business account. -4. Choose the "One time" transfer option and process the transfer. - -No further action needs to be taken, the amount available for use will increase without disruption to regular processes. - -### Check franchise tax status -No later than the second month of every quarter, we check [Delaware divison of corporations](https://icis.corp.delaware.gov) to ensure that Fleet has paid the quarterly franchise tax amounts to remain in good standing with the state of Delaware. -- Go to the [DCIS - eCorp website](https://icis.corp.delaware.gov/ecorp/logintax.aspx?FilingType=FranchiseTax) and use the details in 1Password to look up Fleet's status. -- If no outstanding amounts: the tax has been paid. -- If outstanding amounts shown: ensure payment before due date to avoid penalties, interest, and entering bad standing. - - -### Check finances for quirks -Every quarter, we check Quickbooks Online (QBO) for discrepancies and follow up on quirks. -1. Check to make sure [bookkeeping quirks](https://docs.google.com/spreadsheets/d/1nuUPMZb1z_lrbaQEcgjnxppnYv_GWOTTo4FMqLOlsWg/edit?usp=sharing) are all accounted for and resolved or in progress toward resolution. -2. Check balance sheet and profit and loss statements (P&Ls) in QBO against the latest [monthly workbooks](https://drive.google.com/drive/folders/1ben-xJgL5MlMJhIl2OeQpDjbk-pF6eJM) in Google Drive. Ensure reports are in the "accural" accounting method. -3. Reach out to Pilot with any differences or quirks, and ask them to resolve/provide clarity. This often will need to happen over a call to review sycnhronously. -4. Once quirks are resolved, note the day it was resolved in the spreadsheet. - - -### Report quarterly numbers in Chronograph -Follow these steps to perform quarterly reporting for Fleet's investors: -1. Login to Chronograph and upload our profit and loss statement (P&L), balance sheet and cash flow statements for CRV (all in one book saved in [Google Drive](https://drive.google.com/drive/folders/1ben-xJgL5MlMJhIl2OeQpDjbk-pF6eJM). -2. Provide updated metrics for the following items using Fleet's [KPI spreadsheet](https://docs.google.com/spreadsheets/d/1Hso0LxqwrRVINCyW_n436bNHmoqhoLhC8bcbvLPOs9A/edit#gid=0). - - Headcount at end of the previous quarter. - - Starting ARR for the previous quarter. - - Total new ARR for the previous quarter. - - "Upsell ARR" (new ARR from expansions only- Chronograph defines "upsell" as price increases for any reason. - **- Fleet does not "upsell" anything; we deliver more value and customers enroll more hosts), downgrade ARR and churn ARR (if any) for the previous quarter.** - - Ending ARR for the previous quarter. - - Starting number of customers, churned customers, and the number of new customers Fleet gained during the previous quarter. - - Total amount of Fleet customers at the end of the previous quarter. - - Gross margin % - - How to calculate: (total revenue for the quarter - cost of goods sold for the quarter)/total revenue for the quarter (these metrics can be found in our books from Pilot). Chronograph will automatically conver this number to a %. - - Net dollar retention rate - - How to calculate: (starting ARR + new subscriptions and expansions - churn)/starting ARR. - - Cash burn - - How to calculate: start of quarter runway - end of quarter runway. - - -### Grant equity -Equity grants for new hires are queued up as part of the [hiring process](https://fleetdm.com/handbook/business-operations#hiring), then grants and consents are [batched and processed quarterly](https://github.com/fleetdm/confidential/issues/new/choose). - -Doing an equity grant involves: -- Executing a board consent -- The recipient and CEO signing paperwork about the stock options -- Updating the number of shares for the recipient in the equity plan -- Updating Carta to reflect the grant - -For the status of stock option grants, exercises, and all other _common stock_ including advisor, founder, and team member equity ownership, see [Fleet's equity plan](https://docs.google.com/spreadsheets/d/1_GJlqnWWIQBiZFOoyl9YbTr72bg5qdSSp4O3kuKm1Jc/edit#gid=0). For information about investor ownership, see [Carta](https://app.carta.com/corporations/1234715/summary/). - -> Fleet's [equity plan](https://docs.google.com/spreadsheets/d/1_GJlqnWWIQBiZFOoyl9YbTr72bg5qdSSp4O3kuKm1Jc/edit#gid=0) is the source of truth, not Carta. Neither are pro formas sent in an email attachment, even if they come from lawyers. -> -> Anyone can make mistakes, and none of us are perfect. Even when we triple check. Small mistakes in share counts can be hard to attribute, and can cause headaches and eat up nights of our CEO's and operations team's time. If you notice what might be a discrepancy between the equity plan and any other secondary source of information, please speak up and let Fleet's CEO know ASAP. Even if you're wrong, your note will be appreciated. - - -### Deliver annual report for venture line -Within 60 days of the end of the year, follow these steps: -1. Provide Silicon Valley Bank (SVB) with our balance sheet and profit and loss statement (P&L, sometimes called a cashflow statement) for the past twelve months. -2. Provide SVB with our board-approved annual operating budgets and projections (on a quarterly granularity) for the new year. -3. Deliver this as early as possible in case they have questions. - - -### Process a new vendor invoice -Fleet pays its vendors in less than 15 business days in most cases. All invoices and tax documents should be submitted to the Business Operations department using the [appropriate Fleet email address (confidential Google Doc)](https://docs.google.com/document/d/1tE-NpNfw1icmU2MjYuBRib0VWBPVAdmq4NiCrpuI0F0/edit#heading=h.wqalwz1je6rq). -- After making sure the invoice received from a new vendor is valid, add the new vendor to the recurring expenses section of ["The numbers"](https://docs.google.com/spreadsheets/d/1X-brkmUK7_Rgp7aq42drNcUg8ZipzEiS153uKZSabWc/edit#gid=2112277278) before paying the invoice. -- If we have not paid this vendor before, make sure we have received the required W-9 or W-8 form from the vendor. **Accounting cannot process a payment without these tax forms for compliance reasons.** - - **US-based vendors** are required to complete a [W-9 form](https://www.irs.gov/pub/irs-pdf/fw9.pdf). - - **Non-US based vendors and individuals** are required to follow these [instructions](https://www.irs.gov/instructions/iw8bene) and provide a completed [W-8BEN-E](https://www.irs.gov/pub/irs-pdf/fw8bene.pdf) form. - - - -### Process a request to cancel a vendor -- Make the cancellation notification in accordance with the contract terms between Fleet and the vendor, typically these notifications are made via email and may have a specific address that notice must be sent to. If the vendor has an autorenew contract with Fleet there will often be a window of time in which Fleet can cancel, if notification is made after this time period Fleet may be obligated to pay for the subsequent year even if we don't use the vendor during the next contract term. -- Once cancelled, update the recurring expenses section of [The Numbers](https://docs.google.com/spreadsheets/d/1X-brkmUK7_Rgp7aq42drNcUg8ZipzEiS153uKZSabWc/edit#gid=2112277278) to reflect the cancellation by changing the projected monthly burn in column G to $0 and adding "CANCELLED" in front of the vendor's name in column C. - - -### Review an NDA -We need to review an NDA anytime a vendor, customer or other party wants to: -- Use their own NDA rather than Fleet's standard NDA, or -- "Redline" (modify) Fleet's NDA by removing, adding or altering its terms. - -We should always seek to use Fleet's own NDA first, without alteration. - -When reading an NDA, we want to pay close attention to the following: -- We want to be sure that the confidentiality obligations of the NDA are reciprocal. Fleet and the other party to the agreement should be bound to the same standards of confidentiality toward the handling of each other's confidential information. -- Fleet does not agree to _"do not compete"_ or _"do not solicit clauses"_. An NDA should not contain provisions beyond the scope of an NDA. The two most commonly encountered examples of this are the "do not compete" and "do not solicit" clauses. We want to be free to hire the best people and make the best products, so when reading through an NDA it is important to keep an eye out for language that prohibits Fleet from hiring or soliciting current or former employees of other companies or that prohibit Fleet from independently developing products that compete with another company's products. Using the `cmd + f` function to search for "solici", "compet" and "hir" and reading through the results is a helpful method to quickly scan for these clauses. -- Look for any language that discusses a transfer of property rights. Rarely, you may find a clause snuck into an agreement that discusses the transfer of intellectual property rights. _We want to avoid any situation where Fleet transfers its intellectual property to another party as part of an NDA_. -- Should you find any clauses in steps 2 or 3 that are beyond the scope of protecting both party's confidential information in a customer NDA or an altered version of Fleet's NDA, reject this language and communicate that Fleet cannot agree to those terms. -- Any concerns or uncertainty over _any_ provisions in an NDA should be brought to Nathanael Holliday in BizOps, who will consult legal counsel if necessary to resolve any concerns. - -### Review a vendor agreement -When reviewing contracts from a vendor, Fleet is concerned about the following: -- If there are confidentiality provisions in the agreement in place of a stand-alone NDA, verify the confidentiality provisions are appropriate and protect Fleet when sensitive data is involved that isn't otherwise available to the public. -- We want to make sure there are no _do not solicit_ or _do not compete_ clauses in the contract. To aid in this search, we double check by using the cmd + f function and searching for "solici", "compet" and "hir" and then looking through the results to be sure that nothing prohibits Fleet from independently developing competing products or from hiring personnel with ties to the vendor. -- We want to make sure that contracts can be terminated relatively easily and be aware of what the process is for terminating them, avoiding commitments over 12 months in length. -- We want to make sure the payment terms work for us (i.e. being able to pay via wire transfer, credit card or bill.com) and that the price in any contract or order form is what we have agreed to. While almost never malicious, mistakes often occur in the steps between agreeing on a price, negotiating a contract, and receiving an invoice. We want to be sure at every step that the dollar amount and service provided is consistent with what has been negotiated and agreed upon. -- Remember, once we have signed the agreement - we're stuck with it. If any clause in the agreement appears strange or gives you pause or concern, it is better to seek clarification than to commit to something that might be detrimental to Fleet. Contracts are fairly standardized, and you'll quickly learn what is normal and what feels out of place. Unusual clauses or wording that seems out of the ordinary should get a second set of eyes just to be sure, do not hesitate to reach out to Nathanael Holliday with questions, who will reach out to legal counsel as necessary. - -### Review an order form -- We should always check order forms for additional terms that go beyond the scope of the order form (caps on price increases, for example). -- Be sure the order form includes contact information + billing address and information so that Fleet knows how and who to invoice for payment. -- Verify that the payment terms are correct and matches what's in the agreement. This is a frequent common mistake as companies usually have default payment terms and overlook changing them to match atypical payment terms. -- Make sure the effective term of the order matches what was agreed upon (usually a one year term) and that the order form includes the correct number of hosts and whether or not it should contain professional services (usually, it does not). -- Check that the amount on the order form reflects what Fleet agreed to, as this is the amount that the customer will expect to be invoiced for. -- Lastly, double check one more time to make sure there are no sneaky, unusual terms snuck in at the bottom of an order form or stashed away in fine print. Common things that are included in order forms and not always communicated to Fleet are caps on price increases upon renewal, new SLAs, or a product roadmap or milestones we may not have agreed upon. Any clauses on an order form that appear beyond the scope of simply elaborating on the services being provided, the purchase cost, the contract that the purchase is being made under, how Fleet will bill and how the customer will pay deserves a careful look. Reach out to Nathanael Holliday in BizOps with concerns. - -### Review a non-standard subscription agreement -We want to use our standard terms whenever possible with our customers, but it is common that customers want to use their own agreement or redline (modify) Fleet's terms. -When reviewing subscription agreements on customer paper or when a customer has made changes to Fleet's terms, we review it using [these guidelines](https://docs.google.com/document/d/1aGgN5It1i3fdsBF37vWSbvukO_gQhy5vCp4fINg191Q/edit?usp=sharing). - - -### Update weekly KPIs -- Create the weekly update issue from the template in ZenHub every Friday and update the [KPIs for BizOps](https://docs.google.com/spreadsheets/d/1Hso0LxqwrRVINCyW_n436bNHmoqhoLhC8bcbvLPOs9A/edit#gid=0) by 5pm US central time. -- Check the KPI sheet at 5pm US central time to ensure all departments have updated their KPIs on time. If any departments are delinquent, notify the department head and let the [Apprentice](https://fleetdm.com/handbook/digital-experience#team) know so they can put it on the agenda for their next one-on-one with the CEO. - - -## Rituals - -The following table lists this department's rituals, frequency, and Directly Responsible Individual (DRI). - - - - - -#### Stubs -The following stubs are included only to make links backward compatible. - -##### Vetty -Please see [hanbook/business-operations#access-a-background-check](https://www.fleetdm.com/handbook/business-operations#access-a-background-check). - -##### Role-specific licenses -Please see [hanbook/business-operations#grant-role-specific-license-to-a-team member](https://www.fleetdm.com/handbook/business-operations#grant-role-specific-license-to-a-team-member). - -##### Recurring expenses -##### Tools we use -Please see [hanbook/business-operations#grant-role-specific-license-to-a-team member](https://www.fleetdm.com/handbook/business-operations#reconcile-monthly-recurring-expenses). - -##### Secure company-issued equipment for a team member -Please see [handbook/engineering#secure-company-issued-equipment-for-a-team-member](https://www.fleetdm.com/handbook/engineering#secure-company-issued-equipment-for-a-team-member). - -##### Register a domain for Fleet -Please see [handbook/register-a-domain-for-fleet](https://www.fleetdm.com/handbook/engineering#register-a-domain-for-fleet). - -##### Updating personnel details -Please see [handbook/engineering#update-personnel-details](https://www.fleetdm.com/handbook/engineering#update-personnel-details). - -##### Fix a laptop that's not checking in -Please see [handbook/engineering#fix-a-laptop-thats-not-checking-in](https://www.fleetdm.com/handbook/engineering#fix-a-laptop-thats-not-checking-in) - -##### Enroll a macOS host in dogfood -Please see [handbook/engineering#enroll-a-macos-host-in-dogfood](https://www.fleetdm.com/handbook/engineering#enroll-a-macos-host-in-dogfood) - -##### Enroll a Windows or Ubuntu Linux device in dogfood -Please see [handbook/engineering#enroll-a-windows-or-ubuntu-linux-device-in-dogfood](https://www.fleetdm.com/handbook/engineering#enroll-a-windows-or-ubuntu-linux-device-in-dogfood) - -##### Enroll a ChromeOS device in dogfood -Please see [handbook/engineering#enroll-a-chromeos-device-in-dogfood](https://www.fleetdm.com/handbook/engineering#enroll-a-chromeos-device-in-dogfood) - -##### Lock a macOS host in dogfood using fleetctl CLI tool -Please see [handbook/engineering#lock-a-macos-host-in-dogfood-using-fleetctl-cli-tool](https://www.fleetdm.com/handbook/engineering#lock-a-macos-host-in-dogfood-using-fleetctl-cli-tool) - -##### Book an event -Please see [handbook/engineering#book-an-event](https://www.fleetdm.com/handbook/engineering#book-an-event) - -##### Order SWAG -Please see [handbook/engineering#order-swag](https://www.fleetdm.com/handbook/engineering#order-swag) - - - - diff --git a/handbook/company/README.md b/handbook/company/README.md index 03f0ac42a7..522d1f57bc 100644 --- a/handbook/company/README.md +++ b/handbook/company/README.md @@ -137,34 +137,18 @@ Fleet added support for [scripting and management capabilities](https://fleetdm. ## Org chart To provide clarity about decision-making, [responsibility](https://fleetdm.com/handbook/company/why-this-way#why-direct-responsibility), and resources, everyone at Fleet has a manager, and [every manager](https://fleetdm.com/handbook/company/leadership) has direct reports. Fleet's organizational chart is accessible company-wide as a sub-tab in ["🧑‍🚀 Fleeties" (private google doc)](https://docs.google.com/spreadsheets/d/1OSLn-ZCbGSjPusHPiR5dwQhheH1K8-xqyZdsOe9y7qc/edit#gid=0). On the other sub-tabs, you can also check out a world map of where everyone is located, hiring stats, and fun facts about each team member. -- 🔦 [Business Operations](https://fleetdm.com/handbook/business-operations): The Business Operations department is directly responsible for people operations, finance + invoicing, tax, compliance, and legal + deal desk. -- 🌦️ [Customer Success](https://fleetdm.com/handbook/customer-success): The customer success department is directly responsible for ensuring that customers and community members of Fleet achieve their desired outcomes with Fleet products and services. -- 🐋 [Sales](https://fleetdm.com/handbook/sales): The Sales department is directly responsible for attaining the revenue goals of Fleet and helping customers deliver on their objectives. -- 🫧 [Demand](https://fleetdm.com/handbook/demand): The Demand department is directly responsible for growing awareness of Fleet and nurturing the community through participation in events, conversations, and other programs. - 🚀 [Engineering](https://fleetdm.com/handbook/engineering): The Engineering department at Fleet is directly responsible for writing and maintaining the code for Fleet's core product, as well as Fleet's Information technology (IT) infrastucture. - 🦢 [Product Design](https://fleetdm.com/handbook/product-design): The Product Design department is directly responsible for defining and prioritizing the changes made to the core product, Fleet API, and reference documentation. +- 🌦️ [Customer Success](https://fleetdm.com/handbook/customer-success): The customer success department is directly responsible for ensuring that customers and community members of Fleet achieve their desired outcomes with Fleet products and services. +- 🫧 [Demand](https://fleetdm.com/handbook/demand): The Demand department is directly responsible for growing awareness of Fleet and nurturing the community through participation in events, conversations, and other programs. +- 💸 [Finance](https://fleetdm.com/handbook/finance): The Finance department is directly responsible for accounts receivable including invoicing, accounts payable including commision calculations, exspense reporting including Brex memos and maintaining accurate spend projections in "🧮The numbers", sales taxes, payroll taxes, corporate income/franchise taxes, and financial operations including bank accounts and cash flow management. +- 🐋 [Sales](https://fleetdm.com/handbook/sales): The Sales department is directly responsible for attaining the revenue goals of Fleet and helping customers deliver on their objectives. - 🌐 [Digital Experience](https://fleetdm.com/handbook/digital-experience): The Digital Experience department is directly responsible for the framework, content design, and technology behind Fleet's remote work culture and overall brand experience, including fleetdm.com, the handbook, issue templates, UI style guides, consistent brandfronts, internal tooling, Zapier flows, Docusign templates, key spreadsheets, and project management processes. ## Advisors While most improvements at Fleet are driven by informal conversations with customers and open-source contributors, the company also has a few dozen advisors and investors, including [Sid](https://about.gitlab.com/blog/2022/10/14/one-third-of-what-we-learned-about-ipos-in-taking-gitlab-public/) [Sijbrandij](https://about.gitlab.com/handbook/ceo/#sijbrandij-pronunciation-hint) _(GitLab)_, [Dylan Field](https://en.wikipedia.org/wiki/Dylan_Field) _(Figma)_, [Mike Arpaia](https://www.youtube.com/watch?v=zfCak2UIOD8) _(osquery)_, [Alexandr Wang](https://www.businessofbusiness.com/articles/scale-ai-machine-learning-startup-alexandr-wang/) _(Scale AI)_, [Sanjay](https://www.zdnet.com/article/vmware-buys-airwatch-for-1-54-billion-acquires-mobility-strategy/) [Poonen](https://www.businessinsider.com/vmware-carbon-black-acquisition-sanjay-poonen-cybersecurity-2019-10?op=1) _(VMware, Cohesity)_, and [other smart people who are eager to help](https://docs.google.com/spreadsheets/d/15knBE2-PrQ1Ad-QcIk0mxCN-xFsATKK9hcifqrm0qFQ/edit). If you have a question for one of them, Fleet's CEO is happy to introduce you. ([Just ask](https://fleetdm.com/handbook/company/leadership#contact-the-ceo).) - diff --git a/handbook/company/communications.md b/handbook/company/communications.md index 7b5a97b1ae..b4f9b08ea9 100644 --- a/handbook/company/communications.md +++ b/handbook/company/communications.md @@ -38,8 +38,8 @@ We track competitors' capabilities and adjacent (or commonly integrated) product | Social media | _See [🫧 Digital Marketing Manager](https://fleetdm.com/handbook/demand#team)_ | Blog | _See [🚀 Client Platform Engineer & Community Advocate](https://fleetdm.com/handbook/engineering#team)_ | Information technology (IT) | _See [🚀 Client Platform Engineer & Community Advocate](https://fleetdm.com/handbook/engineering#team)_ -| Payroll, bookkeeping, AR/AP | _See [🔦 Head of Business Operations](https://fleetdm.com/handbook/customer-success#team)_ -| Legal contracts | _See [🔦 Business Operations team](https://fleetdm.com/handbook/customer-success#team)_ +| Payroll, bookkeeping, AR/AP | _See [💸 Head of Finance](https://fleetdm.com/handbook/finance#team)_ +| Legal contracts | _See [🌐 Digital Experience team](https://fleetdm.com/handbook/digital-experience#team)_ | Customer renewals | _See [🌦️ VP of Customer Success](https://fleetdm.com/handbook/customer-success#team)_ | Customer deployments | _See [🌦️ Infrastructure Engineer](https://fleetdm.com/handbook/customer-success#team)_ | Customer support | _See [🌦️ Customer Success team](https://fleetdm.com/handbook/customer-success#team)_ @@ -53,7 +53,7 @@ We track competitors' capabilities and adjacent (or commonly integrated) product | Product introduction docs | _See [🛠️ CEO responsibilities](https://fleetdm.com/handbook/company/leadership#ceo-responsibilities)_ | Product deployment docs | _See [🚀 Chief Technology Officer](https://fleetdm.com/handbook/engineering#team)_ | Product usage docs | _See [🦢 Head of Product Design](https://fleetdm.com/handbook/product-design#team)_ -| Product reference docs | _See [🦢 Noah Talerman](https://fleetdm.com/handbook/product-design#team)_ +| Product reference docs | _See [🦢 Head of Product Design](https://fleetdm.com/handbook/product-design#team)_ | What goes in a release | _See [🚀 Chief Technology Officer](https://fleetdm.com/handbook/engineering#team)_ | Engineering output and architecture | _See [🚀 Chief Technology Officer](https://fleetdm.com/handbook/engineering#team)_ | Product development | _See [🛩️ Product groups](https://fleetdm.com/handbook/company/product-groups#current-product-groups)_ @@ -61,18 +61,18 @@ We track competitors' capabilities and adjacent (or commonly integrated) product ## Tech stack admins | Role | Google Workspace | Slack | GitHub | Gusto | Pilot | Plane | 1Password | -|:----------------------|------------------:|------------------:|------------------:|------------------:|------------------:|------------------:|------------------:| -| CEO | ✅ Super admin | ✅ Primary workspace owner | ✅ Owner | ✅ Primary admin | ✅ Admin| ✅ Owner | ✅ Owner | -| CTO | ❌ | ❌ | ✅ Owner | ❌ | ✅ Admin | ❌ | ❌ | -| Head of BizOps | ✅ Super admin | ✅ Owner | ✅ Owner| ✅ Admin | ✅ Admin| ✅ Admin | ✅ Admin | -| BizOps Engineer | ✅ Super admin| ✅ Owner | ✅ Owner| ✅ Admin | ✅ Admin| ✅ Admin | ✅ Admin| -| Head of Digital Experience | ✅ Super admin| ✅ Owner | ✅ Owner| ❌ | ✅ Admin| ❌ | ✅ Admin| -| Apprentice | ❌ | ❌ | ❌ | ❌ | ✅ Admin| ❌ | ❌ | -| Digital Experience Engineer | ✅ Super admin | ✅ Admin | ❌ | ❌ | ❌ | ❌ | ✅ Admin| +|:-----|-----------------:|------:|-------:|------:|------:|------:|----------:| +| CEO | ✅ Super admin | ✅ Primary workspace owner | ✅ Owner | ✅ Primary admin | ✅ Owner |✅ Owner | ✅ Owner | +| CTO | ❌ | ❌ | ✅ Owner | ❌ | ❌ | ✅ Admin | ❌ | +| Head of Finance | ❌ | ❌ | ❌ | ✅ Admin | ✅ Admin | ✅ Admin | ❌ | +| Finance Engineer | ❌ | ❌ | ❌ | ✅ Admin | ✅ Admin |✅ Admin | ❌ | +| Head of Digital Experience | ✅ Super admin | ✅ Owner | ✅ Owner| ✅ Admin | ❌ | ✅ Admin | ✅ Admin | +| Apprentice | ✅ Super admin| ✅ Owner | ✅ Owner | ✅ Admin | ❌ | ✅ Admin | ✅ Admin | +| Digital Experience Engineer | ✅ Super admin | ✅ Admin | ❌ | ❌ | ❌ | ❌ | ✅ Admin | | Head of Product Design | ❌ | ✅ Admin | ❌ | ❌ | ❌ | ❌ | ❌ | | VP of CX | ❌ | ✅ Owner | ❌ | ❌ | ❌ | ❌ | ❌ | | CX Sr. Suppoert Engineer | ❌ | ✅ Admin | ❌ | ❌ | ❌ | ❌ | ❌ | -| Pilot bookkeeper | ❌ | ❌ | ❌ | ✅ Admin | ❌ | ✅ Admin | ❌ | +| Pilot bookkeeper | ❌ | ❌ | ❌ | ✅ Admin | ❌ | ✅ Admin | ❌ | ### Docs @@ -191,7 +191,7 @@ Fleet uses YouTube to help keep the community up-to-date and informed. These vid When scheduling external meetings, provide external participants with a [Calendly](https://calendly.com) link to schedule with the relevant internal participants. If you -need a Calendly account, reach out to `#g-business-operations` via Slack. +need a Calendly account, reach out to `#g-digital-experience` via Slack. ### Internal meeting scheduling @@ -299,7 +299,7 @@ In some instances, you may need to record a call locally (i.e. save the recordin Fleet uses these levels to standardize a commitment to minimal esotericism across the company. - **Public:** _Share with anyone, anywhere in the world_ - **Confidential:** _Share only with team members who've signed an NDA, consulting agreement, or employment agreement_ -- **Classified:** _Share only with founders of Fleet, business operations, and/or the people involved. e.g., US social security numbers during hiring_ +- **Classified:** _Share only with the CEO, Head of Digital Experience, and/or the people involved. e.g., US social security numbers during hiring_ ### Document titles @@ -308,8 +308,8 @@ Fleet uses these levels to standardize a commitment to minimal esotericism acros - **"Public":** _(Available to public)_ - _(Confidential - for Fleet eyes only)_ - **"¶":** _(E-group - Direct reports the the CEO)_ -- **"¶¶":** _(Classified - CEO, Apprentice, and BizOps)_ -- **"¶¶¶":** _(CEO, Apprentice to the CEO, and board members)_ +- **"¶¶":** _(Classified - CEO, Head of Digital Experience, and Apprentice)_ + ## Google Drive @@ -360,7 +360,7 @@ We use these prefixes to organize the Fleet Slack: ### Create a GitHub issue from a Slack thread -If you need to track content from a Slack channel (ie. #g-sales), you can automatically generate a github issue by selecting the `create-github-issue` emoji on the thread. This will automatically create an issue tagged with the #g-business-operations label. If you need the issue logged against a specific board, ensure that you have updated the label during issue creation. +If you need to track content from a Slack channel (ie. #g-sales), you can automatically generate a github issue by selecting the `create-github-issue` emoji on the thread. This will automatically create an issue tagged with the GitHub label that corisponds with the Slack channel. If you need the issue logged against a specific board, ensure that you have updated the label during issue creation. image @@ -613,7 +613,7 @@ For more developed thoughts about __spending guidelines and limits__, please rea #### Non-travel purchases that exceed a Brex cardholder's limit -For non-travel purchases that would require an increase in the Brex cardholder's limit ($2,000 by default), please [make a request](https://fleetdm.com/handbook/business-operations#contact-us) with following information: +For non-travel purchases that would require an increase in the Brex cardholder's limit ($2,000 by default), please [make a request](https://fleetdm.com/handbook/digital-experience#contact-us) with following information: - The nature of the purchase (i.e. SaaS subscription and what it's used for) - The cost of the purchase and whether it is a fixed or variable (i.e. use-based) cost. - Whether it is a one time purchase or a recurring purchase and at what frequency the purchase will re-occur (annually, monthly, etc.) @@ -636,7 +636,7 @@ When procuring SaaS tools and services, analyze the purchase of these subscripti #### Reimbursements -Fleet does not reimburse expenses. We provide all of our team members with Brex cards for making purchases for the company. For company expenses, **use your Brex card.** If there was an extreme accident, [get help](https://fleetdm.com/handbook/business-operations#contact-us). +Fleet does not reimburse expenses. We provide all of our team members with Brex cards for making purchases for the company. For company expenses, **use your Brex card.** If there was an extreme accident, [get help](https://fleetdm.com/handbook/digital-experience#contact-us). - Be creative. If an AirBnb is the most efficient way to house the team, then do that. If separate hotel rooms are more efficient, then do that. - If the stay is longer than 4 nights and an Airbnb with a washing machine is not available, then dry cleaning can be purchased with your Brex card. -- If you need to meet with a large group that won't fit in your hotel room or Airbnb (e.g. more than 5 people), [contact Business Operations](https://fleetdm.com/handbook/business-operations#contact-us) for their help approving and booking additional event space. +- If you need to meet with a large group that won't fit in your hotel room or Airbnb (e.g. more than 5 people), [contact Digital Experience](https://fleetdm.com/handbook/digital-experience#contact-us) for their help approving and booking additional event space. ### Spending company money while traveling When attending a conference or traveling for Fleet, keep the following in mind: - **No reimbursements:** Use your company Brex card. Reimbursements are time consuming, so Fleet does not do reimbursements for spending on personal credit cards. -- **Food:** Be efficient and use your own credit card when it makes sense. There is a $100 allowance per day for your own personal food and beverage on your company Brex card. _(There are many good reasons to make exceptions to this allowance, such as dinners with customers. Before proceeding, please [request approval from the Head of Business Operations](https://fleetdm.com/handbook/business-operations#contact-us) to avoid complexities._ +- **Food:** Be efficient and use your own credit card when it makes sense. There is a $100 allowance per day for your own personal food and beverage on your company Brex card. _(There are many good reasons to make exceptions to this allowance, such as dinners with customers. - **Tipping:** Tipping norms vary by culture. How you tip when representing the company reflects on Fleet's brand. When traveling in the United States and using your company Brex card, prepare to tip between 18-20% at restaurants. For rideshare, takeout, delivery, and other situations where tipping comes up, tip between 10-20%. - **Personal credit card:** Please use your personal credit card for hotel incidentals, personal consumables, movies, mini bars, and entertainment. These expenses _will not_ be reimbursed. - **Company credit card:** We recommend you order a physical Brex card if you do not have one before traveling. -- **Credit card limit increases:** The monthly limit on your Brex card may need to be increased temporarily as necessary to accommodate the increased spending associated with the conference, such as [booking your own travel](https://fleetdm.com/handbook/company/communications#flights). You can [request that here](https://fleetdm.com/handbook/business-operations#contact-us) by providing the following information: +- **Credit card limit increases:** The monthly limit on your Brex card may need to be increased temporarily as necessary to accommodate the increased spending associated with the conference, such as [booking your own travel](https://fleetdm.com/handbook/company/communications#flights). You can [request that here](https://fleetdm.com/handbook/digital-experience#contact-us) by providing the following information: - The start and end dates for your trip. - The [price of your flight](https://fleetdm.com/handbook/company/communications#flights) - The [price of your hotel or Airbnb](https://fletdm.com/handbook/comopany/communications#lodging) per night @@ -756,7 +756,7 @@ You can learn more about how Fleet approaches security in the [security handbook ## Vendor questionnaires -In responding to security questionnaires, Fleet endeavors to provide full transparency via our [security policies](https://fleetdm.com/handbook/security/security-policies#security-policies), [trust](https://trust.fleetdm.com/), and [application security](https://fleetdm.com/handbook/business-operations/application-security) documentation. In addition to this documentation, please refer to [the vendor questionnaires page](https://fleetdm.com/handbook/business-operations/vendor-questionnaires). [Contact the Sales department](https://fleetdm.com/handbook/sales#contact-us) to address any pending questionnaires. +In responding to security questionnaires, Fleet endeavors to provide full transparency via our [security policies](https://fleetdm.com/handbook/digital-experience/security-policies#security-policies), [trust](https://trust.fleetdm.com/), and [application security](https://fleetdm.com/handbook/digital-experience/application-security) documentation. In addition to this documentation, please refer to [the vendor questionnaires page](https://fleetdm.com/handbook/digital-experience/vendor-questionnaires). [Contact the Sales department](https://fleetdm.com/handbook/sales#contact-us) to address any pending questionnaires. ## Getting a contract signed @@ -780,7 +780,7 @@ Please use [Fleet's billing email address](https://fleetdm.com/handbook/company/ To get a contract reviewed, upload the agreement to [Google Drive](https://drive.google.com/drive/folders/1G1JTpFxhKZZzmn2L2RppohCX5Bv_CQ9c). -Complete the [contract review issue template in GitHub](https://fleetdm.com/handbook/business-operations#contact-us), being sure to include the link to the document you uploaded and using the Calendly link in the issue template to schedule time to discuss the agreement with Nathan Holliday (allowing for sufficient time for him to have reviewed the contract prior to the call). +Complete the [contract review issue template in GitHub](https://github.com/fleetdm/confidential/issues/new?assignees=hollidayn&labels=%23g-digital-experience&projects=&template=contract-review.md&title=Review%3A++%F0%9F%96%8B%EF%B8%8F+__________________________), being sure to include the link to the document you uploaded and using the Calendly link in the issue template to schedule time to discuss the agreement with Nathan Holliday (allowing for sufficient time for him to have reviewed the contract prior to the call). Follow up comments should be made in the GitHub issue and in the document itself so it is all in the same place. @@ -792,7 +792,7 @@ If an agreement requires an additional review during the negotiation process, th When no further review or action is required for an agreement and the document is ready to be signed, the requestor is then responsible for routing the document for signature. -> **Note:** Please submit other legal questions and requests to [Business Operations department](https://fleetdm.com/handbook/business-operations#contact-us). +> **Note:** Please submit other legal questions and requests to [Digital Experience](https://fleetdm.com/handbook/digital-experience#contact-us). ## Trust @@ -810,7 +810,7 @@ Here are a few different entry points for a tour of Fleet's security policies an 3. [Account recovery process](https://fleetdm.com/handbook/security#account-recovery-process) 4. [Personal mobile devices](https://fleetdm.com/handbook/security#personal-mobile-devices) 5. [Hardware security keys](https://fleetdm.com/handbook/security#hardware-security-keys) -6. More details about internal security processes at Fleet are located on [the Security page](https://fleetdm.com/handbook/business-operations/security). +6. More details about internal security processes at Fleet are located on [the Security page](https://fleetdm.com/handbook/digital-experience/security). ## Benefits @@ -864,7 +864,7 @@ When you need to take time off, follow this process: ### Coworking -Your Brex card may be used for up to $500 USD per month in coworking costs. Please get prior approval by making a [custom request to the business operations team](https://fleetdm.com/handbook/business-operations#contact-us). +Your Brex card may be used for up to $500 USD per month in coworking costs. Please get prior approval from the [Digital Experience team](https://fleetdm.com/handbook/digital-experience#contact-us). ## Compensation @@ -886,12 +886,12 @@ We're happy you've ventured a trip around the sun with Fleet- let's celebrate! T ### Compensation changes -Fleet evaluates and (if relevant) updates compensation decisions yearly, shortly after the anniversary of a team member's start date. The Head of BizOps is responsible for the process to [update compensation](https://fleetdm.com/handbook/business-operations#updating-compensation) +Fleet evaluates and (if relevant) updates compensation decisions yearly, shortly after the anniversary of a team member's start date. The Head of Digital Experience is responsible for the process to [update compensation](https://fleetdm.com/handbook/digital-experience#updating-compensation) ### Relocating -When Fleeties relocate, there are vendors that need to be notified of the change. Before relocating, please [let the company know in advance](https://fleetdm.com/handbook/business-operations#contact-us) by following the directions listed in the relevant issue template ("Moving"). +When Fleeties relocate, there are vendors that need to be notified of the change. Before relocating, please [let the company know in advance](https://fleetdm.com/handbook/digital-experience#contact-us) by following the directions listed in the relevant issue template ("Moving"). ## Team member onboarding @@ -924,7 +924,7 @@ We want to make sure that the new team member will be able to complete every tas We believe in taking onboarding and training seriously and that the onboarding template is an essential source of truth and good use of time for every single new hire. If managers see a step that they don't feel is necessary, they should make a pull request to the [onboarding template](https://github.com/fleetdm/confidential/blob/main/.github/ISSUE_TEMPLATE/onboarding.md). Expectations during onboarding: -- Onboarding time (all checkboxes checked) is a KPI for the business operations team. Our goal is 14 days or less. +- Onboarding time (all checkboxes checked) is a KPI for the Digital Experience team. Our goal is 14 days or less. - The first 3 weekdays (excluding days off) for **every new team member** at Fleet is reserved for completing onboarding tasks from the checkboxes in their onboarding issue. New team members **should not work on anything else during this time**, whether or not other tasks are stacking up or assigned. It is OK, expected, and appreciated for new team members to **remind their manager and colleagues** of this [important](https://fleetdm.com/handbook/company/why-this-way#why-the-emphasis-on-training) responsibility. - Even after the first 3 days, during the rest of their first 2 weeks, completing onboarding tasks on time is a new team member's [highest priority](https://fleetdm.com/handbook/company/why-this-way#why-the-emphasis-on-training). @@ -1017,13 +1017,13 @@ Fleet provides laptops, YubiKey security keys, and software licenses for core te ### Requesting new equipment -As soon as an offer is accepted, Business Operations will reach out to the new team member to start this process and will work with the new team member to get their equipment requested and shipped to them on time. From time to time, team members need to purchase additional equipment in the interest of the company. +As soon as an offer is accepted, Digital Experience will reach out to the new team member to start this process and will work with the new team member to get their equipment requested and shipped to them on time. From time to time, team members need to purchase additional equipment in the interest of the company. If you are in need of additional equipment for any reason, [open an IT support request](https://github.com/fleetdm/confidential/issues/new?assignees=spokanemac&labels=%3Ahelp-it&projects=&template=request-it-support.md&title=%F0%9F%92%BB+Request+IT+support). When possible, Fleet will pull from its warehouse of existing assets before spending [more money on new equipment](https://fleetdm.com/handbook/company/why-this-way#why-spend-less). - **Tracking equipment:** When a device has been purchased, it's added to the [spreadsheet of company equipment](https://docs.google.com/spreadsheets/d/1hFlymLlRWIaWeVh14IRz03yE-ytBLfUaqVz0VVmmoGI/edit#gid=0) where we keep track of devices and equipment, purchased by Fleet. When you receive your new computer, complete the entry by adding a description, model, and serial number to the spreadsheet. -- **Returning equipment:** Apple computers with remaining AppleCare Protection Plans should be reprovisioned to other Fleeties who may have older or less-capable computers. Equipment should be returned once offboarded for reprovisioning. Coordinate offboarding and return with the Head of Business Operations. Please return all equipment to the Fleet IT warehouse using Fleet's FedEx account (address and account # in 1Password). +- **Returning equipment:** Apple computers with remaining AppleCare Protection Plans should be reprovisioned to other Fleeties who may have older or less-capable computers. Equipment should be returned once offboarded for reprovisioning. Coordinate offboarding and return with the Head of Digital Experience. Please return all equipment to the Fleet IT warehouse using Fleet's FedEx account (address and account # in 1Password). - **Equipment retention and replacement:** Older equipment results in lost productivity of Fleeties and should be considered for replacement. Replacement candidates are computers that are no longer under an AppleCare+ Protection Plan (or another warranty plan), are >3 years from the [discontinued date](https://everymac.com/systems/apple/macbook_pro/index-macbookpro.html#specs), or when the "Battery condition" status in Fleet is less than "Normal". The old equipment should be evaluated for return or retention as a test environment. @@ -1755,9 +1755,6 @@ Please see 📖[handbook/company/communications#purchase-company-issued-equipmen ##### Buying other new equipment Please see 📖[handbook/company/communications#purchase-company-issued-equipment](https://fleetdm.com/handbook/company/communications#equipment) for above. -##### Purchasing a company-issued device -Please see 📖[handbook/business-operations#secure-company-issued-equipment-for-a-team-member](https://fleetdm.com/handbook/business-operations#secure-company-issued-equipment-for-a-team-member). - ##### Company travel Please see 📖[handbook/company/communications#travel](https://fleetdm.com/handbook/company/communications#travel). diff --git a/handbook/company/handbook.md b/handbook/company/handbook.md index 345437ee0c..f7d416e9b2 100644 --- a/handbook/company/handbook.md +++ b/handbook/company/handbook.md @@ -16,7 +16,7 @@ All done! To contribute a new handbook page: 1. Determine where the new page should live in the handbook. That is, nested under either: a. [the "Company" handbook](https://fleetdm.com/handbook/company), or - b. the handbook for a particular division (Security, Engineering, Product, Sales, Marketing, Business Operations) + b. the handbook for a particular division (Engineering, Product Design, Customer Support, Sales, Demand, Finance, Digital Experience) 2. Locate the appropriate folder for the new page in [the GitHub repository under `handbook/`](https://github.com/fleetdm/fleet/tree/main/handbook). 3. Create a new markdown file (like [one of these](https://github.com/fleetdm/fleet/tree/f90148abad96fccb6c5647a31877fa7e91b5ee57/handbook/digital-experience)). A simple, easy way to do this is by clicking "Add file" on GitHub.com. a. Name your new file the kebab-cased, all lowercase version of your page title, with `.md` at the end. (For example, a page titled "Why this way?" would have the file path: `handbook/company/why-this-way.md`.) diff --git a/handbook/company/leadership.md b/handbook/company/leadership.md index 02eaf66458..41bdd9915f 100644 --- a/handbook/company/leadership.md +++ b/handbook/company/leadership.md @@ -109,7 +109,7 @@ In this meeting, the department leader discusses actual week-over-week progress At Fleet, we collaborate with [core team members](#creating-a-new-position), [consultants](#hiring-a-consultant), [advisors](#adding-an-advisor), and [outside contributors](https://github.com/fleetdm/fleet/graphs/contributors) from the community. -> Are you a new fleetie joining the Business Operations team? For Loom recordings demonstrating how to make offers, hire, onboard, and more please see [this classified Google Doc](https://docs.google.com/document/d/1fimxQguPOtK-2YLAVjWRNCYqs5TszAHJslhtT_23Ly0/edit). +> Are you a new fleetie joining the Digital Experience team? For Loom recordings demonstrating how to make offers, hire, onboard, and more please see [this classified Google Doc](https://docs.google.com/document/d/1fimxQguPOtK-2YLAVjWRNCYqs5TszAHJslhtT_23Ly0/edit). ### Consultants @@ -131,7 +131,7 @@ Consultants: Consultants [track time using the company's tools](#tracking-hours) and sign [Fleet's consulting agreement](#sending-a-consulting-agreement). -To hire a consultant, [submit a new consultant onboarding request](https://github.com/fleetdm/confidential/issues/new?assignees=&labels=%23g-business-operations&projects=&template=new-consultant-onboarding.md&title=New+US%2Finternational+consultant) to the business operations team. +To hire a consultant, [submit a new consultant onboarding request](https://github.com/fleetdm/confidential/issues/new?assignees=&labels=%23g-digital-experience&projects=&template=new-consultant-onboarding.md&title=New+US%2Finternational+consultant) to the Digital Experience team. #### Who ISN'T a consultant? @@ -151,7 +151,7 @@ Consultants aren't required to do any of those things. #### Sending a consulting agreement -To send a consulting agreement, you will need to [submit a new consultant onboarding request](https://github.com/fleetdm/confidential/issues/new?assignees=&labels=%23g-business-operations&projects=&template=new-consultant-onboarding.md&title=New+US%2Finternational+consultant) to the business operations team. They will then peform the steps needed to bring aboard a new consultant. +To send a consulting agreement, you will need to [submit a new consultant onboarding request](https://github.com/fleetdm/confidential/issues/new?assignees=&labels=%23g-digital-experience&projects=&template=new-consultant-onboarding.md&title=New+US%2Finternational+consultant) to the Digital Experience team. They will then peform the steps needed to bring aboard a new consultant. You will be asked to provide the following details: - Consultant's name (or business name) @@ -166,7 +166,7 @@ If the consultant is international, you will also provide: - Consultant's date of birth -> To update a consultant's fee, [submit an issue to BizOps](https://github.com/fleetdm/confidential/issues/new?assignees=&labels=%23g-business-operations&projects=&title=Update%20consultant%20fee) with the consultant's name and new hourly rate. +> To update a consultant's fee, [submit an issue to Digital Experience](https://github.com/fleetdm/confidential/issues/new?assignees=&labels=%23g-digital-experience&projects=&title=Update%20consultant%20fee) with the consultant's name and new hourly rate. image @@ -255,7 +255,7 @@ When review is requested on a proposal to open a new position, the Apprentice to - _Update team database:_ Update the row in ["¶¶ 🥧 Equity plan"](https://docs.google.com/spreadsheets/d/1_GJlqnWWIQBiZFOoyl9YbTr72bg5qdSSp4O3kuKm1Jc/edit#gid=0) using the benchmarked compensation and share count. - _Salary:_ Enter the salary: If the role has variable compensation, use the role's OTE (on-target earning estimate) as the budgeted salary amount, and leave a note in the "Notes (¶¶)" cell clarifying the role's bonus or commission structure. - _Equity:_ Enter the equity as a number of shares, watching the percentage that is automatically calculated in the next cell. Keep guessing different numbers of shares until you get the derived percentage looking like what you want to see. - - _Create Slack channel:_ Create a private "#YYYY-hiring-xxxxxx" Slack channel (where "xxxxxx" is the job title and YYYY is the current year) for discussion and invite the hiring manager and Head of Business Operations. + - _Create Slack channel:_ Create a private "#YYYY-hiring-xxxxxx" Slack channel (where "xxxxxx" is the job title and YYYY is the current year) for discussion and invite the hiring manager and Head of Digital Experience. - _Publish opening:_ Approve and merge the pull request. The job posting will go live within ≤10 minutes. - _Track as approved in "Fleeties":_ In the "Fleeties" spreadsheet, find the row for the new position and update the "Job description" column and replace the URL of the pull request that originally proposed this new position with the URL of the GitHub merge commit when that PR was merged. - _Reply to requestor:_ Post a comment on the pull request, being sure to include a direct link to their live job description on fleetdm.com. (This is the URL where candidates can go to read about the job and apply. For example: `fleetdm.com/handbook/company/product-designer`): @@ -282,7 +282,7 @@ Fleet uses [certain email templates](https://docs.google.com/document/d/1VAMWIH8 ### Hiring restrictions #### Incompatible former employers -Fleet maintains a list of companies with whom Fleet has do-not-solicit terms that prevents us from making offers to employees of these companies. The list is in the Do Not Solicit tab of the [BizOps spreadsheet](https://docs.google.com/spreadsheets/d/1lp3OugxfPfMjAgQWRi_rbyL_3opILq-duHmlng_pwyo/edit#gid=0). +Fleet maintains a list of companies with whom Fleet has do-not-solicit terms that prevents us from making offers to employees of these companies. The list is in the Do Not Solicit tab of the [Digital Experience spreadsheet](https://docs.google.com/spreadsheets/d/1lp3OugxfPfMjAgQWRi_rbyL_3opILq-duHmlng_pwyo/edit#gid=0). #### Incompatible locations Fleet is unable to hire team members in some countries. See [this internal document](https://docs.google.com/document/d/1jHHJqShIyvlVwzx1C-FB9GC74Di_Rfdgmhpai1SPC0g/edit) for the list. @@ -304,7 +304,7 @@ Department specific interviewing instructions: #### Hiring a new team member This section is about the hiring process a new core team member, or fleetie. -> **_Note:_** _Employment classification isn't what makes someone a fleetie. Some Fleet team members are contractors and others are employees. The distinction between "contractor" and "employee" varies in different geographies, and the appropriate employment classification and agreement for any given team member and the place where they work is determined by Head of Business Operations during the process of making an offer._ +> **_Note:_** _Employment classification isn't what makes someone a fleetie. Some Fleet team members are contractors and others are employees. The distinction between "contractor" and "employee" varies in different geographies, and the appropriate employment classification and agreement for any given team member and the place where they work is determined by Head of Digital Experience during the process of making an offer._ Here are the steps hiring managers follow to get an offer out to a candidate: 1. **Call references:** Before proceeding, make sure you have 2-5+ references. Ask the candidate for at least 2-5+ references and contact each reference in parallel using the instructions in [Fleet's reference check template](https://docs.google.com/document/d/1LMOUkLJlAohuFykdgxTPL0RjAQxWkypzEYP_AT-bUAw/edit?usp=sharing). Be respectful and keep these calls very short. @@ -333,25 +333,25 @@ Here are the steps hiring managers follow to get an offer out to a candidate: - Single doc URL: TODO ``` -5. **Confirm intent to offer:** Share the single document (the "interview packet") with the Head of Business Operations via Google Drive. - - _Share_ this single document with the Head of Business Operations via email. - - When the Head of Business Operations receives this shared doc in their email with the compiled feedback about the candidate, they will understand that to mean that it is time for Fleet to make an offer to the candidate. +5. **Confirm intent to offer:** Share the single document (the "interview packet") with the Head of Digital Experience via Google Drive. + - _Share_ this single document with the Head of Digital Experience via email. + - When the Head of Digital Experience receives this shared doc in their email with the compiled feedback about the candidate, they will understand that to mean that it is time for Fleet to make an offer to the candidate. ### Making an offer -After receiving the interview packet, the Head of Business Operations uses the following steps to make an offer: +After receiving the interview packet, the Head of Digital Experience uses the following steps to make an offer: -1. **Prepare the "exit scenarios" spreadsheet:** 🔦 Head of Business Operations [copies the "Exit scenarios (template)"](https://docs.google.com/spreadsheets/d/1k2TzsFYR0QxlD-KGPxuhuvvlJMrCvLPo2z8s8oGChT0/copy) for the candidate, and renames the copy to e.g. "Exit scenarios for Jane Doe". +1. **Prepare the "exit scenarios" spreadsheet:** 🌐 Head of Digital Experience [copies the "Exit scenarios (template)"](https://docs.google.com/spreadsheets/d/1k2TzsFYR0QxlD-KGPxuhuvvlJMrCvLPo2z8s8oGChT0/copy) for the candidate, and renames the copy to e.g. "Exit scenarios for Jane Doe". - _Edit the candidate's copy of the exit scenarios spreadsheet_ to reflect the number of shares in ["🥧 Equity plan"](https://docs.google.com/spreadsheets/d/1_GJlqnWWIQBiZFOoyl9YbTr72bg5qdSSp4O3kuKm1Jc/edit#gid=0), and the spreadsheet will update automatically to reflect their approximate ownership percentage. > _**Note:** Don't play with numbers in the exit scenarios spreadsheet. The revision history is visible to the candidate, and they might misunderstand._ -2. **Prepare offer:** 🔦 Head of Business Operations [copies "Offer email (template)"](https://docs.google.com/document/d/1zpNN2LWzAj-dVBC8iOg9jLurNlSe7XWKU69j7ntWtbY/copy) and renames to e.g. "Offer email for Jane Doe". Edit the candidate's copy of the offer email template doc and fill in the missing information: +2. **Prepare offer:** 🌐 Head of Digital Experience [copies "Offer email (template)"](https://docs.google.com/document/d/1zpNN2LWzAj-dVBC8iOg9jLurNlSe7XWKU69j7ntWtbY/copy) and renames to e.g. "Offer email for Jane Doe". Edit the candidate's copy of the offer email template doc and fill in the missing information: - _Benefits:_ If candidate will work outside the US, [change the "Benefits" bullet](https://docs.google.com/document/d/1zpNN2LWzAj-dVBC8iOg9jLurNlSe7XWKU69j7ntWtbY/edit) to reflect what will be included through Fleet's international payroll provider, depending on the candidate's location. - _Equity:_ Highlight the number of shares with a link to the candidate's custom "exit scenarios" spreadsheet. - _Hand off:_ Share the offer email doc with the [Apprentice to the CEO](https://fleetdm.com/handbook/digital-experience#team). 3. **Draft email:** 🦿 Apprentice to the CEO drafts the offer email in the CEO's inbox, reviews one more time, and then brings it to their next daily meeting for CEO's approval: - To: The candidate's personal email address _(use the email from the CEO interview calendar event)_ - - Cc: Head of Business Operations _(BizOps will participate in the email thread after the offer is accepted)_ + - Cc: Head of Digital Experience - Subject: "Full time?" - Body: _Copy the offer email verbatim from the Google doc into Gmail as the body of the message, formatting and all, then:_ - _Check all links in offer letter for accuracy (e.g. LinkedIn profile of hiring manager, etc.)_ @@ -362,7 +362,7 @@ After receiving the interview packet, the Head of Business Operations uses the f - _Send_ the email. #### Steps after an offer is accepted -Once the new team member replies and accepts their offer in writing, 🔦 Head of Business Operations follows these steps: +Once the new team member replies and accepts their offer in writing, 🌐 Head of Digital Experience follows these steps: 1. **Verify, track, and reply:** Reply to the candidate: - _Verify the candidate replied with their physical address… or else keep asking._ If they did not reply with their physical address, then we are not done. No offer is "accepted" until we've received a physical address. - _Review and update the team database_ to be sure everything is accurate, **one last time**. Remember to read the column headers and precisely follow the instructions about how to format the data: @@ -387,7 +387,7 @@ Once the new team member replies and accepts their offer in writing, 🔦 Head o Thanks, and welcome to the team! - -Joanne + -Sam ``` 2. **Ask hiring manager to send rejections:** Post to the `hiring-xxxxx-yyyy` Slack channel to let folks know the offer was accepted, and at-mention the _hiring manager_ to ask them to communicate with [all other interviewees](https://fleetdm.com/handbook/company#empathy) who are still in the running and [let them know that we chose a different person](https://fleetdm.com/handbook/company/leadership#candidate-correspondence-email-templates). >_**Note:** Send rejection emails quickly, within 1 business day. It only gets harder if you wait._ @@ -397,7 +397,7 @@ Once the new team member replies and accepts their offer in writing, 🔦 Head o - Follow the prompts in the template to fill out the 30-60-90 day plan for the new teammate before they start. 5. **Close Slack channel:** Then archive and close the channel. -Now what happens? 🔦 Business Operations will then follow the steps in the "Hiring" issue, which includes reaching out to the new team member within 1 business day from a separate email thread to get additional information as needed, prepare their agreement, add them to the company's payroll system, and get their new laptop and hardware security keys ordered so that everything is ready for them to start on their first day. +Now what happens? 🌐 Head of Digital Experience will then follow the steps in the "Hiring" issue, which includes reaching out to the new team member within 1 business day from a separate email thread to get additional information as needed, prepare their agreement, add them to the company's payroll system, and get their new laptop and hardware security keys ordered so that everything is ready for them to start on their first day. ## CEO shadow program @@ -436,16 +436,21 @@ This applies to anyone who gets paid by the hour, including consultants and hour ## Communicating departures Although it's sad to see someone go, Fleet understands that not everything is meant to be forever [like open-source is](https://fleetdm.com/handbook/company/why-this-way#why-open-source). There are a few steps that the company needs to take to facilitate a departure. -1. **Departing team member's manager:** Inform the Head of Business Operations about the departure via email and cc your manager. The Head of Business Operations will coordinate the team member's last day, offboarding, and exit meeting. -3. **Business Operations**: Will then create and begin completing [offboarding issue](https://github.com/fleetdm/classified/blob/main/.github/ISSUE_TEMPLATE/%F0%9F%9A%AA-offboarding-____________.md), to include coordinating team member's last day, offboarding, and exit meeting. - > After finding out about the departure, the Head of Business Operations will post in #g-e to inform the E-group of the team member's departure, asking E-group members to inform any other managers on their teams. +1. **Departing team member's manager:** Inform the Head of Digital Experience about the departure via email and cc your manager. The Head of Digital Experience will coordinate the team member's last day, offboarding, and exit meeting. +3. **Digital Experience**: Will then create and begin completing [offboarding issue](https://github.com/fleetdm/classified/blob/main/.github/ISSUE_TEMPLATE/%F0%9F%9A%AA-offboarding-____________.md), to include coordinating team member's last day, offboarding, and exit meeting. + > After finding out about the departure, the Head of Digital Experience will post in #g-e to inform the E-group of the team member's departure, asking E-group members to inform any other managers on their teams. 4. **CEO**: The CEO will make an announcement during the "🌈 Weekly Update" post on Friday in the `#general` channel on Slack. +<<<<<<< HEAD +## Changing someone's position +From time to time, someone's job title changes. To do this, reach out to [Digital Experience](https://fleetdm.com/handbook/digital-experience). + image ## Delivering performance feedback + When it comes to performance feedback, [speak freely](https://fleetdm.com/handbook/company#openness), sooner, and provide an explicit example of the behavior you observed and the impact it had. 1. Deliver negative feedback privately whenever possible, and be constructive not punitive. Celebrate positive feedback publicly. diff --git a/handbook/company/why-this-way.md b/handbook/company/why-this-way.md index 189eb4aa39..6c5e89a942 100644 --- a/handbook/company/why-this-way.md +++ b/handbook/company/why-this-way.md @@ -71,10 +71,10 @@ Investing in people and providing generous, prioritized training, especially up Here are a few examples of how Fleet prioritizes training: - the first 3 days at the company for every new team member are reserved for working on the tasks and training in their onboarding issue. -- during the first 2 weeks at the company, every new fleetie joins a **daily 1:1 meeting** with their manager to check in and see how they're doing, and if they have any questions or blockers. If the manager is not available for this meeting, the CEO (pending availability) or the Head of Business Operations will join this short daily meeting with them instead. +- during the first 2 weeks at the company, every new fleetie joins a **daily 1:1 meeting** with their manager to check in and see how they're doing, and if they have any questions or blockers. If the manager is not available for this meeting, the CEO (pending availability) or the Head of Digital Experience will join this short daily meeting with them instead. - In their first few days, every new fleetie joins: - - hands-on contributor experience training session with the Head of Business Operations where they share their screen, check the configuration of their tools, complete any remaining setup, and discuss best practices. - - a short sightseeing tour with the Head of Business Operations and (pending availability) Fleet's CEO to show them around and welcome them to the company. + - hands-on contributor experience training session with the Head of Digital Experience where they share their screen, check the configuration of their tools, complete any remaining setup, and discuss best practices. + - a short sightseeing tour with the Head of Digital Experience and (pending availability) Fleet's CEO to show them around and welcome them to the company. ## Why direct responsibility? @@ -167,7 +167,7 @@ Every group at Fleet maintains their own Slack channel, which all group members Work is tracked in [GitHub issues](https://github.com/issues?q=archived%3Afalse+org%3Afleetdm+is%3Aissue+is%3Aopen+). -Every department organizes their work into [team-based kanban boards](https://app.zenhub.com/workspaces/-g-business-operations-63f3dc3cc931f6247fcf55a9/board?sprints=none). This provides a consistent framework for how every team works, plans, and requests things from each other. +Every department organizes their work into [team-based kanban boards](https://app.zenhub.com/workspaces/-g-digital-experience-63f3dc3cc931f6247fcf55a9/board?sprints=none). This provides a consistent framework for how every team works, plans, and requests things from each other. 1. **Intake:** Give people from anywhere in the world the ability to [request something](https://github.com/fleetdm/confidential/issues/new/choose) from a particular team, and give that team the ability to see and [respond quickly](https://fleetdm.com/handbook/company#results) to new requests. 2. **Planning:** Give the team's manager and other team members a way to plan the [next three-week iteration](https://fleetdm.com/handbook/company/why-this-way#why-a-three-week-cadence) of what the team is working on. Provide a world (the kanban board) where the team has clarity, and the appropriate [DRI](https://fleetdm.com/handbook/company#why-direct-responsibility) can confidently [prioritize and plan changes](https://fleetdm.com/handbook/company/development-groups#planned-and-unplanned-changes) with enough context to make the right decisions. @@ -185,7 +185,7 @@ We apply the [twelve principles of agile](https://agilemanifesto.org) to Fleet's 3. Deliver working software frequently, from a couple of weeks to a couple of months, with a preference to the shorter timescale. 4. Business people and developers must [work together daily](https://fleetdm.com/handbook/company/product-groups) throughout the project. 5. Build projects around motivated individuals. Give them the environment and support they need, and trust them to get the job done. -6. The most efficient and effective method of conveying information to and within a development team is [face-to-face conversation](https://fleetdm.com/handbook/business-operations#meetings). +6. The most efficient and effective method of conveying information to and within a development team is [face-to-face conversation](https://fleetdm.com/handbook/communications#meetings). 7. Working software is the primary measure of progress. 8. Agile processes promote sustainable development. The sponsors, developers, and users should be able to maintain a constant pace indefinitely. 9. Continuous attention to technical excellence and good design enhances agility. diff --git a/handbook/digital-experience/README.md b/handbook/digital-experience/README.md index d9cc6dbcfe..781f511f24 100644 --- a/handbook/digital-experience/README.md +++ b/handbook/digital-experience/README.md @@ -10,9 +10,10 @@ This page details processes specific to working [with](#contact-us) and [within] | Head of Digital Experience | [Sam Pfluger](https://www.linkedin.com/in/sampfluger88/) _([@sampfluger88](https://github.com/sampfluger88))_ | Head of Design | [Mike Thomas](https://www.linkedin.com/in/mike-thomas-52277938) _([@mike-j-thomas](https://github.com/mike-j-thomas))_ | Software Engineer | [Eric Shaw](https://www.linkedin.com/in/eric-shaw-1423831a9/) _([@eashaw](https://github.com/eashaw))_ +| Contracts and Compliance Engineer | [Nathan Holliday](https://www.linkedin.com/in/nathanael-holliday/) _([@hollidayn](https://github.com/hollidayn))_ | Apprentice to the CEO | See [Head of Digital Experience](https://www.fleetdm.com/handbook/digital-experience#team) | Apprentice | [Savannah Friend](https://www.linkedin.com/in/savannah-friend-2b1a53148/) _([@sfriendlee](https://github.com/sfriendlee))_ - + ## Contact us @@ -25,11 +26,219 @@ This page details processes specific to working [with](#contact-us) and [within] The Digital Experience department is directly responsible for the framework, content design, and technology behind Fleet's remote work culture, including fleetdm.com, the handbook, issue templates, UI style guides, internal tooling, Zapier flows, Docusign templates, key spreadsheets, and project management processes. +Compliance and contracts including maintaining Delaware registered agent and certificate of good standing, receiving and responding to legal notices, SOC2, deal desk, compensation planning, Onboarding, 30/60/90s, manager training, holding hiring managers accountable (for actually getting their open positions filled quickly) +5. Logistical admin and witness for offboarding +6. Logistical admin for pre-start hiring process +7. Logistical admin for position opening and compensation determination process + > _**Note:** If a user story involves only changes to fleetdm.com, without changing the core product, then that user story is prioritized, drafted, implemented, and shipped by the [Digital Experience](https://fleetdm.com/handbook/digital-experience) department. Otherwise, if the story **also** involves changes to the core product **as well as** fleetdm.com, then that user story is prioritized, drafted, implemented, and shipped by [the other relevant product group](https://fleetdm.com/handbook/company/product-groups#current-product-groups), and not by `#g-digital-experience`._ -### QA a change to fleetdm.com +### Access a background check +All Fleet team members undergo a background check provided through [Vetty](https://vetty.co/). Only the most recent background checks appear on the home page of Vetty's dashboard. To access a complete list of background checks run in Vetty, scroll down to the bottom of the candidates page and click "View Historical". + + +### Convert a Fleetie to a consultant + +If a Fleetie decides they want to move to being a [consultant](https://fleetdm.com/handbook/company/leadership#consultants), either the Fleetie or their manager need to create a [custom issue for the Digital Experience team](https://github.com/fleetdm/confidential/issues/new?assignees=&labels=%23g-digital-experience&projects=&template=custom-request.md&title=Request%3A+_______________________) to notify them of the change. +Once notified, Digital Experience takes the following steps: +1. Confirm the following details with the Fleetie: + - Date of change + - Term of consultancy (time period) + - Hours/capacity expected (hours per week or month) + - Confirm hourly rate +2. Once details are confirmed, use the information given to create the consulting agreement for the Fleetie (either in docusign (US-based) or via Plane (international)), and send to their personal email for signature. Once signed, save in Fleetie's [employee file](https://drive.google.com/drive/folders/1UL7o3BzkTKnpvIS4hm_RtbOilSABo3oG?usp=drive_link). +3. Schedule the Fleetie's final day in HRIS (Gusto or Plane). +4. Update final day in ["🧑‍🚀 Fleeties"](https://docs.google.com/spreadsheets/d/1OSLn-ZCbGSjPusHPiR5dwQhheH1K8-xqyZdsOe9y7qc/edit#gid=0) spreadsheet. +5. Create an [offboarding issue](https://github.com/fleetdm/classified/blob/main/.github/ISSUE_TEMPLATE/%F0%9F%9A%AA-offboarding-____________.md) for the Fleetie converting to a consultant, and confirm with their manager if there is a need to retain any tools or access while they are a consultant (default to removing all access from Fleet email, and migrating to personal email for Slack and other tools unless there is a business case to retain the Fleet email and associated tool access). +6. Follow the offboarding issue for next steps, including communicating to teammates and updating equity plan. + + +### Inform managers about hours worked + +Every Friday at 2:00 PM CT, we collect hours worked for all hourly employees at Fleet, including core team members and consultants, regardless of their location. + +Here's how: + +1. Consultants submit their hours through Gusto (US consultants) or Plane.com (international consultants) and require DRI approval (generally their manager) for hours worked. Find the DRI using the [Digital Experience KPIs](https://docs.google.com/spreadsheets/d/1Hso0LxqwrRVINCyW_n436bNHmoqhoLhC8bcbvLPOs9A/edit#gid=0). +2. Send the teammate's DRI a direct message in Slack with a screenshot of the HRIS portal, showing hours logged since last Saturday at midnight, and ask them to confirm the hours are expected. Ensure the screenshot does not include compensation information. + - For international teammates, they cannot enter hours weekly in Plane.com, so you will need to request the hours worked from them in order to have the DRI approve them. +3. The following Monday, check for updates to logged hours and ensure the KPI sheet aligns with HRIS records. + - If there are discrepancies between what was previously reported, reconfirm logged hours with the teammate's DRI and update the KPI sheet to reflect the correct amount. + + +### Change the DRI of a consultant + +1. In the [KPIs](https://docs.google.com/spreadsheets/d/1Hso0LxqwrRVINCyW_n436bNHmoqhoLhC8bcbvLPOs9A/edit#gid=0) sheet, find the consultant's column. +2. Change the DRI documented there to the new DRI who will receive information about the consultant's hours. + + +### Update personnel details +When a Fleetie, consultant or advisor requests an update to their personnel details (name, location, phone, etc), follow these steps to ensure accurate representation across systems. +1. Team member submits a [custom issue](https://github.com/fleetdm/confidential/issues/new?assignees=&labels=%23g-digital-experience&projects=&template=custom-request.md&title=Request%3A+_______________________) to update their personnel details (or Digital Experience team creates if the request comes via email or is sensitive and needs a classified issue). + - If change is for a primary identification or contact method, ask for evidence of change and capture in [employee's personnel file](https://drive.google.com/drive/folders/1UL7o3BzkTKnpvIS4hm_RtbOilSABo3oG?usp=drive_link). +2. Digital Experience makes change to HRIS (Gusto or Plane) to reflect change. + - Note: if making the change requires follow up steps, resolve those steps to action the change. +3. Once change is effected in HRIS, Digital Experience makes changes to ["🧑‍🚀 Fleeties"](https://docs.google.com/spreadsheets/d/1OSLn-ZCbGSjPusHPiR5dwQhheH1K8-xqyZdsOe9y7qc/edit#gid=0) spreadsheet. +4. If required, Digital Experience makes any relevant changes to [Fleet's equity plan](https://docs.google.com/spreadsheets/d/1_GJlqnWWIQBiZFOoyl9YbTr72bg5qdSSp4O3kuKm1Jc/edit#gid=0). +5. If required, Digital Experience makes any relevant changes to the ["🗺️ Geographical factors"](https://docs.google.com/spreadsheets/d/1rCVCs-eOo-VSEG7fPLgdq5l7oSaActl5bewaWP7PnSE/edit#gid=1533353559) spreadsheet and follows through on any action items involving tax implications (i.e. registering with a new state for employer taxes). +6. If required, Digital Experience also makes changes to other core systems (e.g: creating a new email alias in google workspace; updating details in Carta; etc). +7. The change is now actioned, notify the team member and close the issue. + +> Note: if the Fleetie is US based and has a qualifying life event that impacts benefit coverage, they can [follow the Gusto steps](https://support.gusto.com/article/100895878100000/Change-your-benefits-with-a-qualifying-life-event) to update their coverage elections. + + +### Change a Fleetie's job title +When Digital Experience receives notification of a Fleetie's job title changing, follow these steps to ensure accurate recording of the change across our systems. +1. Update ["🧑‍🚀 Fleeties"](https://docs.google.com/spreadsheets/d/1OSLn-ZCbGSjPusHPiR5dwQhheH1K8-xqyZdsOe9y7qc/edit#gid=0): + - Search the spreadsheet for the Fleetie in need of a job title change. + - Input the new job title in the Fleetie's row in the "Job title" cell. + - Navigate to the "Org chart" tab of the spreadsheet, and verify that the Fleetie's title appears correctly in the org chart. +2. Update the departmental handbook page with the change of job title +3. [Prepare salary benchmarking information](#prepare-salary-benchmarking-information) to determine whether the teammate's current compensation aligns with the benchmarks of the new role. + - If the benchmark is significantly different, take the steps to [update a team member's compensation](#prepare-salary-benchmarking-information). +4. Update the relevant payroll/HRIS system. + - For updating Gusto (US-based Fleeties): + - Login to Gusto and navigate to "People > Team members". + - Find the Fleetie and select them to see their profile page. + - Under the "Compensation" heading, select edit and update the "Job title" and input the specific date the change happened. Save the changes. + - For updating Plane (non-US Fleeties): + - Login to Plane and navigate to "People > Team". + - Find the Fleetie and select them to see their profile page. + - Use the "Help" function, or email support@plane.com to notify Plane of the need to change the job title for the Fleetie. Include the Fleetie's name, current title, new title, and effective date. + - Take any relevant steps as directed by Plane in order to make the required changes to the Fleetie's profile. + + +### Change a Fleetie's manager +When Digital Experience receives notification of a Fleetie's manager changing, follow these steps to ensure correct recording in our systems. +1. Update [🧑‍🚀 Fleeties](https://docs.google.com/spreadsheets/d/1OSLn-ZCbGSjPusHPiR5dwQhheH1K8-xqyZdsOe9y7qc/edit#gid=0): + - Search for the Fleetie's new manager, and copy the new manager's unique ID from the far left "Unique ID" column. + - Search for the Fleetie whose manager is changing, and paste (without formatting) their new manager's unique ID in the "Reports to: (manager unique ID)" cell in the Fleetie's row. + - Verify that the "Reports to (auto: manager name and job title)" cell in the Fleetie's row reflects the new manager's details. + - Verify that in the new manager's row, the "# direct reports" cell reflect the correct number. + - Navigate to the "Org chart" tab in the spreadsheet, and verify that the Fleetie now appears in the correct place in the org chart. +2. If the person's department is changing, then update both departmental handbook pages to move the person to their new department: + - Remove the person from the "Team" section of the old department and add them to the "Team" section of the new department. +3. If the person's level of confidential access will change along with the change to their manager, then update that level of access: + - Update Google Workspace to make sure this person lives in the correct Google Group, removing them from the old and/or adding them to the new. + - Update 1password to remove this person from old vaults and/or add them to new vaults. + - For a team member moving from "classified" to "confidential" access, check Gusto, Plane, and other systems to remove their access. + +> **Note:** The Fleeties spreadsheet is the source of truth for who everyone's manager is and their job titles. + +### Recognize employee workiversaries + +At Fleet, everyone is recognized on their [workiversary](https://fleetdm.com/handbook/company/communications#workiversaries). To ensure this happens, take the following steps: + +1. Bimonthly, use [Fleeties (private google doc)](https://docs.google.com/spreadsheets/d/1OSLn-ZCbGSjPusHPiR5dwQhheH1K8-xqyZdsOe9y7qc/edit#gid=0) to determine who is celebrating their workiversary in the following two months. +2. Post in the #help-classifed Slack channel and cc the Head of Digital Experience. Use the following template: + + + ``` + [Month] + [workiversary date (DD-MMM)] - [teammate name] - [number of years at Fleet] + ``` + + + The Head of Digital Experience will also use this post to update the [All hands](https://fleetdm.com/handbook/company/communications#all-hands) deck. +3. On the day prior to a workiversary, send the teammate’s manager a DM on Slack: + + + ``` + Hey! Just a heads up, tomorrow is [teammate’s name] [number of years at Fleet] workiversary at Fleet. + Digital Experience can post something in the #random channel to recognize them, would you like to make that post instead? + ``` + + > If a manager elects to post and hasn't done so by 2pm ET on the day of the workiversary, send them a friendly reminder and offer to post instead. + +4. If the manager has deferred to Digital Experience, schedule a Slack post for the following day to recognize the teammate's contributions at Fleet. If you’re unsure about what to post, take a look at what’s been [posted previously](https://docs.google.com/document/d/1Va4TYAs9Tb0soDQPeoeMr-qHxk0Xrlf-DUlBe4jn29Q/edit). + + + +### Prepare salary benchmarking information +1. Use the relevant template text in the README section of the [¶¶ 💌 Compensation decisions document](https://docs.google.com/document/d/1NQ-IjcOTbyFluCWqsFLMfP4SvnopoXDcX0civ-STS5c/edit?usp=sharing) for a current Fleetie, a new role, a prospective hire, or other benchmarking use case. +2. Copy the template text and paste at the end of the document. +3. Fill in details as required, pulling from [🧑‍🚀 Fleeties spreadsheet](https://docs.google.com/spreadsheets/d/1OSLn-ZCbGSjPusHPiR5dwQhheH1K8-xqyZdsOe9y7qc/edit#gid=0) and [equity spreadsheet](https://docs.google.com/spreadsheets/d/1_GJlqnWWIQBiZFOoyl9YbTr72bg5qdSSp4O3kuKm1Jc/edit?usp=sharing) as required. +4. Use the teammate's information to benchmark in [Pave](https://www.pave.com/) (login details in 1Password). You can pattern match from previous benchmarking entries, and include all company assumtions. Add the direct link to the Pave benchmark. + + +### Update a team member's compensation + +To [change a teammate's compensation](https://fleetdm.com/handbook/company/communications#compensation-changes), follow these steps: +1. Create a copy of the ["Values assessment" template](https://docs.google.com/spreadsheets/d/1P5TyRV2v-YN0aR_X8vd8GksKcr3uHfUDdshqpVzamV8/edit?usp=drive_link) and move it to the teammate's [personnel folder in Google Drive](https://drive.google.com/drive/folders/1UL7o3BzkTKnpvIS4hm_RtbOilSABo3oG?usp=drive_link). +2. Share the values assessment document with the manager and ask them to perform the values assessment. +3. Once the values assessment is complete, [prepare salary benchmarking information](#prepare-salary-benchmarking-information) and notify the Head of Digital Experience so the compensation change can be added to the e-group agenda for discussion amongst Fleet leadership. + - If the teammate's manager is not part of the e-group, the Head of Digital Experience will ensure they're included in the discussion at e-group as well. +4. Once compensation decisions have been finalized, the Head of Digital Experience will post in slack to `#help-classified` to confirm the decisions have been recorded in ["¶¶ 💌 Compensation decisions (offer math)"](https://docs.google.com/document/d/1NQ-IjcOTbyFluCWqsFLMfP4SvnopoXDcX0civ-STS5c/edit#heading=h.slomq4whmyas). +5. Send the teammates manager a Slack DM to determine who will communicate the decision to the teammate. +6. Update the respective payroll platform (Gusto or Plane) by navigating to the personnel page, selecting salary field, and updating with an effective date that makes the next payroll. +7. Update the [equity spreadsheet](https://docs.google.com/spreadsheets/d/1_GJlqnWWIQBiZFOoyl9YbTr72bg5qdSSp4O3kuKm1Jc/edit?usp=sharing) (internal doc) by copying existing OTE to the bottom of the "Notes" cell, updating the OTE column with the new compensation information, and updating the "Last compensation change" column with the effective date from payroll platform. +8. Calculate the monthly burn rate increase percentage and notify the CEO via a Slack DM. + +> If the company decides on an additional equity grant as part of a compensation change, note the previous equity and new situation in detail in the "Notes" column of the equity plan. Update the "Grant started?" column to "todo" which adds it to the queue for the next time grants are processed (quarterly). + + +### Review Fleet's US company benefits + +Annually, around mid-year, Fleet will be prompted by Gusto to review company benefits. The goal is to keep changes minimal. Follow these steps: +1. Log in to your [Gusto admin account](https://gusto.com/). +2. Navigate to "Benefits" and select "Renewal survey". +3. Complete the survey questions, aiming for minimal changes. +4. Approximately 2-3 months after survery completion, Gusto will suggest plans based on Fleet's responses. Choose plans with minimal changes. +5. Gusto will offer these plans to employees during open enrollment, with new coverage starting 3-4 weeks afterward. + +### Grant equity +Equity grants for new hires are queued up as part of the [hiring process](https://fleetdm.com/handbook/digital-experience#hiring), then grants and consents are [batched and processed quarterly](https://github.com/fleetdm/confidential/issues/new/choose). + +Doing an equity grant involves: +- Executing a board consent +- The recipient and CEO signing paperwork about the stock options +- Updating the number of shares for the recipient in the equity plan +- Updating Carta to reflect the grant + +For the status of stock option grants, exercises, and all other _common stock_ including advisor, founder, and team member equity ownership, see [Fleet's equity plan](https://docs.google.com/spreadsheets/d/1_GJlqnWWIQBiZFOoyl9YbTr72bg5qdSSp4O3kuKm1Jc/edit#gid=0). For information about investor ownership, see [Carta](https://app.carta.com/corporations/1234715/summary/). + +> Fleet's [equity plan](https://docs.google.com/spreadsheets/d/1_GJlqnWWIQBiZFOoyl9YbTr72bg5qdSSp4O3kuKm1Jc/edit#gid=0) is the source of truth, not Carta. Neither are pro formas sent in an email attachment, even if they come from lawyers. +> +> Anyone can make mistakes, and none of us are perfect. Even when we triple check. Small mistakes in share counts can be hard to attribute, and can cause headaches and eat up nights of our CEO's and operations team's time. If you notice what might be a discrepancy between the equity plan and any other secondary source of information, please speak up and let Fleet's CEO know ASAP. Even if you're wrong, your note will be appreciated. + + +### Review an NDA +We need to review an NDA anytime a vendor, customer or other party wants to: +- Use their own NDA rather than Fleet's standard NDA, or +- "Redline" (modify) Fleet's NDA by removing, adding or altering its terms. + +We should always seek to use Fleet's own NDA first, without alteration. + +When reading an NDA, we want to pay close attention to the following: +- We want to be sure that the confidentiality obligations of the NDA are reciprocal. Fleet and the other party to the agreement should be bound to the same standards of confidentiality toward the handling of each other's confidential information. +- Fleet does not agree to _"do not compete"_ or _"do not solicit clauses"_. An NDA should not contain provisions beyond the scope of an NDA. The two most commonly encountered examples of this are the "do not compete" and "do not solicit" clauses. We want to be free to hire the best people and make the best products, so when reading through an NDA it is important to keep an eye out for language that prohibits Fleet from hiring or soliciting current or former employees of other companies or that prohibit Fleet from independently developing products that compete with another company's products. Using the `cmd + f` function to search for "solici", "compet" and "hir" and reading through the results is a helpful method to quickly scan for these clauses. +- Look for any language that discusses a transfer of property rights. Rarely, you may find a clause snuck into an agreement that discusses the transfer of intellectual property rights. _We want to avoid any situation where Fleet transfers its intellectual property to another party as part of an NDA_. +- Should you find any clauses in steps 2 or 3 that are beyond the scope of protecting both party's confidential information in a customer NDA or an altered version of Fleet's NDA, reject this language and communicate that Fleet cannot agree to those terms. +- Any concerns or uncertainty over _any_ provisions in an NDA should be brought to Nathanael Holliday in Digital Experience, who will consult legal counsel if necessary to resolve any concerns. + +### Review a vendor agreement +When reviewing contracts from a vendor, Fleet is concerned about the following: +- If there are confidentiality provisions in the agreement in place of a stand-alone NDA, verify the confidentiality provisions are appropriate and protect Fleet when sensitive data is involved that isn't otherwise available to the public. +- We want to make sure there are no _do not solicit_ or _do not compete_ clauses in the contract. To aid in this search, we double check by using the cmd + f function and searching for "solici", "compet" and "hir" and then looking through the results to be sure that nothing prohibits Fleet from independently developing competing products or from hiring personnel with ties to the vendor. +- We want to make sure that contracts can be terminated relatively easily and be aware of what the process is for terminating them, avoiding commitments over 12 months in length. +- We want to make sure the payment terms work for us (i.e. being able to pay via wire transfer, credit card or bill.com) and that the price in any contract or order form is what we have agreed to. While almost never malicious, mistakes often occur in the steps between agreeing on a price, negotiating a contract, and receiving an invoice. We want to be sure at every step that the dollar amount and service provided is consistent with what has been negotiated and agreed upon. +- Remember, once we have signed the agreement - we're stuck with it. If any clause in the agreement appears strange or gives you pause or concern, it is better to seek clarification than to commit to something that might be detrimental to Fleet. Contracts are fairly standardized, and you'll quickly learn what is normal and what feels out of place. Unusual clauses or wording that seems out of the ordinary should get a second set of eyes just to be sure, do not hesitate to reach out to Nathanael Holliday with questions, who will reach out to legal counsel as necessary. + +### Review an order form +- We should always check order forms for additional terms that go beyond the scope of the order form (caps on price increases, for example). +- Be sure the order form includes contact information + billing address and information so that Fleet knows how and who to invoice for payment. +- Verify that the payment terms are correct and matches what's in the agreement. This is a frequent common mistake as companies usually have default payment terms and overlook changing them to match atypical payment terms. +- Make sure the effective term of the order matches what was agreed upon (usually a one year term) and that the order form includes the correct number of hosts and whether or not it should contain professional services (usually, it does not). +- Check that the amount on the order form reflects what Fleet agreed to, as this is the amount that the customer will expect to be invoiced for. +- Lastly, double check one more time to make sure there are no sneaky, unusual terms snuck in at the bottom of an order form or stashed away in fine print. Common things that are included in order forms and not always communicated to Fleet are caps on price increases upon renewal, new SLAs, or a product roadmap or milestones we may not have agreed upon. Any clauses on an order form that appear beyond the scope of simply elaborating on the services being provided, the purchase cost, the contract that the purchase is being made under, how Fleet will bill and how the customer will pay deserves a careful look. Reach out to Nathanael Holliday in Digital Experience with concerns. + +### Review a non-standard subscription agreement +We want to use our standard terms whenever possible with our customers, but it is common that customers want to use their own agreement or redline (modify) Fleet's terms. +When reviewing subscription agreements on customer paper or when a customer has made changes to Fleet's terms, we review it using [these guidelines](https://docs.google.com/document/d/1aGgN5It1i3fdsBF37vWSbvukO_gQhy5vCp4fINg191Q/edit?usp=sharing). + +### QA a change to fleetdm.com Each PR to the website is manually checked for quality and tested before going live on fleetdm.com. To test any change to fleetdm.com 1. Write clear step-by-step instructions to confirm that the change to the fleetdm.com functions as expected and doesn't break any possible automation. These steps should be simple and clear enough for anybody to follow. @@ -226,7 +435,7 @@ Certain new team members, especially in go-to-market (GTM) roles, will need paid ### Downgrade an unused license seat -- On the first Wednesday of every quarter, the CEO, head of BizOps and Head of Digital experience will meet for 30 minutes to audit license seats in Figma, Slack, GitHub, Salesforce and other tools. +- On the first Wednesday of every quarter, the CEO and Head of Digital experience will meet for 30 minutes to audit license seats in Figma, Slack, GitHub, Salesforce and other tools. - During this meeting, as many seats will be downgraded as possible. When doubt exists, downgrade. - Afterward, post in #random letting folks know that the quarterly tool reconciliation and seat clearing is complete, and that any members who lost access to anything they still need can submit a ZenHub issue to Digital Experience to have their access restored. - The goal is to build deep, integrated knowledge of tool usage across Fleet and cut costs whenever possible. It will also force conversations on redundancies and decisions that aren't helping the business that otherwise might not be looked at a second time. @@ -327,11 +536,11 @@ Agenda: When an agreement is routed to the CEO for signature, the [Apprentice](https://fleetdm.com/handbook/digital-experience#team) is responsible for obtaining a signature from the CEO using the following steps: 1. Drag the email to the ["🔏 SAM: Signature wanted"](https://mail.google.com/mail/u/0/#label/SAM%3A+Signature+wanted) label making sure to mark the email as unread. -2. A Business Operations Engineer will at-mention the Apprentice in a legal review issue, letting them know the contract is good to go. After that, move the email to the "[✍️ MIKE: Ready to sign](https://mail.google.com/mail/u/0/#label/%E2%9C%8D%EF%B8%8F+MIKE%3A+Ready+to+sign)" label +2. The [Contracts and Compliance Engineer](https://fleetdm.com/handbook/digital-experience#team) will at-mention the Apprentice in a legal review issue, letting them know the contract is good to go. After that, move the email to the "[✍️ MIKE: Ready to sign](https://mail.google.com/mail/u/0/#label/%E2%9C%8D%EF%B8%8F+MIKE%3A+Ready+to+sign)" label > If the agreement closes a deal, inform the CEO (via Slack DM) that a subscription agreement is ready for his review/signature. The SLA for CEO review and signature is 48hrs. -3. Comment in the issue once the CEO has signed the agreement and assign the issue to [Nathan Holiday](https://fleetdm.com/handbook/business-operations#team). +3. Comment in the issue once the CEO has signed the agreement and assign the issue to [Nathan Holiday](https://fleetdm.com/handbook/digital-experience#team). ### Prepare for CEO office minutes @@ -393,9 +602,9 @@ After the team member notifies the Head of Digital Experience (via Slack), the H ### Document performance feedback -Every Friday at 5PM a [Business Operations team member](https://fleetdm.com/handbook/business-operations#team) will look for missing data in the [KPIs spreadsheet](https://docs.google.com/spreadsheets/d/1Hso0LxqwrRVINCyW_n436bNHmoqhoLhC8bcbvLPOs9A/edit#gid=0). -1. If KPIs are not reported on time, the BizOps Engineer will notify the Apprentice to the CEO and the DRI. -2. The Apprentice will update the "performance management" section of the appropriate individual's 1:1 doc so that the CEO can address during the next 1:1 meeting with the DRI. +Every Friday at 5PM a [Digital Experience team member](https://fleetdm.com/handbook/digital-experience#team) will look for missing data in the [KPIs spreadsheet](https://docs.google.com/spreadsheets/d/1Hso0LxqwrRVINCyW_n436bNHmoqhoLhC8bcbvLPOs9A/edit#gid=0). +1. If KPIs are not reported on time, notify the Head of Digital Experience and the DRI. +2. The Head of Digital Experience will update the "performance management" section of the appropriate individual's 1:1 doc so that the CEO can address during the next 1:1 meeting with the DRI. ### Send the weekly update diff --git a/handbook/business-operations/Application-security.md b/handbook/digital-experience/application-security.md similarity index 77% rename from handbook/business-operations/Application-security.md rename to handbook/digital-experience/application-security.md index e914e99f0f..3c17410299 100644 --- a/handbook/business-operations/Application-security.md +++ b/handbook/digital-experience/application-security.md @@ -1,13 +1,13 @@ # Application security -- [Describe your secure coding practices (SDLC)](https://fleetdm.com/handbook/business-operations/application-security#describe-your-secure-coding-practices-including-code-reviews-use-of-static-dynamic-security-testing-tools-3-rd-party-scans-reviews) -- [SQL injection](https://fleetdm.com/handbook/business-operations/application-security#sql-injection) -- [Broken authentication](https://fleetdm.com/handbook/business-operations/application-security#broken-authentication-authentication-session-management-flaws-that-compromise-passwords-keys-session-tokens-etc) - - [Passwords](https://fleetdm.com/handbook/business-operations/application-security#passwords) - - [Authentication tokens](https://fleetdm.com/handbook/business-operations/application-security#authentication-tokens) -- [Sensitive data exposure](https://fleetdm.com/handbook/business-operations/application-security#sensitive-data-exposure-encryption-in-transit-at-rest-improperly-implemented-apis) -- [Cross-site scripting](https://fleetdm.com/handbook/business-operations/application-security#cross-site-scripting-ensure-an-attacker-cant-execute-scripts-in-the-users-browser) -- [Components with known vulnerabilities](https://fleetdm.com/handbook/business-operations/application-security#components-with-known-vulnerabilities-prevent-the-use-of-libraries-frameworks-other-software-with-existing-vulnerabilities) +- [Describe your secure coding practices (SDLC)](https://fleetdm.com/handbook/digital-experience/application-security#describe-your-secure-coding-practices-including-code-reviews-use-of-static-dynamic-security-testing-tools-3-rd-party-scans-reviews) +- [SQL injection](https://fleetdm.com/handbook/digital-experience/application-security#sql-injection) +- [Broken authentication](https://fleetdm.com/handbook/digital-experience/application-security#broken-authentication-authentication-session-management-flaws-that-compromise-passwords-keys-session-tokens-etc) + - [Passwords](https://fleetdm.com/handbook/digital-experience/application-security#passwords) + - [Authentication tokens](https://fleetdm.com/handbook/digital-experience/application-security#authentication-tokens) +- [Sensitive data exposure](https://fleetdm.com/handbook/digital-experience/application-security#sensitive-data-exposure-encryption-in-transit-at-rest-improperly-implemented-apis) +- [Cross-site scripting](https://fleetdm.com/handbook/digital-experience/application-security#cross-site-scripting-ensure-an-attacker-cant-execute-scripts-in-the-users-browser) +- [Components with known vulnerabilities](https://fleetdm.com/handbook/digital-experience/application-security#components-with-known-vulnerabilities-prevent-the-use-of-libraries-frameworks-other-software-with-existing-vulnerabilities) The Fleet community follows best practices when coding. Here are some of the ways we mitigate against the OWASP top 10 issues: diff --git a/handbook/digital-experience/digital-experience.rituals.yml b/handbook/digital-experience/digital-experience.rituals.yml index 60aff95333..c36e75db31 100644 --- a/handbook/digital-experience/digital-experience.rituals.yml +++ b/handbook/digital-experience/digital-experience.rituals.yml @@ -175,7 +175,7 @@ startedOn: "2024-03-31" frequency: "Quarterly" description: "Downgrade unused or questionable license seats on the first Wednesday of every quarter" - moreInfoUrl: "https://fleetdm.com/handbook/business-operations#downgrade-an-unused-license-seat" + moreInfoUrl: "https://fleetdm.com/handbook/digital-experience#downgrade-an-unused-license-seat" dri: "sampfluger88" - task: "Communicate Fleet's potential energy to stakeholders" @@ -188,21 +188,33 @@ labels: [ "#g-digital-experience" ] repo: "confidential" - - task: "Change password of \"Integrations admin\" Salesforce account" - startedOn: "2024-09-10" + task: "Vanta check" # TODO tie this to a responsibility + startedOn: "2024-04-01" + frequency: "Monthly" + description: "Look for any new actions in Vanta due in the upcoming months and create issues to ensure they're done on time." + moreInfoUrl: + dri: "sampfluger88" + autoIssue: + labels: [ "#g-digital-experience" ] + repo: "confidential" +- + task: "Recognize and benchmark workiversaries" + startedOn: "2024-07-15" + frequency: "Bimonthly" + description: "Identify workiversaries coming up in the next two months and follow the steps to ensure they're recognized and benchmarked" + moreInfoUrl: "https://fleetdm.com/handbook/digital-experience#recognize-employee-workiversaries" + dri: "sampfluger88" +- + task: "Quarterly grants" + startedOn: "2024-02-01" frequency: "Quarterly" - description: "Log into the \"Integrations admin\" account in Salesforce and change the password to prevent a password change being required by Salesforce." + description: "Create the equity grants GitHub issue and walk through the steps." + moreInfoUrl: "https://fleetdm.com/handbook/digital-experience#grant-equity" + dri: "hollidayn" +- + task: "Change password of \"Integrations admin\" Salesforce account" + startedOn: "2024-09-10" + frequency: "Quarterly" + description: "Log into the \"Integrations admin\" account in Salesforce and change the password to prevent a password change being required by Salesforce." moreInfoUrl: "https://fleetdm.com/handbook/digital-experience#change-the-integrations-admin-salesforce-account-password" dri: "eashaw" - - - - - - - - - - - - diff --git a/handbook/business-operations/security-audits.md b/handbook/digital-experience/security-audits.md similarity index 100% rename from handbook/business-operations/security-audits.md rename to handbook/digital-experience/security-audits.md diff --git a/handbook/business-operations/security-policies.md b/handbook/digital-experience/security-policies.md similarity index 99% rename from handbook/business-operations/security-policies.md rename to handbook/digital-experience/security-policies.md index 42a911c991..842e67a44d 100644 --- a/handbook/business-operations/security-policies.md +++ b/handbook/digital-experience/security-policies.md @@ -102,7 +102,7 @@ Fleet policy requires that: - Use of shared credentials/secrets must be minimized. -- If required by business operations, secrets/credentials must be shared securely and stored in encrypted vaults that meet the Fleet data encryption standards. +- If required by Digital Experience, secrets/credentials must be shared securely and stored in encrypted vaults that meet the Fleet data encryption standards. ### Privileged access management @@ -158,7 +158,7 @@ For technical incidents: For business/operational incidents: - CEO (Mike McNeil) -- Head of Business Operations (Joanne Stableford) +- Head of Digital Experience (Sam Pfluger) ### Response Teams and Responsibilities @@ -612,7 +612,7 @@ CTO | Oversight over information sec | System owners | Manage the confidentiality, integrity, and availability of the information systems for which they are responsible in compliance with Fleet policies on information security and privacy.
Approve of technical access and change requests for non-standard access | | Employees, contractors, temporary workers, etc. | Acting at all times in a manner that does not place at risk the security of themselves, colleagues, and the information and resources they have use of
Helping to identify areas where risk management practices should be adopted
Adhering to company policies and standards of conduct Reporting incidents and observed anomalies or weaknesses | | Head of People Operations | Ensuring employees and contractors are qualified and competent for their roles
Ensuring appropriate testing and background checks are completed
Ensuring that employees and relevant contractors are presented with company policies
Ensuring that employee performance and adherence to values is evaluated
Ensuring that employees receive appropriate security training | -| Head of Business Operations | Responsible for oversight over third-party risk management process; responsible for review of vendor service contracts | +| Head of Digital Experience | Responsible for oversight over third-party risk management process; responsible for review of vendor service contracts | ## Network and system hardening standards Fleet leverages industry best practices for network hardening, which involves implementing a layered defense strategy called defense in depth. This approach ensures multiple security controls protect data and systems from internal and external threats. @@ -790,4 +790,4 @@ Fleet makes every effort to assure all third-party organizations are compliant a > Fleet is committed to ethical business practices and compliance with the law. All Fleeties are required to comply with the "Foreign Corrup Practices Act" and anti-bribery laws and regulations in applicable jurisdictions including, but not limited to, the "UK Bribery Act 2010", "European Commission on Anti-Corruption" and others. The policies set forth in [this document](https://docs.google.com/document/d/16iHhLhAV0GS2mBrDKIBaIRe_pmXJrA1y7-gTWNxSR6c/edit?usp=sharing) go over Fleet's anti-corruption policy in detail. - + \ No newline at end of file diff --git a/handbook/business-operations/security.md b/handbook/digital-experience/security.md similarity index 99% rename from handbook/business-operations/security.md rename to handbook/digital-experience/security.md index 0bb01c4ef2..46bbbf31ed 100644 --- a/handbook/business-operations/security.md +++ b/handbook/digital-experience/security.md @@ -27,7 +27,7 @@ As an all-remote company, we do not have the luxury of seeing each other or bein | Participant | Role | | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | Requester | Requests recovery for their own account | -| Recoverer | Person with access to perform the recovery who monitors `#g-business-operations` | +| Recoverer | Person with access to perform the recovery who monitors `#g-digital-experience` | | Identifier | Person that visually identifies the requester in a video call. The identifier can be the recoverer or a person the recoverer can recognize visually | @@ -35,10 +35,10 @@ As an all-remote company, we do not have the luxury of seeing each other or bein 1. If the requester still has access to GitHub and/or Slack, they [ask for - help](https://fleetdm.com/handbook/business-operations#intake). For non-urgent requests, please - prefer filing an issue with the business operations team. If they do not have access, + help](https://fleetdm.com/handbook/digital-experience#contact-us). For non-urgent requests, please + prefer filing an issue with the Digital Experience team. If they do not have access, they can contact their manager or a teammate over the phone via voice or texting, and they will - [ask for help](https://fleetdm.com/handbook/business-operations#intake) on behalf of the + [ask for help](https://fleetdm.com/handbook/digital-experience#contact-us) on behalf of the requester. 2. The recoverer identifies the requester through a live video call. * If the recoverer does not know the requester well enough to positively identify them visually, the @@ -870,12 +870,12 @@ questions and more on [https://fleetdm.com/trust](https://fleetdm.com/trust) ## Securtiy audits -Read about Fleet's security audits on [this page](https://fleetdm.com/handbook/business-operations/security-audits). +Read about Fleet's security audits on [this page](https://fleetdm.com/handbook/digital-experience/security-audits). ## Application security -Read about Fleet's application security practices on the [application security page](https://fleetdm.com/handbook/business-operations/application-security). +Read about Fleet's application security practices on the [application security page](https://fleetdm.com/handbook/digital-experience/application-security). diff --git a/handbook/business-operations/vendor-questionnaires.md b/handbook/digital-experience/vendor-questionnaires.md similarity index 95% rename from handbook/business-operations/vendor-questionnaires.md rename to handbook/digital-experience/vendor-questionnaires.md index 8af1763870..ee3bf32cd2 100644 --- a/handbook/business-operations/vendor-questionnaires.md +++ b/handbook/digital-experience/vendor-questionnaires.md @@ -17,7 +17,7 @@ Please also see [Application security](https://fleetdm.com/docs/using-fleet/appl ## Data security -Please also see ["Data security"](https://fleetdm.com/handbook/business-operations/security-policies#data-management-policy) +Please also see ["Data security"](https://fleetdm.com/handbook/digital-experience/security-policies#data-management-policy) | Question | Answer | | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | Should the need arise during an active relationship, how can our Data be removed from the Fleet's environment? | Customer data is primarily stored in RDS, S3, and Cloudwatch logs. Deleting these resources will remove the vast majority of customer data. Fleet can take further steps to remove data on demand, including deleting individual records in monitoring systems if requested. | @@ -35,7 +35,7 @@ Please also see ["Data security"](https://fleetdm.com/handbook/business-operatio | Can Fleet customers access service logs? | Logs will not be accessible by default, but can be provided upon request. | ## Encryption and key management -Please also see [Encryption and key management](https://fleetdm.com/handbook/business-operations/security-policies#encryption-policy) +Please also see [Encryption and key management](https://fleetdm.com/handbook/digital-experience/security-policies#encryption-policy) | Question | Answer | | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | Does Fleet have a cryptographic key management process (generation, exchange, storage, safeguards, use, vetting, and replacement), that is documented and currently implemented, for all system components? (e.g. database, system, web, etc.) | All data is encrypted at rest using methods appropriate for the system (ie KMS for AWS based resources). Data going over the internet is encrypted using TLS or other appropiate transport security. | @@ -48,10 +48,10 @@ Please also see [Encryption and key management](https://fleetdm.com/handbook/bus | Does Fleet have documented information security baselines for every component of the infrastructure (e.g., hypervisors, operating systems, routers, DNS servers, etc.)? | Fleet follows best practices for the given system. For instance, with AWS we utilize AWS best practices for security including GuardDuty, CloudTrail, etc. | ## Business continuity -Please also see [Business continuity](https://fleetdm.com/handbook/business-operations/security-policies#business-continuity-plan) +Please also see [Business continuity](https://fleetdm.com/handbook/digital-experience/security-policies#business-continuity-plan) | Question | Answer | | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | -| Please provide your application/solution disaster recovery RTO/RPO | RTO and RPO intervals differ depending on the service that is impacted. Please refer to https://fleetdm.com/handbook/business-operations/security-policies#business-continuity-and-disaster-recovery-policy | +| Please provide your application/solution disaster recovery RTO/RPO | RTO and RPO intervals differ depending on the service that is impacted. Please refer to https://fleetdm.com/handbook/digital-experience/security-policies#business-continuity-and-disaster-recovery-policy | ## Network security | Question | Answer | diff --git a/handbook/engineering/README.md b/handbook/engineering/README.md index 2592d5d68a..dc09669833 100644 --- a/handbook/engineering/README.md +++ b/handbook/engineering/README.md @@ -463,7 +463,7 @@ When this occurs, we will begin receiving the following error message when attem 2. Log in using the credentials stored in 1Password under "Apple developer account". -3. Contact the Head of Business Operations to determine which phone number to use for 2FA. +3. Contact the Head of Digital Experience to determine which phone number to use for 2FA. 4. Complete the 2FA process to log in. @@ -535,7 +535,7 @@ Upon receiving any device, follow these steps to process incoming equipment. ### Ship approved equipment -Once the Business Operations department approves inventory to be shipped from Fleet IT, follow these step to ship the equipment. +Once the Digital Experience department approves inventory to be shipped from Fleet IT, follow these step to ship the equipment. 1. Compare the equipment request issue with the ["Company equipment" spreadsheet](https://docs.google.com/spreadsheets/d/1hFlymLlRWIaWeVh14IRz03yE-ytBLfUaqVz0VVmmoGI/edit#gid=0) and verify physical inventory. 2. Plug in the device and ensure inventory has been correctly processed and all components are present (e.g. charger cord, power converter). 3. package equipment for shipment and include Yubikeys (if requested). diff --git a/handbook/engineering/engineering.rituals.yml b/handbook/engineering/engineering.rituals.yml index 2616976cd3..bdc8aa69ec 100644 --- a/handbook/engineering/engineering.rituals.yml +++ b/handbook/engineering/engineering.rituals.yml @@ -96,7 +96,7 @@ startedOn: "2024-02-09" frequency: "Daily" description: "Check event issues and complete steps." - moreInfoUrl: "https://fleetdm.com/handbook/business-operations#book-an-event" + moreInfoUrl: "https://fleetdm.com/handbook/engineering#book-an-event" dri: "spokanemac" diff --git a/handbook/finance/README.md b/handbook/finance/README.md new file mode 100644 index 0000000000..2e48dc6d8a --- /dev/null +++ b/handbook/finance/README.md @@ -0,0 +1,345 @@ +# Finance +This handbook page details processes specific to working [with](#contact-us) and [within](#responsibilities) this department. + +## Team +| Role | Contributor(s) | +|:------------------------------|:-----------------------------------------------------------------------------------------------------------| +| Head of Finance | [Joanne Stableford](https://www.linkedin.com/in/joanne-stableford/) _([@jostableford](https://github.com/JoStableford))_ +| Finance Engineer | [Isabell Reedy](https://www.linkedin.com/in/isabell-reedy-202aa3123/) _([@ireedy](https://github.com/ireedy))_ + + +## Contact us +- To **make a request** of this department, [create an issue](https://github.com/fleetdm/confidential/issues/new?assignees=&labels=%23g-finance&projects=&template=custom-request.md) and a team member will get back to you within one business day (If urgent, mention a [team member](#team) in [#g-finance](https://fleetdm.slack.com/archives/C047N5L6EGH). + - Please **use issue comments and GitHub mentions** to communicate follow-ups or answer questions related to your request. + - Any Fleet team member can [view the kanban board](https://app.zenhub.com/workspaces/-g-finance-63f3dc3cc931f6247fcf55a9/board?sprints=none) for this department, including pending tasks and the status of new requests. + + +## Responsibilities +The Finance department is directly responsible for accounts receivable including invoicing, accounts payable including commision calculations, exspense reporting including Brex memos and maintaining accurate spend projections in "🧮The numbers", sales taxes, payroll taxes, corporate income/franchise taxes, and financial operations including bank accounts and cash flow management. + + +### Run payroll +Many of these processes are automated, but it's vital to check Gusto and Plane manually for accuracy. + - Salaried fleeties are automated in Gusto and Plane. + - Hourly fleeties and consultants are a manual process each month in Gusto and Plane. + +| Payroll type | What to use | DRI | +|:-----------------------------|:-----------------------------|:-----------------------------| +| [Commissions and ramp](https://fleetdm.com/handbook/finance#run-us-commission-payroll) | "Off-cycle - Commission" payroll | Head of Finance +| Sign-on bonus | "Bonus" payroll | Head of Finance +| Performance bonus | "Bonus" payroll | Head of Finance +| Accelerations (quarterly) | "Off-cycle - Commission" payroll | Head of Finance +| [US contractor payroll](https://fleetdm.com/handbook/finance#run-us-contractor-payroll) | "Off-cycle" payroll | Head of Finance + +### Reconcile monthly recurring expenses +Recurring monthly or annual expenses, such as the tools we use throughout Fleet, are tracked as recurring, non-personnel expenses in ["🧮 The Numbers"](https://docs.google.com/spreadsheets/d/1X-brkmUK7_Rgp7aq42drNcUg8ZipzEiS153uKZSabWc/edit#gid=2112277278) _(¶confidential Google Sheet)_, along with their payment source. Reconciliation of recurring expenses happens monthly. + +> Use this spreadsheet as the source of truth. Always make changes to it first before adding or removing a recurring expense. Only track significant expenses. (Other things besides amount can make a payment significant; like it being an individualized expense, for example.) + + +### Register Fleet as an employer with a new state +Fleet must register as an employer in any state where we hire new teammates. To do this, complete the following steps in Gusto: +1. After a new teammate completes their Gusto profile, the Finance department will be prompted to approve it for payroll. Sign in to your Gusto admin account and begin the approval process. +2. Select "yes" when prompted to file a new hire report and complete the approval process. +3. Once the profile is approved, navigate to Tax setup and select the state you’d like to register Fleet in. +4. Select “Have us register for you” and then “Start registration.” +5. Verify, add, and amend any company information to ensure accuracy. +6. Select “Send registration” and authorize payment for the specified amount. CorpNet will then send an email with next steps, which vary by state. +7. Update the [list of states that Fleet is currently registered with as an employer](https://fleetdm.com/handbook/finance#review-state-employment-tax-filings-for-the-previous-quarter). + + +### Process an email from a state agency +From time to time, you may get notices via email (or in the mail) from state agencies regarding Fleet's withholding and/or unemployment tax accounts. You can resolve some of these notices on your own by verifying and/or updating the settings in your Gusto account. + +If the notice is regarding an upcoming change to your deposit schedule or unemployment tax rate, make the required change in Gusto, such as: +- Update your unemployment tax rate. +- Update your federal deposit schedule. +- Update your state deposit schedule. + +In Gusto, you can click **How to review your notice** to help you understand what kind of notice you received and what additional action you can take to help speed up the time it takes to resolve the issue. + +> **Note:** Many agencies do not send notices to Gusto directly, so it’s important that you read and take action before any listed deadlines or effective dates of requested changes, in case you have to do something. If you can't resolve the notice on your own, are unsure what the notice is in reference to, or the tax notice has a missing payment or balance owed, follow the steps in the Report and upload a tax notice in Gusto. + +Every quarter, payroll and tax filings are due for each state. Gusto can handle these automatically if Third-party authorization (TPA) is enabled. Each state is unique and Gusto has a library of [State registration and resources](https://support.gusto.com/hub/Employers-and-admins/Taxes-forms-and-compliance/State-registration-and-resources) available to review. You will need to grant Third-party authorization (TPA) per state and this should be checked quarterly before the filing due dates to ensure that Gusto can file on time. --> + + +### Review state employment tax filings for the previous quarter + +Every quarter, payroll and tax filings are due for each state. Gusto automates this process, however there are often delays or quirks between Gusto's submission and the state receiving the filings. +To mitigate the risk of penalties and to ensure filings occur as expected, follow these steps in the first month of the new quarter, verifying past quarter submission: +1. Create an issue to "Review state filings for the previous quarter". +2. Copy this text block into the issue to track progress by state: + + +``` +States checked: +- [ ] California +- [ ] Colorado +- [ ] Connecticut +- [ ] Florida +- [ ] Georgia +- [ ] Hawaii +- [ ] Illinois +- [ ] Kansas +- [ ] Maryland +- [ ] Massachusetts +- [ ] New York +- [ ] Ohio +- [ ] Oregon +- [ ] Pennsylvania +- [ ] Rhode Island +- [ ] Tennessee +- [ ] Texas +- [ ] Utah +- [ ] Virginia +- [ ] Washington +- [ ] Washington, DC +- [ ] West Virginia +- [ ] Wisconsin +``` + + +3. Login to Gusto and navigate to "Taxes and compliance", then "Tax documents". +4. Login to each State portal (using the details saved in 1Password) and verify that the portal has received the automated submission from Gusto. +5. Check off states that are correct, and use comments to explain any quirks or remediation that's needed. + + +### Run US contractor payroll +For Fleet's US contractors, running payroll is a manual process: +1. Add the amount to be paid to the "Gross" line. +2. Review hours _("Time tools > Time tracking")_ +3. Adjust time frame to match current payroll period (the 27th through 26th of the month) +4. Sync hours and run contractor payroll. + +### Create an invoice +To create a new invoice for a Fleet customer, follow these steps: +1. Go to the [invoice folder in google drive](https://drive.google.com/drive/folders/11limC_KQYNYQPApPoXN0CplHo_5Qgi2b?usp=drive_link). +2. Create a copy of the invoice template, and title the copy `[invoice number] Fleet invoice - [customer name]`. + - The invoice number follows the format of `YYMMDD[daily issued invoice number]`, where the daily issued invoice number should equal `01` if it's the first invoice issued that day, `02` if it's the second, etc. +3. Edit the new invoice to reflect details from the signed subscription agreement (and PO if required). + - Enter the invoice number (and PO number if required) into the top right section of the invoice. + - Update the date of the invoice to reflect the current date. + - Make sure the payment terms match the signed subscription agreement. + - Copy the customer address from the signed subscription agreement and input it in the "Bill to" section of the invoice. + - Copy the "Billing contact" email from the signed subscription agreement and add it to the last line of the "Bill to" address. + - Make sure the start and end dates of the contract and amount match the subscription agreement. + - If professional services are included in the subscription agreement, include as a separate line in the invoice, and ensure the amounts total correctly. + - Ensure the "Notes" section has wiring instructions for payment via SVB. +4. Download the completed invoice as a PDF. +5. Send the PDF to the billing contact from the "Bill to" section of the invoice and cc [Fleet's billing email address](https://fleetdm.com/handbook/company/communications#email-relays). Use the following template for the email: + +``` +Subject: Invoice for Fleet Device Management [invoice number] +Hello, + +I've attached the invoice for [customer name]'s purchase of Fleet Device Management's premium subscription. +For payment instructions please refer to your invoice, and reach out to [insert Fleet's billing address] with any questions. + +Thanks, +[name] +``` + +6. Update the opportunity and the opportunity billing cycle in Salesforce to include the "Invoice date" as the day the invoice was sent. +8. Notify the AE/CSM that the invoice has been sent. + +> Certain vendors require invoices submitted via a payment portal (such as Coupa). Once you've generated the invoice using the steps above, upload it to the relevant payment portal and email the billing contact to let them know you've submitted the invoice. + + +### Communicate the status of customer financial actions +This reporting is performed to update the status of open or upcoming customer actions regarding the financial health of the opportunity. To complete the report: +1. Check [SVB](https://connect.svb.com/#/) and [Brex](https://accounts.brex.com/login) for any recently received payments from customers and record them in SFDC. +2. Go to this [report folder](https://fleetdm.lightning.force.com/lightning/r/Folder/00lUG000000DstpYAC/view?queryScope=userFolders) in SFDC. The three reports will provide the data used in the report. +3. Copy the template below and paste it into the [#g-sales slack channel](https://fleetdm.slack.com/archives/C030A767HQV) and complete all "todos" using the data from Salesforce before sending. + +``` +Weekly revenue report - [@`todo: CRO` and @`todo: CEO`] +- Number accounts with outstanding balances = `todo` +- Number of customers awaiting invoices = `todo` +- Number of past-due renewals = `todo` +``` + +4. Send payment reminders via email to all outstanding accounts by responding to the invoice email initially sent to the customer. + +``` +Hello, +This is a reminder that you have an outstanding balance due for your Fleet Device Management premium subscription. +We have included the invoice here for your convenience. +For payment instructions please refer to your invoice, and reach out to [Fleet's billing contact] with any questions. + +Thanks, +[name] +``` + +5. If any accounts will become overdue within a week, reply in thread to the slack post, mention the opportunity owner of the account, and ask them to notify their contact that Fleet is still awaiting payment. +6. Review the [billing cycles](https://fleetdm.lightning.force.com/lightning/r/Report/00OUG000000yGjR2AU/view) report in SFDC for customers on multiyear deals. For any customers due for invoicing within the next week, create an issue on the Finance board. + + +### Run US commission payroll +1. Update individual teammates commission calculators (linked from [main commission calculator](https://docs.google.com/spreadsheets/d/1PuqUbfPGos87TfcHWgUd05TRJgQLlBmhyz1euj79m2A/edit?usp=sharing)) with new revenue from any deals that are closed-won (have a subscription agreement signed by both parties) and have a **close date** within the previous month. + - Verify closed-won deal numbers with CRO to ensure any agreed upon exceptions are captured (eg: CRO approves an AE to receive commission on a renewal deal due to cross-sell). +2. In the "Monthly commission payroll party" meeting, present the commission calculations for Fleeties receiving commission for approval. + - If there are any quarterly accelerators due for the teammate receiving commission, ensure the individual total includes both the monthly and the quarterly amount. +3. After the amounts are approved in the meeting, process the commission payroll. + - Use the off-cycle payroll option in Gusto. Be sure to classify the payment as "Commission" in the "other earnings" field and not the generic "Bonus." +4. Once commission payroll has been run, update the [main commission calculator](https://docs.google.com/spreadsheets/d/1PuqUbfPGos87TfcHWgUd05TRJgQLlBmhyz1euj79m2A/edit?usp=sharing) to mark the commission as paid. + +### Run international commission payroll +1. Follow the steps in [run US commission payroll](https://fleetdm.com/handbook/finance#run-us-commission-payroll) to have the commission amounts approved by the CRO. +2. After the amounts are approved in the "Monthly commission payroll party", navigate to Help > Ask a question in Plane to request a commission payment for the teammate. +3. Send a message using the following template + + ``` + Hello, + I’d like to run an off-cycle commission payment for [teammate’s full name] for the period of [commission period]. + The amount of [USD amount] should be paid with their next payroll. + Please let me know if you need any additional information to process this request. + + Thanks, + [name] + ``` + +4. Once Plane confirms the payroll change has been actioned, update the [main commission calculator](https://docs.google.com/spreadsheets/d/1PuqUbfPGos87TfcHWgUd05TRJgQLlBmhyz1euj79m2A/edit#gid=928324236) to mark the commission as paid. + + +### Run quarterly or annual employee bonus payroll +1. Update individual teammate bonus calculator (linked from [main commission calculator](https://docs.google.com/spreadsheets/d/1PuqUbfPGos87TfcHWgUd05TRJgQLlBmhyz1euj79m2A/edit?usp=sharing)) with relevant metrics. + - Bonus plans will have details specified on how to measure success, with most drawing from the [KPI spreadsheet](https://docs.google.com/spreadsheets/d/1Hso0LxqwrRVINCyW_n436bNHmoqhoLhC8bcbvLPOs9A/edit?usp=sharing) or from linked SFDC reports. If unsure where to pull achievement metrics from, contact teammate's manager to clarify. +2. In the "Monthly commission payroll party" meeting, present the bonus calculations for Fleeties receiving bonus for approval. +3. After the amounts are approved in the meeting, process the bonus payroll. + - Use the off-cycle payroll option in Gusto and be sure to classify the payment as "Bonus". + - For international teammates, you may need to use the "Help" function, or email support to notify Plane of the amount needing to be paid. +4. Once bonus payroll has been run, update the [main commission calculator](https://docs.google.com/spreadsheets/d/1PuqUbfPGos87TfcHWgUd05TRJgQLlBmhyz1euj79m2A/edit?usp=sharing) to mark the bonus as paid. + + +### Process monthly accounting +Create a [new montly accounting issue](https://github.com/fleetdm/confidential/issues/new/choose) for the current month and year named "Closing out YYYY-MM" in GitHub and complete all of the tasks in the issue. (This uses the [monthly accounting issue template](https://github.com/fleetdm/confidential/blob/main/.github/ISSUE_TEMPLATE/5-monthly-accounting.md). + +- **SLA:** The monthly accounting issue should be completed and closed before the 7th of the month. +- The close date is tracked each month in [KPIs](https://docs.google.com/spreadsheets/d/1Hso0LxqwrRVINCyW_n436bNHmoqhoLhC8bcbvLPOs9A/edit). +- **When is the issue created?** We create and close the monthly accounting issue for the previous month within the first 7 days of the following month. For example, the monthly accounting issue to close out the month of January is created promptly in February and closed before the end of the day, Feb 7th. A convenient trick is to create the issue on the first Friday of the month and close it ASAP. + + +### Respond to low credit alert +Fleet admins will receive an email alert when the usage of company cards for the month is aproaching the company credit limit. To avoid the limit being exceeded, a Brex admin will follow these steps: +1. Sign in to Fleet's Brex account. +2. On the landing page, use the "Move money" button to "Add funds to your Brex business accounts". +3. Select "Transfer from a connected account" and select the primary business account. +4. Choose the "One time" transfer option and process the transfer. + +No further action needs to be taken, the amount available for use will increase without disruption to regular processes. + +### Check franchise tax status +No later than the second month of every quarter, we check [Delaware divison of corporations](https://icis.corp.delaware.gov) to ensure that Fleet has paid the quarterly franchise tax amounts to remain in good standing with the state of Delaware. +- Go to the [DCIS - eCorp website](https://icis.corp.delaware.gov/ecorp/logintax.aspx?FilingType=FranchiseTax) and use the details in 1Password to look up Fleet's status. +- If no outstanding amounts: the tax has been paid. +- If outstanding amounts shown: ensure payment before due date to avoid penalties, interest, and entering bad standing. + + +### Check finances for quirks +Every quarter, we check Quickbooks Online (QBO) for discrepancies and follow up on quirks. +1. Check to make sure [bookkeeping quirks](https://docs.google.com/spreadsheets/d/1nuUPMZb1z_lrbaQEcgjnxppnYv_GWOTTo4FMqLOlsWg/edit?usp=sharing) are all accounted for and resolved or in progress toward resolution. +2. Check balance sheet and profit and loss statements (P&Ls) in QBO against the latest [monthly workbooks](https://drive.google.com/drive/folders/1ben-xJgL5MlMJhIl2OeQpDjbk-pF6eJM) in Google Drive. Ensure reports are in the "accural" accounting method. +3. Reach out to Pilot with any differences or quirks, and ask them to resolve/provide clarity. This often will need to happen over a call to review sycnhronously. +4. Once quirks are resolved, note the day it was resolved in the spreadsheet. + + +### Report quarterly numbers in Chronograph +Follow these steps to perform quarterly reporting for Fleet's investors: +1. Login to Chronograph and upload our profit and loss statement (P&L), balance sheet and cash flow statements for CRV (all in one book saved in [Google Drive](https://drive.google.com/drive/folders/1ben-xJgL5MlMJhIl2OeQpDjbk-pF6eJM). +2. Provide updated metrics for the following items using Fleet's [KPI spreadsheet](https://docs.google.com/spreadsheets/d/1Hso0LxqwrRVINCyW_n436bNHmoqhoLhC8bcbvLPOs9A/edit#gid=0). + - Headcount at end of the previous quarter. + - Starting ARR for the previous quarter. + - Total new ARR for the previous quarter. + - "Upsell ARR" (new ARR from expansions only- Chronograph defines "upsell" as price increases for any reason. + **- Fleet does not "upsell" anything; we deliver more value and customers enroll more hosts), downgrade ARR and churn ARR (if any) for the previous quarter.** + - Ending ARR for the previous quarter. + - Starting number of customers, churned customers, and the number of new customers Fleet gained during the previous quarter. + - Total amount of Fleet customers at the end of the previous quarter. + - Gross margin % + - How to calculate: (total revenue for the quarter - cost of goods sold for the quarter)/total revenue for the quarter (these metrics can be found in our books from Pilot). Chronograph will automatically conver this number to a %. + - Net dollar retention rate + - How to calculate: (starting ARR + new subscriptions and expansions - churn)/starting ARR. + - Cash burn + - How to calculate: start of quarter runway - end of quarter runway. + + +### Deliver annual report for venture line +Within 60 days of the end of the year, follow these steps: +1. Provide Silicon Valley Bank (SVB) with our balance sheet and profit and loss statement (P&L, sometimes called a cashflow statement) for the past twelve months. +2. Provide SVB with our board-approved annual operating budgets and projections (on a quarterly granularity) for the new year. +3. Deliver this as early as possible in case they have questions. + + +### Process a new vendor invoice +Fleet pays its vendors in less than 15 business days in most cases. All invoices and tax documents should be submitted to the Finance department using the [appropriate Fleet email address (confidential Google Doc)](https://docs.google.com/document/d/1tE-NpNfw1icmU2MjYuBRib0VWBPVAdmq4NiCrpuI0F0/edit#heading=h.wqalwz1je6rq). +- After making sure the invoice received from a new vendor is valid, add the new vendor to the recurring expenses section of ["The numbers"](https://docs.google.com/spreadsheets/d/1X-brkmUK7_Rgp7aq42drNcUg8ZipzEiS153uKZSabWc/edit#gid=2112277278) before paying the invoice. +- If we have not paid this vendor before, make sure we have received the required W-9 or W-8 form from the vendor. **Accounting cannot process a payment without these tax forms for compliance reasons.** + - **US-based vendors** are required to complete a [W-9 form](https://www.irs.gov/pub/irs-pdf/fw9.pdf). + - **Non-US based vendors and individuals** are required to follow these [instructions](https://www.irs.gov/instructions/iw8bene) and provide a completed [W-8BEN-E](https://www.irs.gov/pub/irs-pdf/fw8bene.pdf) form. + + +### Process a request to cancel a vendor +- Make the cancellation notification in accordance with the contract terms between Fleet and the vendor, typically these notifications are made via email and may have a specific address that notice must be sent to. If the vendor has an autorenew contract with Fleet there will often be a window of time in which Fleet can cancel, if notification is made after this time period Fleet may be obligated to pay for the subsequent year even if we don't use the vendor during the next contract term. +- Once cancelled, update the recurring expenses section of [The Numbers](https://docs.google.com/spreadsheets/d/1X-brkmUK7_Rgp7aq42drNcUg8ZipzEiS153uKZSabWc/edit#gid=2112277278) to reflect the cancellation by changing the projected monthly burn in column G to $0 and adding "CANCELLED" in front of the vendor's name in column C. + + +### Update weekly KPIs +- Create the weekly update issue from the template in ZenHub every Friday and update the [KPIs for finance](https://docs.google.com/spreadsheets/d/1Hso0LxqwrRVINCyW_n436bNHmoqhoLhC8bcbvLPOs9A/edit#gid=0) by 5pm US central time. +- Check the KPI sheet at 5pm US central time to ensure all departments have updated their KPIs on time. If any departments are delinquent, notify the department head and let the [Apprentice](https://fleetdm.com/handbook/finance#team) know so they can put it on the agenda for their next one-on-one with the CEO. + + +## Rituals + +The following table lists this department's rituals, frequency, and Directly Responsible Individual (DRI). + + + + + +#### Stubs +The following stubs are included only to make links backward compatible. + +##### Secure company-issued equipment for a team member +Please see [handbook/engineering#secure-company-issued-equipment-for-a-team-member](https://www.fleetdm.com/handbook/engineering#secure-company-issued-equipment-for-a-team-member). + +##### Register a domain for Fleet +Please see [handbook/register-a-domain-for-fleet](https://www.fleetdm.com/handbook/engineering#register-a-domain-for-fleet). + +##### Updating personnel details +Please see [handbook/engineering#update-personnel-details](https://www.fleetdm.com/handbook/engineering#update-personnel-details). + +##### Fix a laptop that's not checking in +Please see [handbook/engineering#fix-a-laptop-thats-not-checking-in](https://www.fleetdm.com/handbook/engineering#fix-a-laptop-thats-not-checking-in) + +##### Enroll a macOS host in dogfood +Please see [handbook/engineering#enroll-a-macos-host-in-dogfood](https://www.fleetdm.com/handbook/engineering#enroll-a-macos-host-in-dogfood) + +##### Enroll a Windows or Ubuntu Linux device in dogfood +Please see [handbook/engineering#enroll-a-windows-or-ubuntu-linux-device-in-dogfood](https://www.fleetdm.com/handbook/engineering#enroll-a-windows-or-ubuntu-linux-device-in-dogfood) + +##### Enroll a ChromeOS device in dogfood +Please see [handbook/engineering#enroll-a-chromeos-device-in-dogfood](https://www.fleetdm.com/handbook/engineering#enroll-a-chromeos-device-in-dogfood) + +##### Lock a macOS host in dogfood using fleetctl CLI tool +Please see [handbook/engineering#lock-a-macos-host-in-dogfood-using-fleetctl-cli-tool](https://www.fleetdm.com/handbook/engineering#lock-a-macos-host-in-dogfood-using-fleetctl-cli-tool) + +##### Book an event +Please see [handbook/engineering#book-an-event](https://www.fleetdm.com/handbook/engineering#book-an-event) + +##### Order SWAG +Please see [handbook/engineering#order-swag](https://www.fleetdm.com/handbook/engineering#order-swag) + + + + diff --git a/handbook/business-operations/business-operations.rituals.yml b/handbook/finance/finance.rituals.yml similarity index 60% rename from handbook/business-operations/business-operations.rituals.yml rename to handbook/finance/finance.rituals.yml index fec5055898..0aaea82a6c 100644 --- a/handbook/business-operations/business-operations.rituals.yml +++ b/handbook/finance/finance.rituals.yml @@ -3,40 +3,30 @@ startedOn: "2024-02-12" frequency: "Weekly" description: "At the start of every week, check the Salesforce reports for past due invoices, non-invoiced opportunities, and past due renewals. Report findings to in the `#g-sales` channel." - moreInfoUrl: "https://fleetdm.com/handbook/business-operations#communicate-the-status-of-customer-financial-actions" + moreInfoUrl: "https://fleetdm.com/handbook/finance#communicate-the-status-of-customer-financial-actions" dri: "ireedy" autoIssue: - labels: [ "#g-business-operations" ] + labels: [ "#g-finance" ] repo: "confidential" - task: "AP invoice monitoring" startedOn: "2024-04-01" frequency: "Weekly" description: "Look for new accounts payable invoices and make sure that Fleet's suppliers are paid." - moreInfoUrl: "https://fleetdm.com/handbook/business-operations#process-a-new-vendor-invoice" + moreInfoUrl: "https://fleetdm.com/handbook/finance#process-a-new-vendor-invoice" dri: "ireedy" autoIssue: - labels: [ "#g-business-operations" ] - repo: "confidential" -- - task: "Inform managers about hours worked" - startedOn: "2024-02-09" - frequency: "Weekly" - description: "Gather hours worked for anyone who gets paid hourly by Fleet, and get those hours approved by their manager." - moreInfoUrl: "https://fleetdm.com/handbook/business-operations#inform-managers-about-hours-worked" - dri: "ireedy" - autoIssue: - labels: [ "#g-business-operations" ] + labels: [ "#g-finance" ] repo: "confidential" - - task: "KPI roundup + weekly update" + task: "KPI roundup" startedOn: "2024-02-16" frequency: "Weekly" - description: "Create the weekly KPI issue, complete the BizOps update and ensure all other inputs are completed on time." - moreInfoUrl: "https://fleetdm.com/handbook/business-operations#update-weekly-kpis" - dri: "hollidayn" + description: "Create the weekly KPI issue, complete the finance update." + moreInfoUrl: "https://fleetdm.com/handbook/finance#update-weekly-kpis" + dri: "ireedy" autoIssue: - labels: [ "#g-business-operations" ] + labels: [ "#g-finance" ] repo: "confidential" - task: "Key review prep" @@ -46,7 +36,7 @@ moreInfoUrl: "https://fleetdm.com/handbook/company/leadership#key-reviews" dri: "jostableford" autoIssue: - labels: [ "#g-business-operations" ] + labels: [ "#g-finance" ] repo: "confidential" - task: "Prioritize for next sprint" # Title that will actually show in rituals table @@ -56,38 +46,38 @@ moreInfoUrl: "https://fleetdm.com/handbook/company/why-this-way#why-make-work-visible" #URL used to highlight "description:" test in table dri: "jostableford" # DRI for ritual (assignee if autoIssue) (TODO display GitHub proflie pic instead of name or title) autoIssue: # Enables automation of GitHub issues - labels: [ "#g-business-operations" ] # label to be applied to issue + labels: [ "#g-finance" ] # label to be applied to issue repo: "confidential" # The GitHub repo that issues will be created in -- - task: "Vanta check" # TODO tie this to a responsibility - startedOn: "2024-04-01" - frequency: "Monthly" - description: "Look for any new actions in Vanta due in the upcoming months and create issues to ensure they're done on time." - moreInfoUrl: - dri: "jostableford" - autoIssue: - labels: [ "#g-business-operations" ] - repo: "confidential" - task: "Reconcile monthly recurring expenses" startedOn: "2024-02-28" frequency: "Monthly" description: "Each month, update the inputs in “The numbers” spreadsheet to reflect the actuals for recurring non-personnel spend, and identify any unexpected increase or decrease in spend." - moreInfoUrl: "https://fleetdm.com/handbook/business-operations#reconcile-monthly-recurring-expenses" + moreInfoUrl: "https://fleetdm.com/handbook/finance#reconcile-monthly-recurring-expenses" dri: "jostableford" autoIssue: - labels: [ "#g-business-operations" ] + labels: [ "#g-finance" ] repo: "confidential" - task: "Monthly accounting" startedOn: "2024-02-28" frequency: "Monthly" description: "Create the monthly close GitHub issue and walk through the steps. This process includes fulfilling the monthly reporting requirement for SVB." - moreInfoUrl: "https://fleetdm.com/handbook/business-operations#process-monthly-accounting" + moreInfoUrl: "https://fleetdm.com/handbook/finance#process-monthly-accounting" dri: "hollidayn" autoIssue: - labels: [ "#g-business-operations" ] + labels: [ "#g-finance" ] repo: "confidential" +- + task: "Run regular payroll" + startedOn: "2024-02-24" + frequency: "Monthly" + description: "Verify auto-populated payroll for all full time employees is accurate, and approve for processing." + moreInfoUrl: "https://fleetdm.com/handbook/finance#run-payroll" + dri: "jostableford" + autoIssue: + labels: [ "#g-finance" ] + repo: "confidential" - task: "Monthly mail review" # TODO tie this to a responsibility startedOn: "2024-04-15" @@ -96,86 +86,62 @@ moreInfoUrl: null dri: "ireedy" autoIssue: - labels: [ "#g-business-operations" ] + labels: [ "#g-finance" ] repo: "confidential" -- - task: "Run regular payroll" - startedOn: "2024-02-24" - frequency: "Monthly" - description: "Verify auto-populated payroll for all full time employees is accurate, and approve for processing." - moreInfoUrl: "https://fleetdm.com/handbook/business-operations#run-payroll" - dri: "jostableford" - autoIssue: - labels: [ "#g-business-operations" ] - repo: "confidential" - task: "Run US contractor payroll" startedOn: "2024-02-28" frequency: "Monthly" description: "Manually process US contractor payroll by verifying and syncing time contractor worked, then processing payment." - moreInfoUrl: "https://fleetdm.com/handbook/business-operations#run-us-contractor-payroll" + moreInfoUrl: "https://fleetdm.com/handbook/finance#run-us-contractor-payroll" dri: "jostableford" autoIssue: - labels: [ "#g-business-operations" ] + labels: [ "#g-finance" ] repo: "confidential" - task: "Run US commission payroll" startedOn: "2024-01-31" frequency: "Monthly" description: "Verify closed-won deal amounts, use commission calculators to determine commissions owed, and process payroll." - moreInfoUrl: "https://fleetdm.com/handbook/business-operations#run-us-commission-payroll" + moreInfoUrl: "https://fleetdm.com/handbook/finance#run-us-commission-payroll" dri: "jostableford" autoIssue: - labels: [ "#g-business-operations" ] + labels: [ "#g-finance" ] repo: "confidential" -- - task: "Recognize and benchmark workiversaries" - startedOn: "2024-07-15" - frequency: "Bimonthly" - description: "Identify workiversaries coming up in the next two months and follow the steps to ensure they're recognized and benchmarked" - moreInfoUrl: "https://fleetdm.com/handbook/business-operations#recognize-employee-workiversaries" - dri: "ireedy" - task: "Run bonus payroll" startedOn: "2024-01-31" frequency: "Quarterly" description: "Verify completion of any objective or outcome based bonus plans, and process payroll." - moreInfoUrl: "https://fleetdm.com/handbook/business-operations#run-us-commission-payroll" # TODO update linked process and add a new process that captures MBO payment + moreInfoUrl: "https://fleetdm.com/handbook/finance#run-us-commission-payroll" # TODO update linked process and add a new process that captures MBO payment dri: "jostableford" - task: "Review state filings for the previous quarter" startedOn: "2024-07-19" frequency: "Quarterly" description: "Verify that state filings have been successfully submitted for the previous quarter" - moreInfoUrl: "https://fleetdm.com/handbook/business-operations#review-state-employment-tax-filings-for-the-previous-quarter" + moreInfoUrl: "https://fleetdm.com/handbook/finance#review-state-employment-tax-filings-for-the-previous-quarter" dri: "ireedy" - task: "Investor reporting" startedOn: "2024-03-31" frequency: "Quarterly" description: "Provide updated metrics for CRV in Chronograph." - moreInfoUrl: "https://fleetdm.com/handbook/business-operations#report-quarterly-numbers-in-chronograph" + moreInfoUrl: "https://fleetdm.com/handbook/finance#report-quarterly-numbers-in-chronograph" dri: "hollidayn" - task: "Quartlery finance check" startedOn: "2024-03-31" frequency: "Quarterly" description: "Every quarter, we check Quickbooks Online (QBO) for discrepancies and follow up with accounting providers for any quirks found." - moreInfoUrl: "https://fleetdm.com/handbook/business-operations#check-finances-for-quirks" + moreInfoUrl: "https://fleetdm.com/handbook/finance#check-finances-for-quirks" dri: "jostableford" -- - task: "Quarterly grants" - startedOn: "2024-02-01" - frequency: "Quarterly" - description: "Create the equity grants GitHub issue and walk through the steps." - moreInfoUrl: "https://fleetdm.com/handbook/business-operations#grant-equity" - dri: "hollidayn" - task: "Deliver annual report for venture line" startedOn: "2024-12-01" frequency: "Annually" description: "Within 60 days of the new year, provide financial statements to SVB, along with board-approved projections for the new year" - moreInfoUrl: "https://fleetdm.com/handbook/business-operations#deliver-annual-report-for-venture-line" + moreInfoUrl: "https://fleetdm.com/handbook/finance#deliver-annual-report-for-venture-line" dri: "jostableford" - task: "Tax preparation" # TODO tie this to a responsibility diff --git a/handbook/sales/README.md b/handbook/sales/README.md index 126a67b8d5..2c37b1889d 100644 --- a/handbook/sales/README.md +++ b/handbook/sales/README.md @@ -41,12 +41,12 @@ Once the standard Fleetie onboarding issue is complete, create a new ["Sales tea During the buying cycle, the champion will need to start the process to secure funding in cooperation with the economic buyer and the finance org. -All quotes and purchase orders must be approved by CRO before being sent to the prospect or customer. Often, the CRO will request Fleet business operations/legal of any unique terms required. +All quotes and purchase orders must be approved by CRO before being sent to the prospect or customer. Often, the CRO will request legal review of any unique terms required. The Fleet owner of the opportunity (usually AE or CSM) will prepare a quote and/or a Purchase Order when requested. - Because the champion may need to socialize "what is Fleet" or "what are we getting when buying Fleet," it is most often best to send the quote in [slide form](https://docs.google.com/presentation/d/15kbqm0OYPf1OmmTZvDp4F7VvMERnX4K6TMYqCYNr-wI/edit?usp=sharing). - Docusign can be used to create a [standard Purchase Order](https://www.loom.com/share/Loom-Message-16-January-2023-2ba8cf195ec645ebabac267d7df59823?sid=214f8c6b-beb3-427a-a3a8-e8c20b5dc350) if no special terms or pricing are needed. -- Before sending to prospect, work with the Business operations team to verify if sales tax needs to be charged and, if so, how much. +- Before sending to prospect, work with the Finance team to verify if sales tax needs to be charged and, if so, how much. ### Obtain a copy of Fleet's W-9 @@ -199,7 +199,7 @@ Temp Transfer to: Temp technical DRI 1. If a customer has no objections to using Fleet's NDA, route the NDA to them for signature using the "🙊 NDA (Non-disclosure agreement)" template in [DocuSign](https://apps.docusign.com/send/home). > If a customer would like to review the NDA first, download a .docx of [Fleet's NDA](https://docs.google.com/document/d/1gQCrF3silBFG9dJgyCvpmLa6hPhX_T4V7pL3XAwgqEU/edit?usp=sharing) and send it to the customer. 2. If the customer has no objections, route the NDA using the template in DocuSign (do not upload and use the copy you emailed to the customer). -3. If the customer "redlines" (i.e. wants to change) the NDA, follow the [contract review process](https://fleetdm.com/handbook/company/communications#getting-a-contract-reviewed) so that BizOps can look over any proposed changes and provide guidance on how to proceed. +3. If the customer "redlines" (i.e. wants to change) the NDA, follow the [contract review process](https://fleetdm.com/handbook/company/communications#getting-a-contract-reviewed) so that Digital Experience can look over any proposed changes and provide guidance on how to proceed. ### Create a customer agreement @@ -212,12 +212,12 @@ Temp Transfer to: Temp technical DRI - **Standard terms:** For all subscription agreements, NDAs, and similar contracts, Fleet maintains a [standard set of terms and maximum allowable adjustments for those terms](https://docs.google.com/spreadsheets/d/1gAenC948YWG2NwcaVHleUvX0LzS8suyMFpjaBqxHQNg/edit#gid=1136345578). Exceptions to these maximum allowable adjustments always require CEO approval, whether in the form of redlines to Fleet's agreements or in terms on a prospective customer's own contract. -> All non-standard (from another party) subscription agreements, NDAs, and similar contracts require legal review from the Business Operations department before being signed. [Create an issue to request legal review](https://github.com/fleetdm/confidential/blob/main/.github/ISSUE_TEMPLATE/contract-review.md). +> All non-standard (from another party) subscription agreements, NDAs, and similar contracts require legal review from the Contracts and Compliance department before being signed. [Create an issue to request legal review](https://github.com/fleetdm/confidential/blob/main/.github/ISSUE_TEMPLATE/contract-review.md). ### Close a new customer deal -To close a deal with a new customer (non-self-service), create and complete a GitHub issue using the ["Sale" issue template](https://github.com/fleetdm/confidential/issues/new?assignees=hughestaylor&labels=%23g-business-operations&projects=&template=3-sale.md&title=New+customer%3A+_____________). +To close a deal with a new customer (non-self-service), create and complete a GitHub issue using the ["Sale" issue template](https://github.com/fleetdm/confidential/issues/new?assignees=alexmitchelliii&labels=%23g-sales&projects=&template=3-sale.md&title=New+customer%3A+_____________). ### Change customer credit card number @@ -227,8 +227,8 @@ You can help a Premium license dispenser customers change their credit card by d ### Process a security questionnaire -- The AE will [use the handbook](https://fleetdm.com/handbook/company/communications#vendor-questionnaires) to answer most of the questions with links to appropriate sections in the handbook. After this first pass has been completed, and if there are outstanding questions, the AE will [assign the issue to Business Operations (#g-business-operations)](https://fleetdm.com/handbook/business-operations#contact-us) with a requested timeline for completion defined. -- BizOps consults the handbook to validate that nothing was missed by the AE. After the second pass has been completed, and if there are outstanding questions, BizOps will [reassign the issue to Sales (#g-sales)](https://fleetdm.com/handbook/sales#contact-us) for intake. +- The AE will [use the handbook](https://fleetdm.com/handbook/company/communications#vendor-questionnaires) to answer most of the questions with links to appropriate sections in the handbook. After this first pass has been completed, and if there are outstanding questions, the AE will [assign the issue to Digital Experience (#g-digital-experience)](https://fleetdm.com/handbook/digital-experience#contact-us) with a requested timeline for completion defined. +- Digital Experience consults the handbook to validate that nothing was missed by the AE. After the second pass has been completed, and if there are outstanding questions, Digital Experience will [reassign the issue to Sales (#g-sales)](https://fleetdm.com/handbook/sales#contact-us) for intake. - The issue will be assigned to the Solutions Consultant (SC) associated to the opportunity in order to complete any unanswered questions. - The SC will search for unanswered questions and confirm again that nothing was missed from the handbook. Content missing from the handbook will need to be added via PR by the SC. Any unanswered questions after this pass has been completed by the SC will need to be [escalated to the Infrastructure team (#g-customer-success)](https://fleetdm.com/handbook/customer-success#contact-us) with the requested timeline for completion defined in the issue. Once complete, the infra team will assign the issue back to the #g-sales board. - Any questions answered by the infra team will be added to the handbook by the SC. diff --git a/website/assets/resources/security-awareness/2022-05-security-awareness-slides.md b/website/assets/resources/security-awareness/2022-05-security-awareness-slides.md index f3fad15238..f1fbefd163 100644 --- a/website/assets/resources/security-awareness/2022-05-security-awareness-slides.md +++ b/website/assets/resources/security-awareness/2022-05-security-awareness-slides.md @@ -132,7 +132,7 @@ BEC leverages our willingness to help people. ## Money transfers -We have a strict process related to payments and wire transfers. If you are in the BizOps team, make sure you are aware of it. +We have a strict process related to payments and wire transfers. If you are in the Digital Experience team, make sure you are aware of it. ## Working from shady networks and cool locations @@ -179,7 +179,7 @@ Undoing git history is complicated. Consider this secret forever leaked. 1. Don't panic. It's encrypted. 2. Post about it in #g-security. -3. In the thread in #g-security, inform someone from the BizOps team. They'll help you get a new one ASAP! +3. In the thread in #g-security, inform someone from the Digital Experience team. They'll help you get a new one ASAP! ## If... you lose your Yubikey(s) diff --git a/website/config/custom.js b/website/config/custom.js index 9bb96b75d5..249d5238e0 100644 --- a/website/config/custom.js +++ b/website/config/custom.js @@ -266,7 +266,7 @@ module.exports.custom = { 'handbook/company/product-groups.md': ['lukeheath', 'sampfluger88','mikermcneil'], 'handbook/company/open-positions.yml': ['@sampfluger88','mikermcneil'], 'handbook/digital-experience': ['sampfluger88','mikermcneil'], - 'handbook/business-operations': ['sampfluger88','mikermcneil'], + 'handbook/finance': ['sampfluger88','mikermcneil'], 'handbook/engineering': ['sampfluger88','mikermcneil', 'lukeheath'], 'handbook/product-design': ['sampfluger88','mikermcneil'], 'handbook/sales': ['sampfluger88','mikermcneil'], diff --git a/website/config/routes.js b/website/config/routes.js index d6a0470344..2252a70729 100644 --- a/website/config/routes.js +++ b/website/config/routes.js @@ -331,7 +331,6 @@ module.exports.routes = { 'GET /use-cases/using-elasticsearch-and-kibana-to-visualize-osquery-performance': '/guides/using-elasticsearch-and-kibana-to-visualize-osquery-performance', 'GET /use-cases/work-may-be-watching-but-it-might-not-be-as-bad-as-you-think': '/securing/work-may-be-watching-but-it-might-not-be-as-bad-as-you-think', 'GET /docs/contributing/testing': '/docs/contributing/testing-and-local-development', - 'GET /handbook/people': '/handbook/business-operations', 'GET /handbook/people/ceo-handbook': '/handbook/ceo', 'GET /handbook/company/ceo-handbook': '/handbook/ceo', 'GET /handbook/growth': '/handbook/marketing#growth', @@ -351,8 +350,8 @@ module.exports.routes = { 'GET /device-management/fleet-user-stories-f100': '/success-stories/fleet-user-stories-wayfair', 'GET /device-management/fleet-user-stories-schrodinger': '/success-stories/fleet-user-stories-wayfair', 'GET /device-management/fleet-user-stories-wayfair': '/success-stories/fleet-user-stories-wayfair', - 'GET /handbook/security': '/handbook/business-operations/security', - 'GET /handbook/security/security-policies':'/handbook/business-operations/security-policies#information-security-policy-and-acceptable-use-policy',// « reasoning: https://github.com/fleetdm/fleet/pull/9624 + 'GET /handbook/security': '/handbook/digital-experience/security', + 'GET /handbook/security/security-policies':'/handbook/digital-experience/security-policies#information-security-policy-and-acceptable-use-policy',// « reasoning: https://github.com/fleetdm/fleet/pull/9624 'GET /handbook/handbook': '/handbook/company/handbook', 'GET /handbook/company/development-groups': '/handbook/company/product-groups', 'GET /docs/using-fleet/mdm-macos-settings': '/docs/using-fleet/mdm-custom-macos-settings', @@ -363,6 +362,7 @@ module.exports.routes = { 'GET /handbook/marketing': '/handbook/demand/', 'GET /handbook/customers': '/handbook/sales/', 'GET /handbook/product': '/handbook/product-design', + 'GET /handbook/business-operations': '/handbook/finance', 'GET /docs': '/docs/get-started/why-fleet', 'GET /docs/get-started': '/docs/get-started/why-fleet', @@ -379,8 +379,8 @@ module.exports.routes = { 'GET /docs/using-fleet/chromeos': '/docs/using-fleet/enroll-chromebooks', 'GET /docs/using-fleet/rest-api': '/docs/rest-api/rest-api', 'GET /docs/using-fleet/configuration-files': '/docs/configuration/configuration-files/', - 'GET /docs/using-fleet/application-security': '/handbook/business-operations/application-security', - 'GET /docs/using-fleet/security-audits': '/handbook/business-operations/security-audits', + 'GET /docs/using-fleet/application-security': '/handbook/digital-experience/application-security', + 'GET /docs/using-fleet/security-audits': '/handbook/digital-experience/security-audits', 'GET /docs/using-fleet/process-file-events': '/guides/querying-process-file-events-table-on-centos-7', 'GET /docs/using-fleet/audit-activities': '/docs/using-fleet/audit-logs', 'GET /docs/using-fleet/detail-queries-summary': '/docs/using-fleet/understanding-host-vitals', From bfeeba10cd9e35623c6680f429ff931b90ffa83b Mon Sep 17 00:00:00 2001 From: Mike McNeil Date: Fri, 13 Sep 2024 02:31:58 -0500 Subject: [PATCH 53/55] =?UTF-8?q?Add=20Luke=E2=80=99s=20face=20but=20keep?= =?UTF-8?q?=20auto-request=20for=20review=20the=20same=20for=20now=20(up?= =?UTF-8?q?=E2=80=A6=20(#22068)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit … to dexp when to align/changeover) # Checklist for submitter If some of the following don't apply, delete the relevant line. - [ ] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/Committing-Changes.md#changes-files) for more information. - [ ] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements) - [ ] Added support on fleet's osquery simulator `cmd/osquery-perf` for new osquery data ingestion features. - [ ] Added/updated tests - [ ] If paths of existing endpoints are modified without backwards compatibility, checked the frontend/CLI for any necessary changes - [ ] If database migrations are included, checked table schema to confirm autoupdate - For database migrations: - [ ] Checked schema for all modified table for columns that will auto-update timestamps during migration. - [ ] Confirmed that updating the timestamps is acceptable, and will not cause unwanted side effects. - [ ] Ensured the correct collation is explicitly set for character columns (`COLLATE utf8mb4_unicode_ci`). - [ ] Manual QA for all new/changed functionality - For Orbit and Fleet Desktop changes: - [ ] Orbit runs on macOS, Linux and Windows. Check if the orbit feature/bugfix should only apply to one platform (`runtime.GOOS`). - [ ] Manual QA must be performed in the three main OSs, macOS, Windows and Linux. - [ ] Auto-update manual QA, from released version of component to new version (see [tools/tuf/test](../tools/tuf/test/README.md)). --- handbook/company/product-groups.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/handbook/company/product-groups.md b/handbook/company/product-groups.md index ede2220a48..5ab7903457 100644 --- a/handbook/company/product-groups.md +++ b/handbook/company/product-groups.md @@ -835,5 +835,5 @@ Please see [handbook/company/initiate-an-air-guitar-session](https://fleetdm.com ##### High priority user stories and bugs Please see [handbook/company/communications/high-priority-user-stories-and-bugs](https://fleetdm.com/handbook/company/communications#high-priority-user-stories-and-bugs) - + From 419433fb44875ee1bc3be8fa8665cb9e23beb05d Mon Sep 17 00:00:00 2001 From: Tim Lee Date: Fri, 13 Sep 2024 06:00:12 -0600 Subject: [PATCH 54/55] Homebrew git false negative vulnerability (#22002) --- changes/21779-git-false-negative | 1 + server/vulnerabilities/nvd/cpe_translations.json | 10 ++++++++++ 2 files changed, 11 insertions(+) create mode 100644 changes/21779-git-false-negative diff --git a/changes/21779-git-false-negative b/changes/21779-git-false-negative new file mode 100644 index 0000000000..080dfe1a4e --- /dev/null +++ b/changes/21779-git-false-negative @@ -0,0 +1 @@ +- fixed a false negative vulnerability for git \ No newline at end of file diff --git a/server/vulnerabilities/nvd/cpe_translations.json b/server/vulnerabilities/nvd/cpe_translations.json index 73d64cd787..bc9fe3536c 100644 --- a/server/vulnerabilities/nvd/cpe_translations.json +++ b/server/vulnerabilities/nvd/cpe_translations.json @@ -407,5 +407,15 @@ "vendor": ["linux"], "part": "o" } + }, + { + "software": { + "name": ["git"], + "source": ["homebrew_packages"] + }, + "filter": { + "product": ["git"], + "vendor": ["git"] + } } ] From a2c6de65d6b0f087d68a32e1bbfc562ba4452982 Mon Sep 17 00:00:00 2001 From: Jahziel Villasana-Espinoza Date: Fri, 13 Sep 2024 08:41:52 -0400 Subject: [PATCH 55/55] fix: add missing check for invalid email (#22057) > Related issue: #21813 # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/Committing-Changes.md#changes-files) for more information. - [x] Added/updated tests - [x] Manual QA for all new/changed functionality --- changes/21813-email-err | 2 ++ .../modals/RenewCertModal/RenewCertModal.tsx | 8 +++++++- server/service/integration_mdm_test.go | 8 ++++++++ server/service/mdm.go | 10 ++++++++++ 4 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 changes/21813-email-err diff --git a/changes/21813-email-err b/changes/21813-email-err new file mode 100644 index 0000000000..a9d25ecc21 --- /dev/null +++ b/changes/21813-email-err @@ -0,0 +1,2 @@ +- Fixed regression: we now check if the email used to get a signed CSR is invalid (i.e. is an email + from a free email provider). \ No newline at end of file diff --git a/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/AppleMdmPage/components/modals/RenewCertModal/RenewCertModal.tsx b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/AppleMdmPage/components/modals/RenewCertModal/RenewCertModal.tsx index f596e8574c..8d71e91817 100644 --- a/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/AppleMdmPage/components/modals/RenewCertModal/RenewCertModal.tsx +++ b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/AppleMdmPage/components/modals/RenewCertModal/RenewCertModal.tsx @@ -65,7 +65,13 @@ const RenewCertModal = ({ const onDownloadError = useCallback( // eslint-disable-next-line @typescript-eslint/no-unused-vars (e: unknown) => { - renderFlash("error", "Something's gone wrong. Please try again."); + const msg = getErrorReason(e); + + if (msg.toLowerCase().includes("email address is not valid")) { + renderFlash("error", msg); + } else { + renderFlash("error", "Something's gone wrong. Please try again."); + } }, [renderFlash] ); diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go index f8b3fb6790..61d25d5459 100644 --- a/server/service/integration_mdm_test.go +++ b/server/service/integration_mdm_test.go @@ -1329,6 +1329,14 @@ func (s *integrationMDMTestSuite) TestGetMDMCSR() { require.Len(t, errResp.Errors, 1) require.Contains(t, errResp.Errors[0].Reason, "FleetDM CSR request failed") + // Check that we return bad request if the website API does (it will do this in case of an + // invalid email address + s.FailNextCSRRequestWith(http.StatusUnprocessableEntity) + errResp = validationErrResp{} + s.DoJSON("GET", "/api/latest/fleet/mdm/apple/request_csr", getMDMAppleCSRRequest{}, http.StatusUnprocessableEntity, &errResp) + require.Len(t, errResp.Errors, 1) + require.Contains(t, errResp.Errors[0].Reason, "this email address is not valid") + // Invalid APNS cert upload attempt s.uploadDataViaForm("/api/latest/fleet/mdm/apple/apns_certificate", "certificate", "certificate.pem", []byte("invalid-cert"), http.StatusUnprocessableEntity, "Invalid certificate. Please provide a valid certificate from Apple Push Certificate Portal.", nil) diff --git a/server/service/mdm.go b/server/service/mdm.go index 294d503d81..7a06c015cd 100644 --- a/server/service/mdm.go +++ b/server/service/mdm.go @@ -2351,6 +2351,16 @@ func (svc *Service) GetMDMAppleCSR(ctx context.Context) ([]byte, error) { if err != nil { var fwe apple_mdm.FleetWebsiteError if errors.As(err, &fwe) { + // From svc.RequestMDMAppleCSR: fleetdm.com returns a bad request here if the email is invalid. + if fwe.Status >= 400 && fwe.Status <= 499 { + return nil, ctxerr.Wrap( + ctx, + fleet.NewInvalidArgumentError( + "email_address", + fmt.Sprintf("this email address is not valid: %v", err), + ), + ) + } return nil, ctxerr.Wrap( ctx, fleet.NewUserMessageError(