From ee365ea276e0895aaa5e096cd8bbe46781f9e36c Mon Sep 17 00:00:00 2001 From: RachelElysia <71795832+RachelElysia@users.noreply.github.com> Date: Mon, 2 Oct 2023 12:26:53 -0700 Subject: [PATCH 01/18] Frontend: Step 1 for query caching (organize FE directories, build save modals, modify routes/URLs) (#13908) --- assets/images/phone-home.svg | 1 - frontend/components/EmptyTable/_styles.scss | 1 - .../components/LiveQuery/SelectTargets.tsx | 4 +- .../LiveQuery/TargetsInput/_styles.scss | 3 + .../TooltipWrapper/TooltipWrapper.tsx | 1 + .../components/icons/CollectingResults.tsx | 254 ++++++++++++++++++ frontend/components/icons/index.ts | 2 + .../AwaitingResults/AwaitingResults.tsx | 17 +- .../queryResults/AwaitingResults/_styles.scss | 15 -- frontend/context/query.tsx | 21 ++ frontend/interfaces/target.ts | 3 + .../HostDetailsPage/HostDetailsPage.tsx | 12 +- .../pages/policies/PolicyPage/PolicyPage.tsx | 18 +- .../pages/policies/PolicyPage/_styles.scss | 32 --- .../PolicyPage/screens/QueryEditor.tsx | 2 +- .../QueriesTable/QueriesTableConfig.tsx | 2 +- frontend/pages/queries/QueryPage/index.ts | 1 - .../queries/QueryPage/screens/QueryEditor.tsx | 186 ------------- .../pages/queries/QueryPage/screens/test.js | 0 .../QueryDetailsPage/QueryDetailsPage.tsx | 241 +++++++++++++++++ .../details/QueryDetailsPage/_styles.scss | 65 +++++ .../queries/details/QueryDetailsPage/index.ts | 1 + .../CachedDetails/CachedDetails.tsx | 14 + .../details/components/CachedDetails/index.ts | 1 + .../components/NoResults/NoResults.tsx | 116 ++++++++ .../details/components/NoResults/index.ts | 1 + .../EditQueryPage/EditQueryPage.tsx} | 237 +++++++++------- .../queries/edit/EditQueryPage/_styles.scss | 54 ++++ .../pages/queries/edit/EditQueryPage/index.ts | 1 + .../components/QueryForm/QueryForm.tests.tsx | 1 - .../components/QueryForm/QueryForm.tsx | 46 +++- .../components/QueryForm/_styles.scss | 5 - .../components/QueryForm/index.ts | 0 .../components/QueryResults/QueryResults.tsx | 0 .../QueryResults/QueryResultsTableConfig.tsx | 0 .../components/QueryResults/_styles.scss | 0 .../components/QueryResults/index.ts | 0 .../SaveChangesModal/SaveChangesModal.tsx | 53 ++++ .../edit/components/SaveChangesModal/index.ts | 1 + .../SaveQueryModal/SaveQueryModal.tsx | 214 ++++++++------- .../components/SaveQueryModal/_styles.scss | 0 .../components/SaveQueryModal/index.ts | 0 .../live/LiveQueryPage/LiveQueryPage.tsx | 213 +++++++++++++++ .../LiveQueryPage}/_styles.scss | 63 +---- .../pages/queries/live/LiveQueryPage/index.ts | 1 + .../{QueryPage => live}/screens/RunQuery.tsx | 2 +- frontend/router/index.tsx | 12 +- frontend/router/paths.ts | 10 + frontend/services/entities/queries.ts | 4 +- frontend/utilities/constants.ts | 7 +- frontend/utilities/endpoints.ts | 2 +- frontend/utilities/helpers.ts | 7 + 52 files changed, 1391 insertions(+), 556 deletions(-) delete mode 100644 assets/images/phone-home.svg create mode 100644 frontend/components/icons/CollectingResults.tsx delete mode 100644 frontend/pages/queries/QueryPage/index.ts delete mode 100644 frontend/pages/queries/QueryPage/screens/QueryEditor.tsx delete mode 100644 frontend/pages/queries/QueryPage/screens/test.js create mode 100644 frontend/pages/queries/details/QueryDetailsPage/QueryDetailsPage.tsx create mode 100644 frontend/pages/queries/details/QueryDetailsPage/_styles.scss create mode 100644 frontend/pages/queries/details/QueryDetailsPage/index.ts create mode 100644 frontend/pages/queries/details/components/CachedDetails/CachedDetails.tsx create mode 100644 frontend/pages/queries/details/components/CachedDetails/index.ts create mode 100644 frontend/pages/queries/details/components/NoResults/NoResults.tsx create mode 100644 frontend/pages/queries/details/components/NoResults/index.ts rename frontend/pages/queries/{QueryPage/QueryPage.tsx => edit/EditQueryPage/EditQueryPage.tsx} (54%) create mode 100644 frontend/pages/queries/edit/EditQueryPage/_styles.scss create mode 100644 frontend/pages/queries/edit/EditQueryPage/index.ts rename frontend/pages/queries/{QueryPage => edit}/components/QueryForm/QueryForm.tests.tsx (98%) rename frontend/pages/queries/{QueryPage => edit}/components/QueryForm/QueryForm.tsx (95%) rename frontend/pages/queries/{QueryPage => edit}/components/QueryForm/_styles.scss (98%) rename frontend/pages/queries/{QueryPage => edit}/components/QueryForm/index.ts (100%) rename frontend/pages/queries/{QueryPage => edit}/components/QueryResults/QueryResults.tsx (100%) rename frontend/pages/queries/{QueryPage => edit}/components/QueryResults/QueryResultsTableConfig.tsx (100%) rename frontend/pages/queries/{QueryPage => edit}/components/QueryResults/_styles.scss (100%) rename frontend/pages/queries/{QueryPage => edit}/components/QueryResults/index.ts (100%) create mode 100644 frontend/pages/queries/edit/components/SaveChangesModal/SaveChangesModal.tsx create mode 100644 frontend/pages/queries/edit/components/SaveChangesModal/index.ts rename frontend/pages/queries/{QueryPage => edit}/components/SaveQueryModal/SaveQueryModal.tsx (51%) rename frontend/pages/queries/{QueryPage => edit}/components/SaveQueryModal/_styles.scss (100%) rename frontend/pages/queries/{QueryPage => edit}/components/SaveQueryModal/index.ts (100%) create mode 100644 frontend/pages/queries/live/LiveQueryPage/LiveQueryPage.tsx rename frontend/pages/queries/{QueryPage => live/LiveQueryPage}/_styles.scss (69%) create mode 100644 frontend/pages/queries/live/LiveQueryPage/index.ts rename frontend/pages/queries/{QueryPage => live}/screens/RunQuery.tsx (98%) diff --git a/assets/images/phone-home.svg b/assets/images/phone-home.svg deleted file mode 100644 index a0335a1113..0000000000 --- a/assets/images/phone-home.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/components/EmptyTable/_styles.scss b/frontend/components/EmptyTable/_styles.scss index c24d1c9543..15517d8d8f 100644 --- a/frontend/components/EmptyTable/_styles.scss +++ b/frontend/components/EmptyTable/_styles.scss @@ -57,7 +57,6 @@ &__container { align-self: center; justify-content: center; - margin: 0; margin-bottom: 20px; min-height: 155px; max-width: none; diff --git a/frontend/components/LiveQuery/SelectTargets.tsx b/frontend/components/LiveQuery/SelectTargets.tsx index c65a3e0e60..2052d13e14 100644 --- a/frontend/components/LiveQuery/SelectTargets.tsx +++ b/frontend/components/LiveQuery/SelectTargets.tsx @@ -48,7 +48,9 @@ interface ISelectTargetsProps { targetedTeams: ITeam[]; goToQueryEditor: () => void; goToRunQuery: () => void; - setSelectedTargets: React.Dispatch>; + setSelectedTargets: + | React.Dispatch> // Used for policies page level useState hook + | ((value: ITarget[]) => void); // Used for queries app level QueryContext setTargetedHosts: React.Dispatch>; setTargetedLabels: React.Dispatch>; setTargetedTeams: React.Dispatch>; diff --git a/frontend/components/LiveQuery/TargetsInput/_styles.scss b/frontend/components/LiveQuery/TargetsInput/_styles.scss index e2f14ce6e1..137525dd04 100644 --- a/frontend/components/LiveQuery/TargetsInput/_styles.scss +++ b/frontend/components/LiveQuery/TargetsInput/_styles.scss @@ -79,4 +79,7 @@ overflow: auto; } } + .input-icon-field__icon { + top: 34px; // Override styling to include label header + } } diff --git a/frontend/components/TooltipWrapper/TooltipWrapper.tsx b/frontend/components/TooltipWrapper/TooltipWrapper.tsx index 0b48e54b91..43e334495a 100644 --- a/frontend/components/TooltipWrapper/TooltipWrapper.tsx +++ b/frontend/components/TooltipWrapper/TooltipWrapper.tsx @@ -6,6 +6,7 @@ import * as DOMPurify from "dompurify"; interface ITooltipWrapperProps { children: string | JSX.Element; tipContent: string; + /** Default: bottom */ position?: "top" | "bottom"; isDelayed?: boolean; className?: string; diff --git a/frontend/components/icons/CollectingResults.tsx b/frontend/components/icons/CollectingResults.tsx new file mode 100644 index 0000000000..0df9824b3e --- /dev/null +++ b/frontend/components/icons/CollectingResults.tsx @@ -0,0 +1,254 @@ +import React from "react"; + +const CollectingResults = () => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export default CollectingResults; diff --git a/frontend/components/icons/index.ts b/frontend/components/icons/index.ts index cd18850643..36ac93a842 100644 --- a/frontend/components/icons/index.ts +++ b/frontend/components/icons/index.ts @@ -4,6 +4,7 @@ import ArrowInternalLink from "./ArrowInternalLink"; import CalendarCheck from "./CalendarCheck"; import Check from "./Check"; import Chevron from "./Chevron"; +import CollectingResults from "./CollectingResults"; import Columns from "./Columns"; import CriticalPolicy from "./CriticalPolicy"; import Disable from "./Disable"; @@ -84,6 +85,7 @@ export const ICON_MAP = { "calendar-check": CalendarCheck, chevron: Chevron, check: Check, + "collecting-results": CollectingResults, columns: Columns, "critical-policy": CriticalPolicy, disable: Disable, diff --git a/frontend/components/queries/queryResults/AwaitingResults/AwaitingResults.tsx b/frontend/components/queries/queryResults/AwaitingResults/AwaitingResults.tsx index ed2bf4953a..e807a83b6a 100644 --- a/frontend/components/queries/queryResults/AwaitingResults/AwaitingResults.tsx +++ b/frontend/components/queries/queryResults/AwaitingResults/AwaitingResults.tsx @@ -1,19 +1,18 @@ import React from "react"; -import PhoneHome from "../../../../../assets/images/phone-home.svg"; +import EmptyTable from "components/EmptyTable/EmptyTable"; const baseClass = "awaiting-results"; const AwaitingResults = () => { return ( -
- awaiting results - Phoning home... -

- There are currently no results to your query. Please wait while we talk - to more hosts. -

-
+ ); }; diff --git a/frontend/components/queries/queryResults/AwaitingResults/_styles.scss b/frontend/components/queries/queryResults/AwaitingResults/_styles.scss index 12463a3844..ba2b2dad9b 100644 --- a/frontend/components/queries/queryResults/AwaitingResults/_styles.scss +++ b/frontend/components/queries/queryResults/AwaitingResults/_styles.scss @@ -5,19 +5,4 @@ flex-direction: column; align-items: center; text-align: center; - - img { - margin-bottom: $pad-medium; - } - - &__title { - font-size: $small; - font-weight: $bold; - margin-bottom: $pad-small; - } - - &__description { - font-size: $x-small; - margin: 0; - } } diff --git a/frontend/context/query.tsx b/frontend/context/query.tsx index 992e152cad..e313a0a156 100644 --- a/frontend/context/query.tsx +++ b/frontend/context/query.tsx @@ -6,6 +6,7 @@ import { DEFAULT_QUERY } from "utilities/constants"; import { DEFAULT_OSQUERY_TABLE, IOsQueryTable } from "interfaces/osquery_table"; import { SelectedPlatformString } from "interfaces/platform"; import { QueryLoggingOption } from "interfaces/schedulable_query"; +import { DEFAULT_TARGETS, ITarget } from "interfaces/target"; type Props = { children: ReactNode; @@ -22,6 +23,7 @@ type InitialStateType = { lastEditedQueryPlatforms: SelectedPlatformString; lastEditedQueryMinOsqueryVersion: string; lastEditedQueryLoggingType: QueryLoggingOption; + selectedQueryTargets: ITarget[]; setLastEditedQueryId: (value: number | null) => void; setLastEditedQueryName: (value: string) => void; setLastEditedQueryDescription: (value: string) => void; @@ -32,6 +34,7 @@ type InitialStateType = { setLastEditedQueryMinOsqueryVersion: (value: string) => void; setLastEditedQueryLoggingType: (value: string) => void; setSelectedOsqueryTable: (tableName: string) => void; + setSelectedQueryTargets: (value: ITarget[]) => void; }; export type IQueryContext = InitialStateType; @@ -48,6 +51,7 @@ const initialState = { lastEditedQueryPlatforms: DEFAULT_QUERY.platform, lastEditedQueryMinOsqueryVersion: DEFAULT_QUERY.min_osquery_version, lastEditedQueryLoggingType: DEFAULT_QUERY.logging, + selectedQueryTargets: DEFAULT_TARGETS, setLastEditedQueryId: () => null, setLastEditedQueryName: () => null, setLastEditedQueryDescription: () => null, @@ -58,11 +62,13 @@ const initialState = { setLastEditedQueryMinOsqueryVersion: () => null, setLastEditedQueryLoggingType: () => null, setSelectedOsqueryTable: () => null, + setSelectedQueryTargets: () => null, }; const actions = { SET_SELECTED_OSQUERY_TABLE: "SET_SELECTED_OSQUERY_TABLE", SET_LAST_EDITED_QUERY_INFO: "SET_LAST_EDITED_QUERY_INFO", + SET_SELECTED_QUERY_TARGETS: "SET_SELECTED_QUERY_TARGETS", } as const; const reducer = (state: InitialStateType, action: any) => { @@ -114,6 +120,14 @@ const reducer = (state: InitialStateType, action: any) => { ? state.lastEditedQueryLoggingType : action.lastEditedQueryLoggingType, }; + case actions.SET_SELECTED_QUERY_TARGETS: + return { + ...state, + selectedQueryTargets: + typeof action.selectedQueryTargets === "undefined" + ? state.selectedQueryTargets + : action.selectedQueryTargets, + }; default: return state; } @@ -135,6 +149,7 @@ const QueryProvider = ({ children }: Props) => { lastEditedQueryPlatforms: state.lastEditedQueryPlatforms, lastEditedQueryMinOsqueryVersion: state.lastEditedQueryMinOsqueryVersion, lastEditedQueryLoggingType: state.lastEditedQueryLoggingType, + selectedQueryTargets: state.selectedQueryTargets, setLastEditedQueryId: (lastEditedQueryId: number | null) => { dispatch({ type: actions.SET_LAST_EDITED_QUERY_INFO, @@ -193,6 +208,12 @@ const QueryProvider = ({ children }: Props) => { lastEditedQueryLoggingType, }); }, + setSelectedQueryTargets: (selectedQueryTargets: ITarget[]) => { + dispatch({ + type: actions.SET_SELECTED_QUERY_TARGETS, + selectedQueryTargets, + }); + }, setSelectedOsqueryTable: (tableName: string) => { dispatch({ type: actions.SET_SELECTED_OSQUERY_TABLE, tableName }); }, diff --git a/frontend/interfaces/target.ts b/frontend/interfaces/target.ts index 7526719212..873d7907f3 100644 --- a/frontend/interfaces/target.ts +++ b/frontend/interfaces/target.ts @@ -49,3 +49,6 @@ export interface IPackTargets { label_ids: (number | string)[]; team_ids: (number | string)[]; } + +// TODO: Also use for testing +export const DEFAULT_TARGETS: ITarget[] = []; diff --git a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx index 0d1a0b6b46..132dd5b502 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx +++ b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx @@ -39,7 +39,11 @@ import MainContent from "components/MainContent"; import InfoBanner from "components/InfoBanner"; import BackLink from "components/BackLink"; -import { normalizeEmptyValues, wrapFleetHelper } from "utilities/helpers"; +import { + normalizeEmptyValues, + wrapFleetHelper, + TAGGED_TEMPLATES, +} from "utilities/helpers"; import permissions from "utilities/permissions"; import HostSummaryCard from "../cards/HostSummary"; @@ -99,12 +103,6 @@ interface IHostDetailsSubNavItem { pathname: string; } -const TAGGED_TEMPLATES = { - queryByHostRoute: (hostId: number | undefined | null) => { - return `${hostId ? `?host_ids=${hostId}` : ""}`; - }, -}; - const HostDetailsPage = ({ route, router, diff --git a/frontend/pages/policies/PolicyPage/PolicyPage.tsx b/frontend/pages/policies/PolicyPage/PolicyPage.tsx index ef3fc1eb4c..5d10551229 100644 --- a/frontend/pages/policies/PolicyPage/PolicyPage.tsx +++ b/frontend/pages/policies/PolicyPage/PolicyPage.tsx @@ -19,7 +19,7 @@ import globalPoliciesAPI from "services/entities/global_policies"; import teamPoliciesAPI from "services/entities/team_policies"; import hostAPI from "services/entities/hosts"; import statusAPI from "services/entities/status"; -import { QUERIES_PAGE_STEPS } from "utilities/constants"; +import { LIVE_POLICY_STEPS } from "utilities/constants"; import QuerySidePanel from "components/side_panels/QuerySidePanel"; import QueryEditor from "pages/policies/PolicyPage/screens/QueryEditor"; @@ -127,7 +127,7 @@ const PolicyPage = ({ }; }, []); - const [step, setStep] = useState(QUERIES_PAGE_STEPS[1]); + const [step, setStep] = useState(LIVE_POLICY_STEPS[1]); const [selectedTargets, setSelectedTargets] = useState([]); const [targetedHosts, setTargetedHosts] = useState([]); const [targetedLabels, setTargetedLabels] = useState([]); @@ -260,7 +260,7 @@ const PolicyPage = ({ storedPolicyError, createPolicy, onOsqueryTableSelect, - goToSelectTargets: () => setStep(QUERIES_PAGE_STEPS[2]), + goToSelectTargets: () => setStep(LIVE_POLICY_STEPS[2]), onOpenSchemaSidebar, renderLiveQueryWarning, }; @@ -272,8 +272,8 @@ const PolicyPage = ({ targetedLabels, targetedTeams, targetsTotalCount, - goToQueryEditor: () => setStep(QUERIES_PAGE_STEPS[1]), - goToRunQuery: () => setStep(QUERIES_PAGE_STEPS[3]), + goToQueryEditor: () => setStep(LIVE_POLICY_STEPS[1]), + goToRunQuery: () => setStep(LIVE_POLICY_STEPS[3]), setSelectedTargets, setTargetedHosts, setTargetedLabels, @@ -285,21 +285,21 @@ const PolicyPage = ({ selectedTargets, storedPolicy, setSelectedTargets, - goToQueryEditor: () => setStep(QUERIES_PAGE_STEPS[1]), + goToQueryEditor: () => setStep(LIVE_POLICY_STEPS[1]), targetsTotalCount, }; switch (step) { - case QUERIES_PAGE_STEPS[2]: + case LIVE_POLICY_STEPS[2]: return ; - case QUERIES_PAGE_STEPS[3]: + case LIVE_POLICY_STEPS[3]: return ; default: return ; } }; - const isFirstStep = step === QUERIES_PAGE_STEPS[1]; + const isFirstStep = step === LIVE_POLICY_STEPS[1]; const showSidebar = isFirstStep && isSidebarOpen && diff --git a/frontend/pages/policies/PolicyPage/_styles.scss b/frontend/pages/policies/PolicyPage/_styles.scss index 262b83b515..1eb28ded67 100644 --- a/frontend/pages/policies/PolicyPage/_styles.scss +++ b/frontend/pages/policies/PolicyPage/_styles.scss @@ -34,33 +34,6 @@ } } - &__observer-query-details { - padding: 0 2rem; - - h1 { - margin: $pad-large 0; - font-size: $large; - } - - p { - margin-bottom: $pad-small; - } - - .sql-button { - color: $core-vibrant-blue; - font-weight: $bold; - font-size: $x-small; - } - } - - &__query-preview { - margin-top: 15px; - - .fleet-ace__label { - display: none; - } - } - .ace_content { min-height: 500px !important; } @@ -177,9 +150,4 @@ margin-bottom: 0; } } - .targets-input { - .input-icon-field__icon { - top: 34px; // Override styling to include label header - } - } } diff --git a/frontend/pages/policies/PolicyPage/screens/QueryEditor.tsx b/frontend/pages/policies/PolicyPage/screens/QueryEditor.tsx index 89e46d024a..5e42e673de 100644 --- a/frontend/pages/policies/PolicyPage/screens/QueryEditor.tsx +++ b/frontend/pages/policies/PolicyPage/screens/QueryEditor.tsx @@ -173,7 +173,7 @@ const QueryEditor = ({ return null; } - // Function instead of constant eliminates race condition with filteredSoftwarePath + // Function instead of constant eliminates race condition with filteredPoliciesPath const backToPoliciesPath = () => { return filteredPoliciesPath || PATHS.MANAGE_POLICIES; }; diff --git a/frontend/pages/queries/ManageQueriesPage/components/QueriesTable/QueriesTableConfig.tsx b/frontend/pages/queries/ManageQueriesPage/components/QueriesTable/QueriesTableConfig.tsx index f24c17af24..10b4611433 100644 --- a/frontend/pages/queries/ManageQueriesPage/components/QueriesTable/QueriesTableConfig.tsx +++ b/frontend/pages/queries/ManageQueriesPage/components/QueriesTable/QueriesTableConfig.tsx @@ -152,7 +152,7 @@ const generateTableHeaders = ({ )} } - path={PATHS.EDIT_QUERY( + path={PATHS.QUERY( cellProps.row.original.id, cellProps.row.original.team_id ?? undefined )} diff --git a/frontend/pages/queries/QueryPage/index.ts b/frontend/pages/queries/QueryPage/index.ts deleted file mode 100644 index 8d00fa0475..0000000000 --- a/frontend/pages/queries/QueryPage/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./QueryPage"; diff --git a/frontend/pages/queries/QueryPage/screens/QueryEditor.tsx b/frontend/pages/queries/QueryPage/screens/QueryEditor.tsx deleted file mode 100644 index 7b94956efd..0000000000 --- a/frontend/pages/queries/QueryPage/screens/QueryEditor.tsx +++ /dev/null @@ -1,186 +0,0 @@ -import React, { useContext, useEffect, useState } from "react"; - -import { InjectedRouter } from "react-router/lib/Router"; -import { UseMutateAsyncFunction } from "react-query"; - -import queryAPI from "services/entities/queries"; -import { AppContext } from "context/app"; -import { QueryContext } from "context/query"; -import { NotificationContext } from "context/notification"; -import { - ICreateQueryRequestBody, - ISchedulableQuery, -} from "interfaces/schedulable_query"; -import PATHS from "router/paths"; -import debounce from "utilities/debounce"; -import deepDifference from "utilities/deep_difference"; - -import BackLink from "components/BackLink"; -import QueryForm from "pages/queries/QueryPage/components/QueryForm"; - -interface IQueryEditorProps { - router: InjectedRouter; - baseClass: string; - queryIdForEdit: number | null; - teamNameForQuery?: string; - apiTeamIdForQuery?: number; - storedQuery: ISchedulableQuery | undefined; - storedQueryError: Error | null; - showOpenSchemaActionText: boolean; - isStoredQueryLoading: boolean; - onOsqueryTableSelect: (tableName: string) => void; - goToSelectTargets: () => void; - onOpenSchemaSidebar: () => void; - renderLiveQueryWarning: () => JSX.Element | null; -} - -const QueryEditor = ({ - router, - baseClass, - queryIdForEdit, - teamNameForQuery, - apiTeamIdForQuery, - storedQuery, - storedQueryError, - showOpenSchemaActionText, - isStoredQueryLoading, - onOsqueryTableSelect, - goToSelectTargets, - onOpenSchemaSidebar, - renderLiveQueryWarning, -}: IQueryEditorProps): JSX.Element | null => { - const { currentUser, filteredQueriesPath } = useContext(AppContext); - const { renderFlash } = useContext(NotificationContext); - - // Note: The QueryContext values should always be used for any mutable query data such as query name - // The storedQuery prop should only be used to access immutable metadata such as author id - const { - lastEditedQueryName, - lastEditedQueryDescription, - lastEditedQueryBody, - lastEditedQueryObserverCanRun, - lastEditedQueryFrequency, - lastEditedQueryLoggingType, - lastEditedQueryPlatforms, - lastEditedQueryMinOsqueryVersion, - } = useContext(QueryContext); - - const [isQuerySaving, setIsQuerySaving] = useState(false); - const [isQueryUpdating, setIsQueryUpdating] = useState(false); - - useEffect(() => { - if (storedQueryError) { - renderFlash( - "error", - "Something went wrong retrieving your query. Please try again." - ); - } - }, []); - - const [backendValidators, setBackendValidators] = useState<{ - [key: string]: string; - }>({}); - - const saveQuery = debounce(async (formData: ICreateQueryRequestBody) => { - setIsQuerySaving(true); - try { - const { query } = await queryAPI.create(formData); - router.push(PATHS.EDIT_QUERY(query.id)); - renderFlash("success", "Query created!"); - setBackendValidators({}); - } catch (createError: any) { - if (createError.data.errors[0].reason.includes("already exists")) { - const teamErrorText = - teamNameForQuery && apiTeamIdForQuery !== 0 - ? `the ${teamNameForQuery} team` - : "all teams"; - setBackendValidators({ - name: `A query with that name already exists for ${teamErrorText}.`, - }); - } else { - renderFlash( - "error", - "Something went wrong creating your query. Please try again." - ); - setBackendValidators({}); - } - } finally { - setIsQuerySaving(false); - } - }); - - const onUpdateQuery = async (formData: ICreateQueryRequestBody) => { - if (!queryIdForEdit) { - return false; - } - - setIsQueryUpdating(true); - - const updatedQuery = deepDifference(formData, { - lastEditedQueryName, - lastEditedQueryDescription, - lastEditedQueryBody, - lastEditedQueryObserverCanRun, - lastEditedQueryFrequency, - lastEditedQueryPlatforms, - lastEditedQueryLoggingType, - lastEditedQueryMinOsqueryVersion, - }); - - try { - await queryAPI.update(queryIdForEdit, updatedQuery); - renderFlash("success", "Query updated!"); - } catch (updateError: any) { - console.error(updateError); - if (updateError.data.errors[0].reason.includes("Duplicate")) { - renderFlash("error", "A query with this name already exists."); - } else { - renderFlash( - "error", - "Something went wrong updating your query. Please try again." - ); - } - } - - setIsQueryUpdating(false); - - return false; - }; - - if (!currentUser) { - return null; - } - - // Function instead of constant eliminates race condition with filteredSoftwarePath - const backToQueriesPath = () => { - return filteredQueriesPath || PATHS.MANAGE_QUERIES; - }; - - return ( -
-
- -
- -
- ); -}; - -export default QueryEditor; diff --git a/frontend/pages/queries/QueryPage/screens/test.js b/frontend/pages/queries/QueryPage/screens/test.js deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/frontend/pages/queries/details/QueryDetailsPage/QueryDetailsPage.tsx b/frontend/pages/queries/details/QueryDetailsPage/QueryDetailsPage.tsx new file mode 100644 index 0000000000..be52bd6ad3 --- /dev/null +++ b/frontend/pages/queries/details/QueryDetailsPage/QueryDetailsPage.tsx @@ -0,0 +1,241 @@ +import React, { useContext, useEffect } from "react"; +import { useQuery } from "react-query"; +import { InjectedRouter, Params } from "react-router/lib/Router"; +import { useErrorHandler } from "react-error-boundary"; + +import PATHS from "router/paths"; +import { AppContext } from "context/app"; +import { QueryContext } from "context/query"; +import useTeamIdParam from "hooks/useTeamIdParam"; + +import { + IGetQueryResponse, + ISchedulableQuery, +} from "interfaces/schedulable_query"; + +import queryAPI from "services/entities/queries"; + +import Spinner from "components/Spinner/Spinner"; +import Button from "components/buttons/Button"; +import BackLink from "components/BackLink"; +import MainContent from "components/MainContent"; +import TooltipWrapper from "components/TooltipWrapper/TooltipWrapper"; +import QueryAutomationsStatusIndicator from "pages/queries/ManageQueriesPage/components/QueryAutomationsStatusIndicator/QueryAutomationsStatusIndicator"; +import DataError from "components/DataError/DataError"; +import LogDestinationIndicator from "components/LogDestinationIndicator/LogDestinationIndicator"; +import CachedDetails from "../components/CachedDetails/CachedDetails"; +import NoResults from "../components/NoResults/NoResults"; + +interface IQueryDetailsPageProps { + router: InjectedRouter; // v3 + params: Params; + location: { + pathname: string; + query: { team_id?: string }; + search: string; + }; +} + +const baseClass = "query-details-page"; + +const QueryDetailsPage = ({ + router, + params: { id: paramsQueryId }, + location, +}: IQueryDetailsPageProps): JSX.Element => { + const queryId = paramsQueryId ? parseInt(paramsQueryId, 10) : null; + + const { + currentTeamName: teamNameForQuery, + teamIdForApi: apiTeamIdForQuery, + } = useTeamIdParam({ + location, + router, + includeAllTeams: true, + includeNoTeam: false, + }); + + const handlePageError = useErrorHandler(); + const { + isGlobalAdmin, + isGlobalMaintainer, + isAnyTeamMaintainerOrTeamAdmin, + isObserverPlus, + isAnyTeamObserverPlus, + config, + filteredQueriesPath, + } = useContext(AppContext); + const { + lastEditedQueryName, + lastEditedQueryDescription, + lastEditedQueryObserverCanRun, + setLastEditedQueryId, + setLastEditedQueryName, + setLastEditedQueryDescription, + setLastEditedQueryBody, + setLastEditedQueryObserverCanRun, + setLastEditedQueryFrequency, + setLastEditedQueryLoggingType, + setLastEditedQueryMinOsqueryVersion, + setLastEditedQueryPlatforms, + } = useContext(QueryContext); + + // Title that shows up on browser tabs (e.g., Query details | Discover TLS certificates | Fleet for osquery) + document.title = `Query details | ${lastEditedQueryName} | Fleet for osquery`; + + // disabled on page load so we can control the number of renders + // else it will re-populate the context on occasion + const { + isLoading: isStoredQueryLoading, + data: storedQuery, + error: storedQueryError, + } = useQuery( + ["query", queryId], + () => queryAPI.load(queryId as number), + { + enabled: !!queryId, + refetchOnWindowFocus: false, + select: (data) => data.query, + onSuccess: (returnedQuery) => { + setLastEditedQueryId(returnedQuery.id); + setLastEditedQueryName(returnedQuery.name); + setLastEditedQueryDescription(returnedQuery.description); + setLastEditedQueryBody(returnedQuery.query); + setLastEditedQueryObserverCanRun(returnedQuery.observer_can_run); + setLastEditedQueryFrequency(returnedQuery.interval); + setLastEditedQueryPlatforms(returnedQuery.platform); + setLastEditedQueryLoggingType(returnedQuery.logging); + setLastEditedQueryMinOsqueryVersion(returnedQuery.min_osquery_version); + }, + onError: (error) => handlePageError(error), + } + ); + + const isLoading = isStoredQueryLoading; // TODO: Add || isCachedResultsLoading for new API response + const isApiError = storedQueryError || true; // TODO: Add || isCachedResultsError for new API response + + const renderHeader = () => { + const canEditQuery = + isGlobalAdmin || isGlobalMaintainer || isAnyTeamMaintainerOrTeamAdmin; + + // Function instead of constant eliminates race condition with filteredQueriesPath + const backToQueriesPath = () => { + return filteredQueriesPath || PATHS.MANAGE_QUERIES; + }; + + return ( + <> +
+ +
+ {!isLoading && !isApiError && ( +
+
+

+ {lastEditedQueryName} +

+

+ {lastEditedQueryDescription} +

+
+
+ + {(lastEditedQueryObserverCanRun || + isObserverPlus || + isAnyTeamObserverPlus || + canEditQuery) && ( +
+ +
+ )} +
+
+ )} + {!isLoading && !isApiError && ( +
+
+ + Automations: + + +
+
+ Log destination:{" "} + +
+
+ )} + + ); + }; + + const renderReport = () => { + const disabledCachingGlobally = true; // TODO: Update accordingly to config?.server_settings.query_reports_disabled + const discardDataEnabled = true; // TODO: Update accordingly to storedQuery?.discard_data + const loggingSnapshot = storedQuery?.logging === "snapshot"; + const disabledCaching = + disabledCachingGlobally || discardDataEnabled || !loggingSnapshot; + const emptyCache = true; // TODO: Update with API response + const errorsOnly = true; // TODO: Update with API response + + // Loading state + if (isLoading) { + return ; + } + + // Error state + if (isApiError) { + return ; + } + + // Empty state with varying messages explaining why there's no results + if (emptyCache) { + return ( + + ); + } + return ; // TODO: Everything related to new APIs including surfacing errorsOnly + }; + + return ( + +
+ {renderHeader()} + {renderReport()} +
+
+ ); +}; + +export default QueryDetailsPage; diff --git a/frontend/pages/queries/details/QueryDetailsPage/_styles.scss b/frontend/pages/queries/details/QueryDetailsPage/_styles.scss new file mode 100644 index 0000000000..9b0a5a0a25 --- /dev/null +++ b/frontend/pages/queries/details/QueryDetailsPage/_styles.scss @@ -0,0 +1,65 @@ +.query-details-page { + &__title-bar { + display: flex; + justify-content: space-between; + margin-top: $pad-large; + + .name-description { + display: flex; + flex-direction: column; + gap: $pad-small; + } + } + + &__action-button-container { + display: flex; + justify-content: flex-end; + min-width: 266px; + gap: $pad-medium; + } + + &__query-name { + margin-top: 0; + font-size: $large; + } + + &__query-description { + margin-top: 0; + margin-bottom: $pad-small; + font-size: $x-small; + } + + &__settings { + display: flex; + gap: $pad-large; + font-size: $x-small; + } + + &__automations, + &__log-destination { + display: flex; + gap: $pad-small; + } + + .empty-table__inner { + .component__tooltip-wrapper__tip-text { + text-align: left; + width: 320px; + } + + ul { + color: $core-white; + + li { + &::before { + content: "•"; + color: $core-white; + } + } + } + } + + .data-error { + padding-top: $pad-xxxlarge; + } +} diff --git a/frontend/pages/queries/details/QueryDetailsPage/index.ts b/frontend/pages/queries/details/QueryDetailsPage/index.ts new file mode 100644 index 0000000000..9bb526e7b5 --- /dev/null +++ b/frontend/pages/queries/details/QueryDetailsPage/index.ts @@ -0,0 +1 @@ +export { default } from "./QueryDetailsPage"; diff --git a/frontend/pages/queries/details/components/CachedDetails/CachedDetails.tsx b/frontend/pages/queries/details/components/CachedDetails/CachedDetails.tsx new file mode 100644 index 0000000000..ab5255e3f0 --- /dev/null +++ b/frontend/pages/queries/details/components/CachedDetails/CachedDetails.tsx @@ -0,0 +1,14 @@ +import React from "react"; + +// TODO: This whole section +// interface ICachedDetailsProps { +// +// } + +const baseClass = "cached-details"; + +const CachedDetails = (): JSX.Element => { + return
TODO
; +}; + +export default CachedDetails; diff --git a/frontend/pages/queries/details/components/CachedDetails/index.ts b/frontend/pages/queries/details/components/CachedDetails/index.ts new file mode 100644 index 0000000000..b50b73552b --- /dev/null +++ b/frontend/pages/queries/details/components/CachedDetails/index.ts @@ -0,0 +1 @@ +export { default } from "./CachedDetails"; diff --git a/frontend/pages/queries/details/components/NoResults/NoResults.tsx b/frontend/pages/queries/details/components/NoResults/NoResults.tsx new file mode 100644 index 0000000000..7897facb23 --- /dev/null +++ b/frontend/pages/queries/details/components/NoResults/NoResults.tsx @@ -0,0 +1,116 @@ +import React from "react"; + +import differenceInSeconds from "date-fns/differenceInSeconds"; +import formatDistance from "date-fns/formatDistance"; +import add from "date-fns/add"; + +import TooltipWrapper from "components/TooltipWrapper/TooltipWrapper"; +import EmptyTable from "components/EmptyTable/EmptyTable"; + +interface INoResultsProps { + queryInterval?: number; + queryUpdatedAt?: string; + disabledCaching: boolean; + disabledCachingGlobally: boolean; + discardDataEnabled: boolean; + loggingSnapshot: boolean; + errorsOnly: boolean; +} + +const baseClass = "no-results"; + +const NoResults = ({ + queryInterval, + queryUpdatedAt, + disabledCaching, + disabledCachingGlobally, + discardDataEnabled, + loggingSnapshot, + errorsOnly, +}: INoResultsProps): JSX.Element => { + // Returns how many seconds it takes to expect a cached update + const secondsCheckbackTime = () => { + const secondsSinceUpdate = queryUpdatedAt + ? differenceInSeconds(new Date(), new Date(queryUpdatedAt)) + : 0; + const secondsUpdateWaittime = (queryInterval || 0) + 60; + return secondsUpdateWaittime - secondsSinceUpdate; + }; + + // Update status of collecting cached results + const collectingResults = secondsCheckbackTime() > 0; + + // Converts seconds takes to update to human readable format + const readableCheckbackTime = formatDistance( + add(new Date(), { seconds: secondsCheckbackTime() }), + new Date() + ); + + // Collecting results state + if (collectingResults) { + const collectingResultsInfo = () => + `Fleet is collecting query results. Check back in about ${readableCheckbackTime}.`; + + return ( + + ); + } + + const noResultsInfo = () => { + if (!queryInterval) { + return ( + <> + This query does not collect data on a schedule. Add a{" "} + frequency or run this as a live query to see results. + + ); + } + if (disabledCaching) { + const tipContent = () => { + if (disabledCachingGlobally) { + return "The following setting prevents saving this query's results in Fleet:
  • Query reports are globally disabled in organization settings.
"; + } + if (discardDataEnabled) { + return "The following setting prevents saving this query's results in Fleet:
  • This query has Discard data enabled.
"; + } + if (!loggingSnapshot) { + return "The following setting prevents saving this query's results in Fleet:
  • The logging setting for this query is not Snapshot.
"; + } + return "Unknown"; + }; + return ( + <> + Results from this query are{" "} + + not reported in Fleet + + . + + ); + } + if (errorsOnly) { + return ( + <> + This query had trouble collecting data on some hosts. Check out the{" "} + Errors tab to see why. + + ); + } + return "This query has returned no data so far."; + }; + + return ( + + ); +}; + +export default NoResults; diff --git a/frontend/pages/queries/details/components/NoResults/index.ts b/frontend/pages/queries/details/components/NoResults/index.ts new file mode 100644 index 0000000000..04bef19e77 --- /dev/null +++ b/frontend/pages/queries/details/components/NoResults/index.ts @@ -0,0 +1 @@ +export { default } from "./NoResults"; diff --git a/frontend/pages/queries/QueryPage/QueryPage.tsx b/frontend/pages/queries/edit/EditQueryPage/EditQueryPage.tsx similarity index 54% rename from frontend/pages/queries/QueryPage/QueryPage.tsx rename to frontend/pages/queries/edit/EditQueryPage/EditQueryPage.tsx index ca93efe3d4..e9aa57dce9 100644 --- a/frontend/pages/queries/QueryPage/QueryPage.tsx +++ b/frontend/pages/queries/edit/EditQueryPage/EditQueryPage.tsx @@ -1,34 +1,36 @@ -import React, { useState, useEffect, useContext, useCallback } from "react"; -import { useQuery, useMutation } from "react-query"; +import React, { useState, useEffect, useContext } from "react"; +import { useQuery } from "react-query"; import { useErrorHandler } from "react-error-boundary"; import { InjectedRouter, Params } from "react-router/lib/Router"; import { AppContext } from "context/app"; import { QueryContext } from "context/query"; -import { QUERIES_PAGE_STEPS, DEFAULT_QUERY } from "utilities/constants"; +import { DEFAULT_QUERY } from "utilities/constants"; import queryAPI from "services/entities/queries"; -import hostAPI from "services/entities/hosts"; import statusAPI from "services/entities/status"; -import { IHost, IHostResponse } from "interfaces/host"; -import { ILabel } from "interfaces/label"; -import { ITeam } from "interfaces/team"; import { IGetQueryResponse, + ICreateQueryRequestBody, ISchedulableQuery, } from "interfaces/schedulable_query"; -import { ITarget } from "interfaces/target"; import QuerySidePanel from "components/side_panels/QuerySidePanel"; import MainContent from "components/MainContent"; import SidePanelContent from "components/SidePanelContent"; -import SelectTargets from "components/LiveQuery/SelectTargets"; import CustomLink from "components/CustomLink"; -import QueryEditor from "pages/queries/QueryPage/screens/QueryEditor"; -import RunQuery from "pages/queries/QueryPage/screens/RunQuery"; import useTeamIdParam from "hooks/useTeamIdParam"; -interface IQueryPageProps { +import { NotificationContext } from "context/notification"; + +import PATHS from "router/paths"; +import debounce from "utilities/debounce"; +import deepDifference from "utilities/deep_difference"; + +import BackLink from "components/BackLink"; +import QueryForm from "pages/queries/edit/components/QueryForm"; + +interface IEditQueryPageProps { router: InjectedRouter; params: Params; location: { @@ -38,13 +40,13 @@ interface IQueryPageProps { }; } -const baseClass = "query-page"; +const baseClass = "edit-query-page"; -const QueryPage = ({ +const EditQueryPage = ({ router, params: { id: paramsQueryId }, location, -}: IQueryPageProps): JSX.Element => { +}: IEditQueryPageProps): JSX.Element => { const queryId = paramsQueryId ? parseInt(paramsQueryId, 10) : null; const { currentTeamName: teamNameForQuery, @@ -67,6 +69,15 @@ const QueryPage = ({ const { selectedOsqueryTable, setSelectedOsqueryTable, + lastEditedQueryName, + lastEditedQueryDescription, + lastEditedQueryBody, + lastEditedQueryObserverCanRun, + lastEditedQueryFrequency, + lastEditedQueryPlatforms, + lastEditedQueryLoggingType, + lastEditedQueryMinOsqueryVersion, + selectedQueryTargets, setLastEditedQueryId, setLastEditedQueryName, setLastEditedQueryDescription, @@ -76,15 +87,14 @@ const QueryPage = ({ setLastEditedQueryLoggingType, setLastEditedQueryMinOsqueryVersion, setLastEditedQueryPlatforms, + // setSelectedQueryTargets, } = useContext(QueryContext); + const { currentUser } = useContext(AppContext); + const { renderFlash } = useContext(NotificationContext); + + // const [queryParamHostsAdded, setQueryParamHostsAdded] = useState(false); + // const [targetedHosts, setTargetedHosts] = useState([]); - const [queryParamHostsAdded, setQueryParamHostsAdded] = useState(false); - const [step, setStep] = useState(QUERIES_PAGE_STEPS[1]); - const [selectedTargets, setSelectedTargets] = useState([]); - const [targetedHosts, setTargetedHosts] = useState([]); - const [targetedLabels, setTargetedLabels] = useState([]); - const [targetedTeams, setTargetedTeams] = useState([]); - const [targetsTotalCount, setTargetsTotalCount] = useState(0); const [isLiveQueryRunnable, setIsLiveQueryRunnable] = useState(true); const [isSidebarOpen, setIsSidebarOpen] = useState(true); const [showOpenSchemaActionText, setShowOpenSchemaActionText] = useState( @@ -119,29 +129,6 @@ const QueryPage = ({ } ); - useQuery( - "hostFromURL", - () => - hostAPI.loadHostDetails(parseInt(location.query.host_ids as string, 10)), - { - enabled: !!location.query.host_ids && !queryParamHostsAdded, - select: (data: IHostResponse) => data.host, - onSuccess: (host) => { - setTargetedHosts((prevHosts) => - prevHosts.filter((h) => h.id !== host.id).concat(host) - ); - const targets = selectedTargets; - host.target_type = "hosts"; - targets.push(host); - setSelectedTargets([...targets]); - if (!queryParamHostsAdded) { - setQueryParamHostsAdded(true); - } - router.replace(location.pathname); - }, - } - ); - const detectIsFleetQueryRunnable = () => { statusAPI.live_query().catch(() => { setIsLiveQueryRunnable(false); @@ -163,16 +150,88 @@ const QueryPage = ({ } }, [queryId]); + const [isQuerySaving, setIsQuerySaving] = useState(false); + const [isQueryUpdating, setIsQueryUpdating] = useState(false); + const [backendValidators, setBackendValidators] = useState<{ + [key: string]: string; + }>({}); + // Updates title that shows up on browser tabs useEffect(() => { // e.g., Query details | Discover TLS certificates | Fleet for osquery - document.title = `Query details | ${storedQuery?.name} | Fleet for osquery`; + document.title = `Edit query | ${storedQuery?.name} | Fleet for osquery`; }, [location.pathname, storedQuery?.name]); useEffect(() => { setShowOpenSchemaActionText(!isSidebarOpen); }, [isSidebarOpen]); + const saveQuery = debounce(async (formData: ICreateQueryRequestBody) => { + setIsQuerySaving(true); + try { + const { query } = await queryAPI.create(formData); + router.push(PATHS.EDIT_QUERY(query.id)); + renderFlash("success", "Query created!"); + setBackendValidators({}); + } catch (createError: any) { + if (createError.data.errors[0].reason.includes("already exists")) { + const teamErrorText = + teamNameForQuery && apiTeamIdForQuery !== 0 + ? `the ${teamNameForQuery} team` + : "all teams"; + setBackendValidators({ + name: `A query with that name already exists for ${teamErrorText}.`, + }); + } else { + renderFlash( + "error", + "Something went wrong creating your query. Please try again." + ); + setBackendValidators({}); + } + } finally { + setIsQuerySaving(false); + } + }); + + const onUpdateQuery = async (formData: ICreateQueryRequestBody) => { + if (!queryId) { + return false; + } + + setIsQueryUpdating(true); + + const updatedQuery = deepDifference(formData, { + lastEditedQueryName, + lastEditedQueryDescription, + lastEditedQueryBody, + lastEditedQueryObserverCanRun, + lastEditedQueryFrequency, + lastEditedQueryPlatforms, + lastEditedQueryLoggingType, + lastEditedQueryMinOsqueryVersion, + }); + + try { + await queryAPI.update(queryId, updatedQuery); + renderFlash("success", "Query updated!"); + } catch (updateError: any) { + console.error(updateError); + if (updateError.data.errors[0].reason.includes("Duplicate")) { + renderFlash("error", "A query with this name already exists."); + } else { + renderFlash( + "error", + "Something went wrong updating your query. Please try again." + ); + } + } + + setIsQueryUpdating(false); + + return false; + }; + const onOsqueryTableSelect = (tableName: string) => { setSelectedOsqueryTable(tableName); }; @@ -207,64 +266,12 @@ const QueryPage = ({ ); }; - const goToQueryEditor = useCallback(() => setStep(QUERIES_PAGE_STEPS[1]), []); - - const renderScreen = () => { - const step1Props = { - router, - baseClass, - queryIdForEdit: queryId, - teamNameForQuery, - apiTeamIdForQuery, - showOpenSchemaActionText, - storedQuery, - isStoredQueryLoading, - storedQueryError, - onOsqueryTableSelect, - goToSelectTargets: () => setStep(QUERIES_PAGE_STEPS[2]), - onOpenSchemaSidebar, - renderLiveQueryWarning, - }; - - const step2Props = { - baseClass, - queryId, - selectedTargets, - targetedHosts, - targetedLabels, - targetedTeams, - targetsTotalCount, - goToQueryEditor: () => setStep(QUERIES_PAGE_STEPS[1]), - goToRunQuery: () => setStep(QUERIES_PAGE_STEPS[3]), - setSelectedTargets, - setTargetedHosts, - setTargetedLabels, - setTargetedTeams, - setTargetsTotalCount, - }; - - const step3Props = { - queryId, - selectedTargets, - storedQuery, - setSelectedTargets, - goToQueryEditor, - targetsTotalCount, - }; - - switch (step) { - case QUERIES_PAGE_STEPS[2]: - return ; - case QUERIES_PAGE_STEPS[3]: - return ; - default: - return ; - } + // Function instead of constant eliminates race condition + const backToQueriesPath = () => { + return queryId ? PATHS.QUERY(queryId) : PATHS.MANAGE_QUERIES; }; - const isFirstStep = step === QUERIES_PAGE_STEPS[1]; const showSidebar = - isFirstStep && isSidebarOpen && (isGlobalAdmin || isGlobalMaintainer || @@ -275,7 +282,31 @@ const QueryPage = ({ return ( <> -
{renderScreen()}
+
+
+
+ +
+ +
+
{showSidebar && ( @@ -290,4 +321,4 @@ const QueryPage = ({ ); }; -export default QueryPage; +export default EditQueryPage; diff --git a/frontend/pages/queries/edit/EditQueryPage/_styles.scss b/frontend/pages/queries/edit/EditQueryPage/_styles.scss new file mode 100644 index 0000000000..f4ffcbbfd2 --- /dev/null +++ b/frontend/pages/queries/edit/EditQueryPage/_styles.scss @@ -0,0 +1,54 @@ +.edit-query-page { + .body-wrap { + min-width: 0; + } + + &__warning { + padding: $pad-medium; + font-size: $x-small; + color: $core-fleet-black; + background-color: #fff0b9; + border: 1px solid #f2c94c; + border-radius: $border-radius; + margin: 0; + margin-top: $pad-large; + + p { + margin: 0; + line-height: 20px; + } + } + + .ace_content { + min-height: 500px !important; + } + + &__count-spinner { + margin-right: $pad-small; + } + &__page-loading { + .loading-spinner { + margin: $pad-large 0 0; + } + } + &__page-error { + h4 { + margin: 0; + margin-top: 28px; + margin-left: -7px; + font-size: $small; + + img { + transform: scale(0.5); + vertical-align: middle; + position: relative; + top: -2px; + } + } + p { + margin: 0; + margin-top: $pad-medium; + font-size: $x-small; + } + } +} diff --git a/frontend/pages/queries/edit/EditQueryPage/index.ts b/frontend/pages/queries/edit/EditQueryPage/index.ts new file mode 100644 index 0000000000..29c0d100ac --- /dev/null +++ b/frontend/pages/queries/edit/EditQueryPage/index.ts @@ -0,0 +1 @@ +export { default } from "./EditQueryPage"; diff --git a/frontend/pages/queries/QueryPage/components/QueryForm/QueryForm.tests.tsx b/frontend/pages/queries/edit/components/QueryForm/QueryForm.tests.tsx similarity index 98% rename from frontend/pages/queries/QueryPage/components/QueryForm/QueryForm.tests.tsx rename to frontend/pages/queries/edit/components/QueryForm/QueryForm.tests.tsx index 87bc29c8fd..bb5286895f 100644 --- a/frontend/pages/queries/QueryPage/components/QueryForm/QueryForm.tests.tsx +++ b/frontend/pages/queries/edit/components/QueryForm/QueryForm.tests.tsx @@ -68,7 +68,6 @@ describe("QueryForm - component", () => { isQueryUpdating={false} saveQuery={jest.fn()} onOsqueryTableSelect={jest.fn()} - goToSelectTargets={jest.fn()} onUpdate={jest.fn()} onOpenSchemaSidebar={jest.fn()} renderLiveQueryWarning={jest.fn()} diff --git a/frontend/pages/queries/QueryPage/components/QueryForm/QueryForm.tsx b/frontend/pages/queries/edit/components/QueryForm/QueryForm.tsx similarity index 95% rename from frontend/pages/queries/QueryPage/components/QueryForm/QueryForm.tsx rename to frontend/pages/queries/edit/components/QueryForm/QueryForm.tsx index 452e06d4ee..a704ee2ef8 100644 --- a/frontend/pages/queries/QueryPage/components/QueryForm/QueryForm.tsx +++ b/frontend/pages/queries/edit/components/QueryForm/QueryForm.tsx @@ -15,7 +15,11 @@ import PATHS from "router/paths"; import { AppContext } from "context/app"; import { QueryContext } from "context/query"; import { NotificationContext } from "context/notification"; -import { addGravatarUrlToResource, secondsToDhms } from "utilities/helpers"; +import { + addGravatarUrlToResource, + secondsToDhms, + TAGGED_TEMPLATES, +} from "utilities/helpers"; import { FREQUENCY_DROPDOWN_OPTIONS, SCHEDULE_PLATFORM_DROPDOWN_OPTIONS, @@ -48,6 +52,7 @@ import Spinner from "components/Spinner"; import Icon from "components/Icon/Icon"; import AutoSizeInputField from "components/forms/fields/AutoSizeInputField"; import SaveQueryModal from "../SaveQueryModal"; +import SaveChangesModal from "../SaveChangesModal"; const baseClass = "query-form"; @@ -63,11 +68,11 @@ interface IQueryFormProps { isQueryUpdating: boolean; saveQuery: (formData: ICreateQueryRequestBody) => void; onOsqueryTableSelect: (tableName: string) => void; - goToSelectTargets: () => void; onUpdate: (formData: ICreateQueryRequestBody) => void; onOpenSchemaSidebar: () => void; renderLiveQueryWarning: () => JSX.Element | null; backendValidators: { [key: string]: string }; + hostId?: number; } const validateQuerySQL = (query: string) => { @@ -110,11 +115,11 @@ const QueryForm = ({ isQueryUpdating, saveQuery, onOsqueryTableSelect, - goToSelectTargets, onUpdate, onOpenSchemaSidebar, renderLiveQueryWarning, backendValidators, + hostId, }: IQueryFormProps): JSX.Element => { // Note: The QueryContext values should always be used for any mutable query data such as query name // The storedQuery prop should only be used to access immutable metadata such as author id @@ -153,6 +158,7 @@ const QueryForm = ({ const savedQueryMode = !!queryIdForEdit; const [errors, setErrors] = useState<{ [key: string]: any }>({}); // string | null | undefined or boolean | undefined const [showSaveQueryModal, setShowSaveQueryModal] = useState(false); + const [showSaveChangesModal, setShowSaveChangesModal] = useState(false); // #7766 implementation const [showQueryEditor, setShowQueryEditor] = useState( isObserverPlus || isAnyTeamObserverPlus || false ); @@ -205,6 +211,11 @@ const QueryForm = ({ setShowSaveQueryModal(!showSaveQueryModal); }; + // #7766 implementation + const toggleSaveChangesModal = () => { + setShowSaveChangesModal(!showSaveChangesModal); + }; + const onLoad = (editor: IAceEditor) => { editor.setOptions({ enableLinking: true, @@ -400,6 +411,12 @@ const QueryForm = ({ logging: lastEditedQueryLoggingType, }); } + + // #7766 implementation + // savedQueryMode + // ? setShowSaveChangesModal(true) + // : setShowSaveQueryModal(true); + // TODO: onUpdate for saveChangesModal } }; @@ -575,7 +592,13 @@ const QueryForm = ({ @@ -749,7 +772,13 @@ const QueryForm = ({ @@ -765,6 +794,13 @@ const QueryForm = ({ isLoading={isQuerySaving} /> )} + {showSaveChangesModal && ( + + )} ); }; diff --git a/frontend/pages/queries/QueryPage/components/QueryForm/_styles.scss b/frontend/pages/queries/edit/components/QueryForm/_styles.scss similarity index 98% rename from frontend/pages/queries/QueryPage/components/QueryForm/_styles.scss rename to frontend/pages/queries/edit/components/QueryForm/_styles.scss index 5a9ab0d49f..bc63146bac 100644 --- a/frontend/pages/queries/QueryPage/components/QueryForm/_styles.scss +++ b/frontend/pages/queries/edit/components/QueryForm/_styles.scss @@ -3,11 +3,6 @@ position: relative; font-size: $x-small; - .query-page__warning { - margin: 0; - margin-top: $pad-large; - } - .form-field--input { margin: 0; } diff --git a/frontend/pages/queries/QueryPage/components/QueryForm/index.ts b/frontend/pages/queries/edit/components/QueryForm/index.ts similarity index 100% rename from frontend/pages/queries/QueryPage/components/QueryForm/index.ts rename to frontend/pages/queries/edit/components/QueryForm/index.ts diff --git a/frontend/pages/queries/QueryPage/components/QueryResults/QueryResults.tsx b/frontend/pages/queries/edit/components/QueryResults/QueryResults.tsx similarity index 100% rename from frontend/pages/queries/QueryPage/components/QueryResults/QueryResults.tsx rename to frontend/pages/queries/edit/components/QueryResults/QueryResults.tsx diff --git a/frontend/pages/queries/QueryPage/components/QueryResults/QueryResultsTableConfig.tsx b/frontend/pages/queries/edit/components/QueryResults/QueryResultsTableConfig.tsx similarity index 100% rename from frontend/pages/queries/QueryPage/components/QueryResults/QueryResultsTableConfig.tsx rename to frontend/pages/queries/edit/components/QueryResults/QueryResultsTableConfig.tsx diff --git a/frontend/pages/queries/QueryPage/components/QueryResults/_styles.scss b/frontend/pages/queries/edit/components/QueryResults/_styles.scss similarity index 100% rename from frontend/pages/queries/QueryPage/components/QueryResults/_styles.scss rename to frontend/pages/queries/edit/components/QueryResults/_styles.scss diff --git a/frontend/pages/queries/QueryPage/components/QueryResults/index.ts b/frontend/pages/queries/edit/components/QueryResults/index.ts similarity index 100% rename from frontend/pages/queries/QueryPage/components/QueryResults/index.ts rename to frontend/pages/queries/edit/components/QueryResults/index.ts diff --git a/frontend/pages/queries/edit/components/SaveChangesModal/SaveChangesModal.tsx b/frontend/pages/queries/edit/components/SaveChangesModal/SaveChangesModal.tsx new file mode 100644 index 0000000000..362af3918d --- /dev/null +++ b/frontend/pages/queries/edit/components/SaveChangesModal/SaveChangesModal.tsx @@ -0,0 +1,53 @@ +import React from "react"; + +import Button from "components/buttons/Button"; +import Modal from "components/Modal"; +import { ICreateQueryRequestBody } from "interfaces/schedulable_query"; + +const baseClass = "save-changes-modal"; + +export interface ISaveChangesModalProps { + isUpdating: boolean; + onSaveChanges: (formData: ICreateQueryRequestBody) => void; + toggleSaveChangesModal: () => void; + sqlUpdated?: boolean; +} + +const SaveChangesModal = ({ + isUpdating, + onSaveChanges, + toggleSaveChangesModal, + sqlUpdated = false, +}: ISaveChangesModalProps): JSX.Element => { + const warningText = () => { + if (sqlUpdated) { + return "Changing this query's SQL will delete its previous results, since the existing report does not reflect the updated query."; + } + return "The changes you are making to this query will delete its previous results."; + }; + + return ( + +
+

{warningText()}

+

You cannot undo this action.

+
+ + +
+
+
+ ); +}; + +export default SaveChangesModal; diff --git a/frontend/pages/queries/edit/components/SaveChangesModal/index.ts b/frontend/pages/queries/edit/components/SaveChangesModal/index.ts new file mode 100644 index 0000000000..15fa4a05cf --- /dev/null +++ b/frontend/pages/queries/edit/components/SaveChangesModal/index.ts @@ -0,0 +1 @@ +export { default } from "./SaveChangesModal"; diff --git a/frontend/pages/queries/QueryPage/components/SaveQueryModal/SaveQueryModal.tsx b/frontend/pages/queries/edit/components/SaveQueryModal/SaveQueryModal.tsx similarity index 51% rename from frontend/pages/queries/QueryPage/components/SaveQueryModal/SaveQueryModal.tsx rename to frontend/pages/queries/edit/components/SaveQueryModal/SaveQueryModal.tsx index f74a45f66c..a8cd8dc099 100644 --- a/frontend/pages/queries/QueryPage/components/SaveQueryModal/SaveQueryModal.tsx +++ b/frontend/pages/queries/edit/components/SaveQueryModal/SaveQueryModal.tsx @@ -141,116 +141,114 @@ const SaveQueryModal = ({ return ( - <> -
+ setName(value)} + value={name} + error={errors.name} + inputClassName={`${baseClass}__name`} + label="Name" + placeholder="What is your query called?" + autofocus + ignore1password + /> + setDescription(value)} + value={description} + inputClassName={`${baseClass}__description`} + label="Description" + type="textarea" + placeholder="What information does your query reveal? (optional)" + /> + { + setSelectedFrequency(value); + }} + placeholder={"Every hour"} + value={selectedFrequency} + label="Frequency" + wrapperClassName={`${baseClass}__form-field ${baseClass}__form-field--frequency`} + /> +

+ If automations are on, this is how often your query collects data. +

+ - setName(value)} - value={name} - error={errors.name} - inputClassName={`${baseClass}__name`} - label="Name" - placeholder="What is your query called?" - autofocus - ignore1password - /> - setDescription(value)} - value={description} - inputClassName={`${baseClass}__description`} - label="Description" - type="textarea" - placeholder="What information does your query reveal? (optional)" - /> - { - setSelectedFrequency(value); - }} - placeholder={"Every hour"} - value={selectedFrequency} - label="Frequency" - wrapperClassName={`${baseClass}__form-field ${baseClass}__form-field--frequency`} - /> -

- If automations are on, this is how often your query collects data. -

- +

+ Users with the Observer role will be able to run this query as a live + query. +

+ + {showAdvancedOptions && ( + <> + +

+ If automations are turned on, your query collects data on + compatible platforms. +
+ If you want more control, override platforms. +

+ + + + )} +
+ - -
- - + Save + + + +
); }; diff --git a/frontend/pages/queries/QueryPage/components/SaveQueryModal/_styles.scss b/frontend/pages/queries/edit/components/SaveQueryModal/_styles.scss similarity index 100% rename from frontend/pages/queries/QueryPage/components/SaveQueryModal/_styles.scss rename to frontend/pages/queries/edit/components/SaveQueryModal/_styles.scss diff --git a/frontend/pages/queries/QueryPage/components/SaveQueryModal/index.ts b/frontend/pages/queries/edit/components/SaveQueryModal/index.ts similarity index 100% rename from frontend/pages/queries/QueryPage/components/SaveQueryModal/index.ts rename to frontend/pages/queries/edit/components/SaveQueryModal/index.ts diff --git a/frontend/pages/queries/live/LiveQueryPage/LiveQueryPage.tsx b/frontend/pages/queries/live/LiveQueryPage/LiveQueryPage.tsx new file mode 100644 index 0000000000..c49d4c333b --- /dev/null +++ b/frontend/pages/queries/live/LiveQueryPage/LiveQueryPage.tsx @@ -0,0 +1,213 @@ +import React, { useState, useEffect, useContext, useCallback } from "react"; +import { useQuery } from "react-query"; +import { useErrorHandler } from "react-error-boundary"; +import { InjectedRouter, Params } from "react-router/lib/Router"; +import PATHS from "router/paths"; + +import { AppContext } from "context/app"; +import { QueryContext } from "context/query"; +import { LIVE_QUERY_STEPS, DEFAULT_QUERY } from "utilities/constants"; +import queryAPI from "services/entities/queries"; +import hostAPI from "services/entities/hosts"; +import statusAPI from "services/entities/status"; +import { IHost, IHostResponse } from "interfaces/host"; +import { ILabel } from "interfaces/label"; +import { ITeam } from "interfaces/team"; +import { + IGetQueryResponse, + ISchedulableQuery, +} from "interfaces/schedulable_query"; + +import MainContent from "components/MainContent"; +import SelectTargets from "components/LiveQuery/SelectTargets"; + +import RunQuery from "pages/queries/live/screens/RunQuery"; +import useTeamIdParam from "hooks/useTeamIdParam"; + +interface IRunQueryPageProps { + router: InjectedRouter; + params: Params; + location: { + pathname: string; + query: { host_ids: string; team_id?: string }; + search: string; + }; +} + +const baseClass = "run-query-page"; + +const RunQueryPage = ({ + router, + params: { id: paramsQueryId }, + location, +}: IRunQueryPageProps): JSX.Element => { + const queryId = paramsQueryId ? parseInt(paramsQueryId, 10) : null; + const { + currentTeamName: teamNameForQuery, + teamIdForApi: apiTeamIdForQuery, + } = useTeamIdParam({ + location, + router, + includeAllTeams: true, + includeNoTeam: false, + }); + + const handlePageError = useErrorHandler(); + const { + isGlobalAdmin, + isGlobalMaintainer, + isAnyTeamMaintainerOrTeamAdmin, + isObserverPlus, + isAnyTeamObserverPlus, + } = useContext(AppContext); + const { + selectedQueryTargets, + setSelectedQueryTargets, + setLastEditedQueryId, + setLastEditedQueryName, + setLastEditedQueryDescription, + setLastEditedQueryBody, + setLastEditedQueryObserverCanRun, + setLastEditedQueryFrequency, + setLastEditedQueryLoggingType, + setLastEditedQueryMinOsqueryVersion, + setLastEditedQueryPlatforms, + } = useContext(QueryContext); + + const [queryParamHostsAdded, setQueryParamHostsAdded] = useState(false); + const [step, setStep] = useState(LIVE_QUERY_STEPS[1]); + const [targetedHosts, setTargetedHosts] = useState([]); + const [targetedLabels, setTargetedLabels] = useState([]); + const [targetedTeams, setTargetedTeams] = useState([]); + const [targetsTotalCount, setTargetsTotalCount] = useState(0); + const [isLiveQueryRunnable, setIsLiveQueryRunnable] = useState(true); + + const TAGGED_TEMPLATES = { + queryByHostRoute: (hostId: number | undefined | null) => { + return `${hostId ? `?host_ids=${hostId}` : ""}`; + }, + }; + + // disabled on page load so we can control the number of renders + // else it will re-populate the context on occasion + const { data: storedQuery } = useQuery< + IGetQueryResponse, + Error, + ISchedulableQuery + >(["query", queryId], () => queryAPI.load(queryId as number), { + enabled: !!queryId, + refetchOnWindowFocus: false, + select: (data) => data.query, + onSuccess: (returnedQuery) => { + setLastEditedQueryId(returnedQuery.id); + setLastEditedQueryName(returnedQuery.name); + setLastEditedQueryDescription(returnedQuery.description); + setLastEditedQueryBody(returnedQuery.query); + setLastEditedQueryObserverCanRun(returnedQuery.observer_can_run); + setLastEditedQueryFrequency(returnedQuery.interval); + setLastEditedQueryPlatforms(returnedQuery.platform); + setLastEditedQueryLoggingType(returnedQuery.logging); + setLastEditedQueryMinOsqueryVersion(returnedQuery.min_osquery_version); + }, + onError: (error) => handlePageError(error), + }); + + useQuery( + "hostFromURL", + () => + hostAPI.loadHostDetails(parseInt(location.query.host_ids as string, 10)), + { + enabled: !!location.query.host_ids && !queryParamHostsAdded, + select: (data: IHostResponse) => data.host, + onSuccess: (host) => { + setTargetedHosts((prevHosts) => + prevHosts.filter((h) => h.id !== host.id).concat(host) + ); + const targets = selectedQueryTargets; + host.target_type = "hosts"; + targets.push(host); + setSelectedQueryTargets([...targets]); + if (!queryParamHostsAdded) { + setQueryParamHostsAdded(true); + } + router.replace(location.pathname); + }, + } + ); + + const detectIsFleetQueryRunnable = () => { + statusAPI.live_query().catch(() => { + setIsLiveQueryRunnable(false); + }); + }; + + useEffect(() => { + detectIsFleetQueryRunnable(); + if (!queryId) { + setLastEditedQueryId(DEFAULT_QUERY.id); + setLastEditedQueryName(DEFAULT_QUERY.name); + setLastEditedQueryDescription(DEFAULT_QUERY.description); + setLastEditedQueryBody(DEFAULT_QUERY.query); + setLastEditedQueryObserverCanRun(DEFAULT_QUERY.observer_can_run); + setLastEditedQueryFrequency(DEFAULT_QUERY.interval); + setLastEditedQueryLoggingType(DEFAULT_QUERY.logging); + setLastEditedQueryMinOsqueryVersion(DEFAULT_QUERY.min_osquery_version); + setLastEditedQueryPlatforms(DEFAULT_QUERY.platform); + } + }, [queryId]); + + // Updates title that shows up on browser tabs + useEffect(() => { + // e.g., Run live query | Discover TLS certificates | Fleet for osquery + document.title = `Run live query | ${storedQuery?.name} | Fleet for osquery`; + }, [location.pathname, storedQuery?.name]); + + const goToQueryEditor = useCallback( + () => queryId && router.push(PATHS.EDIT_QUERY(queryId)), + [] + ); + // const params = { id: paramsQueryId }; + + const renderScreen = () => { + const step1Props = { + baseClass, + queryId, + selectedTargets: selectedQueryTargets, + targetedHosts, + targetedLabels, + targetedTeams, + targetsTotalCount, + goToQueryEditor, + goToRunQuery: () => setStep(LIVE_QUERY_STEPS[2]), + setSelectedTargets: setSelectedQueryTargets, + setTargetedHosts, + setTargetedLabels, + setTargetedTeams, + setTargetsTotalCount, + }; + + const step2Props = { + queryId, + selectedTargets: selectedQueryTargets, + storedQuery, + setSelectedTargets: setSelectedQueryTargets, + goToQueryEditor, + targetsTotalCount, + }; + + switch (step) { + case LIVE_QUERY_STEPS[2]: + return ; + default: + return ; + } + }; + + return ( + +
{renderScreen()}
+
+ ); +}; + +export default RunQueryPage; diff --git a/frontend/pages/queries/QueryPage/_styles.scss b/frontend/pages/queries/live/LiveQueryPage/_styles.scss similarity index 69% rename from frontend/pages/queries/QueryPage/_styles.scss rename to frontend/pages/queries/live/LiveQueryPage/_styles.scss index 8e1198b7f8..1ec2385d09 100644 --- a/frontend/pages/queries/QueryPage/_styles.scss +++ b/frontend/pages/queries/live/LiveQueryPage/_styles.scss @@ -1,4 +1,4 @@ -.query-page { +.run-query-page { .body-wrap { min-width: 0; } @@ -9,61 +9,6 @@ min-height: 400px; } - &__warning { - padding: $pad-medium; - font-size: $x-small; - color: $core-fleet-black; - background-color: #fff0b9; - border: 1px solid #f2c94c; - border-radius: $border-radius; - - p { - margin: 0; - line-height: 20px; - } - } - - &__observer-query-view { - width: 90%; - max-width: 1060px; - margin: 0 auto; - color: $core-fleet-black; - - h1 { - font-size: $medium; - } - p { - font-size: $x-small; - } - } - - &__observer-query-details { - padding: 0 2rem; - - h1 { - margin: $pad-large 0; - font-size: $large; - } - - p { - margin-bottom: $pad-small; - } - - .sql-button { - color: $core-vibrant-blue; - font-weight: $bold; - font-size: $x-small; - } - } - - &__query-preview { - margin-top: 15px; - - .fleet-ace__label { - display: none; - } - } - .ace_content { min-height: 500px !important; } @@ -172,10 +117,4 @@ font-size: $x-small; } } - - .targets-input { - .input-icon-field__icon { - top: 34px; // Override styling to include label header - } - } } diff --git a/frontend/pages/queries/live/LiveQueryPage/index.ts b/frontend/pages/queries/live/LiveQueryPage/index.ts new file mode 100644 index 0000000000..354c0445d1 --- /dev/null +++ b/frontend/pages/queries/live/LiveQueryPage/index.ts @@ -0,0 +1 @@ +export { default } from "./LiveQueryPage"; diff --git a/frontend/pages/queries/QueryPage/screens/RunQuery.tsx b/frontend/pages/queries/live/screens/RunQuery.tsx similarity index 98% rename from frontend/pages/queries/QueryPage/screens/RunQuery.tsx rename to frontend/pages/queries/live/screens/RunQuery.tsx index dbceec8069..7715899b34 100644 --- a/frontend/pages/queries/QueryPage/screens/RunQuery.tsx +++ b/frontend/pages/queries/live/screens/RunQuery.tsx @@ -15,7 +15,7 @@ import { ICampaign, ICampaignState } from "interfaces/campaign"; import { IQuery } from "interfaces/query"; import { ITarget } from "interfaces/target"; -import QueryResults from "../components/QueryResults"; +import QueryResults from "../../edit/components/QueryResults"; interface IRunQueryProps { storedQuery: IQuery | undefined; diff --git a/frontend/router/index.tsx b/frontend/router/index.tsx index f314b5ecc8..07f1256436 100644 --- a/frontend/router/index.tsx +++ b/frontend/router/index.tsx @@ -36,7 +36,9 @@ import ManagePoliciesPage from "pages/policies/ManagePoliciesPage"; import NoAccessPage from "pages/NoAccessPage"; import PackComposerPage from "pages/packs/PackComposerPage"; import PolicyPage from "pages/policies/PolicyPage"; -import QueryPage from "pages/queries/QueryPage"; +import QueryDetailsPage from "pages/queries/details/QueryDetailsPage"; +import LiveQueryPage from "pages/queries/live/LiveQueryPage"; +import EditQueryPage from "pages/queries/edit/EditQueryPage"; import RegistrationPage from "pages/RegistrationPage"; import ResetPasswordPage from "pages/ResetPasswordPage"; import MDMAppleSSOPage from "pages/MDMAppleSSOPage"; @@ -220,9 +222,13 @@ const routes = ( - + + + + + + - diff --git a/frontend/router/paths.ts b/frontend/router/paths.ts index 834403705e..3190f09c45 100644 --- a/frontend/router/paths.ts +++ b/frontend/router/paths.ts @@ -53,6 +53,16 @@ export default { return `${URL_PREFIX}/labels/${labelId}`; }, EDIT_QUERY: (queryId: number, teamId?: number): string => { + return `${URL_PREFIX}/queries/${queryId}/edit${ + teamId ? `?team_id=${teamId}` : "" + }`; + }, + LIVE_QUERY: (queryId: number, teamId?: number): string => { + return `${URL_PREFIX}/queries/${queryId}/live${ + teamId ? `?team_id=${teamId}` : "" + }`; + }, + QUERY: (queryId: number, teamId?: number): string => { return `${URL_PREFIX}/queries/${queryId}${ teamId ? `?team_id=${teamId}` : "" }`; diff --git a/frontend/services/entities/queries.ts b/frontend/services/entities/queries.ts index 89765f6481..bfad2ff9ed 100644 --- a/frontend/services/entities/queries.ts +++ b/frontend/services/entities/queries.ts @@ -54,10 +54,10 @@ export default { queryId: number | null; selected: ISelectedTargets; }) => { - const { RUN_QUERY } = endpoints; + const { LIVE_QUERY } = endpoints; try { - const { campaign } = await sendRequest("POST", RUN_QUERY, { + const { campaign } = await sendRequest("POST", LIVE_QUERY, { query, query_id: queryId, selected, diff --git a/frontend/utilities/constants.ts b/frontend/utilities/constants.ts index 14530e240b..079e929753 100644 --- a/frontend/utilities/constants.ts +++ b/frontend/utilities/constants.ts @@ -96,12 +96,17 @@ export const MIN_OSQUERY_VERSION_OPTIONS = [ { label: "1.8.1 +", value: "1.8.1" }, ]; -export const QUERIES_PAGE_STEPS = { +export const LIVE_POLICY_STEPS = { 1: "EDITOR", 2: "TARGETS", 3: "RUN", }; +export const LIVE_QUERY_STEPS = { + 1: "TARGETS", + 2: "RUN", +}; + export const DEFAULT_QUERY: ISchedulableQuery = { description: "", name: "", diff --git a/frontend/utilities/endpoints.ts b/frontend/utilities/endpoints.ts index ce6d89b952..e1a3880b99 100644 --- a/frontend/utilities/endpoints.ts +++ b/frontend/utilities/endpoints.ts @@ -82,7 +82,7 @@ export default { PERFORM_REQUIRED_PASSWORD_RESET: `/${API_VERSION}/fleet/perform_required_password_reset`, QUERIES: `/${API_VERSION}/fleet/queries`, RESET_PASSWORD: `/${API_VERSION}/fleet/reset_password`, - RUN_QUERY: `/${API_VERSION}/fleet/queries/run`, + LIVE_QUERY: `/${API_VERSION}/fleet/queries/run`, SCHEDULE_QUERY: `/${API_VERSION}/fleet/packs/schedule`, SCHEDULED_QUERIES: (packId: number): string => { return `/${API_VERSION}/fleet/packs/${packId}/scheduled`; diff --git a/frontend/utilities/helpers.ts b/frontend/utilities/helpers.ts index 641b5cf734..c519b9a263 100644 --- a/frontend/utilities/helpers.ts +++ b/frontend/utilities/helpers.ts @@ -910,6 +910,12 @@ export const getSoftwareBundleTooltipMarkup = (bundle: string) => { `; }; +export const TAGGED_TEMPLATES = { + queryByHostRoute: (hostId: number | undefined | null) => { + return `${hostId ? `?host_ids=${hostId}` : ""}`; + }, +}; + export default { addGravatarUrlToResource, formatConfigDataForServer, @@ -945,4 +951,5 @@ export default { syntaxHighlight, normalizeEmptyValues, wrapFleetHelper, + TAGGED_TEMPLATES, }; From be0ce917c113976f85e9ebae161b165cfb51cc73 Mon Sep 17 00:00:00 2001 From: Jacob Shandling <61553566+jacobshandling@users.noreply.github.com> Date: Wed, 4 Oct 2023 11:31:26 -0700 Subject: [PATCH 02/18] UI - Add global "Disable query reports" setting to advanced org settings (#14268) ## Addresses #13474 Screenshot 2023-10-03 at 11 38 12 AM - small alignment issues with tooltip-wrapped text should be fixed by upcoming TooltipWrapper refactor PR ## Checklist for submitter - [x] Manual QA for all new/changed functionality --------- Co-authored-by: Jacob Shandling --- frontend/__mocks__/configMock.ts | 1 + frontend/interfaces/config.ts | 1 + .../cards/Advanced/Advanced.tsx | 24 ++++++++++++++++++- 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/frontend/__mocks__/configMock.ts b/frontend/__mocks__/configMock.ts index c51d2895d5..9f9e8f638c 100644 --- a/frontend/__mocks__/configMock.ts +++ b/frontend/__mocks__/configMock.ts @@ -12,6 +12,7 @@ const DEFAULT_CONFIG_MOCK: IConfig = { live_query_disabled: false, enable_analytics: true, deferred_save_host: false, + query_reports_disabled: false, }, smtp_settings: { enable_smtp: false, diff --git a/frontend/interfaces/config.ts b/frontend/interfaces/config.ts index c604629c0c..1d42508ee4 100644 --- a/frontend/interfaces/config.ts +++ b/frontend/interfaces/config.ts @@ -196,6 +196,7 @@ export interface IConfig { live_query_disabled: boolean; enable_analytics: boolean; deferred_save_host: boolean; + query_reports_disabled: boolean; }; smtp_settings: { enable_smtp: boolean; diff --git a/frontend/pages/admin/OrgSettingsPage/cards/Advanced/Advanced.tsx b/frontend/pages/admin/OrgSettingsPage/cards/Advanced/Advanced.tsx index 61da4d5b8d..62f6d4d4b5 100644 --- a/frontend/pages/admin/OrgSettingsPage/cards/Advanced/Advanced.tsx +++ b/frontend/pages/admin/OrgSettingsPage/cards/Advanced/Advanced.tsx @@ -18,7 +18,7 @@ const Advanced = ({ handleSubmit, isUpdatingSettings, }: IAppConfigFormProps): JSX.Element => { - const [formData, setFormData] = useState({ + const [formData, setFormData] = useState({ domain: appConfig.smtp_settings.domain || "", verifySSLCerts: appConfig.smtp_settings.verify_ssl_certs || false, enableStartTLS: appConfig.smtp_settings.enable_start_tls, @@ -26,6 +26,8 @@ const Advanced = ({ appConfig.host_expiry_settings.host_expiry_enabled || false, hostExpiryWindow: appConfig.host_expiry_settings.host_expiry_window || 0, disableLiveQuery: appConfig.server_settings.live_query_disabled || false, + disableQueryReports: + appConfig.server_settings.query_reports_disabled || false, }); const { @@ -35,6 +37,7 @@ const Advanced = ({ enableHostExpiry, hostExpiryWindow, disableLiveQuery, + disableQueryReports, } = formData; const [formErrors, setFormErrors] = useState({}); @@ -69,6 +72,7 @@ const Advanced = ({ server_url: appConfig.server_settings.server_url || "", live_query_disabled: disableLiveQuery, enable_analytics: appConfig.server_settings.enable_analytics, + query_reports_disabled: disableQueryReports, }, smtp_settings: { enable_smtp: appConfig.smtp_settings.enable_smtp || false, @@ -172,6 +176,24 @@ const Advanced = ({ > Disable live queries + Disabling query reports will decrease database usage,
\ + but will prevent you from accessing query results in
\ + Fleet and will delete existing reports. This can also be
\ + disabled on a per-query basis by enabling "Discard
\ + data". (Default: Off)

' + } + > + Disable query reports +
From 5ed443590e2bb935e3ee450df034740eff067c92 Mon Sep 17 00:00:00 2001 From: RachelElysia <71795832+RachelElysia@users.noreply.github.com> Date: Wed, 4 Oct 2023 14:35:18 -0700 Subject: [PATCH 03/18] Fleet UI: Surface delete previous results modals (#14257) --- .../QueryDetailsPage/QueryDetailsPage.tsx | 4 +- .../edit/EditQueryPage/EditQueryPage.tsx | 7 ++- .../components/QueryForm/QueryForm.tests.tsx | 2 + .../edit/components/QueryForm/QueryForm.tsx | 47 ++++++++++++++----- .../SaveChangesModal/SaveChangesModal.tsx | 2 +- 5 files changed, 46 insertions(+), 16 deletions(-) diff --git a/frontend/pages/queries/details/QueryDetailsPage/QueryDetailsPage.tsx b/frontend/pages/queries/details/QueryDetailsPage/QueryDetailsPage.tsx index be52bd6ad3..17c46c3ade 100644 --- a/frontend/pages/queries/details/QueryDetailsPage/QueryDetailsPage.tsx +++ b/frontend/pages/queries/details/QueryDetailsPage/QueryDetailsPage.tsx @@ -1,4 +1,4 @@ -import React, { useContext, useEffect } from "react"; +import React, { useContext } from "react"; import { useQuery } from "react-query"; import { InjectedRouter, Params } from "react-router/lib/Router"; import { useErrorHandler } from "react-error-boundary"; @@ -112,7 +112,7 @@ const QueryDetailsPage = ({ ); const isLoading = isStoredQueryLoading; // TODO: Add || isCachedResultsLoading for new API response - const isApiError = storedQueryError || true; // TODO: Add || isCachedResultsError for new API response + const isApiError = storedQueryError || false; // TODO: Add || isCachedResultsError for new API response const renderHeader = () => { const canEditQuery = diff --git a/frontend/pages/queries/edit/EditQueryPage/EditQueryPage.tsx b/frontend/pages/queries/edit/EditQueryPage/EditQueryPage.tsx index e9aa57dce9..59b14feb72 100644 --- a/frontend/pages/queries/edit/EditQueryPage/EditQueryPage.tsx +++ b/frontend/pages/queries/edit/EditQueryPage/EditQueryPage.tsx @@ -100,13 +100,14 @@ const EditQueryPage = ({ const [showOpenSchemaActionText, setShowOpenSchemaActionText] = useState( false ); + const [showSaveChangesModal, setShowSaveChangesModal] = useState(false); // disabled on page load so we can control the number of renders // else it will re-populate the context on occasion const { isLoading: isStoredQueryLoading, data: storedQuery, - error: storedQueryError, + refetch: refetchStoredQuery, } = useQuery( ["query", queryId], () => queryAPI.load(queryId as number), @@ -215,6 +216,7 @@ const EditQueryPage = ({ try { await queryAPI.update(queryId, updatedQuery); renderFlash("success", "Query updated!"); + refetchStoredQuery(); // Required to compare recently saved query to a subsequent save to the query } catch (updateError: any) { console.error(updateError); if (updateError.data.errors[0].reason.includes("Duplicate")) { @@ -228,6 +230,7 @@ const EditQueryPage = ({ } setIsQueryUpdating(false); + setShowSaveChangesModal(false); // Closes conditionally opened modal when discarding previous results return false; }; @@ -304,6 +307,8 @@ const EditQueryPage = ({ isQuerySaving={isQuerySaving} isQueryUpdating={isQueryUpdating} hostId={parseInt(location.query.host_ids as string, 10)} + showSaveChangesModal={showSaveChangesModal} + setShowSaveChangesModal={setShowSaveChangesModal} /> diff --git a/frontend/pages/queries/edit/components/QueryForm/QueryForm.tests.tsx b/frontend/pages/queries/edit/components/QueryForm/QueryForm.tests.tsx index bb5286895f..f3655dd3f9 100644 --- a/frontend/pages/queries/edit/components/QueryForm/QueryForm.tests.tsx +++ b/frontend/pages/queries/edit/components/QueryForm/QueryForm.tests.tsx @@ -72,6 +72,8 @@ describe("QueryForm - component", () => { onOpenSchemaSidebar={jest.fn()} renderLiveQueryWarning={jest.fn()} backendValidators={{}} + showSaveChangesModal={false} + setShowSaveChangesModal={jest.fn()} /> ); diff --git a/frontend/pages/queries/edit/components/QueryForm/QueryForm.tsx b/frontend/pages/queries/edit/components/QueryForm/QueryForm.tsx index a704ee2ef8..f5d269b1d2 100644 --- a/frontend/pages/queries/edit/components/QueryForm/QueryForm.tsx +++ b/frontend/pages/queries/edit/components/QueryForm/QueryForm.tsx @@ -73,6 +73,8 @@ interface IQueryFormProps { renderLiveQueryWarning: () => JSX.Element | null; backendValidators: { [key: string]: string }; hostId?: number; + showSaveChangesModal: boolean; + setShowSaveChangesModal: (bool: boolean) => void; } const validateQuerySQL = (query: string) => { @@ -120,6 +122,8 @@ const QueryForm = ({ renderLiveQueryWarning, backendValidators, hostId, + showSaveChangesModal, + setShowSaveChangesModal, }: IQueryFormProps): JSX.Element => { // Note: The QueryContext values should always be used for any mutable query data such as query name // The storedQuery prop should only be used to access immutable metadata such as author id @@ -158,7 +162,6 @@ const QueryForm = ({ const savedQueryMode = !!queryIdForEdit; const [errors, setErrors] = useState<{ [key: string]: any }>({}); // string | null | undefined or boolean | undefined const [showSaveQueryModal, setShowSaveQueryModal] = useState(false); - const [showSaveChangesModal, setShowSaveChangesModal] = useState(false); // #7766 implementation const [showQueryEditor, setShowQueryEditor] = useState( isObserverPlus || isAnyTeamObserverPlus || false ); @@ -211,7 +214,6 @@ const QueryForm = ({ setShowSaveQueryModal(!showSaveQueryModal); }; - // #7766 implementation const toggleSaveChangesModal = () => { setShowSaveChangesModal(!showSaveChangesModal); }; @@ -411,12 +413,6 @@ const QueryForm = ({ logging: lastEditedQueryLoggingType, }); } - - // #7766 implementation - // savedQueryMode - // ? setShowSaveChangesModal(true) - // : setShowSaveQueryModal(true); - // TODO: onUpdate for saveChangesModal } }; @@ -609,6 +605,26 @@ const QueryForm = ({ const hasSavePermissions = isGlobalAdmin || isGlobalMaintainer; + const hasSqlChange = storedQuery && lastEditedQueryBody !== storedQuery.query; + const hasSnapshotChange = + storedQuery && + lastEditedQueryLoggingType !== "snapshot" && + storedQuery.logging === "snapshot"; + // Use commented out logic when discard data checkbox is implemented #13470 + const hasEnabledDiscardData = false; + // const hasEnabledDiscardData = + // storedQuery && lastEditedDiscardData && !storedQuery.discardData; + + const confirmChanges = (): boolean => { + // Confirm changes if the query has been edited, removed snapshot logging, or enabled discard data + return hasSqlChange || hasSnapshotChange || hasEnabledDiscardData; + }; + + const confirmSqlChange = (): boolean => { + // Confirm sql changes message if sql changed but snapshot and enabling discard data has not + return !!hasSqlChange && !hasSnapshotChange && !hasEnabledDiscardData; + }; + // Global admin, any maintainer, any observer+ on new query const renderEditableQueryForm = () => { // Save disabled for team maintainer/admins viewing global queries @@ -640,7 +656,9 @@ const QueryForm = ({ onLoad={onLoad} wrapperClassName={`${baseClass}__text-editor-wrapper`} onChange={onChangeQuery} - handleSubmit={promptSaveQuery} + handleSubmit={ + confirmChanges() ? toggleSaveChangesModal : promptSaveQuery + } wrapEnabled focus={!savedQueryMode} /> @@ -743,7 +761,11 @@ const QueryForm = ({ + + + + + ); +}; + +export default ConfirmSaveChangesModal; diff --git a/frontend/pages/queries/edit/components/ConfirmSaveChangesModal/index.ts b/frontend/pages/queries/edit/components/ConfirmSaveChangesModal/index.ts new file mode 100644 index 0000000000..c8c31da396 --- /dev/null +++ b/frontend/pages/queries/edit/components/ConfirmSaveChangesModal/index.ts @@ -0,0 +1 @@ +export { default } from "./ConfirmSaveChangesModal"; diff --git a/frontend/pages/queries/edit/components/DiscardDataOption/DiscardDataOption.stories.tsx b/frontend/pages/queries/edit/components/DiscardDataOption/DiscardDataOption.stories.tsx new file mode 100644 index 0000000000..7a3b738ff1 --- /dev/null +++ b/frontend/pages/queries/edit/components/DiscardDataOption/DiscardDataOption.stories.tsx @@ -0,0 +1,14 @@ +import { Meta, StoryObj } from "@storybook/react"; + +import DiscardDataOption from "./DiscardDataOption"; + +const meta: Meta = { + title: "Components/DiscardDataOption", + component: DiscardDataOption, +}; + +export default meta; + +type Story = StoryObj; + +export const Basic: Story = {}; diff --git a/frontend/pages/queries/edit/components/DiscardDataOption/DiscardDataOption.tests.tsx b/frontend/pages/queries/edit/components/DiscardDataOption/DiscardDataOption.tests.tsx new file mode 100644 index 0000000000..011e23d502 --- /dev/null +++ b/frontend/pages/queries/edit/components/DiscardDataOption/DiscardDataOption.tests.tsx @@ -0,0 +1,86 @@ +import React from "react"; + +import { fireEvent, render, screen } from "@testing-library/react"; + +import DiscardDataOption from "./DiscardDataOption"; + +describe("DiscardDataOption component", () => { + const selectedLoggingType = "snapshot"; + const [discardData, setDiscardData] = [false, jest.fn()]; + + it("Renders normal help text when the global option is not disabled", () => { + render( + + ); + + expect(screen.getByText(/Discard data/)).toBeInTheDocument(); + expect(screen.getByText(/Data will still be sent/)).toBeInTheDocument(); + }); + + it('Renders the "disabled" help text with tooltip when the global option is disabled', async () => { + render( + + ); + + expect(screen.getByText(/Discard data/)).toBeInTheDocument(); + expect(screen.getByText(/This setting is ignored/)).toBeInTheDocument(); + + await fireEvent.mouseOver(screen.getByText(/globally disabled/)); + + expect(screen.getByText(/A Fleet administrator/)).toBeInTheDocument(); + }); + + it('Restores normal help text when disabled and then "Edit anyway" is clicked', async () => { + render( + + ); + + // disabled + expect(screen.getByText(/Discard data/)).toBeInTheDocument(); + expect(screen.getByText(/This setting is ignored/)).toBeInTheDocument(); + + // enable + await fireEvent.click(screen.getByText(/Edit anyway/)); + + // normal text + expect(screen.getByText(/Data will still be sent/)).toBeInTheDocument(); + }); + it('Renders the info banner when "Differential" logging option is selected', () => { + render( + + ); + + expect( + screen.getByText( + /setting is ignored when differential logging is enabled. This/ + ) + ).toBeInTheDocument(); + }); + it('Renders the info banner when "Differential (ignore removals)" logging option is selected', () => { + render( + + ); + expect( + screen.getByText( + /setting is ignored when differential logging is enabled. This/ + ) + ).toBeInTheDocument(); + }); +}); diff --git a/frontend/pages/queries/edit/components/DiscardDataOption/DiscardDataOption.tsx b/frontend/pages/queries/edit/components/DiscardDataOption/DiscardDataOption.tsx new file mode 100644 index 0000000000..aef48c79b6 --- /dev/null +++ b/frontend/pages/queries/edit/components/DiscardDataOption/DiscardDataOption.tsx @@ -0,0 +1,103 @@ +import Checkbox from "components/forms/fields/Checkbox"; +import Icon from "components/Icon"; +import InfoBanner from "components/InfoBanner"; +import TooltipWrapper from "components/TooltipWrapper"; +import { QueryLoggingOption } from "interfaces/schedulable_query"; +import React, { useState } from "react"; +import { Link } from "react-router"; + +const baseClass = "discard-data-option"; + +interface IDiscardDataOptionProps { + queryReportsDisabled: boolean; + selectedLoggingType: QueryLoggingOption; + discardData: boolean; + setDiscardData: (value: boolean) => void; + breakHelpText?: boolean; +} + +const DiscardDataOption = ({ + queryReportsDisabled, + selectedLoggingType, + discardData, + setDiscardData, + breakHelpText = false, +}: IDiscardDataOptionProps) => { + const [forceEditDiscardData, setForceEditDiscardData] = useState(false); + const disable = queryReportsDisabled && !forceEditDiscardData; + + const renderHelpText = () => ( +
+ {disable ? ( + <> + This setting is ignored because query reports in Fleet have been{" "} + \ + Organization settings > Advanced options > Disable query reports." + } + position="bottom" + > + {"globally disabled."} + {" "} + { + e.preventDefault(); + setForceEditDiscardData(true); + }} + className={`${baseClass}__edit-anyway`} + > + <> + Edit anyway + + + + + ) : ( + <> + The most recent results for each host will not be available in Fleet. + {breakHelpText ?
: " "} + Data will still be sent to your log destination if + automations + {" "} + are on. + + )} +
+ ); + return ( +
+ {["differential", "differential_ignore_removals"].includes( + selectedLoggingType + ) && ( + + <> + The Discard data setting is ignored when differential logging + is enabled. This
+ query's results will not be saved in Fleet. + +
+ )} + + Discard data + + {renderHelpText()} +
+ ); +}; + +export default DiscardDataOption; diff --git a/frontend/pages/queries/edit/components/DiscardDataOption/_styles.scss b/frontend/pages/queries/edit/components/DiscardDataOption/_styles.scss new file mode 100644 index 0000000000..c938b58069 --- /dev/null +++ b/frontend/pages/queries/edit/components/DiscardDataOption/_styles.scss @@ -0,0 +1,20 @@ +.discard-data-option { + .info-banner { + margin-bottom: 1.5rem; + &__info { + line-height: 21px; + } + } + + &__disabled-discard-data-checkbox { + @include disabled; + } + + &__edit-anyway { + display: inline-flex; + align-items: center; + cursor: pointer; + font-weight: inherit; + font-size: inherit; + } +} diff --git a/frontend/pages/queries/edit/components/DiscardDataOption/index.ts b/frontend/pages/queries/edit/components/DiscardDataOption/index.ts new file mode 100644 index 0000000000..71d3111a3b --- /dev/null +++ b/frontend/pages/queries/edit/components/DiscardDataOption/index.ts @@ -0,0 +1 @@ +export { default } from "./DiscardDataOption"; diff --git a/frontend/pages/queries/edit/components/QueryForm/QueryForm.tests.tsx b/frontend/pages/queries/edit/components/EditQueryForm/EditQueryForm.tests.tsx similarity index 92% rename from frontend/pages/queries/edit/components/QueryForm/QueryForm.tests.tsx rename to frontend/pages/queries/edit/components/EditQueryForm/EditQueryForm.tests.tsx index f3655dd3f9..a203afbf96 100644 --- a/frontend/pages/queries/edit/components/QueryForm/QueryForm.tests.tsx +++ b/frontend/pages/queries/edit/components/EditQueryForm/EditQueryForm.tests.tsx @@ -5,7 +5,7 @@ import { createCustomRenderer } from "test/test-utils"; import createMockQuery from "__mocks__/queryMock"; import createMockUser from "__mocks__/userMock"; -import QueryForm from "./QueryForm"; +import EditQueryForm from "./EditQueryForm"; const mockQuery = createMockQuery(); const mockRouter = { @@ -20,7 +20,7 @@ const mockRouter = { createPath: jest.fn(), }; -describe("QueryForm - component", () => { +describe("EditQueryForm - component", () => { it("disables save button for missing query name", async () => { const render = createCustomRenderer({ context: { @@ -56,7 +56,7 @@ describe("QueryForm - component", () => { }); render( - { onOpenSchemaSidebar={jest.fn()} renderLiveQueryWarning={jest.fn()} backendValidators={{}} - showSaveChangesModal={false} - setShowSaveChangesModal={jest.fn()} + showConfirmSaveChangesModal={false} + setShowConfirmSaveChangesModal={jest.fn()} /> ); diff --git a/frontend/pages/queries/edit/components/QueryForm/QueryForm.tsx b/frontend/pages/queries/edit/components/EditQueryForm/EditQueryForm.tsx similarity index 89% rename from frontend/pages/queries/edit/components/QueryForm/QueryForm.tsx rename to frontend/pages/queries/edit/components/EditQueryForm/EditQueryForm.tsx index 02a8d0d4ac..ce2a027e32 100644 --- a/frontend/pages/queries/edit/components/QueryForm/QueryForm.tsx +++ b/frontend/pages/queries/edit/components/EditQueryForm/EditQueryForm.tsx @@ -34,7 +34,6 @@ import { QueryLoggingOption, } from "interfaces/schedulable_query"; import { SelectedPlatformString } from "interfaces/platform"; -import { IConfig } from "interfaces/config"; import queryAPI from "services/entities/queries"; import { IAceEditor } from "react-ace/lib/types"; @@ -53,11 +52,12 @@ import Spinner from "components/Spinner"; import Icon from "components/Icon/Icon"; import AutoSizeInputField from "components/forms/fields/AutoSizeInputField"; import SaveQueryModal from "../SaveQueryModal"; -import SaveChangesModal from "../SaveChangesModal"; +import ConfirmSaveChangesModal from "../ConfirmSaveChangesModal"; +import DiscardDataOption from "../DiscardDataOption"; -const baseClass = "query-form"; +const baseClass = "edit-query-form"; -interface IQueryFormProps { +interface IEditQueryFormProps { router: InjectedRouter; queryIdForEdit: number | null; apiTeamIdForQuery?: number; @@ -74,10 +74,9 @@ interface IQueryFormProps { renderLiveQueryWarning: () => JSX.Element | null; backendValidators: { [key: string]: string }; hostId?: number; - appConfig?: IConfig; - isLoadingAppConfig?: boolean; - showSaveChangesModal: boolean; - setShowSaveChangesModal: (bool: boolean) => void; + queryReportsDisabled?: boolean; + showConfirmSaveChangesModal: boolean; + setShowConfirmSaveChangesModal: (bool: boolean) => void; } const validateQuerySQL = (query: string) => { @@ -108,7 +107,7 @@ const customFrequencyOptions = (frequency: number) => { return FREQUENCY_DROPDOWN_OPTIONS; }; -const QueryForm = ({ +const EditQueryForm = ({ router, queryIdForEdit, apiTeamIdForQuery, @@ -125,11 +124,10 @@ const QueryForm = ({ renderLiveQueryWarning, backendValidators, hostId, - appConfig, - isLoadingAppConfig, - showSaveChangesModal, - setShowSaveChangesModal, -}: IQueryFormProps): JSX.Element => { + queryReportsDisabled, + showConfirmSaveChangesModal, + setShowConfirmSaveChangesModal, +}: IEditQueryFormProps): JSX.Element => { // Note: The QueryContext values should always be used for any mutable query data such as query name // The storedQuery prop should only be used to access immutable metadata such as author id const { @@ -142,6 +140,7 @@ const QueryForm = ({ lastEditedQueryPlatforms, lastEditedQueryMinOsqueryVersion, lastEditedQueryLoggingType, + lastEditedQueryDiscardData, setLastEditedQueryName, setLastEditedQueryDescription, setLastEditedQueryBody, @@ -150,6 +149,7 @@ const QueryForm = ({ setLastEditedQueryPlatforms, setLastEditedQueryMinOsqueryVersion, setLastEditedQueryLoggingType, + setLastEditedQueryDiscardData, } = useContext(QueryContext); const { @@ -183,9 +183,7 @@ const QueryForm = ({ const { setCompatiblePlatforms } = platformCompatibility; const debounceSQL = useDebouncedCallback((sql: string) => { - let valid = true; - const { valid: isValidated, errors: newErrors } = validateQuerySQL(sql); - valid = isValidated; + const { errors: newErrors } = validateQuerySQL(sql); setErrors({ ...newErrors, @@ -208,19 +206,12 @@ const QueryForm = ({ } }, [lastEditedQueryFrequency, isInitialFrequency]); - const hasTeamMaintainerPermissions = savedQueryMode - ? isAnyTeamMaintainerOrTeamAdmin && - storedQuery && - currentUser && - storedQuery.author_id === currentUser.id - : isAnyTeamMaintainerOrTeamAdmin; - const toggleSaveQueryModal = () => { setShowSaveQueryModal(!showSaveQueryModal); }; - const toggleSaveChangesModal = () => { - setShowSaveChangesModal(!showSaveChangesModal); + const toggleConfirmSaveChangesModal = () => { + setShowConfirmSaveChangesModal(!showConfirmSaveChangesModal); }; const onLoad = (editor: IAceEditor) => { @@ -416,6 +407,7 @@ const QueryForm = ({ platform: lastEditedQueryPlatforms, min_osquery_version: lastEditedQueryMinOsqueryVersion, logging: lastEditedQueryLoggingType, + discard_data: lastEditedQueryDiscardData, }); } } @@ -609,25 +601,27 @@ const QueryForm = ({ const hasSavePermissions = isGlobalAdmin || isGlobalMaintainer; - const hasSqlChange = storedQuery && lastEditedQueryBody !== storedQuery.query; - const hasSnapshotChange = + const currentlySavingQueryResults = storedQuery && - lastEditedQueryLoggingType !== "snapshot" && - storedQuery.logging === "snapshot"; - // Use commented out logic when discard data checkbox is implemented #13470 - const hasEnabledDiscardData = false; - // const hasEnabledDiscardData = - // storedQuery && lastEditedDiscardData && !storedQuery.discardData; + !storedQuery.discard_data && + !["differential", "differential_ignore_removals"].includes( + storedQuery.logging + ); + const changedSQL = storedQuery && lastEditedQueryBody !== storedQuery.query; + const changedLoggingToDifferential = [ + "differential", + "differential_ignore_removals", + ].includes(lastEditedQueryLoggingType); - const confirmChanges = (): boolean => { - // Confirm changes if the query has been edited, removed snapshot logging, or enabled discard data - return hasSqlChange || hasSnapshotChange || hasEnabledDiscardData; - }; + const enabledDiscardData = + storedQuery && lastEditedQueryDiscardData && !storedQuery.discard_data; - const confirmSqlChange = (): boolean => { - // Confirm sql changes message if sql changed but snapshot and enabling discard data has not - return !!hasSqlChange && !hasSnapshotChange && !hasEnabledDiscardData; - }; + const confirmChanges = + currentlySavingQueryResults && + (changedSQL || changedLoggingToDifferential || enabledDiscardData); + + const showChangedSQLCopy = + changedSQL && !changedLoggingToDifferential && !enabledDiscardData; // Global admin, any maintainer, any observer+ on new query const renderEditableQueryForm = () => { @@ -661,7 +655,7 @@ const QueryForm = ({ wrapperClassName={`${baseClass}__text-editor-wrapper`} onChange={onChangeQuery} handleSubmit={ - confirmChanges() ? toggleSaveChangesModal : promptSaveQuery + confirmChanges ? toggleConfirmSaveChangesModal : promptSaveQuery } wrapEnabled focus={!savedQueryMode} @@ -679,10 +673,11 @@ const QueryForm = ({ placeholder={"Every day"} value={lastEditedQueryFrequency} label={"Frequency"} - wrapperClassName={`${baseClass}__form-field ${baseClass}__form-field--frequency`} + wrapperClassName={`${baseClass}__form-field form-field--frequency`} /> - If automations are on, this is how often your query collects - data. +
+ This is how often your query collects data. +
setLastEditedQueryObserverCanRun(value) } - wrapperClassName={`${baseClass}__query-observer-can-run-wrapper`} + wrapperClassName={"observer-can-run-wrapper"} > Observers can run -

+

Users with the observer role will be able to run this query on hosts where they have access. -

+
+
+ By default, your query collects data on all compatible + platforms. +
+ {queryReportsDisabled !== undefined && ( + + )} )} @@ -755,7 +762,7 @@ const QueryForm = ({ Save as new )} -
+
)} - {showSaveChangesModal && ( - )} @@ -854,4 +860,4 @@ const QueryForm = ({ return renderEditableQueryForm(); }; -export default QueryForm; +export default EditQueryForm; diff --git a/frontend/pages/queries/edit/components/QueryForm/_styles.scss b/frontend/pages/queries/edit/components/EditQueryForm/_styles.scss similarity index 88% rename from frontend/pages/queries/edit/components/QueryForm/_styles.scss rename to frontend/pages/queries/edit/components/EditQueryForm/_styles.scss index bc63146bac..f2ee6ab788 100644 --- a/frontend/pages/queries/edit/components/QueryForm/_styles.scss +++ b/frontend/pages/queries/edit/components/EditQueryForm/_styles.scss @@ -1,4 +1,4 @@ -.query-form { +.edit-query-form { &__wrapper { position: relative; font-size: $x-small; @@ -46,7 +46,7 @@ .query-name-wrapper { display: flex; - &:not(.query-form--editing) { + &:not(.edit-query-form--editing) { textarea:hover { cursor: pointer; color: $core-vibrant-blue; @@ -57,7 +57,7 @@ top: 13px; margin-left: 0; } - .query-form__query-name, + .edit-query-form__query-name, .input-sizer::after { font-size: $large; } @@ -70,7 +70,7 @@ .query-description-wrapper { display: flex; padding-top: $pad-small; - &:not(.query-form--editing) { + &:not(.edit-query-form--editing) { textarea:hover { cursor: pointer; color: $core-vibrant-blue; @@ -161,26 +161,6 @@ } } - &__advanced-options { - margin-top: $pad-medium; - } - - &__query-observer-can-run-wrapper { - margin: 0; - margin-top: $pad-large; - font-weight: $bold !important; // override checkbox default - - & + p { - margin: 0; - margin-top: $pad-small; - } - - .fleet-checkbox { - display: flex; - align-items: center; - } - } - &__button-wrap { margin: 0; margin-top: $pad-large; diff --git a/frontend/pages/queries/edit/components/EditQueryForm/index.ts b/frontend/pages/queries/edit/components/EditQueryForm/index.ts new file mode 100644 index 0000000000..a657cb4f46 --- /dev/null +++ b/frontend/pages/queries/edit/components/EditQueryForm/index.ts @@ -0,0 +1 @@ +export { default } from "./EditQueryForm"; diff --git a/frontend/pages/queries/edit/components/QueryForm/index.ts b/frontend/pages/queries/edit/components/QueryForm/index.ts deleted file mode 100644 index 6bc72d25d4..0000000000 --- a/frontend/pages/queries/edit/components/QueryForm/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./QueryForm"; diff --git a/frontend/pages/queries/edit/components/SaveChangesModal/SaveChangesModal.tsx b/frontend/pages/queries/edit/components/SaveChangesModal/SaveChangesModal.tsx deleted file mode 100644 index aa3601e903..0000000000 --- a/frontend/pages/queries/edit/components/SaveChangesModal/SaveChangesModal.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import React from "react"; - -import Button from "components/buttons/Button"; -import Modal from "components/Modal"; -import { ICreateQueryRequestBody } from "interfaces/schedulable_query"; - -const baseClass = "save-changes-modal"; - -export interface ISaveChangesModalProps { - isUpdating: boolean; - onSaveChanges: (evt: React.MouseEvent) => void; - toggleSaveChangesModal: () => void; - sqlUpdated?: boolean; -} - -const SaveChangesModal = ({ - isUpdating, - onSaveChanges, - toggleSaveChangesModal, - sqlUpdated = false, -}: ISaveChangesModalProps): JSX.Element => { - const warningText = () => { - if (sqlUpdated) { - return "Changing this query's SQL will delete its previous results, since the existing report does not reflect the updated query."; - } - return "The changes you are making to this query will delete its previous results."; - }; - - return ( - -
-

{warningText()}

-

You cannot undo this action.

-
- - -
-
-
- ); -}; - -export default SaveChangesModal; diff --git a/frontend/pages/queries/edit/components/SaveChangesModal/index.ts b/frontend/pages/queries/edit/components/SaveChangesModal/index.ts deleted file mode 100644 index 15fa4a05cf..0000000000 --- a/frontend/pages/queries/edit/components/SaveChangesModal/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./SaveChangesModal"; diff --git a/frontend/pages/queries/edit/components/SaveQueryModal/SaveQueryModal.tsx b/frontend/pages/queries/edit/components/SaveQueryModal/SaveQueryModal.tsx index c2e3126448..f7b3a341ee 100644 --- a/frontend/pages/queries/edit/components/SaveQueryModal/SaveQueryModal.tsx +++ b/frontend/pages/queries/edit/components/SaveQueryModal/SaveQueryModal.tsx @@ -23,11 +23,7 @@ import { ISchedulableQuery, QueryLoggingOption, } from "interfaces/schedulable_query"; -import TooltipWrapper from "components/TooltipWrapper"; -import { Link } from "react-router"; -import Icon from "components/Icon"; -import { IConfig } from "interfaces/config"; -import InfoBanner from "components/InfoBanner"; +import DiscardDataOption from "../DiscardDataOption"; const baseClass = "save-query-modal"; export interface ISaveQueryModalProps { @@ -38,8 +34,7 @@ export interface ISaveQueryModalProps { toggleSaveQueryModal: () => void; backendValidators: { [key: string]: string }; existingQuery?: ISchedulableQuery; - appConfig?: IConfig; - isLoadingAppConfig?: boolean; + queryReportsDisabled?: boolean; } const validateQueryName = (name: string) => { @@ -61,8 +56,7 @@ const SaveQueryModal = ({ toggleSaveQueryModal, backendValidators, existingQuery, - appConfig, - isLoadingAppConfig, + queryReportsDisabled, }: ISaveQueryModalProps): JSX.Element => { const [name, setName] = useState(""); const [description, setDescription] = useState(""); @@ -87,15 +81,11 @@ const SaveQueryModal = ({ backendValidators ); const [showAdvancedOptions, setShowAdvancedOptions] = useState(false); - const [forceEditDiscardData, setForceEditDiscardData] = useState(false); const toggleAdvancedOptions = () => { setShowAdvancedOptions(!showAdvancedOptions); }; - const query_reports_disabled = - appConfig?.server_settings?.query_reports_disabled; - useDeepEffect(() => { if (name) { setErrors({}); @@ -154,76 +144,6 @@ const SaveQueryModal = ({ [setSelectedPlatformOptions] ); - const renderDiscardDataOption = () => { - const disable = query_reports_disabled && !forceEditDiscardData; - return ( - <> - {["differential", "differential_ignore_removals"].includes( - selectedLoggingType - ) && ( - - <> - The Discard data setting is ignored when differential - logging is enabled. This
- query's results will not be saved in Fleet. - -
- )} - - Discard data - -
- {disable ? ( - <> - This setting is ignored because query reports in Fleet have been{" "} - \ - Organization settings > Advanced options > Disable query reports." - } - position="bottom" - > - <>globally disabled. - {" "} - { - setForceEditDiscardData(true); - }} - className={`${baseClass}__edit-anyway`} - > - <> - Edit anyway - - - - - ) : ( - <> - The most recent results for each host will not be available in - Fleet. -
- Data will still be sent to your log destination if{" "} - automations are on. - - )} -
- - ); - }; return (
This is how often your query collects data. @@ -269,7 +189,7 @@ const SaveQueryModal = ({ name="observerCanRun" onChange={setObserverCanRun} value={observerCanRun} - wrapperClassName={`${baseClass}__observer-can-run-wrapper`} + wrapperClassName={"observer-can-run-wrapper"} > Observers can run @@ -279,7 +199,7 @@ const SaveQueryModal = ({
By default, your query collects data on all compatible platforms. @@ -315,7 +235,17 @@ const SaveQueryModal = ({ label="Logging" wrapperClassName={`${baseClass}__form-field ${baseClass}__form-field--logging`} /> - {!isLoadingAppConfig && renderDiscardDataOption()} + {queryReportsDisabled !== undefined && ( + + )} )}
diff --git a/frontend/pages/queries/edit/components/SaveQueryModal/_styles.scss b/frontend/pages/queries/edit/components/SaveQueryModal/_styles.scss deleted file mode 100644 index f1df24381f..0000000000 --- a/frontend/pages/queries/edit/components/SaveQueryModal/_styles.scss +++ /dev/null @@ -1,51 +0,0 @@ -.save-query-modal { - .fleet-checkbox { - display: flex; - align-items: center; - } - - .help-text { - margin-top: $pad-small; - margin-bottom: $pad-large; - font-weight: $regular; - font-size: 0.75rem; - color: $ui-fleet-black-75; - } - - &__form-field { - &--frequency { - margin-bottom: 0; - } - &--platform { - margin-bottom: 0; - margin-top: $pad-large; - } - } - - &__observer-can-run-wrapper { - margin-bottom: 0; - } - - &__advanced-options-toggle { - font-weight: $xbold; - } - - .info-banner { - margin-bottom: 1.5rem; - &__info { - line-height: 21px; - } - } - - &__disabled-discard-data-checkbox { - @include disabled; - } - - &__edit-anyway { - display: inline-flex; - align-items: center; - cursor: pointer; - font-weight: inherit; - font-size: inherit; - } -} diff --git a/frontend/pages/queries/edit/EditQueryPage/index.ts b/frontend/pages/queries/edit/index.ts similarity index 100% rename from frontend/pages/queries/edit/EditQueryPage/index.ts rename to frontend/pages/queries/edit/index.ts From a85f399cac364c2708d7a910c67ea9ea71e095c6 Mon Sep 17 00:00:00 2001 From: RachelElysia <71795832+RachelElysia@users.noreply.github.com> Date: Mon, 9 Oct 2023 13:38:34 -0700 Subject: [PATCH 08/18] Fleet UI: Query report (table, buttons, api calls, etc) (#14325) ## Issue Cerra #13472 ## Description - Surface query report on the `/queries/{id}` route - Include table buttons to show query and export query - Include results count - Clientside sorting and filtering for columns - Add mock data to frontend integration mocks and to API mocks for concurrent development - 331 + 351 + 2 = 684 lines of code is just mocking data and not actual changes - If modifying sorting/filter, modify the exported results sorting/filter as well - Last fetched column is sentence cased, sortable by chronological order and not alpha order of the readable string (e.g., "a year ago" should be sorted _after_ "over 1 month ago" if sorted most recent to oldest even though a comes before o in the alphabet) ## Screen recordings (Uses mock data) https://github.com/fleetdm/fleet/assets/71795832/22766f2b-3387-4a95-b505-b530dda582fa https://github.com/fleetdm/fleet/assets/71795832/5c2cd8cc-d00e-4ead-b111-e3b33cb7c955 # Checklist for submitter If some of the following don't apply, delete the relevant line. - TODO for QA: Added/updated E2E tests (consider testing some of the features mentioned in the description) - [x] Manual QA for all new/changed functionality --- frontend/__mocks__/queryReportMock.ts | 331 +++++++++++++++++ frontend/interfaces/query_report.ts | 12 + .../QueryDetailsPage/QueryDetailsPage.tsx | 62 +++- .../QueryDetailsPageConfig.tsx | 13 + .../details/QueryDetailsPage/_styles.scss | 4 + .../CachedDetails/CachedDetails.tsx | 14 - .../details/components/CachedDetails/index.ts | 1 - .../components/NoResults/NoResults.tsx | 19 +- .../components/QueryReport/QueryReport.tsx | 142 +++++++ .../QueryReport/QueryReportTableConfig.tsx | 93 +++++ .../components/QueryReport/_styles.scss | 14 + .../details/components/QueryReport/index.ts | 1 + frontend/services/entities/query_report.ts | 50 +++ .../services/mock_service/mocks/config.ts | 2 + .../services/mock_service/mocks/responses.ts | 351 ++++++++++++++++++ frontend/utilities/generate_csv/index.ts | 6 +- 16 files changed, 1075 insertions(+), 40 deletions(-) create mode 100644 frontend/__mocks__/queryReportMock.ts create mode 100644 frontend/interfaces/query_report.ts create mode 100644 frontend/pages/queries/details/QueryDetailsPage/QueryDetailsPageConfig.tsx delete mode 100644 frontend/pages/queries/details/components/CachedDetails/CachedDetails.tsx delete mode 100644 frontend/pages/queries/details/components/CachedDetails/index.ts create mode 100644 frontend/pages/queries/details/components/QueryReport/QueryReport.tsx create mode 100644 frontend/pages/queries/details/components/QueryReport/QueryReportTableConfig.tsx create mode 100644 frontend/pages/queries/details/components/QueryReport/_styles.scss create mode 100644 frontend/pages/queries/details/components/QueryReport/index.ts create mode 100644 frontend/services/entities/query_report.ts diff --git a/frontend/__mocks__/queryReportMock.ts b/frontend/__mocks__/queryReportMock.ts new file mode 100644 index 0000000000..eb538473d9 --- /dev/null +++ b/frontend/__mocks__/queryReportMock.ts @@ -0,0 +1,331 @@ +import { IQueryReport } from "interfaces/query_report"; + +const DEFAULT_QUERY_REPORT_MOCK: IQueryReport = { + query_id: 31, + results: [ + { + host_id: 1, + host_name: "foo", + last_fetched: "2021-01-19T17:08:31Z", + columns: { + model: "Razer Viper", + vendor: "Razer", + model_id: "0078", + }, + }, + { + host_id: 1, + host_name: "foo", + last_fetched: "2021-01-19T17:08:31Z", + columns: { + model: "USB Keyboard", + vendor: "VIA Labs, Inc.", + }, + }, + { + host_id: 2, + host_name: "bar", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "USB Reciever", + vendor: "Logitech", + }, + }, + { + host_id: 2, + host_name: "bar", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "USB Keyboard", + vendor: "Logitech", + }, + }, + { + host_id: 2, + host_name: "bar", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "YubiKey OTP+FIDO+CCID", + vendor: "Yubico", + }, + }, + { + host_id: 2, + host_name: "bar", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "Lenovo USB Optical Mouse", + vendor: "PixArt", + }, + }, + { + host_id: 2, + host_name: "bar", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "Lenovo Traditional USB Keyboard", + vendor: "Lenovo", + }, + }, + { + host_id: 2, + host_name: "bar", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "Display Audio", + vendor: "Bose", + }, + }, + { + host_id: 2, + host_name: "bar", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "USB-C Digital AV Multiport Adapter", + vendor: "Apple, Inc.", + model_id: "1460", + }, + }, + { + host_id: 2, + host_name: "bar", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "USB Reciever", + vendor: "Logitech", + }, + }, + { + host_id: 2, + host_name: "bar", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "USB-C Digital AV Multiport Adapter", + vendor: "Apple Inc.", + model_id: "1460", + }, + }, + { + host_id: 2, + host_name: "bar", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "USB Reciever", + vendor: "Logitech", + }, + }, + { + host_id: 3, + host_name: "zoo", + last_fetched: "2022-04-09T17:20:00Z", + columns: { + model: "Logitech Webcam C925e", + model_id: "085b", + }, + }, + { + host_id: 3, + host_name: "zoo", + last_fetched: "2022-04-09T17:20:00Z", + columns: { + model: "Display Audio", + vendor: "Apple Inc.", + }, + }, + { + host_id: 3, + host_name: "zoo", + last_fetched: "2022-04-09T17:20:00Z", + columns: { + model: "Ambient Light Sensor", + vendor: "Apple Inc.", + }, + }, + { + host_id: 3, + host_name: "zoo", + last_fetched: "2022-04-09T17:20:00Z", + columns: { + model: "DELL Laser Mouse", + model_id: "4d51", + }, + }, + { + host_id: 7, + host_name: "Rachel's Magnificent Testing Computer of All Computers", + last_fetched: "2023-09-21T19:03:30Z", + columns: { + model: "AppleUSBVHCIBCE Root Hub Simulation", + vendor: "Apple Inc.", + }, + }, + { + host_id: 7, + host_name: "Rachel's Magnificent Testing Computer of All Computers", + last_fetched: "2023-09-21T19:03:30Z", + columns: { + model: "QuickFire Rapid keyboard", + vendor: "CM Storm", + model_id: "0004", + }, + }, + { + host_id: 7, + host_name: "Rachel's Magnificent Testing Computer of All Computers", + last_fetched: "2023-09-21T19:03:30Z", + columns: { + model: "Lenovo USB Optical Mouse", + vendor: "Lenovo", + }, + }, + { + host_id: 7, + host_name: "Rachel's Magnificent Testing Computer of All Computers", + last_fetched: "2023-09-21T19:03:30Z", + columns: { + model: "YubiKey FIDO+CCID", + vendor: "Yubico", + }, + }, + { + host_id: 4, + host_name: "car", + last_fetched: "2023-01-14T12:40:30Z", + columns: { + model: "USB2.0 Hub", + vendor: "Apple Inc.", + }, + }, + { + host_id: 8, + host_name: "apple man", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "FaceTime HD Camera (Display)", + vendor: "Apple Inc.", + model_id: "1112", + }, + }, + { + host_id: 8, + host_name: "apple man", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "Apple Internal Keyboard / Trackpad", + model_id: "027e", + vendor: "Apple Inc.", + }, + }, + { + host_id: 8, + host_name: "apple man", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "Apple Thunderbolt Display", + vendor: "Apple Inc.", + model_id: "9227", + }, + }, + { + host_id: 8, + host_name: "apple man", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "AppleUSBXHCI Root Hub Simulation", + vendor: "Apple Inc.", + model_id: "8007", + }, + }, + { + host_id: 8, + host_name: "apple man", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "Apple T2 Controller", + vendor: "Apple Inc.", + model_id: "8233", + }, + }, + { + host_id: 5, + host_name: "choo", + last_fetched: "2023-09-03T03:40:30Z", + columns: { + model: "4-Port USB 2.0 Hub", + vendor: "Generic", + }, + }, + { + host_id: 5, + host_name: "choo", + last_fetched: "2023-09-03T03:40:30Z", + columns: { + model: "USB 10_100_1000 LAN", + vendor: "Realtek", + }, + }, + { + host_id: 5, + host_name: "choo", + last_fetched: "2023-09-03T03:40:30Z", + columns: { + model: "Display Audio", + vendor: "Apple Inc.", + }, + }, + { + host_id: 5, + host_name: "choo", + last_fetched: "2023-09-03T03:40:30Z", + columns: { + model: "USB Mouse", + vendor: "Razor", + }, + }, + { + host_id: 5, + host_name: "choo", + last_fetched: "2023-09-03T03:40:30Z", + columns: { + model: "USB Audio", + vendor: "Apple, Inc.", + }, + }, + { + host_id: 6, + host_name: "moo", + last_fetched: "2023-09-20T07:02:34Z", + columns: { + model: "Display Audio", + vendor: "Apple Inc.", + }, + }, + { + host_id: 6, + host_name: "moo", + last_fetched: "2023-09-20T07:02:34Z", + columns: { + model: "USB Reciever", + vendor: "Logitech", + }, + }, + { + host_id: 6, + host_name: "moo", + last_fetched: "2023-09-20T07:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + ], +}; + +const createMockQueryReport = ( + overrides?: Partial +): IQueryReport => { + return { ...DEFAULT_QUERY_REPORT_MOCK, ...overrides }; +}; + +export default createMockQueryReport; diff --git a/frontend/interfaces/query_report.ts b/frontend/interfaces/query_report.ts new file mode 100644 index 0000000000..9310fcc4e8 --- /dev/null +++ b/frontend/interfaces/query_report.ts @@ -0,0 +1,12 @@ +export interface IQueryReportResultRow { + host_id: number; + host_name: string; + last_fetched: string; + columns: any; +} + +// Query report +export interface IQueryReport { + query_id: number; + results: IQueryReportResultRow[]; +} diff --git a/frontend/pages/queries/details/QueryDetailsPage/QueryDetailsPage.tsx b/frontend/pages/queries/details/QueryDetailsPage/QueryDetailsPage.tsx index 17c46c3ade..6c52aaeda8 100644 --- a/frontend/pages/queries/details/QueryDetailsPage/QueryDetailsPage.tsx +++ b/frontend/pages/queries/details/QueryDetailsPage/QueryDetailsPage.tsx @@ -1,4 +1,4 @@ -import React, { useContext } from "react"; +import React, { useContext, useState } from "react"; import { useQuery } from "react-query"; import { InjectedRouter, Params } from "react-router/lib/Router"; import { useErrorHandler } from "react-error-boundary"; @@ -12,8 +12,10 @@ import { IGetQueryResponse, ISchedulableQuery, } from "interfaces/schedulable_query"; +import { IQueryReport } from "interfaces/query_report"; import queryAPI from "services/entities/queries"; +import queryReportAPI, { ISortOption } from "services/entities/query_report"; import Spinner from "components/Spinner/Spinner"; import Button from "components/buttons/Button"; @@ -23,15 +25,20 @@ import TooltipWrapper from "components/TooltipWrapper/TooltipWrapper"; import QueryAutomationsStatusIndicator from "pages/queries/ManageQueriesPage/components/QueryAutomationsStatusIndicator/QueryAutomationsStatusIndicator"; import DataError from "components/DataError/DataError"; import LogDestinationIndicator from "components/LogDestinationIndicator/LogDestinationIndicator"; -import CachedDetails from "../components/CachedDetails/CachedDetails"; +import QueryReport from "../components/QueryReport/QueryReport"; import NoResults from "../components/NoResults/NoResults"; +import { + DEFAULT_SORT_HEADER, + DEFAULT_SORT_DIRECTION, +} from "./QueryDetailsPageConfig"; + interface IQueryDetailsPageProps { router: InjectedRouter; // v3 params: Params; location: { pathname: string; - query: { team_id?: string }; + query: { team_id?: string; order_key?: string; order_direction?: string }; search: string; }; } @@ -43,7 +50,20 @@ const QueryDetailsPage = ({ params: { id: paramsQueryId }, location, }: IQueryDetailsPageProps): JSX.Element => { - const queryId = paramsQueryId ? parseInt(paramsQueryId, 10) : null; + const queryId = parseInt(paramsQueryId, 10); + const queryParams = location.query; + + // Functions to avoid race conditions + const initialSortBy: ISortOption[] = (() => { + return [ + { + key: queryParams?.order_key ?? DEFAULT_SORT_HEADER, + direction: queryParams?.order_direction ?? DEFAULT_SORT_DIRECTION, + }, + ]; + })(); + + const [sortBy, setSortBy] = useState(initialSortBy); const { currentTeamName: teamNameForQuery, @@ -91,7 +111,7 @@ const QueryDetailsPage = ({ error: storedQueryError, } = useQuery( ["query", queryId], - () => queryAPI.load(queryId as number), + () => queryAPI.load(queryId), { enabled: !!queryId, refetchOnWindowFocus: false, @@ -111,8 +131,26 @@ const QueryDetailsPage = ({ } ); - const isLoading = isStoredQueryLoading; // TODO: Add || isCachedResultsLoading for new API response - const isApiError = storedQueryError || false; // TODO: Add || isCachedResultsError for new API response + const { + isLoading: isQueryReportLoading, + data: queryReport, + error: queryReportError, + } = useQuery( + [], + () => + queryReportAPI.load({ + sortBy, + id: queryId, + }), + { + enabled: !!queryId, + refetchOnWindowFocus: false, + onError: (error) => handlePageError(error), + } + ); + + const isLoading = isStoredQueryLoading || isQueryReportLoading; + const isApiError = storedQueryError || queryReportError; const renderHeader = () => { const canEditQuery = @@ -172,7 +210,9 @@ const QueryDetailsPage = ({ {!isLoading && !isApiError && (
- + on, data is sent according to a query’s frequency.`} + > Automations: ); } - return ; // TODO: Everything related to new APIs including surfacing errorsOnly + return ; // TODO: Everything related to new APIs including surfacing errorsOnly }; return ( diff --git a/frontend/pages/queries/details/QueryDetailsPage/QueryDetailsPageConfig.tsx b/frontend/pages/queries/details/QueryDetailsPage/QueryDetailsPageConfig.tsx new file mode 100644 index 0000000000..10cc329d00 --- /dev/null +++ b/frontend/pages/queries/details/QueryDetailsPage/QueryDetailsPageConfig.tsx @@ -0,0 +1,13 @@ +// TODO +export const QUERY_DETAILS_PAGE_FILTER_KEYS = ["model", "vendor"] as const; + +// TODO: refactor to use this type as the location.query prop of the page +export type QueryDetailsPageQueryParams = Record< + | "order_key" + | "order_direction" + | typeof QUERY_DETAILS_PAGE_FILTER_KEYS[number], + string +>; + +export const DEFAULT_SORT_HEADER = "host_name"; +export const DEFAULT_SORT_DIRECTION = "asc"; diff --git a/frontend/pages/queries/details/QueryDetailsPage/_styles.scss b/frontend/pages/queries/details/QueryDetailsPage/_styles.scss index 9b0a5a0a25..a48b335706 100644 --- a/frontend/pages/queries/details/QueryDetailsPage/_styles.scss +++ b/frontend/pages/queries/details/QueryDetailsPage/_styles.scss @@ -39,6 +39,10 @@ &__log-destination { display: flex; gap: $pad-small; + + .component__tooltip-wrapper__element { + font-weight: $bold; + } } .empty-table__inner { diff --git a/frontend/pages/queries/details/components/CachedDetails/CachedDetails.tsx b/frontend/pages/queries/details/components/CachedDetails/CachedDetails.tsx deleted file mode 100644 index ab5255e3f0..0000000000 --- a/frontend/pages/queries/details/components/CachedDetails/CachedDetails.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import React from "react"; - -// TODO: This whole section -// interface ICachedDetailsProps { -// -// } - -const baseClass = "cached-details"; - -const CachedDetails = (): JSX.Element => { - return
TODO
; -}; - -export default CachedDetails; diff --git a/frontend/pages/queries/details/components/CachedDetails/index.ts b/frontend/pages/queries/details/components/CachedDetails/index.ts deleted file mode 100644 index b50b73552b..0000000000 --- a/frontend/pages/queries/details/components/CachedDetails/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./CachedDetails"; diff --git a/frontend/pages/queries/details/components/NoResults/NoResults.tsx b/frontend/pages/queries/details/components/NoResults/NoResults.tsx index 7897facb23..1657923339 100644 --- a/frontend/pages/queries/details/components/NoResults/NoResults.tsx +++ b/frontend/pages/queries/details/components/NoResults/NoResults.tsx @@ -14,7 +14,6 @@ interface INoResultsProps { disabledCachingGlobally: boolean; discardDataEnabled: boolean; loggingSnapshot: boolean; - errorsOnly: boolean; } const baseClass = "no-results"; @@ -26,7 +25,6 @@ const NoResults = ({ disabledCachingGlobally, discardDataEnabled, loggingSnapshot, - errorsOnly, }: INoResultsProps): JSX.Element => { // Returns how many seconds it takes to expect a cached update const secondsCheckbackTime = () => { @@ -92,14 +90,15 @@ const NoResults = ({ ); } - if (errorsOnly) { - return ( - <> - This query had trouble collecting data on some hosts. Check out the{" "} - Errors tab to see why. - - ); - } + // No errors will be reported in V1 + // if (errorsOnly) { + // return ( + // <> + // This query had trouble collecting data on some hosts. Check out the{" "} + // Errors tab to see why. + // + // ); + // } return "This query has returned no data so far."; }; diff --git a/frontend/pages/queries/details/components/QueryReport/QueryReport.tsx b/frontend/pages/queries/details/components/QueryReport/QueryReport.tsx new file mode 100644 index 0000000000..4456fe402c --- /dev/null +++ b/frontend/pages/queries/details/components/QueryReport/QueryReport.tsx @@ -0,0 +1,142 @@ +import React, { useState, useContext, useEffect } from "react"; + +import { Row, Column } from "react-table"; +import FileSaver from "file-saver"; +import { QueryContext } from "context/query"; + +import { + generateCSVFilename, + generateCSVQueryResults, +} from "utilities/generate_csv"; +import { IQueryReport, IQueryReportResultRow } from "interfaces/query_report"; + +import Button from "components/buttons/Button"; +import Icon from "components/Icon/Icon"; +import TableContainer from "components/TableContainer"; +import ShowQueryModal from "components/modals/ShowQueryModal"; + +import generateResultsTableHeaders from "./QueryReportTableConfig"; + +interface IQueryReportProps { + queryReport?: IQueryReport; +} + +const baseClass = "query-report"; +const CSV_TITLE = "Query"; + +const tableResults = (results: IQueryReportResultRow[]) => { + return results.map((result: IQueryReportResultRow) => { + const hostInfoColumns = { + host_display_name: result.host_name, + last_fetched: result.last_fetched, + }; + + // hostInfoColumns displays the host metadata that is returned with every query + // result.columns are the variable columns returned by the API that differ per query + return { ...hostInfoColumns, ...result.columns }; + }); +}; + +const QueryReport = ({ queryReport }: IQueryReportProps): JSX.Element => { + const { lastEditedQueryName, lastEditedQueryBody } = useContext(QueryContext); + + const [showQueryModal, setShowQueryModal] = useState(false); + const [filteredResults, setFilteredResults] = useState( + tableResults(queryReport?.results || []) + ); + const [tableHeaders, setTableHeaders] = useState([]); + + useEffect(() => { + if (queryReport && queryReport.results && queryReport.results.length > 0) { + const generatedTableHeaders = generateResultsTableHeaders( + tableResults(queryReport.results) + ); + // Update tableHeaders if new headers are found + if (generatedTableHeaders !== tableHeaders) { + setTableHeaders(generatedTableHeaders); + } + } + }, [queryReport]); // Cannot use tableHeaders as it will cause infinite loop with setTableHeaders + + const onExportQueryResults = (evt: React.MouseEvent) => { + evt.preventDefault(); + FileSaver.saveAs( + generateCSVQueryResults( + filteredResults, + generateCSVFilename( + `${lastEditedQueryName || CSV_TITLE} - Query Report` + ), + tableHeaders + ) + ); + }; + + const onShowQueryModal = () => { + setShowQueryModal(!showQueryModal); + }; + + const renderNoResults = () => { + return

TODO

; + }; + + const renderTableButtons = () => { + return ( +
+ + +
+ ); + }; + + const renderTable = () => { + return ( +
+ renderTableButtons()} + setExportRows={setFilteredResults} + /> +
+ ); + }; + + return ( +
+ {renderTable()} + {showQueryModal && ( + + )} +
+ ); +}; + +export default QueryReport; diff --git a/frontend/pages/queries/details/components/QueryReport/QueryReportTableConfig.tsx b/frontend/pages/queries/details/components/QueryReport/QueryReportTableConfig.tsx new file mode 100644 index 0000000000..1babdb0969 --- /dev/null +++ b/frontend/pages/queries/details/components/QueryReport/QueryReportTableConfig.tsx @@ -0,0 +1,93 @@ +/* eslint-disable react/prop-types */ +// disable this rule as it was throwing an error in Header and Cell component +// definitions for the selection row for some reason when we dont really need it. +import React from "react"; + +import { + CellProps, + Column, + ColumnInstance, + ColumnInterface, + HeaderProps, + TableInstance, +} from "react-table"; + +import DefaultColumnFilter from "components/TableContainer/DataTable/DefaultColumnFilter"; +import HeaderCell from "components/TableContainer/DataTable/HeaderCell/HeaderCell"; + +import { humanHostLastSeen } from "utilities/helpers"; + +type IHeaderProps = HeaderProps & { + column: ColumnInstance & IDataColumn; +}; + +type ICellProps = CellProps; + +interface IDataColumn extends ColumnInterface { + title?: string; + accessor: string; +} + +const _unshiftHostname = (headers: IDataColumn[]) => { + const newHeaders = [...headers]; + const displayNameIndex = headers.findIndex( + (h) => h.id === "host_display_name" + ); + if (displayNameIndex >= 0) { + // remove hostname header from headers + const [displayNameHeader] = newHeaders.splice(displayNameIndex, 1); + // reformat title and insert at start of headers array + newHeaders.unshift({ ...displayNameHeader, title: "Host" }); + } + // TODO: Remove after v5 when host_hostname is removed rom API response. + const hostNameIndex = headers.findIndex((h) => h.id === "host_hostname"); + if (hostNameIndex >= 0) { + newHeaders.splice(hostNameIndex, 1); + } + // end remove + return newHeaders; +}; + +const generateResultsTableHeaders = (results: any[]): Column[] => { + /* Results include an array of objects, each representing a table row + Each key value pair in an object represents a column name and value + To create headers, use JS set to create an array of all unique column names */ + const uniqueColumnNames = Array.from( + results.reduce( + (s, o) => Object.keys(o).reduce((t, k) => t.add(k), s), + new Set() // Set prevents listing duplicate headers + ) + ); + + const headers = uniqueColumnNames.map((key) => { + return { + id: key as string, + title: key as string, + Header: (headerProps: IHeaderProps) => ( + + ), + accessor: key as string, + Cell: (cellProps: ICellProps) => { + // Filters chronologically by date, but UI displays readable last fetched + if (cellProps.column.id === "last_fetched") { + return humanHostLastSeen(cellProps?.cell?.value); + } + return cellProps?.cell?.value || null; + }, + Filter: DefaultColumnFilter, + filterType: "text", + disableSortBy: false, + }; + }); + return _unshiftHostname(headers); +}; + +export default generateResultsTableHeaders; diff --git a/frontend/pages/queries/details/components/QueryReport/_styles.scss b/frontend/pages/queries/details/components/QueryReport/_styles.scss new file mode 100644 index 0000000000..6ca33cb40b --- /dev/null +++ b/frontend/pages/queries/details/components/QueryReport/_styles.scss @@ -0,0 +1,14 @@ +.query-report { + &__wrapper { + margin-top: $pad-large; + + .host_id__header { + width: 95px; // Min width for 6 digits host IDs + } + } + + &__results-cta { + display: flex; + gap: $pad-medium; + } +} diff --git a/frontend/pages/queries/details/components/QueryReport/index.ts b/frontend/pages/queries/details/components/QueryReport/index.ts new file mode 100644 index 0000000000..7e9fe702db --- /dev/null +++ b/frontend/pages/queries/details/components/QueryReport/index.ts @@ -0,0 +1 @@ +export { default } from "./QueryReport"; diff --git a/frontend/services/entities/query_report.ts b/frontend/services/entities/query_report.ts new file mode 100644 index 0000000000..9dbf13834e --- /dev/null +++ b/frontend/services/entities/query_report.ts @@ -0,0 +1,50 @@ +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ +// import sendRequest from "services"; +import endpoints from "utilities/endpoints"; + +import { buildQueryStringFromParams } from "utilities/url"; + +// Mock API requests to be used in developing FE for #7766 in parallel with BE development +import { sendRequest } from "services/mock_service/service/service"; + +export interface ISortOption { + key: string; + direction: string; +} + +export interface ILoadQueryReportOptions { + id: number; + sortBy: ISortOption[]; +} + +const getSortParams = (sortOptions?: ISortOption[]) => { + if (sortOptions === undefined || sortOptions.length === 0) { + return {}; + } + + const sortItem = sortOptions[0]; + return { + order_key: sortItem.key, + order_direction: sortItem.direction, + }; +}; + +export default { + load: ({ id, sortBy }: ILoadQueryReportOptions) => { + const sortParams = getSortParams(sortBy); + + const { QUERIES } = endpoints; + + const queryParams = { + order_key: sortParams.order_key, + order_direction: sortParams.order_direction, + }; + + const queryString = buildQueryStringFromParams(queryParams); + + // const endpoint = `${QUERIES}/${id}/report`; + const endpoint = `${QUERIES}/113/report`; + const path = `${endpoint}?${queryString}`; + return sendRequest("GET", path); + }, +}; diff --git a/frontend/services/mock_service/mocks/config.ts b/frontend/services/mock_service/mocks/config.ts index 50ebcbf1f9..5fd108c994 100644 --- a/frontend/services/mock_service/mocks/config.ts +++ b/frontend/services/mock_service/mocks/config.ts @@ -33,6 +33,8 @@ const REQUEST_RESPONSE_MAPPINGS: IResponses = { "queries/7": RESPONSES.globalQuery6, "queries/8": RESPONSES.teamQuery2, "queries?team_id=13": RESPONSES.teamQueries, + "queries/113/report?order_key=host_name&order_direction=asc": + RESPONSES.queryReport, }, POST: { // request body is ISelectedTargets diff --git a/frontend/services/mock_service/mocks/responses.ts b/frontend/services/mock_service/mocks/responses.ts index d20d275f8f..860259f8c4 100644 --- a/frontend/services/mock_service/mocks/responses.ts +++ b/frontend/services/mock_service/mocks/responses.ts @@ -598,6 +598,356 @@ const teamQueries = { ], }; +const queryReport = { + query_id: 31, + results: [ + { + host_id: 1, + host_name: "foo", + last_fetched: "2021-01-19T17:08:31Z", + columns: { + model: "Razer Viper", + vendor: "Razer", + model_id: "0078", + }, + }, + { + host_id: 1, + host_name: "foo", + last_fetched: "2021-01-19T17:08:31Z", + columns: { + model: "USB Keyboard", + vendor: "VIA Labs, Inc.", + }, + }, + { + host_id: 2, + host_name: "bar", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "USB Reciever", + vendor: "Logitech", + }, + }, + { + host_id: 2, + host_name: "bar", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "USB Keyboard", + vendor: "Logitech", + }, + }, + { + host_id: 2, + host_name: "bar", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "YubiKey OTP+FIDO+CCID", + vendor: "Yubico", + }, + }, + { + host_id: 2, + host_name: "bar", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "Lenovo USB Optical Mouse", + vendor: "PixArt", + }, + }, + { + host_id: 2, + host_name: "bar", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "Lenovo Traditional USB Keyboard", + vendor: "Lenovo", + }, + }, + { + host_id: 2, + host_name: "bar", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "Display Audio", + vendor: "Bose", + }, + }, + { + host_id: 2, + host_name: "bar", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "USB-C Digital AV Multiport Adapter", + vendor: "Apple, Inc.", + model_id: "1460", + }, + }, + { + host_id: 2, + host_name: "bar", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "USB Reciever", + vendor: "Logitech", + }, + }, + { + host_id: 2, + host_name: "bar", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "USB-C Digital AV Multiport Adapter", + vendor: "Apple Inc.", + model_id: "1460", + }, + }, + { + host_id: 2, + host_name: "bar", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "USB Reciever", + vendor: "Logitech", + }, + }, + { + host_id: 3, + host_name: "zoo", + last_fetched: "2022-04-09T17:20:00Z", + columns: { + model: "Logitech Webcam C925e", + model_id: "085b", + }, + }, + { + host_id: 3, + host_name: "zoo", + last_fetched: "2022-04-09T17:20:00Z", + columns: { + model: "Display Audio", + vendor: "Apple Inc.", + }, + }, + { + host_id: 3, + host_name: "zoo", + last_fetched: "2022-04-09T17:20:00Z", + columns: { + model: "Ambient Light Sensor", + vendor: "Apple Inc.", + }, + }, + { + host_id: 3, + host_name: "zoo", + last_fetched: "2022-04-09T17:20:00Z", + columns: { + model: "DELL Laser Mouse", + model_id: "4d51", + }, + }, + { + host_id: 7, + host_name: "Rachel's Magnificent Testing Computer of All Computers", + last_fetched: "2023-09-21T19:03:30Z", + columns: { + model: "AppleUSBVHCIBCE Root Hub Simulation", + vendor: "Apple Inc.", + }, + }, + { + host_id: 7, + host_name: "Rachel's Magnificent Testing Computer of All Computers", + last_fetched: "2023-09-21T19:03:30Z", + columns: { + model: "QuickFire Rapid keyboard", + vendor: "CM Storm", + model_id: "0004", + }, + }, + { + host_id: 7, + host_name: "Rachel's Magnificent Testing Computer of All Computers", + last_fetched: "2023-09-21T19:03:30Z", + columns: { + model: "Lenovo USB Optical Mouse", + vendor: "Lenovo", + }, + }, + { + host_id: 7, + host_name: "Rachel's Magnificent Testing Computer of All Computers", + last_fetched: "2023-09-21T19:03:30Z", + columns: { + model: "YubiKey FIDO+CCID", + vendor: "Yubico", + }, + }, + { + host_id: 4, + host_name: "car", + last_fetched: "2023-01-14T12:40:30Z", + columns: { + model: "USB2.0 Hub", + vendor: "Apple Inc.", + }, + }, + { + host_id: 8, + host_name: "apple man", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "FaceTime HD Camera (Display)", + vendor: "Apple Inc.", + model_id: "1112", + }, + }, + { + host_id: 8, + host_name: "apple man", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "Apple Internal Keyboard / Trackpad", + model_id: "027e", + vendor: "Apple Inc.", + }, + }, + { + host_id: 8, + host_name: "apple man", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "Apple Thunderbolt Display", + vendor: "Apple Inc.", + model_id: "9227", + }, + }, + { + host_id: 8, + host_name: "apple man", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "AppleUSBXHCI Root Hub Simulation", + vendor: "Apple Inc.", + model_id: "8007", + }, + }, + { + host_id: 8, + host_name: "apple man", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "Apple T2 Controller", + vendor: "Apple Inc.", + model_id: "8233", + }, + }, + { + host_id: 5, + host_name: "choo", + last_fetched: "2023-09-03T03:40:30Z", + columns: { + model: "4-Port USB 2.0 Hub", + vendor: "Generic", + }, + }, + { + host_id: 5, + host_name: "choo", + last_fetched: "2023-09-03T03:40:30Z", + columns: { + model: "USB 10_100_1000 LAN", + vendor: "Realtek", + }, + }, + { + host_id: 5, + host_name: "choo", + last_fetched: "2023-09-03T03:40:30Z", + columns: { + model: "Display Audio", + vendor: "Apple Inc.", + }, + }, + { + host_id: 5, + host_name: "choo", + last_fetched: "2023-09-03T03:40:30Z", + columns: { + model: "USB Mouse", + vendor: "Razor", + }, + }, + { + host_id: 5, + host_name: "choo", + last_fetched: "2023-09-03T03:40:30Z", + columns: { + model: "USB Audio", + vendor: "Apple, Inc.", + }, + }, + { + host_id: 6, + host_name: "moo", + last_fetched: "2023-09-20T07:02:34Z", + columns: { + model: "Display Audio", + vendor: "Apple Inc.", + }, + }, + { + host_id: 6, + host_name: "moo", + last_fetched: "2023-09-20T07:02:34Z", + columns: { + model: "USB Reciever", + vendor: "Logitech", + }, + }, + { + host_id: 6, + host_name: "moo", + last_fetched: "2023-09-20T07:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "Display Audio", + vendor: "Apple Inc.", + }, + }, + { + host_id: 9, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "USB Reciever", + vendor: "Logitech", + }, + }, + { + host_id: 9, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + ], +}; + const globalQuery1 = { query: globalQueries.queries[0] }; const globalQuery2 = { query: globalQueries.queries[1] }; const globalQuery3 = { query: globalQueries.queries[2] }; @@ -611,6 +961,7 @@ export default { count, hosts, labels, + queryReport, globalQueries, globalQuery1, globalQuery2, diff --git a/frontend/utilities/generate_csv/index.ts b/frontend/utilities/generate_csv/index.ts index 8ee514ef93..501441a887 100644 --- a/frontend/utilities/generate_csv/index.ts +++ b/frontend/utilities/generate_csv/index.ts @@ -14,7 +14,7 @@ export const generateCSVFilename = (descriptor: string) => { return `${descriptor} (${format(new Date(), "MM-dd-yy hh-mm-ss")}).csv`; }; -// Query results and query errors +// Live query results, live query errors, and query report export const generateCSVQueryResults = ( rows: Row[], filename: string, @@ -35,7 +35,7 @@ export const generateCSVQueryResults = ( ); }; -// Policy results only +// Live policy results only export const generateCSVPolicyResults = ( rows: { host: string; status: string }[], filename: string @@ -45,7 +45,7 @@ export const generateCSVPolicyResults = ( }); }; -// Policy errors only +// Live policy errors only export const generateCSVPolicyErrors = ( rows: ICampaignError[], filename: string From eb327faabfe31ac74c48a9b126e7577f7a39e029 Mon Sep 17 00:00:00 2001 From: Jacob Shandling <61553566+jacobshandling@users.noreply.github.com> Date: Mon, 9 Oct 2023 14:28:35 -0700 Subject: [PATCH 09/18] maintenance merge of `main` into feature branch (#14393) maintenance merge of `main` into feature branch --- .github/workflows/build-orbit.yaml | 6 + CHANGELOG.md | 6 + CODEOWNERS | 1 - articles/fleet-4.37.0.md | 5 +- ...troducing-cross-platform-script-execution} | 0 changes/12927-disk-encryption-settings | 1 + changes/12932-bitlocker-api-updates | 4 + changes/12933-bitlocker-host-details-api | 1 + changes/bug-13894-failing-policies-styling | 1 + ...953-changes-to-controls-page-for-bitlocker | 1 + changes/issue-13954-orbit-disk-encryption-key | 1 + ...e-14007-support-get-windows-encryption-key | 1 + charts/fleet/Chart.yaml | 2 +- charts/fleet/values.yaml | 2 +- cmd/fleet/cron.go | 3 +- cmd/fleetctl/apply_test.go | 16 +- cmd/fleetctl/get.go | 17 +- cmd/fleetctl/get_test.go | 13 +- .../expectedGetConfigAppConfigJson.json | 4 +- .../expectedGetConfigAppConfigYaml.yml | 2 +- ...ectedGetConfigIncludeServerConfigJson.json | 4 +- ...pectedGetConfigIncludeServerConfigYaml.yml | 2 +- .../testdata/expectedGetTeamsJson.json | 8 +- .../testdata/expectedGetTeamsYaml.yml | 4 +- .../macosSetupExpectedAppConfigEmpty.yml | 2 +- .../macosSetupExpectedAppConfigSet.yml | 2 +- .../macosSetupExpectedTeam1And2Empty.yml | 4 +- .../macosSetupExpectedTeam1And2Set.yml | 4 +- .../testdata/macosSetupExpectedTeam1Empty.yml | 2 +- .../configuration-files/README.md | 4 +- docs/Contributing/API-for-contributors.md | 44 +- docs/Contributing/FAQ.md | 1 + docs/Get started/anatomy.md | 2 +- docs/REST API/rest-api.md | 90 +-- docs/Using Fleet/CIS-Benchmarks.md | 120 +--- docs/Using Fleet/manage-access.md | 2 +- ee/server/service/mdm.go | 56 +- ee/server/service/teams.go | 58 +- frontend/__mocks__/configMock.ts | 1 + frontend/__mocks__/hostMock.ts | 13 +- frontend/__mocks__/mdmMock.ts | 5 + frontend/components/InfoBanner/InfoBanner.tsx | 10 + frontend/components/InfoBanner/_styles.scss | 18 + .../StatusIndicatorWithIcon.tsx | 10 +- .../StatusIndicatorWithIcon/_styles.scss | 7 + frontend/interfaces/config.ts | 90 +-- frontend/interfaces/host.ts | 24 +- frontend/interfaces/mdm.ts | 24 +- frontend/interfaces/team.ts | 1 + .../pages/LoginSuccessfulPage/_styles.scss | 2 +- .../AggregateMacSettingsIndicators.tsx | 99 --- .../AggregateMacSettingsIndicators/index.ts | 1 - .../OSSettings/OSSettings.tsx | 15 +- .../ProfileStatusAggregate.tsx | 95 +++ .../ProfileStatusAggregateOptions.ts | 43 ++ .../_styles.scss | 14 +- .../ProfileStatusAggregate/index.ts | 1 + .../cards/DiskEncryption/DiskEncryption.tsx | 21 +- .../cards/DiskEncryption/_styles.scss | 1 + .../DiskEncryptionTable.tsx | 29 +- .../DiskEncryptionTableConfig.tsx | 111 +++- .../DiskEncryptionTable/_styles.scss | 3 - .../TurnOnMdmMessage/TurnOnMdmMessage.tsx | 2 +- .../hosts/ManageHostsPage/HostsPageConfig.tsx | 3 +- .../hosts/ManageHostsPage/ManageHostsPage.tsx | 13 +- .../CustomLabelGroupHeading/_styles.scss | 4 +- .../DiskEncryptionStatusFilter.tsx | 6 +- .../components/FilterPill/_styles.scss | 2 +- .../HostsFilterBlock/HostsFilterBlock.tsx | 17 +- .../details/DeviceUserPage/DeviceUserPage.tsx | 2 + .../HostDetailsPage/HostDetailsPage.tsx | 18 +- .../DiskEncryptionKeyModal.tsx | 38 +- .../modals/OSPolicyModal/_styles.scss | 2 +- .../details/MacSettingsIndicator/index.ts | 1 - .../MacSettingsModal/MacSettingsModal.tsx | 20 +- .../MacSettingStatusCell.tsx | 70 +- .../MacSettingsTableConfig.tsx | 40 +- .../ProfileStatusIndicator.tests.tsx} | 13 +- .../ProfileStatusIndicator.tsx} | 10 +- .../_styles.scss | 2 +- .../details/ProfileStatusIndicator/index.ts | 1 + .../details/cards/HostSummary/HostSummary.tsx | 44 +- .../MacSettingsIndicator.tsx | 16 +- .../PolicyFailingCount/PolicyFailingCount.tsx | 7 +- .../PolicyFailingCount/_styles.scss | 23 +- .../hosts/details/cards/Policies/_styles.scss | 4 - .../SoftwareVulnCount/SoftwareVulnCount.tsx | 11 +- .../Software/SoftwareVulnCount/_styles.scss | 22 +- .../hosts/details/cards/Software/_styles.scss | 3 + frontend/pages/hosts/details/helpers.ts | 33 + .../policies/ManagePoliciesPage/_styles.scss | 2 +- .../components/AddPolicyModal/_styles.scss | 2 +- .../ManageAutomationsModal/_styles.scss | 2 +- .../pages/policies/PolicyPage/_styles.scss | 2 +- .../PolicyQueriesErrorsTable/_styles.scss | 2 +- .../PolicyQueriesTable/_styles.scss | 2 +- .../ManageAutomationsModal/_styles.scss | 2 +- .../ManageAutomationsModal/_styles.scss | 2 +- frontend/services/entities/host_count.ts | 4 +- frontend/services/entities/hosts.ts | 11 +- frontend/services/entities/mdm.ts | 91 ++- frontend/styles/var/_global.scss | 1 + frontend/utilities/endpoints.ts | 2 +- frontend/utilities/url/index.ts | 12 +- go.mod | 1 + go.sum | 2 + handbook/business-operations/README.md | 1 + handbook/ceo.md | 2 +- handbook/company/open-positions.yml | 19 + handbook/company/pricing-features-table.yml | 321 ++++++---- handbook/company/why-this-way.md | 2 +- handbook/engineering/Load-testing.md | 2 +- handbook/engineering/README.md | 11 + handbook/engineering/scaling-fleet.md | 2 +- handbook/marketing/README.md | 31 +- handbook/marketing/marketing.rituals.yml | 21 + handbook/product/README.md | 4 +- .../dogfood/terraform/aws/variables.tf | 2 +- .../dogfood/terraform/gcp/variables.tf | 2 +- .../loadtesting/terraform/readme.md | 2 +- .../sandbox/JITProvisioner/jitprovisioner.tf | 2 +- .../lambda/deploy_terraform/main.tf | 2 +- .../changes/12842-orbit-bitlocker-management | 1 + orbit/cmd/orbit/orbit.go | 2 + orbit/pkg/bitlocker/bitlocker_management.go | 17 + .../bitlocker_management_notwindows.go | 19 + .../bitlocker/bitlocker_management_windows.go | 573 +++++++++++++++++ orbit/pkg/update/execwinapi_stub.go | 4 + orbit/pkg/update/execwinapi_windows.go | 14 + orbit/pkg/update/notifications.go | 117 ++++ orbit/pkg/update/notifications_test.go | 64 ++ pkg/optjson/optjson.go | 39 ++ pkg/optjson/optjson_test.go | 81 +++ pkg/rawjson/rawjson.go | 55 ++ pkg/rawjson/rawjson_test.go | 104 +++ server/datastore/mysql/app_configs.go | 15 + server/datastore/mysql/app_configs_test.go | 48 +- server/datastore/mysql/apple_mdm.go | 26 +- server/datastore/mysql/apple_mdm_test.go | 259 ++++---- server/datastore/mysql/hosts.go | 208 +++++- server/datastore/mysql/hosts_test.go | 201 ++++-- server/datastore/mysql/labels.go | 26 +- server/datastore/mysql/labels_test.go | 116 +++- server/datastore/mysql/microsoft_mdm.go | 234 ++++++- server/datastore/mysql/microsoft_mdm_test.go | 389 +++++++++++ ...0230918221115_MoveDiskEncryptionSetting.go | 32 + ...18221115_MoveDiskEncryptionSetting_test.go | 67 ++ server/datastore/mysql/schema.sql | 6 +- server/fleet/app.go | 116 +++- server/fleet/app_test.go | 152 +++++ server/fleet/datastore.go | 22 +- server/fleet/hosts.go | 70 +- server/fleet/hosts_test.go | 51 ++ server/fleet/mdm.go | 13 + server/fleet/orbit.go | 10 + server/fleet/service.go | 16 + server/fleet/teams.go | 20 +- server/fleet/windows_mdm.go | 16 + server/mdm/apple/cert.go | 17 - server/mdm/mdm.go | 25 + .../mdm/{apple/cert_test.go => mdm_test.go} | 2 +- server/mdm/microsoft/microsoft_mdm.go | 17 +- server/mock/datastore_mock.go | 62 +- server/service/appconfig.go | 78 ++- server/service/appconfig_test.go | 25 +- server/service/apple_mdm.go | 8 +- server/service/apple_mdm_test.go | 8 +- server/service/handler.go | 13 +- server/service/hosts.go | 87 ++- server/service/hosts_test.go | 170 ++++- server/service/integration_core_test.go | 23 +- server/service/integration_enterprise_test.go | 8 +- server/service/integration_mdm_test.go | 604 ++++++++++++++++-- server/service/mdm.go | 58 ++ server/service/mdm_test.go | 215 ++++++- server/service/microsoft_mdm.go | 32 +- .../middleware/mdmconfigured/mdmconfigured.go | 12 + .../mdmconfigured/mdmconfigured_test.go | 54 ++ server/service/orbit.go | 93 +++ server/service/orbit_client.go | 15 + server/service/osquery_utils/queries.go | 8 +- server/service/osquery_utils/queries_test.go | 2 +- server/service/testing_utils.go | 4 +- server/service/transport.go | 32 +- terraform/byo-vpc/byo-db/byo-ecs/variables.tf | 2 +- terraform/byo-vpc/byo-db/variables.tf | 2 +- terraform/byo-vpc/example/main.tf | 2 +- terraform/byo-vpc/variables.tf | 2 +- terraform/variables.tf | 2 +- tools/fleetctl-npm/package.json | 2 +- website/api/controllers/view-integrations.js | 27 + .../webhooks/receive-from-stripe.js | 12 +- ...ice-management-transparency-438x373@2x.png | Bin 0 -> 154237 bytes .../icon-checkmark-circle-green-16x16@2x.png | Bin 0 -> 797 bytes website/assets/images/icon-idp-22x28@2x.png | Bin 0 -> 1121 bytes .../assets/images/icon-rest-api-35x28@2x.png | Bin 0 -> 1394 bytes .../assets/images/icon-webhooks-30x28@2x.png | Bin 0 -> 2404 bytes .../logo-active-directory-169x28@2x.png | Bin 0 -> 5327 bytes .../assets/images/logo-ansible-147x28@2x.png | Bin 0 -> 3435 bytes website/assets/images/logo-aws-46x28@2x.png | Bin 0 -> 2898 bytes .../assets/images/logo-azure-169x28@2x.png | Bin 0 -> 7166 bytes website/assets/images/logo-chef-169x28@2x.png | Bin 0 -> 6354 bytes .../assets/images/logo-deloitte-166x36@2x.png | Bin 0 -> 3992 bytes .../assets/images/logo-elastic-82x28@2x.png | Bin 0 -> 3751 bytes .../assets/images/logo-github-89x28@2x.png | Bin 0 -> 2011 bytes .../assets/images/logo-gitlab-124x28@2x.png | Bin 0 -> 4117 bytes .../logo-google-chronicle-128x28@2x.png | Bin 0 -> 5801 bytes .../images/logo-google-cloud-174x28@2x.png | Bin 0 -> 23926 bytes website/assets/images/logo-jira-185x28@2x.png | Bin 0 -> 5649 bytes .../assets/images/logo-munki-101x28@2x.png | Bin 0 -> 4003 bytes website/assets/images/logo-okta-85x28@2x.png | Bin 0 -> 3936 bytes .../assets/images/logo-puppet-79x28@2x.png | Bin 0 -> 2025 bytes .../images/logo-snowflake-color-117x28@2x.png | Bin 0 -> 4934 bytes .../assets/images/logo-splunk-95x28@2x.png | Bin 0 -> 2707 bytes website/assets/images/logo-tines-90x28@2x.png | Bin 0 -> 2119 bytes .../components/scrollable-tweets.component.js | 25 +- .../assets/js/pages/entrance/signup.page.js | 3 +- website/assets/js/pages/integrations.page.js | 25 + website/assets/js/pages/pricing.page.js | 2 +- website/assets/styles/importer.less | 1 + website/assets/styles/layout.less | 15 +- website/assets/styles/pages/compliance.less | 1 + .../styles/pages/customers/new-license.less | 3 + website/assets/styles/pages/fleet-mdm.less | 113 +++- .../styles/pages/handbook/basic-handbook.less | 44 ++ website/assets/styles/pages/homepage.less | 1 + website/assets/styles/pages/integrations.less | 166 +++++ .../styles/pages/osquery-management.less | 1 + website/assets/styles/pages/pricing.less | 84 ++- .../pages/vulnerability-management.less | 1 + website/config/policies.js | 1 + website/config/routes.js | 9 + website/scripts/build-static-content.js | 28 +- website/views/emails/email-mdm-video.ejs | 2 +- website/views/layouts/layout-customer.ejs | 39 +- website/views/layouts/layout-landing.ejs | 348 ---------- website/views/layouts/layout-sandbox.ejs | 238 +++---- website/views/layouts/layout.ejs | 238 +++---- website/views/pages/customers/new-license.ejs | 4 +- website/views/pages/entrance/signup.ejs | 2 +- website/views/pages/fleet-mdm.ejs | 31 +- website/views/pages/integrations.ejs | 303 +++++++++ website/views/pages/pricing.ejs | 124 +--- website/views/pages/try-fleet/register.ejs | 2 +- .../views/pages/try-fleet/sandbox-login.ejs | 2 +- 245 files changed, 6876 insertions(+), 2033 deletions(-) rename articles/{introducing-cross-platform-script-execution.md => introducing-cross-platform-script-execution} (100%) create mode 100644 changes/12927-disk-encryption-settings create mode 100644 changes/12932-bitlocker-api-updates create mode 100644 changes/12933-bitlocker-host-details-api create mode 100644 changes/bug-13894-failing-policies-styling create mode 100644 changes/issue-13953-changes-to-controls-page-for-bitlocker create mode 100644 changes/issue-13954-orbit-disk-encryption-key create mode 100644 changes/issue-14007-support-get-windows-encryption-key delete mode 100644 frontend/pages/ManageControlsPage/OSSettings/AggregateMacSettingsIndicators/AggregateMacSettingsIndicators.tsx delete mode 100644 frontend/pages/ManageControlsPage/OSSettings/AggregateMacSettingsIndicators/index.ts create mode 100644 frontend/pages/ManageControlsPage/OSSettings/ProfileStatusAggregate/ProfileStatusAggregate.tsx create mode 100644 frontend/pages/ManageControlsPage/OSSettings/ProfileStatusAggregate/ProfileStatusAggregateOptions.ts rename frontend/pages/ManageControlsPage/OSSettings/{AggregateMacSettingsIndicators => ProfileStatusAggregate}/_styles.scss (73%) create mode 100644 frontend/pages/ManageControlsPage/OSSettings/ProfileStatusAggregate/index.ts delete mode 100644 frontend/pages/hosts/details/MacSettingsIndicator/index.ts rename frontend/pages/hosts/details/{MacSettingsIndicator/MacSettingsIndicator.tests.tsx => ProfileStatusIndicator/ProfileStatusIndicator.tests.tsx} (87%) rename frontend/pages/hosts/details/{MacSettingsIndicator/MacSettingsIndicator.tsx => ProfileStatusIndicator/ProfileStatusIndicator.tsx} (92%) rename frontend/pages/hosts/details/{MacSettingsIndicator => ProfileStatusIndicator}/_styles.scss (88%) create mode 100644 frontend/pages/hosts/details/ProfileStatusIndicator/index.ts create mode 100644 frontend/pages/hosts/details/helpers.ts create mode 100644 orbit/changes/12842-orbit-bitlocker-management create mode 100644 orbit/pkg/bitlocker/bitlocker_management.go create mode 100644 orbit/pkg/bitlocker/bitlocker_management_notwindows.go create mode 100644 orbit/pkg/bitlocker/bitlocker_management_windows.go create mode 100644 pkg/rawjson/rawjson.go create mode 100644 pkg/rawjson/rawjson_test.go create mode 100644 server/datastore/mysql/migrations/tables/20230918221115_MoveDiskEncryptionSetting.go create mode 100644 server/datastore/mysql/migrations/tables/20230918221115_MoveDiskEncryptionSetting_test.go create mode 100644 server/fleet/windows_mdm.go create mode 100644 server/mdm/mdm.go rename server/mdm/{apple/cert_test.go => mdm_test.go} (99%) create mode 100644 website/api/controllers/view-integrations.js create mode 100644 website/assets/images/device-management-transparency-438x373@2x.png create mode 100644 website/assets/images/icon-checkmark-circle-green-16x16@2x.png create mode 100644 website/assets/images/icon-idp-22x28@2x.png create mode 100644 website/assets/images/icon-rest-api-35x28@2x.png create mode 100644 website/assets/images/icon-webhooks-30x28@2x.png create mode 100644 website/assets/images/logo-active-directory-169x28@2x.png create mode 100644 website/assets/images/logo-ansible-147x28@2x.png create mode 100644 website/assets/images/logo-aws-46x28@2x.png create mode 100644 website/assets/images/logo-azure-169x28@2x.png create mode 100644 website/assets/images/logo-chef-169x28@2x.png create mode 100644 website/assets/images/logo-deloitte-166x36@2x.png create mode 100644 website/assets/images/logo-elastic-82x28@2x.png create mode 100644 website/assets/images/logo-github-89x28@2x.png create mode 100644 website/assets/images/logo-gitlab-124x28@2x.png create mode 100644 website/assets/images/logo-google-chronicle-128x28@2x.png create mode 100644 website/assets/images/logo-google-cloud-174x28@2x.png create mode 100644 website/assets/images/logo-jira-185x28@2x.png create mode 100644 website/assets/images/logo-munki-101x28@2x.png create mode 100644 website/assets/images/logo-okta-85x28@2x.png create mode 100644 website/assets/images/logo-puppet-79x28@2x.png create mode 100644 website/assets/images/logo-snowflake-color-117x28@2x.png create mode 100644 website/assets/images/logo-splunk-95x28@2x.png create mode 100644 website/assets/images/logo-tines-90x28@2x.png create mode 100644 website/assets/js/pages/integrations.page.js create mode 100644 website/assets/styles/pages/integrations.less delete mode 100644 website/views/layouts/layout-landing.ejs create mode 100644 website/views/pages/integrations.ejs diff --git a/.github/workflows/build-orbit.yaml b/.github/workflows/build-orbit.yaml index 41ec1816c1..229b6612b7 100644 --- a/.github/workflows/build-orbit.yaml +++ b/.github/workflows/build-orbit.yaml @@ -2,9 +2,15 @@ name: Build, Sign and Notarize Orbit for macOS on: workflow_dispatch: # allow manual action + push: + paths: + # The workflow can be triggered by modifying ORBIT_VERSION env. + - '.github/workflows/build-orbit.yaml' pull_request: paths: - 'orbit/**.go' + # The workflow can be triggered by modifying ORBIT_VERSION env. + - '.github/workflows/build-orbit.yaml' env: ORBIT_VERSION: 1.17.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index 56c28a4c98..c18bd7fe75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## Fleet 4.38.1 (Oct 5, 2023) + +### Bug Fixes + +* Fixed a bug that would cause live queries to stall if a detail query override was set for a team. + ## Fleet 4.38.0 (Sep 25, 2023) ### Changes diff --git a/CODEOWNERS b/CODEOWNERS index 4a7bc630f4..b952d8777f 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -72,7 +72,6 @@ go.mod @fleetdm/go # # (see website/config/custom.js for DRIs of other paths not listed here) ############################################################################################## -/website/views/pages/pricing.ejs @mikermcneil # « CEO is DRI for pricing /handbook/company/pricing-features-table.yml @mikermcneil # « CEO is current DRI for features table ############################################################################################## diff --git a/articles/fleet-4.37.0.md b/articles/fleet-4.37.0.md index 0f4cf5ed80..f3a2c7c37b 100644 --- a/articles/fleet-4.37.0.md +++ b/articles/fleet-4.37.0.md @@ -1,4 +1,4 @@ -# Fleet 4.37.0 | Remote script execution & Puppet support. +# Fleet 4.37.0 | Puppet support. ![Fleet 4.37.0](../website/assets/images/articles/fleet-4.37.0-1600x900@2x.png) @@ -13,11 +13,12 @@ For upgrade instructions, see our [upgrade guide](https://fleetdm.com/docs/deplo * Puppet support * Web user interface improvements + ### Vulnerability dashboard diff --git a/articles/introducing-cross-platform-script-execution.md b/articles/introducing-cross-platform-script-execution similarity index 100% rename from articles/introducing-cross-platform-script-execution.md rename to articles/introducing-cross-platform-script-execution diff --git a/changes/12927-disk-encryption-settings b/changes/12927-disk-encryption-settings new file mode 100644 index 0000000000..a9464b7d5b --- /dev/null +++ b/changes/12927-disk-encryption-settings @@ -0,0 +1 @@ +* Deprecate `mdm.macos_settings.enable_disk_encryption` in favor of `mdm.enable_disk_encryption` diff --git a/changes/12932-bitlocker-api-updates b/changes/12932-bitlocker-api-updates new file mode 100644 index 0000000000..0ce9b45e8a --- /dev/null +++ b/changes/12932-bitlocker-api-updates @@ -0,0 +1,4 @@ +- Added `GET /mdm/disk_encryption/summary` endpoint to get the disk encryption summary for macOS and + Windows devices. +- Added `os_settings` and `os_settings_disk_encryption` filters to `GET /hosts`, `GET /hosts/count`, + `GET /api/v1/fleet/labels/{id}/hosts` endpoints to filter hosts by OS settings. diff --git a/changes/12933-bitlocker-host-details-api b/changes/12933-bitlocker-host-details-api new file mode 100644 index 0000000000..ccb11df8b7 --- /dev/null +++ b/changes/12933-bitlocker-host-details-api @@ -0,0 +1 @@ +- Added `mdm.os_settings` to `GET /api/v1/hosts/{id}` response. diff --git a/changes/bug-13894-failing-policies-styling b/changes/bug-13894-failing-policies-styling new file mode 100644 index 0000000000..5bd83a7b09 --- /dev/null +++ b/changes/bug-13894-failing-policies-styling @@ -0,0 +1 @@ +* Fix styling for host details/device user failing policies call out \ No newline at end of file diff --git a/changes/issue-13953-changes-to-controls-page-for-bitlocker b/changes/issue-13953-changes-to-controls-page-for-bitlocker new file mode 100644 index 0000000000..728d93122e --- /dev/null +++ b/changes/issue-13953-changes-to-controls-page-for-bitlocker @@ -0,0 +1 @@ +- change Controls/Disk Encryption and host details page to include windows bitlocker information. diff --git a/changes/issue-13954-orbit-disk-encryption-key b/changes/issue-13954-orbit-disk-encryption-key new file mode 100644 index 0000000000..82767942ec --- /dev/null +++ b/changes/issue-13954-orbit-disk-encryption-key @@ -0,0 +1 @@ +* Added the `POST /api/fleet/orbit/disk_encryption_key` endpoint for Windows hosts to report the bitlocker encryption key. diff --git a/changes/issue-14007-support-get-windows-encryption-key b/changes/issue-14007-support-get-windows-encryption-key new file mode 100644 index 0000000000..0705f8e974 --- /dev/null +++ b/changes/issue-14007-support-get-windows-encryption-key @@ -0,0 +1 @@ +* Added support to return the decrypted disk encryption key of a Windows host. diff --git a/charts/fleet/Chart.yaml b/charts/fleet/Chart.yaml index e9d974fbcb..655c606998 100644 --- a/charts/fleet/Chart.yaml +++ b/charts/fleet/Chart.yaml @@ -8,4 +8,4 @@ version: v5.0.1 home: https://github.com/fleetdm/fleet sources: - https://github.com/fleetdm/fleet.git -appVersion: v4.38.0 +appVersion: v4.38.1 diff --git a/charts/fleet/values.yaml b/charts/fleet/values.yaml index d9394eea34..4c01fb7ab5 100644 --- a/charts/fleet/values.yaml +++ b/charts/fleet/values.yaml @@ -2,7 +2,7 @@ # All settings related to how Fleet is deployed in Kubernetes hostName: fleet.localhost replicas: 3 # The number of Fleet instances to deploy -imageTag: v4.38.0 # Version of Fleet to deploy +imageTag: v4.38.1 # Version of Fleet to deploy podAnnotations: {} # Additional annotations to add to the Fleet pod serviceAccountAnnotations: {} # Additional annotations to add to the Fleet service account resources: diff --git a/cmd/fleet/cron.go b/cmd/fleet/cron.go index 4835eb3e19..50120b0434 100644 --- a/cmd/fleet/cron.go +++ b/cmd/fleet/cron.go @@ -18,6 +18,7 @@ import ( "github.com/fleetdm/fleet/v4/server/contexts/license" "github.com/fleetdm/fleet/v4/server/datastore/mysql" "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/mdm" apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple" "github.com/fleetdm/fleet/v4/server/policies" "github.com/fleetdm/fleet/v4/server/ptr" @@ -838,7 +839,7 @@ func verifyDiskEncryptionKeys( if key.UpdatedAt.After(latest) { latest = key.UpdatedAt } - if _, err := apple_mdm.DecryptBase64CMS(key.Base64Encrypted, cert.Leaf, cert.PrivateKey); err != nil { + if _, err := mdm.DecryptBase64CMS(key.Base64Encrypted, cert.Leaf, cert.PrivateKey); err != nil { undecryptable = append(undecryptable, key.HostID) continue } diff --git a/cmd/fleetctl/apply_test.go b/cmd/fleetctl/apply_test.go index 395fe0a6cd..8110239cac 100644 --- a/cmd/fleetctl/apply_test.go +++ b/cmd/fleetctl/apply_test.go @@ -1044,13 +1044,13 @@ spec: foo: qux name: Team1 mdm: + enable_disk_encryption: false macos_updates: minimum_version: 10.10.10 deadline: 1992-03-01 macos_settings: custom_settings: - %s - enable_disk_encryption: false secrets: - secret: BBB `, mobileConfigPath)) @@ -1062,9 +1062,9 @@ spec: require.Equal(t, "[+] applied 1 teams\n", runAppForTest(t, []string{"apply", "-f", name})) assert.JSONEq(t, string(json.RawMessage(`{"config":{"views":{"foo":"qux"}}}`)), string(*savedTeam.Config.AgentOptions)) assert.Equal(t, fleet.TeamMDM{ + EnableDiskEncryption: false, MacOSSettings: fleet.MacOSSettings{ - CustomSettings: []string{mobileConfigPath}, - EnableDiskEncryption: false, + CustomSettings: []string{mobileConfigPath}, }, MacOSUpdates: fleet.MacOSUpdates{ MinimumVersion: optjson.SetString("10.10.10"), @@ -1097,9 +1097,9 @@ spec: require.True(t, ds.NewJobFuncInvoked) // all left untouched, only setup assistant added assert.Equal(t, fleet.TeamMDM{ + EnableDiskEncryption: false, MacOSSettings: fleet.MacOSSettings{ - CustomSettings: []string{mobileConfigPath}, - EnableDiskEncryption: false, + CustomSettings: []string{mobileConfigPath}, }, MacOSUpdates: fleet.MacOSUpdates{ MinimumVersion: optjson.SetString("10.10.10"), @@ -1129,9 +1129,9 @@ spec: require.Equal(t, "[+] applied 1 teams\n", runAppForTest(t, []string{"apply", "-f", name})) // all left untouched, only bootstrap package added assert.Equal(t, fleet.TeamMDM{ + EnableDiskEncryption: false, MacOSSettings: fleet.MacOSSettings{ - CustomSettings: []string{mobileConfigPath}, - EnableDiskEncryption: false, + CustomSettings: []string{mobileConfigPath}, }, MacOSUpdates: fleet.MacOSUpdates{ MinimumVersion: optjson.SetString("10.10.10"), @@ -2886,7 +2886,7 @@ spec: macos_settings: enable_disk_encryption: true `, - wantErr: `Couldn't update macos_settings because MDM features aren't turned on in Fleet.`, + wantErr: `Couldn't edit enable_disk_encryption. Neither macOS MDM nor Windows is turned on`, }, { desc: "app config macos_settings.enable_disk_encryption false", diff --git a/cmd/fleetctl/get.go b/cmd/fleetctl/get.go index 3136dfd5b6..baa6cf5c61 100644 --- a/cmd/fleetctl/get.go +++ b/cmd/fleetctl/get.go @@ -13,6 +13,7 @@ import ( "time" "github.com/fatih/color" + "github.com/fleetdm/fleet/v4/pkg/rawjson" "github.com/fleetdm/fleet/v4/pkg/secure" kithttp "github.com/go-kit/kit/transport/http" "gopkg.in/guregu/null.v3" @@ -167,12 +168,15 @@ func (eacp enrichedAppConfigPresenter) MarshalJSON() ([]byte, error) { *fleet.VulnerabilitiesConfig } - return json.Marshal(&struct { - fleet.EnrichedAppConfig + enrichedJSON, err := json.Marshal(fleet.EnrichedAppConfig(eacp)) + if err != nil { + return nil, err + } + + extraFieldsJSON, err := json.Marshal(&struct { UpdateInterval UpdateIntervalConfigPresenter `json:"update_interval,omitempty"` Vulnerabilities VulnerabilitiesConfigPresenter `json:"vulnerabilities,omitempty"` }{ - EnrichedAppConfig: fleet.EnrichedAppConfig(eacp), UpdateInterval: UpdateIntervalConfigPresenter{ eacp.UpdateInterval.OSQueryDetail.String(), eacp.UpdateInterval.OSQueryPolicy.String(), @@ -184,6 +188,13 @@ func (eacp enrichedAppConfigPresenter) MarshalJSON() ([]byte, error) { eacp.Vulnerabilities, }, }) + if err != nil { + return nil, err + } + + // we need to marshal and combine both groups separately because + // enrichedAppConfig has a custom marshaler. + return rawjson.CombineRoots(enrichedJSON, extraFieldsJSON) } func printConfig(c *cli.Context, config interface{}) error { diff --git a/cmd/fleetctl/get_test.go b/cmd/fleetctl/get_test.go index a57d06413b..1872649874 100644 --- a/cmd/fleetctl/get_test.go +++ b/cmd/fleetctl/get_test.go @@ -7,7 +7,6 @@ import ( "errors" "fmt" "io" - "io/ioutil" "os" "path/filepath" "strings" @@ -168,15 +167,15 @@ func TestGetTeams(t *testing.T) { }, nil } - b, err := ioutil.ReadFile(filepath.Join("testdata", "expectedGetTeamsText.txt")) + b, err := os.ReadFile(filepath.Join("testdata", "expectedGetTeamsText.txt")) require.NoError(t, err) expectedText := string(b) - b, err = ioutil.ReadFile(filepath.Join("testdata", "expectedGetTeamsYaml.yml")) + b, err = os.ReadFile(filepath.Join("testdata", "expectedGetTeamsYaml.yml")) require.NoError(t, err) expectedYaml := string(b) - b, err = ioutil.ReadFile(filepath.Join("testdata", "expectedGetTeamsJson.json")) + b, err = os.ReadFile(filepath.Join("testdata", "expectedGetTeamsJson.json")) require.NoError(t, err) // must read each JSON value separately and compact it var buf bytes.Buffer @@ -206,8 +205,8 @@ func TestGetTeams(t *testing.T) { errBuffer.Reset() actualJSON, err := runWithErrWriter([]string{"get", "teams", "--json"}, &errBuffer) require.NoError(t, err) - require.Equal(t, expectedJson, actualJSON.String()) require.Equal(t, errBuffer.String() == expiredBanner.String(), tt.shouldHaveExpiredBanner) + require.Equal(t, expectedJson, actualJSON.String()) errBuffer.Reset() actualYaml, err := runWithErrWriter([]string{"get", "teams", "--yaml"}, &errBuffer) @@ -433,7 +432,7 @@ func TestGetHosts(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - expected, err := ioutil.ReadFile(filepath.Join("testdata", tt.goldenFile)) + expected, err := os.ReadFile(filepath.Join("testdata", tt.goldenFile)) require.NoError(t, err) expectedResults := tt.scanner(string(expected)) actualResult := tt.scanner(runAppForTest(t, tt.args)) @@ -536,7 +535,7 @@ func TestGetHostsMDM(t *testing.T) { } if tt.goldenFile != "" { - expected, err := ioutil.ReadFile(filepath.Join("testdata", tt.goldenFile)) + expected, err := os.ReadFile(filepath.Join("testdata", tt.goldenFile)) require.NoError(t, err) if ext := filepath.Ext(tt.goldenFile); ext == ".json" { // the output of --json is not a json array, but a list of diff --git a/cmd/fleetctl/testdata/expectedGetConfigAppConfigJson.json b/cmd/fleetctl/testdata/expectedGetConfigAppConfigJson.json index e6ae712e28..2f0c98c740 100644 --- a/cmd/fleetctl/testdata/expectedGetConfigAppConfigJson.json +++ b/cmd/fleetctl/testdata/expectedGetConfigAppConfigJson.json @@ -85,6 +85,7 @@ "enabled_and_configured": false, "apple_bm_default_team": "", "windows_enabled_and_configured": false, + "enable_disk_encryption": false, "macos_updates": { "minimum_version": null, "deadline": null @@ -95,8 +96,7 @@ "webhook_url": "" }, "macos_settings": { - "custom_settings": null, - "enable_disk_encryption": false + "custom_settings": null }, "macos_setup": { "bootstrap_package": null, diff --git a/cmd/fleetctl/testdata/expectedGetConfigAppConfigYaml.yml b/cmd/fleetctl/testdata/expectedGetConfigAppConfigYaml.yml index 1c0d778685..e7a5843214 100644 --- a/cmd/fleetctl/testdata/expectedGetConfigAppConfigYaml.yml +++ b/cmd/fleetctl/testdata/expectedGetConfigAppConfigYaml.yml @@ -19,6 +19,7 @@ spec: enabled_and_configured: false apple_bm_default_team: "" windows_enabled_and_configured: false + enable_disk_encryption: false macos_migration: enable: false mode: "" @@ -28,7 +29,6 @@ spec: deadline: null macos_settings: custom_settings: - enable_disk_encryption: false macos_setup: bootstrap_package: enable_end_user_authentication: false diff --git a/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigJson.json b/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigJson.json index 2030db5afe..94c6e70a77 100644 --- a/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigJson.json +++ b/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigJson.json @@ -43,6 +43,7 @@ "apple_bm_enabled_and_configured": false, "enabled_and_configured": false, "windows_enabled_and_configured": false, + "enable_disk_encryption": false, "macos_updates": { "minimum_version": null, "deadline": null @@ -53,8 +54,7 @@ "webhook_url": "" }, "macos_settings": { - "custom_settings": null, - "enable_disk_encryption": false + "custom_settings": null }, "macos_setup": { "bootstrap_package": null, diff --git a/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigYaml.yml b/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigYaml.yml index 9d3bf00ace..1b03fe13d3 100644 --- a/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigYaml.yml +++ b/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigYaml.yml @@ -19,6 +19,7 @@ spec: apple_bm_terms_expired: false enabled_and_configured: false windows_enabled_and_configured: false + enable_disk_encryption: false macos_migration: enable: false mode: "" @@ -28,7 +29,6 @@ spec: deadline: null macos_settings: custom_settings: - enable_disk_encryption: false macos_setup: bootstrap_package: enable_end_user_authentication: false diff --git a/cmd/fleetctl/testdata/expectedGetTeamsJson.json b/cmd/fleetctl/testdata/expectedGetTeamsJson.json index 19152a690c..6a99943e94 100644 --- a/cmd/fleetctl/testdata/expectedGetTeamsJson.json +++ b/cmd/fleetctl/testdata/expectedGetTeamsJson.json @@ -24,13 +24,13 @@ "enable_software_inventory": true }, "mdm": { + "enable_disk_encryption": false, "macos_updates": { "minimum_version": null, "deadline": null }, "macos_settings": { - "custom_settings": null, - "enable_disk_encryption": false + "custom_settings": null }, "macos_setup": { "bootstrap_package": null, @@ -84,13 +84,13 @@ } }, "mdm": { + "enable_disk_encryption": false, "macos_updates": { "minimum_version": "12.3.1", "deadline": "2021-12-14" }, "macos_settings": { - "custom_settings": null, - "enable_disk_encryption": false + "custom_settings": null }, "macos_setup": { "bootstrap_package": null, diff --git a/cmd/fleetctl/testdata/expectedGetTeamsYaml.yml b/cmd/fleetctl/testdata/expectedGetTeamsYaml.yml index 2b571ae8b5..a6905cf569 100644 --- a/cmd/fleetctl/testdata/expectedGetTeamsYaml.yml +++ b/cmd/fleetctl/testdata/expectedGetTeamsYaml.yml @@ -7,12 +7,12 @@ spec: enable_host_users: true enable_software_inventory: true mdm: + enable_disk_encryption: false macos_updates: minimum_version: null deadline: null macos_settings: custom_settings: - enable_disk_encryption: false macos_setup: bootstrap_package: enable_end_user_authentication: false @@ -36,12 +36,12 @@ spec: enable_host_users: false enable_software_inventory: false mdm: + enable_disk_encryption: false macos_updates: minimum_version: "12.3.1" deadline: "2021-12-14" macos_settings: custom_settings: - enable_disk_encryption: false macos_setup: bootstrap_package: enable_end_user_authentication: false diff --git a/cmd/fleetctl/testdata/macosSetupExpectedAppConfigEmpty.yml b/cmd/fleetctl/testdata/macosSetupExpectedAppConfigEmpty.yml index 4fc311a8dd..d641e98b0a 100644 --- a/cmd/fleetctl/testdata/macosSetupExpectedAppConfigEmpty.yml +++ b/cmd/fleetctl/testdata/macosSetupExpectedAppConfigEmpty.yml @@ -19,13 +19,13 @@ spec: apple_bm_terms_expired: false enabled_and_configured: true windows_enabled_and_configured: false + enable_disk_encryption: false macos_migration: enable: false mode: "" webhook_url: "" macos_settings: custom_settings: null - enable_disk_encryption: false macos_setup: bootstrap_package: null enable_end_user_authentication: false diff --git a/cmd/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml b/cmd/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml index 72b5d2c599..433d80c586 100644 --- a/cmd/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml +++ b/cmd/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml @@ -19,13 +19,13 @@ spec: apple_bm_terms_expired: false enabled_and_configured: true windows_enabled_and_configured: false + enable_disk_encryption: false macos_migration: enable: false mode: "" webhook_url: "" macos_settings: custom_settings: null - enable_disk_encryption: false macos_setup: bootstrap_package: %s enable_end_user_authentication: false diff --git a/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Empty.yml b/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Empty.yml index 346bbc2eb7..a3668e64b3 100644 --- a/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Empty.yml +++ b/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Empty.yml @@ -7,9 +7,9 @@ spec: enable_host_users: true enable_software_inventory: true mdm: + enable_disk_encryption: false macos_settings: custom_settings: null - enable_disk_encryption: false macos_setup: bootstrap_package: null enable_end_user_authentication: false @@ -27,9 +27,9 @@ spec: enable_host_users: true enable_software_inventory: true mdm: + enable_disk_encryption: false macos_settings: custom_settings: null - enable_disk_encryption: false macos_setup: bootstrap_package: null macos_setup_assistant: null diff --git a/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Set.yml b/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Set.yml index 45f1733019..95e49d0321 100644 --- a/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Set.yml +++ b/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Set.yml @@ -7,9 +7,9 @@ spec: enable_host_users: true enable_software_inventory: true mdm: + enable_disk_encryption: false macos_settings: custom_settings: null - enable_disk_encryption: false macos_setup: bootstrap_package: %s enable_end_user_authentication: false @@ -27,9 +27,9 @@ spec: enable_host_users: false enable_software_inventory: false mdm: + enable_disk_encryption: false macos_settings: custom_settings: null - enable_disk_encryption: false macos_setup: bootstrap_package: %s macos_setup_assistant: %s diff --git a/cmd/fleetctl/testdata/macosSetupExpectedTeam1Empty.yml b/cmd/fleetctl/testdata/macosSetupExpectedTeam1Empty.yml index 21d9b9d8db..8ad10fc6c5 100644 --- a/cmd/fleetctl/testdata/macosSetupExpectedTeam1Empty.yml +++ b/cmd/fleetctl/testdata/macosSetupExpectedTeam1Empty.yml @@ -7,9 +7,9 @@ spec: enable_host_users: false enable_software_inventory: false mdm: + enable_disk_encryption: false macos_settings: custom_settings: null - enable_disk_encryption: false macos_setup: bootstrap_package: null enable_end_user_authentication: false diff --git a/docs/Configuration/configuration-files/README.md b/docs/Configuration/configuration-files/README.md index 9cc12b3321..8b8f34b8cf 100644 --- a/docs/Configuration/configuration-files/README.md +++ b/docs/Configuration/configuration-files/README.md @@ -529,8 +529,10 @@ Use with caution as this may break Fleet ingestion of hosts data. ```yaml features: detail_query_overrides: - # null allows to disable the "users" query from running on hosts. + # null disables the "users" query from running on hosts. users: null + # "" disables the "disk_encryption_linux" query from running on hosts. + disk_encryption_linux: "" # this replaces the hardcoded "mdm" detail query. mdm: "SELECT enrolled, server_url, installed_from_dep, payload_identifier FROM mdm;" ``` diff --git a/docs/Contributing/API-for-contributors.md b/docs/Contributing/API-for-contributors.md index 7724b770b7..ddefdb2e9a 100644 --- a/docs/Contributing/API-for-contributors.md +++ b/docs/Contributing/API-for-contributors.md @@ -533,6 +533,7 @@ The MDM endpoints exist to support the related command-line interface sub-comman - [Complete SSO during DEP enrollment](#complete-sso-during-dep-enrollment) - [Preassign profiles to devices](#preassign-profiles-to-devices) - [Match preassigned profiles](#match-preassigned-profiles) +- [Get FileVault statistics](#get-filevault-statistics) ### Generate Apple DEP Key Pair @@ -701,6 +702,44 @@ This endpoint stores a profile to be assigned to a host at some point in the fut `Status: 204` +### Get FileVault statistics + +_Available in Fleet Premium_ + +Get aggregate status counts of disk encryption enforced on macOS hosts. + +The summary can optionally be filtered by team id. + +`GET /api/v1/fleet/mdm/apple/filevault/summary` + +#### Parameters + +| Name | Type | In | Description | +| ------------------------- | ------ | ----- | ------------------------------------------------------------------------- | +| team_id | string | query | _Available in Fleet Premium_ The team id to filter the summary. | + +#### Example + +Get aggregate status counts of Apple disk encryption profiles applying to macOS hosts enrolled to Fleet's MDM that are not assigned to any team. + +`GET /api/v1/fleet/mdm/apple/filevault/summary` + +##### Default response + +`Status: 200` + +```json +{ + "verified": 123, + "verifying": 123, + "action_required": 123, + "enforcing": 123, + "failed": 123, + "removing_enforcement": 123 +} +``` + + ### Match preassigned profiles _Available in Fleet Premium_ @@ -2291,7 +2330,9 @@ Gets all information required by Fleet Desktop, this includes things like the nu { "failing_policies_count": 3, "notifications": { - "needs_mdm_migration": true + "needs_mdm_migration": true, + "renew_enrollment_profile": false, + "enforce_bitlocker_encryption": false, }, "config": { "org_info": { @@ -2313,6 +2354,7 @@ In regards to the `notifications` key: - `needs_mdm_migration` means that the device fits all the requirements to allow the user to initiate an MDM migration to Fleet. - `renew_enrollment_profile` means that the device is currently unmanaged from MDM but should be DEP enrolled into Fleet. +- `enforce_bitlocker_encryption` applies only to Windows devices and means that it should encrypt the disk and report the encryption key back to Fleet. #### Get device's policies diff --git a/docs/Contributing/FAQ.md b/docs/Contributing/FAQ.md index 3d3f69c92b..c7522aa537 100644 --- a/docs/Contributing/FAQ.md +++ b/docs/Contributing/FAQ.md @@ -93,6 +93,7 @@ If you also have Fleetd running on hosts, it will need access to these API endpo * `/api/fleet/orbit/ping` * `/api/fleet/orbit/scripts/request` * `/api/fleet/orbit/scripts/result` +* `/api/fleet/orbit/disk_encryption_key` * `/api/osquery/log` diff --git a/docs/Get started/anatomy.md b/docs/Get started/anatomy.md index 757c9614a1..7bc314d245 100644 --- a/docs/Get started/anatomy.md +++ b/docs/Get started/anatomy.md @@ -12,7 +12,7 @@ Fleetctl (pronouced “fleet control”) is a CLI (command line interface) tool ## Fleetd -Fleetd is a bundle of agents provided by Fleet to gather information about your devices. Fleetd includes [osquery](https://www.osquery.io/), Orbit, and Fleet Desktop. [Docs](https://fleetdm.com/docs/using-fleet/fleet-ui). +Fleetd is a bundle of agents provided by Fleet to gather information about your devices. Fleetd includes [osquery](https://www.osquery.io/), Orbit, and Fleet Desktop. [Docs](https://fleetdm.com/docs/using-fleet/fleetd). ## Osquery Osquery is an open-source tool for gathering information about the state of any device that the osquery agent has been installed on. [Learn more](https://www.osquery.io/). diff --git a/docs/REST API/rest-api.md b/docs/REST API/rest-api.md index a40f5daa39..753288389a 100644 --- a/docs/REST API/rest-api.md +++ b/docs/REST API/rest-api.md @@ -1829,14 +1829,14 @@ the `software` table. | page | integer | query | Page number of the results to fetch. | | per_page | integer | query | Results per page. | | order_key | string | query | What to order results by. Can be any column in the hosts table. | -| after | string | query | The value to get results after. This needs `order_key` defined, as that's the column that would be used. **Note:** Use `page` instead of `after`. | -| order_direction | string | query | **Requires `order_key`**. The direction of the order given the order key. Options include `asc` and `desc`. Default is `asc`. | -| status | string | query | Indicates the status of the hosts to return. Can either be `new`, `online`, `offline`, `mia` or `missing`. | -| query | string | query | Search query keywords. Searchable fields include `hostname`, `machine_serial`, `uuid`, `ipv4` and the hosts' email addresses (only searched if the query looks like an email address, i.e. contains an `@`, no space, etc.). | -| additional_info_filters | string | query | A comma-delimited list of fields to include in each host's additional information object. See [Fleet Configuration Options](https://fleetdm.com/docs/using-fleet/fleetctl-cli#fleet-configuration-options) for an example configuration with hosts' additional information. Use `*` to get all stored fields. | +| after | string | query | The value to get results after. This needs `order_key` defined, as that's the column that would be used. **Note:** Use `page` instead of `after` | +| order_direction | string | query | **Requires `order_key`**. The direction of the order given the order key. Options include 'asc' and 'desc'. Default is 'asc'. | +| status | string | query | Indicates the status of the hosts to return. Can either be 'new', 'online', 'offline', 'mia' or 'missing'. | +| query | string | query | Search query keywords. Searchable fields include `hostname`, `machine_serial`, `uuid`, `ipv4` and the hosts' email addresses (only searched if the query looks like an email address, i.e. contains an '@', no space, etc.). | +| additional_info_filters | string | query | A comma-delimited list of fields to include in each host's additional information object. See [Fleet Configuration Options](https://fleetdm.com/docs/using-fleet/fleetctl-cli#fleet-configuration-options) for an example configuration with hosts' additional information. Use '*' to get all stored fields. | | team_id | integer | query | _Available in Fleet Premium_ Filters the hosts to only include hosts in the specified team. | | policy_id | integer | query | The ID of the policy to filter hosts by. | -| policy_response | string | query | Valid options are `passing` or `failing`. `policy_id` must also be specified with `policy_response`. | +| policy_response | string | query | Valid options are 'passing' or 'failing'. `policy_id` must also be specified with `policy_response`. | | software_id | integer | query | The ID of the software to filter hosts by. | | os_id | integer | query | The ID of the operating system to filter hosts by. | | os_name | string | query | The name of the operating system to filter hosts by. `os_version` must also be specified with `os_name` | @@ -1849,8 +1849,11 @@ the `software` table. | munki_issue_id | integer | query | The ID of the _munki issue_ (a Munki-reported error or warning message) to filter hosts by (that is, filter hosts that are affected by that corresponding error or warning message). | | low_disk_space | integer | query | _Available in Fleet Premium_ Filters the hosts to only include hosts with less GB of disk space available than this value. Must be a number between 1-100. | | disable_failing_policies| boolean | query | If "true", hosts will return failing policies as 0 regardless of whether there are any that failed for the host. This is meant to be used when increased performance is needed in exchange for the extra information. | -| macos_settings_disk_encryption | string | query | Filters the hosts by the status of the macOS disk encryption MDM profile on the host. Can be one of `verified`, `verifying`, `action_required`, `enforcing`, `failed`, or `removing_enforcement`. | -| bootstrap_package | string | query | _Available in Fleet Premium_ Filters the hosts by the status of the MDM bootstrap package on the host. Can be one of `installed`, `pending`, or `failed`. | +| macos_settings_disk_encryption | string | query | Filters the hosts by the status of the macOS disk encryption MDM profile on the host. Can be one of 'verified', 'verifying', 'action_required', 'enforcing', 'failed', or 'removing_enforcement'. | +| bootstrap_package | string | query | _Available in Fleet Premium_ Filters the hosts by the status of the MDM bootstrap package on the host. Can be one of 'installed', 'pending', or 'failed'. | +| os_settings | string | query | Filters the hosts by the status of the operating system settings applied to the hosts. Can be one of 'verified', 'verifying', 'pending', or 'failed'. **Note: If this filter is used in Fleet Premium without a team id filter, the results include only hosts that are not assigned to any team.** | +| os_settings_disk_encryption | string | query | Filters the hosts by the status of the disk encryption setting applied to the hosts. Can be one of 'verified', 'verifying', 'action_required', 'enforcing', 'failed', or 'removing_enforcement'. **Note: If this filter is used in Fleet Premium without a team id filter, the results include only hosts that are not assigned to any team.** | + If `additional_info_filters` is not specified, no `additional` information will be returned. @@ -1858,9 +1861,9 @@ If `software_id` is specified, an additional top-level key `"software"` is retur If `mdm_id` is specified, an additional top-level key `"mobile_device_management_solution"` is returned with the information corresponding to the `mdm_id`. -If `mdm_id`, `mdm_name` or `mdm_enrollment_status` is specified, then Windows Servers are excluded from the results. +If `mdm_id`, `mdm_name`, `mdm_enrollment_status`, `os_settings`, or `os_settings_disk_encryption` is specified, then Windows Servers are excluded from the results. -If `munki_issue_id` is specified, an additional top-level key `"munki_issue"` is returned with the information corresponding to the `munki_issue_id`. +If `munki_issue_id` is specified, an additional top-level key `munki_issue` is returned with the information corresponding to the `munki_issue_id`. If `after` is being used with `created_at` or `updated_at`, the table must be specified in `order_key`. Those columns become `h.created_at` and `h.updated_at`. @@ -1988,13 +1991,13 @@ Response payload with the `munki_issue_id` filter provided: | Name | Type | In | Description | | ----------------------- | ------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | order_key | string | query | What to order results by. Can be any column in the hosts table. | -| order_direction | string | query | **Requires `order_key`**. The direction of the order given the order key. Options include `asc` and `desc`. Default is `asc`. | +| order_direction | string | query | **Requires `order_key`**. The direction of the order given the order key. Options include 'asc' and 'desc'. Default is 'asc'. | | after | string | query | The value to get results after. This needs `order_key` defined, as that's the column that would be used. | -| status | string | query | Indicates the status of the hosts to return. Can either be `new`, `online`, `offline`, `mia` or `missing`. | -| query | string | query | Search query keywords. Searchable fields include `hostname`, `machine_serial`, `uuid`, `ipv4` and the hosts' email addresses (only searched if the query looks like an email address, i.e. contains an `@`, no space, etc.). | +| status | string | query | Indicates the status of the hosts to return. Can either be 'new', 'online', 'offline', 'mia' or 'missing'. | +| query | string | query | Search query keywords. Searchable fields include `hostname`, `machine_serial`, `uuid`, `ipv4` and the hosts' email addresses (only searched if the query looks like an email address, i.e. contains an '@', no space, etc.). | | team_id | integer | query | _Available in Fleet Premium_ Filters the hosts to only include hosts in the specified team. | | policy_id | integer | query | The ID of the policy to filter hosts by. | -| policy_response | string | query | Valid options are `passing` or `failing`. `policy_id` must also be specified with `policy_response`. | +| policy_response | string | query | Valid options are 'passing' or 'failing'. `policy_id` must also be specified with `policy_response`. | | software_id | integer | query | The ID of the software to filter hosts by. | | os_id | integer | query | The ID of the operating system to filter hosts by. | | os_name | string | query | The name of the operating system to filter hosts by. `os_version` must also be specified with `os_name` | @@ -2006,8 +2009,10 @@ Response payload with the `munki_issue_id` filter provided: | macos_settings | string | query | Filters the hosts by the status of the _mobile device management_ (MDM) profiles applied to hosts. Can be one of 'verified', 'verifying', 'pending', or 'failed'. **Note: If this filter is used in Fleet Premium without a team id filter, the results include only hosts that are not assigned to any team.** | | munki_issue_id | integer | query | The ID of the _munki issue_ (a Munki-reported error or warning message) to filter hosts by (that is, filter hosts that are affected by that corresponding error or warning message). | | low_disk_space | integer | query | _Available in Fleet Premium_ Filters the hosts to only include hosts with less GB of disk space available than this value. Must be a number between 1-100. | -| macos_settings_disk_encryption | string | query | Filters the hosts by the status of the macOS disk encryption MDM profile on the host. Can be one of `verified`, `verifying`, `action_required`, `enforcing`, `failed`, or `removing_enforcement`. | -| bootstrap_package | string | query | _Available in Fleet Premium_ Filters the hosts by the status of the MDM bootstrap package on the host. Can be one of `installed`, `pending`, or `failed`. **Note: If this filter is used in Fleet Premium without a team id filter, the results include only hosts that are not assigned to any team.** | +| macos_settings_disk_encryption | string | query | Filters the hosts by the status of the macOS disk encryption MDM profile on the host. Can be one of 'verified', 'verifying', 'action_required', 'enforcing', 'failed', or 'removing_enforcement'. | +| bootstrap_package | string | query | _Available in Fleet Premium_ Filters the hosts by the status of the MDM bootstrap package on the host. Can be one of 'installed', 'pending', or 'failed'. **Note: If this filter is used in Fleet Premium without a team id filter, the results include only hosts that are not assigned to any team.** | +| os_settings | string | query | Filters the hosts by the status of the operating system settings applied to the hosts. Can be one of 'verified', 'verifying', 'pending', or 'failed'. **Note: If this filter is used in Fleet Premium without a team id filter, the results include only hosts that are not assigned to any team.** | +| os_settings_disk_encryption | string | query | Filters the hosts by the status of the disk encryption setting applied to the hosts. Can be one of 'verified', 'verifying', 'action_required', 'enforcing', 'failed', or 'removing_enforcement'. **Note: If this filter is used in Fleet Premium without a team id filter, the results include only hosts that are not assigned to any team.** | If `additional_info_filters` is not specified, no `additional` information will be returned. @@ -2555,6 +2560,9 @@ Returns the information of the host specified using the `uuid`, `osquery_host_id "bootstrap_package_status": "installed", "detail": "" }, + "os_settings": { + "disk_encryption": null + }, "profiles": [ { "profile_id": 999, @@ -2743,6 +2751,9 @@ This is the API route used by the **My device** page in Fleet desktop to display "detail": "", "bootstrap_package_name": "test.pkg" }, + "os_settings": { + "disk_encryption": null + }, "profiles": [ { "profile_id": 999, @@ -3291,12 +3302,12 @@ requested by a web browser. | format | string | query | **Required**, must be "csv" (only supported format for now). | | columns | string | query | Comma-delimited list of columns to include in the report (returns all columns if none is specified). | | order_key | string | query | What to order results by. Can be any column in the hosts table. | -| order_direction | string | query | **Requires `order_key`**. The direction of the order given the order key. Options include `asc` and `desc`. Default is `asc`. | -| status | string | query | Indicates the status of the hosts to return. Can either be `new`, `online`, `offline`, `mia` or `missing`. | +| order_direction | string | query | **Requires `order_key`**. The direction of the order given the order key. Options include 'asc' and 'desc'. Default is 'asc'. | +| status | string | query | Indicates the status of the hosts to return. Can either be 'new', 'online', 'offline', 'mia' or 'missing'. | | query | string | query | Search query keywords. Searchable fields include `hostname`, `machine_serial`, `uuid`, `ipv4` and the hosts' email addresses (only searched if the query looks like an email address, i.e. contains an `@`, no space, etc.). | | team_id | integer | query | _Available in Fleet Premium_ Filters the hosts to only include hosts in the specified team. | | policy_id | integer | query | The ID of the policy to filter hosts by. | -| policy_response | string | query | Valid options are `passing` or `failing`. `policy_id` must also be specified with `policy_response`. **Note: If `policy_id` is specified _without_ including `policy_response`, this will also return hosts where the policy is not configured to run or failed to run.** | +| policy_response | string | query | Valid options are 'passing' or 'failing'. `policy_id` must also be specified with `policy_response`. **Note: If `policy_id` is specified _without_ including `policy_response`, this will also return hosts where the policy is not configured to run or failed to run.** | | software_id | integer | query | The ID of the software to filter hosts by. | | os_id | integer | query | The ID of the operating system to filter hosts by. | | os_name | string | query | The name of the operating system to filter hosts by. `os_version` must also be specified with `os_name` | @@ -3308,7 +3319,7 @@ requested by a web browser. | munki_issue_id | integer | query | The ID of the _munki issue_ (a Munki-reported error or warning message) to filter hosts by (that is, filter hosts that are affected by that corresponding error or warning message). | | low_disk_space | integer | query | _Available in Fleet Premium_ Filters the hosts to only include hosts with less GB of disk space available than this value. Must be a number between 1-100. | | label_id | integer | query | A valid label ID. Can only be used in combination with `order_key`, `order_direction`, `status`, `query` and `team_id`. | -| bootstrap_package | string | query | _Available in Fleet Premium_ Filters the hosts by the status of the MDM bootstrap package on the host. Can be one of `installed`, `pending`, or `failed`. **Note: If this filter is used in Fleet Premium without a team id filter, the results include only hosts that are not assigned to any team.** | +| bootstrap_package | string | query | _Available in Fleet Premium_ Filters the hosts by the status of the MDM bootstrap package on the host. Can be one of 'installed', 'pending', or 'failed'. **Note: If this filter is used in Fleet Premium without a team id filter, the results include only hosts that are not assigned to any team.** | | disable_failing_policies | boolean | query | If `true`, hosts will return failing policies as 0 (returned as the `issues` column) regardless of whether there are any that failed for the host. This is meant to be used when increased performance is needed in exchange for the extra information. | If `mdm_id`, `mdm_name` or `mdm_enrollment_status` is specified, then Windows Servers are excluded from the results. @@ -3330,7 +3341,7 @@ created_at,updated_at,id,detail_updated_at,label_updated_at,policy_updated_at,la ### Get host's disk encryption key -Requires the [macadmins osquery extension](https://github.com/macadmins/osquery-extension) which comes bundled +For macOS, requires the [macadmins osquery extension](https://github.com/macadmins/osquery-extension) which comes bundled in [Fleet's osquery installers](https://fleetdm.com/docs/using-fleet/adding-hosts#osquery-installer). Requires Fleet's MDM properly [enabled and configured](https://fleetdm.com/docs/using-fleet/mdm-macos-setup). @@ -3724,9 +3735,9 @@ Returns a list of the hosts that belong to the specified label. | page | integer | query | Page number of the results to fetch. | | per_page | integer | query | Results per page. | | order_key | string | query | What to order results by. Can be any column in the hosts table. | -| order_direction | string | query | **Requires `order_key`**. The direction of the order given the order key. Options include `asc` and `desc`. Default is `asc`. | +| order_direction | string | query | **Requires `order_key`**. The direction of the order given the order key. Options include 'asc' and 'desc'. Default is 'asc'. | | after | string | query | The value to get results after. This needs `order_key` defined, as that's the column that would be used. | -| status | string | query | Indicates the status of the hosts to return. Can either be `new`, `online`, `offline`, `mia` or `missing`. | +| status | string | query | Indicates the status of the hosts to return. Can either be 'new', 'online', 'offline', 'mia' or 'missing'. | | query | string | query | Search query keywords. Searchable fields include `hostname`, `machine_serial`, `uuid`, and `ipv4`. | | team_id | integer | query | _Available in Fleet Premium_ Filters the hosts to only include hosts in the specified team. | | disable_failing_policies | boolean | query | If "true", hosts will return failing policies as 0 regardless of whether there are any that failed for the host. This is meant to be used when increased performance is needed in exchange for the extra information. | @@ -3735,10 +3746,12 @@ Returns a list of the hosts that belong to the specified label. | mdm_enrollment_status | string | query | The _mobile device management_ (MDM) enrollment status to filter hosts by. Can be one of 'manual', 'automatic', 'enrolled', 'pending', or 'unenrolled'. | | macos_settings | string | query | Filters the hosts by the status of the _mobile device management_ (MDM) profiles applied to hosts. Can be one of 'verified', 'verifying', 'pending', or 'failed'. **Note: If this filter is used in Fleet Premium without a team id filter, the results include only hosts that are not assigned to any team.** | | low_disk_space | integer | query | _Available in Fleet Premium_ Filters the hosts to only include hosts with less GB of disk space available than this value. Must be a number between 1-100. | -| macos_settings_disk_encryption | string | query | Filters the hosts by the status of the macOS disk encryption MDM profile on the host. Can be one of `verified`, `verifying`, `action_required`, `enforcing`, `failed`, or `removing_enforcement`. | -| bootstrap_package | string | query | _Available in Fleet Premium_ Filters the hosts by the status of the MDM bootstrap package on the host. Can be one of `installed`, `pending`, or `failed`. **Note: If this filter is used in Fleet Premium without a team id filter, the results include only hosts that are not assigned to any team.** | +| macos_settings_disk_encryption | string | query | Filters the hosts by the status of the macOS disk encryption MDM profile on the host. Can be one of 'verified', 'verifying', 'action_required', 'enforcing', 'failed', or 'removing_enforcement'. | +| bootstrap_package | string | query | _Available in Fleet Premium_ Filters the hosts by the status of the MDM bootstrap package on the host. Can be one of 'installed', 'pending', or 'failed'. **Note: If this filter is used in Fleet Premium without a team id filter, the results include only hosts that are not assigned to any team.** | +| os_settings | string | query | Filters the hosts by the status of the operating system settings applied to the hosts. Can be one of 'verified', 'verifying', 'pending', or 'failed'. **Note: If this filter is used in Fleet Premium without a team id filter, the results include only hosts that are not assigned to any team.** | +| os_settings_disk_encryption | string | query | Filters the hosts by the status of the disk encryption setting applied to the hosts. Can be one of 'verified', 'verifying', 'action_required', 'enforcing', 'failed', or 'removing_enforcement'. **Note: If this filter is used in Fleet Premium without a team id filter, the results include only hosts that are not assigned to any team.** | -If `mdm_id`, `mdm_name` or `mdm_enrollment_status` is specified, then Windows Servers are excluded from the results. +If `mdm_id`, `mdm_name`, `mdm_enrollment_status`, `os_settings`, or `os_settings_disk_encryption` is specified, then Windows Servers are excluded from the results. #### Example @@ -4090,23 +4103,23 @@ _Available in Fleet Premium_ _Available in Fleet Premium_ -Get aggregate status counts of disk encryption enforced on hosts. +Get aggregate status counts of disk encryption enforced on macOS and Windows hosts. The summary can optionally be filtered by team id. -`GET /api/v1/fleet/mdm/apple/filevault/summary` +`GET /api/v1/fleet/mdm/disk_encryption/summary` #### Parameters | Name | Type | In | Description | | ------------------------- | ------ | ----- | ------------------------------------------------------------------------- | -| team_id | string | query | _Available in Fleet Premium_ The team id to filter the summary. | +| team_id | string | query | _Available in Fleet Premium_ The team id to filter the summary. | #### Example -Get aggregate status counts of Apple disk encryption profiles applying to macOS hosts enrolled to Fleet's MDM that are not assigned to any team. +Get aggregate disk encryption status counts of macOS and Windows hosts enrolled to Fleet's MDM that are not assigned to any team. -`GET /api/v1/fleet/mdm/apple/filevault/summary` +`GET /api/v1/fleet/mdm/disk_encryption/summary` ##### Default response @@ -4114,12 +4127,12 @@ Get aggregate status counts of Apple disk encryption profiles applying to macOS ```json { - "verified": 123, - "verifying": 123, - "action_required": 123, - "enforcing": 123, - "failed": 123, - "removing_enforcement": 123 + "verified": {"macos": 123, "windows": 123}, + "verifying": {"macos": 123, "windows": 0}, + "action_required": {"macos": 123, "windows": 0}, + "enforcing": {"macos": 123, "windows": 123}, + "failed": {"macos": 123, "windows": 123}, + "removing_enforcement": {"macos": 123, "windows": 0}, } ``` @@ -6600,7 +6613,8 @@ Deletes the session specified by ID. When the user associated with the session n "epss_probability": 0.01537, "cisa_known_exploit": false, "cve_published": "2022-01-01 12:32:00", - "cve_description": "In the GNU C Library (aka glibc or libc6) before 2.28, parse_reg_exp in posix/regcomp.c misparses alternatives, which allows attackers to cause a denial of service (assertion failure and application exit) or trigger an incorrect result by attempting a regular-expression match." + "cve_description": "In the GNU C Library (aka glibc or libc6) before 2.28, parse_reg_exp in posix/regcomp.c misparses alternatives, which allows attackers to cause a denial of service (assertion failure and application exit) or trigger an incorrect result by attempting a regular-expression match.", + "resolved_in_version": "2.28" } ], "hosts_count": 1 diff --git a/docs/Using Fleet/CIS-Benchmarks.md b/docs/Using Fleet/CIS-Benchmarks.md index 5d92bc4607..632caf0ae6 100644 --- a/docs/Using Fleet/CIS-Benchmarks.md +++ b/docs/Using Fleet/CIS-Benchmarks.md @@ -170,127 +170,11 @@ The following CIS benchmark checks cannot be automated and must be addressed man Fleet's policies have been written against v1.12.0 of the benchmark. You can refer to the [CIS website](https://www.cisecurity.org/cis-benchmarks) for full details about this version. -### Checks that require a Group Policy Template +### Checks that require a Group Policy template -38 items require Group Policy Template in place in order to audit them. +Several items require Group Policy templates in place in order to audit them. These items are tagged with the label `CIS_group_policy_template_required` in the YAML file, and details about the required Group Policy templates can be found in each item's `resolution`. -``` -18.3.1 CIS - Ensure 'Apply UAC restrictions to local accounts on network logons' is set to 'Enabled' -Requires this GPO in place: 'Computer Configuration\Policies\Administrative Templates\MS Security Guide\Apply UAC restrictions to local accounts on network logons' - -18.3.2 CIS - Ensure 'Configure SMB v1 client driver' is set to 'Enabled: Disable driver (recommended)' -Requires this GPO in place: 'Computer Configuration\Policies\Administrative Templates\MS Security Guide\Configure SMB v1 client driver' - -18.3.3 CIS - Ensure 'Configure SMB v1 server' is set to 'Disabled' -Requires this GPO in place: 'Computer Configuration\Policies\Administrative Templates\MS Security Guide\Configure SMB v1 server' - -18.3.4 CIS - Ensure 'Enable Structured Exception Handling Overwrite Protection (SEHOP)' is set to 'Enabled' -Requires this GPO in place: 'Computer Configuration\Policies\Administrative Templates\MS Security Guide\Enable Structured Exception Handling Overwrite Protection (SEHOP)' - -18.3.5 CIS - Ensure 'Limits print driver installation to Administrators' is set to 'Enabled' -Requires this GPO in place: 'Computer Configuration\Policies\Administrative Templates\MS Security Guide\Limits print driver installation to Administrators' - -18.3.6 CIS - Ensure 'NetBT NodeType configuration' is set to 'Enabled: P-node (recommended)' -Requires this GPO in place: 'Computer Configuration\Policies\Administrative Templates\MS Security Guide\NetBT NodeType configuration' - -18.3.7 CIS - Ensure 'WDigest Authentication' is set to 'Disabled' -Requires this GPO in place: 'Computer Configuration\Policies\Administrative Templates\MS Security Guide\WDigest Authentication (disabling may require KB2871997)' - -18.4.1 CIS - Ensure 'MSS: (AutoAdminLogon) Enable Automatic Logon (not recommended)' is set to 'Disabled' -Requires this GPO in place: 'Computer Configuration\Policies\Administrative Templates\MSS (Legacy)\MSS: (AutoAdminLogon) Enable Automatic Logon (not recommended)' - -18.4.2 CIS - Ensure 'MSS: (DisableIPSourceRouting IPv6) IP source routing protection level (protects against packet spoofing)' is set to 'Enabled: Highest protection, source routing is completely disabled' -Requires this GPO in place: 'Computer Configuration\Policies\Administrative Templates\MSS (Legacy)\MSS: (DisableIPSourceRouting IPv6) IP source routing protection level (protects against packet spoofing)' - -18.4.3 CIS - Ensure 'MSS: (DisableIPSourceRouting) IP source routing protection level (protects against packet spoofing)' is set to 'Enabled: Highest protection, source routing is completely disabled' -Requires this GPO in place: 'Computer Configuration\Policies\Administrative Templates\MSS (Legacy)\MSS: (DisableIPSourceRouting) IP source routing protection level (protects against packet spoofing)' - -18.4.4 CIS - Ensure 'MSS: (DisableSavePassword) Prevent the dial-up password from being saved' is set to 'Enabled' -Requires this GPO in place: 'Computer Configuration\Policies\Administrative Templates\MSS (Legacy)\MSS:(DisableSavePassword) Prevent the dial-up password from being saved' - -18.4.5 CIS - Ensure 'MSS: (EnableICMPRedirect) Allow ICMP redirects to override OSPF generated routes' is set to 'Disabled' -Requires this GPO in place: 'Computer Configuration\Policies\Administrative Templates\MSS (Legacy)\MSS: (EnableICMPRedirect) Allow ICMP redirects to override OSPF generated routes' - -18.4.6 CIS - Ensure 'MSS: (KeepAliveTime) How often keep-alive packets are sent in milliseconds' is set to 'Enabled: 300,000 or 5 minutes' -Requires this GPO in place: 'Computer Configuration\Policies\Administrative Templates\MSS (Legacy)\MSS: (KeepAliveTime) How often keep-alive packets are sent in milliseconds' - -18.4.7 CIS - Ensure 'MSS: (NoNameReleaseOnDemand) Allow the computer to ignore NetBIOS name release requests except from WINS servers' is set to 'Enabled' -Requires this GPO in place: 'Computer Configuration\Policies\Administrative Templates\MSS (Legacy)\MSS: (NoNameReleaseOnDemand) Allow the computer to ignore NetBIOS name release requests except from WINS servers' - -18.4.8 CIS - Ensure 'MSS: (PerformRouterDiscovery) Allow IRDP to detect and configure Default Gateway addresses (could lead to DoS)' is set to 'Disabled' -Requires this GPO in place: 'Computer Configuration\Policies\Administrative Templates\MSS (Legacy)\MSS: (PerformRouterDiscovery) Allow IRDP to detect and configure Default Gateway addresses (could lead to DoS)' - -18.4.9 CIS - Ensure 'MSS: (SafeDllSearchMode) Enable Safe DLL search mode (recommended)' is set to 'Enabled' -Requires this GPO in place: 'Computer Configuration\Policies\Administrative Templates\MSS (Legacy)\MSS: (SafeDllSearchMode) Enable Safe DLL search mode (recommended)' - -18.4.10 CIS - Ensure 'MSS: (ScreenSaverGracePeriod) The time in seconds before the screen saver grace period expires (0 recommended)' is set to 'Enabled: 5 or fewer seconds' -Requires this GPO in place: 'Computer Configuration\Policies\Administrative Templates\MSS (Legacy)\MSS: (ScreenSaverGracePeriod) The time in seconds before the screen saver grace period expires (0 recommended)' - -18.4.11 CIS - Ensure 'MSS: (TcpMaxDataRetransmissions IPv6) How many times unacknowledged data is retransmitted' is set to 'Enabled: 3' -Requires this GPO in place: 'Computer Configuration\Policies\Administrative Templates\MSS (Legacy)\MSS:(TcpMaxDataRetransmissions IPv6) How many times unacknowledged data is retransmitted' - -18.4.12 CIS - Ensure 'MSS: (TcpMaxDataRetransmissions) How many times unacknowledged data is retransmitted' is set to 'Enabled: 3' -Requires this GPO in place: 'Computer Configuration\Policies\Administrative Templates\MSS (Legacy)\MSS:(TcpMaxDataRetransmissions) How many times unacknowledged data is retransmitted' - -18.4.13 CIS - Ensure 'MSS: (WarningLevel) Percentage threshold for the security event log at which the system will generate a warning' is set to 'Enabled: 90% or less' -Requires this GPO in place: 'Computer Configuration\Policies\Administrative Templates\MSS (Legacy)\MSS: (WarningLevel) Percentage threshold for the security event log at which the system will generate a warning' - -18.8.21.2 CIS - Ensure 'Configure registry policy processing: Do not apply during periodic background processing' is set to 'Enabled: FALSE' -Requires this GPO in place: 'Computer Configuration\Policies\Administrative Templates\System\Group Policy\Configure registry policy processing' - -18.8.22.1.1 CIS - Ensure 'Turn off access to the Store' is set to 'Enabled' -Requires this GPO in place: 'Computer Configuration\Policies\Administrative Templates\System\Internet Communication Management\Internet Communication settings\Turn off access to the Store' - -18.8.22.1.2 CIS - Ensure 'Turn off downloading of print drivers over HTTP' is set to 'Enabled' -Requires this GPO in place: 'Computer Configuration\Policies\Administrative Templates\System\Internet Communication Management\Internet Communication settings\Turn off downloading of print drivers over HTTP' - -18.8.22.1.3 CIS - Ensure 'Turn off handwriting personalization data sharing' is set to 'Enabled' -Requires this GPO in place: 'Computer Configuration\Policies\Administrative Templates\System\Internet Communication Management\Internet Communication settings\Turn off handwriting personalization data sharing' - -18.8.22.1.4 CIS - Ensure 'Turn off handwriting recognition error reporting' is set to 'Enabled' -Requires this GPO in place: 'Computer Configuration\Policies\Administrative Templates\System\Internet Communication Management\Internet Communication settings\Turn off handwriting recognition error reporting' - -18.8.22.1.5 CIS - Ensure 'Turn off Internet Connection Wizard if URL connection is referring to Microsoft.com' is set to 'Enabled' -Requires this GPO in place: 'Computer Configuration\Policies\Administrative Templates\System\Internet Communication Management\Internet Communication settings\Turn off Internet Connection Wizard if URL connection is referring to Microsoft.com' - -18.8.22.1.6 CIS - Ensure 'Turn off Internet download for Web publishing and online ordering wizards' is set to 'Enabled' -Requires this GPO in place: 'Computer Configuration\Policies\Administrative Templates\System\Internet Communication Management\Internet Communication settings\Turn off Internet download for Web publishing and online ordering wizards' - -18.8.22.1.7 CIS - Ensure 'Turn off printing over HTTP' is set to 'Enabled' -Requires this GPO in place: 'Computer Configuration\Policies\Administrative Templates\System\Internet Communication Management\Internet Communication settings\Turn off printing over HTTP' - -18.8.22.1.8 CIS - Ensure 'Turn off Registration if URL connection is referring to Microsoft.com' is set to 'Enabled' -Requires this GPO in place: 'Computer Configuration\Policies\Administrative Templates\System\Internet Communication Management\Internet Communication settings\Turn off Registration if URL connection is referring to Microsoft.com' - -18.8.22.1.9 CIS - Ensure 'Turn off Search Companion content file updates' is set to 'Enabled' -Requires this GPO in place: 'Computer Configuration\Policies\Administrative Templates\System\Internet Communication Management\Internet Communication settings\Turn off Search Companion content file updates' - -18.8.22.1.10 CIS - Ensure 'Turn off the "Order Prints" picture task' is set to 'Enabled' -Requires this GPO in place: 'Computer Configuration\Policies\Administrative Templates\System\Internet Communication Management\Internet Communication settings\Turn off the "Order Prints" picture task' - -18.8.22.1.11 CIS - Ensure 'Turn off the "Publish to Web" task for files and folders' is set to 'Enabled' -Requires this GPO in place: 'Computer Configuration\Policies\Administrative Templates\System\Internet Communication Management\Internet Communication settings\Turn off the "Publish to Web" task for files and folders' - -18.8.22.1.12 CIS - Ensure 'Turn off the Windows Messenger Customer Experience Improvement Program' is set to 'Enabled' -Requires this GPO in place: 'Computer Configuration\Policies\Administrative Templates\System\Internet Communication Management\Internet Communication settings\Turn off the Windows Messenger Customer Experience Improvement Program' - -18.8.22.1.13 CIS - Ensure 'Turn off Windows Customer Experience Improvement Program' is set to 'Enabled' -Requires this GPO in place: 'Computer Configuration\Policies\Administrative Templates\System\Internet Communication Management\Internet Communication settings\Turn off Windows Customer Experience Improvement Program' - -18.8.22.1.14 CIS - Ensure 'Turn off Windows Error Reporting' is set to 'Enabled' -Requires this GPO in place: 'Computer Configuration\Policies\Administrative Templates\System\Internet Communication Management\Internet Communication settings\Turn off Windows Error Reporting' - -18.8.25.1 CIS - Ensure 'Support device authentication using certificate' is set to 'Enabled: Automatic' -Requires this GPO in place: 'Computer Configuration\Policies\Administrative Templates\System\Kerberos\Support device authentication using certificate' - -18.8.26.1 CIS - Ensure 'Enumeration policy for external devices incompatible with Kernel DMA Protection' is set to 'Enabled: Block All' -Requires this GPO in place: 'Computer Configuration\Policies\Administrative Templates\System\Kernel DMA Protection\Enumeration policy for external devices incompatible with Kernel DMA Protection' - -18.8.27.1 CIS - Ensure 'Disallow copying of user input methods to the system account for sign-in' is set to 'Enabled' (Automated) -Requires this GPO in place: 'Computer Configuration\Policies\Administrative Templates\System\Locale Services\Disallow copying of user input methods to the system account for sign-in' -``` - ## Performance testing In August 2023, we completed scale testing on 10k Windows hosts and 70k macOS hosts. Ultimately, we validated both server and host performance at that scale. diff --git a/docs/Using Fleet/manage-access.md b/docs/Using Fleet/manage-access.md index 48c1a21526..90774c86e1 100644 --- a/docs/Using Fleet/manage-access.md +++ b/docs/Using Fleet/manage-access.md @@ -75,7 +75,7 @@ GitOps is an API-only and write-only role that can be used on CI/CD pipelines. | View Apple mobile device management (MDM) certificate information | | | | ✅ | | | View Apple business manager (BM) information | | | | ✅ | | | Generate Apple mobile device management (MDM) certificate signing request (CSR) | | | | ✅ | | -| View disk encryption key for macOS hosts | ✅ | ✅ | ✅ | ✅ | | +| View disk encryption key for macOS and Windows hosts | ✅ | ✅ | ✅ | ✅ | | | Create edit and delete configuration profiles for macOS hosts | | | ✅ | ✅ | ✅ | | Execute MDM commands on macOS and Windows hosts*** | | | ✅ | ✅ | | | View results of MDM commands executed on macOS and Windows hosts*** | ✅ | ✅ | ✅ | ✅ | | diff --git a/ee/server/service/mdm.go b/ee/server/service/mdm.go index afd304a6c8..37cf537218 100644 --- a/ee/server/service/mdm.go +++ b/ee/server/service/mdm.go @@ -15,6 +15,7 @@ import ( "strings" "github.com/fleetdm/fleet/v4/pkg/file" + "github.com/fleetdm/fleet/v4/pkg/optjson" "github.com/fleetdm/fleet/v4/server/authz" "github.com/fleetdm/fleet/v4/server/contexts/ctxdb" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" @@ -890,12 +891,7 @@ func (svc *Service) getOrCreatePreassignTeam(ctx context.Context, groups []strin } payload.MDM = &fleet.TeamPayloadMDM{ - MacOSSettings: &fleet.MacOSSettings{ - // teams created by the match endpoint have disk encryption - // enabled by default. - // TODO: maybe make this configurable? - EnableDiskEncryption: true, - }, + EnableDiskEncryption: optjson.SetBool(true), MacOSSetup: &fleet.MacOSSetup{ MacOSSetupAssistant: ac.MDM.MacOSSetup.MacOSSetupAssistant, // NOTE: BootstrapPackage is currently ignored by svc.ModifyTeam and gets set @@ -968,3 +964,51 @@ func teamNameFromPreassignGroups(groups []string) string { return strings.Join(groups, " - ") } + +func (svc *Service) GetMDMDiskEncryptionSummary(ctx context.Context, teamID *uint) (*fleet.MDMDiskEncryptionSummary, error) { + // TODO: Consider adding a new generic OSSetting type or Windows-specific type for authz checks + // like this. + if err := svc.authz.Authorize(ctx, fleet.MDMAppleConfigProfile{TeamID: teamID}, fleet.ActionRead); err != nil { + return nil, ctxerr.Wrap(ctx, err) + } + var macOS fleet.MDMAppleFileVaultSummary + if m, err := svc.ds.GetMDMAppleFileVaultSummary(ctx, teamID); err != nil { + return nil, ctxerr.Wrap(ctx, err, "getting filevault summary") + } else if m != nil { + macOS = *m + } + + var windows fleet.MDMWindowsBitLockerSummary + if w, err := svc.ds.GetMDMWindowsBitLockerSummary(ctx, teamID); err != nil { + return nil, ctxerr.Wrap(ctx, err, "getting bitlocker summary") + } else if w != nil { + windows = *w + } + + return &fleet.MDMDiskEncryptionSummary{ + Verified: fleet.MDMPlatformsCounts{ + MacOS: macOS.Verified, + Windows: windows.Verified, + }, + Verifying: fleet.MDMPlatformsCounts{ + MacOS: macOS.Verifying, + Windows: windows.Verifying, + }, + ActionRequired: fleet.MDMPlatformsCounts{ + MacOS: macOS.ActionRequired, + Windows: windows.ActionRequired, + }, + Enforcing: fleet.MDMPlatformsCounts{ + MacOS: macOS.Enforcing, + Windows: windows.Enforcing, + }, + Failed: fleet.MDMPlatformsCounts{ + MacOS: macOS.Failed, + Windows: windows.Failed, + }, + RemovingEnforcement: fleet.MDMPlatformsCounts{ + MacOS: macOS.RemovingEnforcement, + Windows: windows.RemovingEnforcement, + }, + }, nil +} diff --git a/ee/server/service/teams.go b/ee/server/service/teams.go index 33a777323f..529408de47 100644 --- a/ee/server/service/teams.go +++ b/ee/server/service/teams.go @@ -150,13 +150,13 @@ func (svc *Service) ModifyTeam(ctx context.Context, teamID uint, payload fleet.T } } - if payload.MDM.MacOSSettings != nil { - if !appCfg.MDM.EnabledAndConfigured && payload.MDM.MacOSSettings.EnableDiskEncryption { + if payload.MDM.EnableDiskEncryption.Valid { + macOSDiskEncryptionUpdated = team.Config.MDM.EnableDiskEncryption != payload.MDM.EnableDiskEncryption.Value + if macOSDiskEncryptionUpdated && !appCfg.MDM.EnabledAndConfigured { return nil, fleet.NewInvalidArgumentError("macos_settings.enable_disk_encryption", `Couldn't update macos_settings because MDM features aren't turned on in Fleet. Use fleetctl generate mdm-apple and then fleet serve with mdm configuration to turn on MDM features.`) } - macOSDiskEncryptionUpdated = team.Config.MDM.MacOSSettings.EnableDiskEncryption != payload.MDM.MacOSSettings.EnableDiskEncryption - team.Config.MDM.MacOSSettings.EnableDiskEncryption = payload.MDM.MacOSSettings.EnableDiskEncryption + team.Config.MDM.EnableDiskEncryption = payload.MDM.EnableDiskEncryption.Value } if payload.MDM.MacOSSetup != nil { @@ -225,7 +225,7 @@ func (svc *Service) ModifyTeam(ctx context.Context, teamID uint, payload fleet.T } if macOSDiskEncryptionUpdated { var act fleet.ActivityDetails - if team.Config.MDM.MacOSSettings.EnableDiskEncryption { + if team.Config.MDM.EnableDiskEncryption { act = fleet.ActivityTypeEnabledMacosDiskEncryption{TeamID: &team.ID, TeamName: &team.Name} if err := svc.MDMAppleEnableFileVaultAndEscrow(ctx, &team.ID); err != nil { return nil, ctxerr.Wrap(ctx, err, "enable team filevault and escrow") @@ -802,6 +802,17 @@ func (svc *Service) createTeamFromSpec( `Couldn't update macos_setup because MDM features aren't turned on in Fleet. Use fleetctl generate mdm-apple and then fleet serve with mdm configuration to turn on MDM features.`)) } } + enableDiskEncryption := spec.MDM.EnableDiskEncryption.Value + if !spec.MDM.EnableDiskEncryption.Valid { + if de := macOSSettings.DeprecatedEnableDiskEncryption; de != nil { + enableDiskEncryption = *de + } + } + + if enableDiskEncryption && !defaults.MDM.AtLeastOnePlatformEnabledAndConfigured() { + return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("mdm", + `Couldn't edit enable_disk_encryption. Neither macOS MDM nor Windows is turned on. Visit https://fleetdm.com/docs/using-fleet to learn how to turn on MDM.`)) + } if dryRun { return &fleet.Team{Name: spec.Name}, nil @@ -813,9 +824,10 @@ func (svc *Service) createTeamFromSpec( AgentOptions: agentOptions, Features: features, MDM: fleet.TeamMDM{ - MacOSUpdates: spec.MDM.MacOSUpdates, - MacOSSettings: macOSSettings, - MacOSSetup: macOSSetup, + EnableDiskEncryption: enableDiskEncryption, + MacOSUpdates: spec.MDM.MacOSUpdates, + MacOSSettings: macOSSettings, + MacOSSetup: macOSSetup, }, }, Secrets: secrets, @@ -824,7 +836,7 @@ func (svc *Service) createTeamFromSpec( return nil, err } - if macOSSettings.EnableDiskEncryption { + if enableDiskEncryption && defaults.MDM.EnabledAndConfigured { if err := svc.MDMAppleEnableFileVaultAndEscrow(ctx, &tm.ID); err != nil { return nil, ctxerr.Wrap(ctx, err, "enable team filevault and escrow") } @@ -871,11 +883,23 @@ func (svc *Service) editTeamFromSpec( team.Config.MDM.MacOSUpdates = spec.MDM.MacOSUpdates } - oldMacOSDiskEncryption := team.Config.MDM.MacOSSettings.EnableDiskEncryption + oldMacOSDiskEncryption := team.Config.MDM.EnableDiskEncryption if err := svc.applyTeamMacOSSettings(ctx, spec, &team.Config.MDM.MacOSSettings); err != nil { return err } - newMacOSDiskEncryption := team.Config.MDM.MacOSSettings.EnableDiskEncryption + + // 1. if the spec has the new setting, use that + // 2. else if the spec has the deprecated setting, use that + // 3. otherwise, leave the setting untouched + if spec.MDM.EnableDiskEncryption.Valid { + team.Config.MDM.EnableDiskEncryption = spec.MDM.EnableDiskEncryption.Value + } else if de := team.Config.MDM.MacOSSettings.DeprecatedEnableDiskEncryption; de != nil { + team.Config.MDM.EnableDiskEncryption = *de + } + if team.Config.MDM.EnableDiskEncryption && !appCfg.MDM.AtLeastOnePlatformEnabledAndConfigured() { + return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("mdm", + `Couldn't edit enable_disk_encryption. Neither macOS MDM nor Windows is turned on. Visit https://fleetdm.com/docs/using-fleet to learn how to turn on MDM.`)) + } oldMacOSSetup := team.Config.MDM.MacOSSetup if spec.MDM.MacOSSetup.MacOSSetupAssistant.Set || spec.MDM.MacOSSetup.BootstrapPackage.Set { @@ -925,9 +949,9 @@ func (svc *Service) editTeamFromSpec( return err } } - if oldMacOSDiskEncryption != newMacOSDiskEncryption { + if appCfg.MDM.EnabledAndConfigured && oldMacOSDiskEncryption != team.Config.MDM.EnableDiskEncryption { var act fleet.ActivityDetails - if team.Config.MDM.MacOSSettings.EnableDiskEncryption { + if team.Config.MDM.EnableDiskEncryption { act = fleet.ActivityTypeEnabledMacosDiskEncryption{TeamID: &team.ID, TeamName: &team.Name} if err := svc.MDMAppleEnableFileVaultAndEscrow(ctx, &team.ID); err != nil { return ctxerr.Wrap(ctx, err, "enable team filevault and escrow") @@ -982,7 +1006,7 @@ func (svc *Service) applyTeamMacOSSettings(ctx context.Context, spec *fleet.Team } if (setFields["custom_settings"] && len(applyUpon.CustomSettings) > 0) || - (setFields["enable_disk_encryption"] && applyUpon.EnableDiskEncryption) { + (setFields["enable_disk_encryption"] && *applyUpon.DeprecatedEnableDiskEncryption) { field := "custom_settings" if !setFields["custom_settings"] { field = "enable_disk_encryption" @@ -1016,8 +1040,8 @@ func unmarshalWithGlobalDefaults(b *json.RawMessage) (fleet.Features, error) { func (svc *Service) updateTeamMDMAppleSettings(ctx context.Context, tm *fleet.Team, payload fleet.MDMAppleSettingsPayload) error { var didUpdate, didUpdateMacOSDiskEncryption bool if payload.EnableDiskEncryption != nil { - if tm.Config.MDM.MacOSSettings.EnableDiskEncryption != *payload.EnableDiskEncryption { - tm.Config.MDM.MacOSSettings.EnableDiskEncryption = *payload.EnableDiskEncryption + if tm.Config.MDM.EnableDiskEncryption != *payload.EnableDiskEncryption { + tm.Config.MDM.EnableDiskEncryption = *payload.EnableDiskEncryption didUpdate = true didUpdateMacOSDiskEncryption = true } @@ -1029,7 +1053,7 @@ func (svc *Service) updateTeamMDMAppleSettings(ctx context.Context, tm *fleet.Te } if didUpdateMacOSDiskEncryption { var act fleet.ActivityDetails - if tm.Config.MDM.MacOSSettings.EnableDiskEncryption { + if tm.Config.MDM.EnableDiskEncryption { act = fleet.ActivityTypeEnabledMacosDiskEncryption{TeamID: &tm.ID, TeamName: &tm.Name} if err := svc.MDMAppleEnableFileVaultAndEscrow(ctx, &tm.ID); err != nil { return ctxerr.Wrap(ctx, err, "enable team filevault and escrow") diff --git a/frontend/__mocks__/configMock.ts b/frontend/__mocks__/configMock.ts index 9f9e8f638c..7b5131a2da 100644 --- a/frontend/__mocks__/configMock.ts +++ b/frontend/__mocks__/configMock.ts @@ -126,6 +126,7 @@ const DEFAULT_CONFIG_MOCK: IConfig = { }, fleet_desktop: { transparency_url: "https://fleetdm.com/transparency" }, mdm: { + enable_disk_encryption: false, windows_enabled_and_configured: true, apple_bm_default_team: "Apples", apple_bm_enabled_and_configured: true, diff --git a/frontend/__mocks__/hostMock.ts b/frontend/__mocks__/hostMock.ts index c2e0993649..6834d0c703 100644 --- a/frontend/__mocks__/hostMock.ts +++ b/frontend/__mocks__/hostMock.ts @@ -1,7 +1,7 @@ import { IHost } from "interfaces/host"; -import { IHostMacMdmProfile } from "interfaces/mdm"; +import { IHostMdmProfile } from "interfaces/mdm"; -const DEFAULT_HOST_PROFILE_MOCK: IHostMacMdmProfile = { +const DEFAULT_HOST_PROFILE_MOCK: IHostMdmProfile = { profile_id: 1, name: "Test Profile", operation_type: "install", @@ -10,8 +10,8 @@ const DEFAULT_HOST_PROFILE_MOCK: IHostMacMdmProfile = { }; export const createMockHostMacMdmProfile = ( - overrides?: Partial -): IHostMacMdmProfile => { + overrides?: Partial +): IHostMdmProfile => { return { ...DEFAULT_HOST_PROFILE_MOCK, ...overrides }; }; @@ -53,6 +53,11 @@ const DEFAULT_HOST_MOCK: IHost = { enrollment_status: "Off", server_url: "https://www.example.com/1", profiles: [], + os_settings: { + disk_encryption: { + status: null, + }, + }, macos_settings: { disk_encryption: null, action_required: null, diff --git a/frontend/__mocks__/mdmMock.ts b/frontend/__mocks__/mdmMock.ts index c0584bf66a..5ffebcdbc3 100644 --- a/frontend/__mocks__/mdmMock.ts +++ b/frontend/__mocks__/mdmMock.ts @@ -36,6 +36,11 @@ const DEFAULT_HOST_MDM_DATA: IHostMdmData = { name: "MDM Solution", id: 1, profiles: [], + os_settings: { + disk_encryption: { + status: "verified", + }, + }, macos_settings: { disk_encryption: null, action_required: null, diff --git a/frontend/components/InfoBanner/InfoBanner.tsx b/frontend/components/InfoBanner/InfoBanner.tsx index f1af1558b5..1e02b38b6e 100644 --- a/frontend/components/InfoBanner/InfoBanner.tsx +++ b/frontend/components/InfoBanner/InfoBanner.tsx @@ -3,6 +3,7 @@ import classNames from "classnames"; import Icon from "components/Icon"; import Button from "components/buttons/Button"; +import { IconNames } from "components/icons"; const baseClass = "info-banner"; @@ -11,28 +12,36 @@ export interface IInfoBannerProps { className?: string; /** default light purple */ color?: "purple" | "purple-bold-border" | "yellow" | "grey"; + /** default 4px */ + borderRadius?: "large" | "xlarge"; pageLevel?: boolean; /** cta and link are mutually exclusive */ cta?: JSX.Element; /** closable and link are mutually exclusive */ closable?: boolean; link?: string; + icon?: IconNames; } const InfoBanner = ({ children, className, color = "purple", + borderRadius, pageLevel, cta, closable, link, + icon, }: IInfoBannerProps): JSX.Element => { const wrapperClasses = classNames( baseClass, `${baseClass}__${color}`, { + [`${baseClass}__${color}`]: !!color, + [`${baseClass}__border-radius-${borderRadius}`]: !!borderRadius, [`${baseClass}__page-banner`]: !!pageLevel, + [`${baseClass}__icon`]: !!icon, }, className ); @@ -42,6 +51,7 @@ const InfoBanner = ({ const content = ( <>
{children}
+ {(cta || closable) && (
{cta} diff --git a/frontend/components/InfoBanner/_styles.scss b/frontend/components/InfoBanner/_styles.scss index f5977b57f5..81635a1062 100644 --- a/frontend/components/InfoBanner/_styles.scss +++ b/frontend/components/InfoBanner/_styles.scss @@ -34,6 +34,20 @@ width: auto; } + &__border-radius-large { + border-radius: $border-radius-large; + } + + &__border-radius-xlarge { + border-radius: $border-radius-xlarge; + } + + &__info { + display: flex; + flex-direction: column; + gap: $pad-small; + } + &__cta { display: flex; align-items: center; @@ -59,4 +73,8 @@ } } } + + p { + margin: 0; + } } diff --git a/frontend/components/StatusIndicatorWithIcon/StatusIndicatorWithIcon.tsx b/frontend/components/StatusIndicatorWithIcon/StatusIndicatorWithIcon.tsx index e510748a7a..95ae8d5659 100644 --- a/frontend/components/StatusIndicatorWithIcon/StatusIndicatorWithIcon.tsx +++ b/frontend/components/StatusIndicatorWithIcon/StatusIndicatorWithIcon.tsx @@ -23,7 +23,10 @@ interface IStatusIndicatorWithIconProps { tooltipText: string | JSX.Element; position?: "top" | "bottom"; }; + layout?: "horizontal" | "vertical"; className?: string; + /** Classname to add to the value text */ + valueClassName?: string; } const statusIconNameMapping: Record = { @@ -38,13 +41,18 @@ const StatusIndicatorWithIcon = ({ status, value, tooltip, + layout = "horizontal", className, + valueClassName, }: IStatusIndicatorWithIconProps) => { const classNames = classnames(baseClass, className); const id = `status-${uniqueId()}`; + const valueClasses = classnames(`${baseClass}__value`, valueClassName, { + [`${baseClass}__value-vertical`]: layout === "vertical", + }); const valueContent = ( - + {value} diff --git a/frontend/components/StatusIndicatorWithIcon/_styles.scss b/frontend/components/StatusIndicatorWithIcon/_styles.scss index 6dea8de697..12d60c0f2d 100644 --- a/frontend/components/StatusIndicatorWithIcon/_styles.scss +++ b/frontend/components/StatusIndicatorWithIcon/_styles.scss @@ -1,4 +1,5 @@ .status-indicator-with-icon { + // default layout is horizontal &__value { display: inline-flex; align-items: center; @@ -8,4 +9,10 @@ margin-right: $pad-xsmall; } } + + // overrides for different layout + &__value-vertical { + flex-direction: column; + gap: $pad-xsmall; + } } diff --git a/frontend/interfaces/config.ts b/frontend/interfaces/config.ts index 1d42508ee4..cf15ccca64 100644 --- a/frontend/interfaces/config.ts +++ b/frontend/interfaces/config.ts @@ -1,95 +1,11 @@ /* Config interface is a flattened version of the fleet/config API response */ - import { IWebhookHostStatus, IWebhookFailingPolicies, IWebhookSoftwareVulnerabilities, } from "interfaces/webhook"; -import PropTypes from "prop-types"; import { IIntegrations } from "./integration"; -export default PropTypes.shape({ - org_name: PropTypes.string, - org_logo_url: PropTypes.string, - contact_url: PropTypes.string, - server_url: PropTypes.string, - live_query_disabled: PropTypes.bool, - enable_analytics: PropTypes.bool, - enable_smtp: PropTypes.bool, - configured: PropTypes.bool, - sender_address: PropTypes.string, - server: PropTypes.string, - port: PropTypes.number, - authentication_type: PropTypes.string, - user_name: PropTypes.string, - password: PropTypes.string, - enable_ssl_tls: PropTypes.bool, - authentication_method: PropTypes.string, - domain: PropTypes.string, - verify_sll_certs: PropTypes.bool, - enable_start_tls: PropTypes.bool, - entity_id: PropTypes.string, - idp_image_url: PropTypes.string, - metadata: PropTypes.string, - metadata_url: PropTypes.string, - idp_name: PropTypes.string, - enable_sso: PropTypes.bool, - enable_sso_idp_login: PropTypes.bool, - enable_jit_provisioning: PropTypes.bool, - host_expiry_enabled: PropTypes.bool, - host_expiry_window: PropTypes.number, - agent_options: PropTypes.string, - tier: PropTypes.string, - organization: PropTypes.string, - device_count: PropTypes.number, - expiration: PropTypes.string, - mdm: PropTypes.shape({ - enabled_and_configured: PropTypes.bool, - apple_bm_terms_expired: PropTypes.bool, - apple_bm_enabled_and_configured: PropTypes.bool, - windows_enabled_and_configured: PropTypes.bool, - macos_updates: PropTypes.shape({ - minimum_version: PropTypes.string, - deadline: PropTypes.string, - }), - }), - note: PropTypes.string, - // vulnerability_settings: PropTypes.any, TODO - enable_host_status_webhook: PropTypes.bool, - destination_url: PropTypes.string, - host_percentage: PropTypes.number, - days_count: PropTypes.number, - logging: PropTypes.shape({ - debug: PropTypes.bool, - json: PropTypes.bool, - result: PropTypes.shape({ - plugin: PropTypes.string, - config: PropTypes.shape({ - status_log_file: PropTypes.string, - result_log_file: PropTypes.string, - enable_log_rotation: PropTypes.bool, - enable_log_compression: PropTypes.bool, - }), - }), - status: PropTypes.shape({ - plugin: PropTypes.string, - config: PropTypes.shape({ - status_log_file: PropTypes.string, - result_log_file: PropTypes.string, - enable_log_rotation: PropTypes.bool, - enable_log_compression: PropTypes.bool, - }), - }), - }), - email: PropTypes.shape({ - backend: PropTypes.string, - config: PropTypes.shape({ - region: PropTypes.string, - source_arn: PropTypes.string, - }), - }), -}); - export interface ILicense { tier: string; device_count: number; @@ -113,6 +29,7 @@ export interface IMacOsMigrationSettings { } export interface IMdmConfig { + enable_disk_encryption: boolean; enabled_and_configured: boolean; apple_bm_default_team?: string; apple_bm_terms_expired: boolean; @@ -286,7 +203,10 @@ export interface IConfig { }; }; mdm: IMdmConfig; - mdm_enabled?: boolean; // TODO: remove when windows MDM is released. Only used for windows MDM dev currently. + /** This is the flag that determines if the windwos mdm feature flag is enabled. + TODO: WINDOWS FEATURE FLAG: remove when windows MDM is released. Only used for windows MDM dev currently. + */ + mdm_enabled?: boolean; } export interface IWebhookSettings { diff --git a/frontend/interfaces/host.ts b/frontend/interfaces/host.ts index 1927351b7d..ebfde247f4 100644 --- a/frontend/interfaces/host.ts +++ b/frontend/interfaces/host.ts @@ -8,9 +8,10 @@ import hostQueryResult from "./campaign"; import queryStatsInterface, { IQueryStats } from "./query_stats"; import { ILicense, IDeviceGlobalConfig } from "./config"; import { - IHostMacMdmProfile, + IHostMdmProfile, MdmEnrollmentStatus, BootstrapPackageStatus, + DiskEncryptionStatus, } from "./mdm"; export default PropTypes.shape({ @@ -90,18 +91,16 @@ export interface IMunkiData { version: string; } -type MacDiskEncryptionState = - | "applied" - | "action_required" - | "enforcing" - | "failed" - | "removing_enforcement" - | null; - type MacDiskEncryptionActionRequired = "log_out" | "rotate_key" | null; +export interface IOSSettings { + disk_encryption: { + status: DiskEncryptionStatus | null; + }; +} + interface IMdmMacOsSettings { - disk_encryption: MacDiskEncryptionState | null; + disk_encryption: DiskEncryptionStatus | null; action_required: MacDiskEncryptionActionRequired | null; } @@ -117,7 +116,8 @@ export interface IHostMdmData { name?: string; server_url: string | null; id?: number; - profiles: IHostMacMdmProfile[] | null; + profiles: IHostMdmProfile[] | null; + os_settings?: IOSSettings; macos_settings?: IMdmMacOsSettings; macos_setup?: IMdmMacOsSetup; } @@ -210,7 +210,7 @@ export interface IHost { osquery_version: string; os_version: string; build: string; - platform_like: string; + platform_like: string; // TODO: replace with more specific union type code_name: string; uptime: number; memory: number; diff --git a/frontend/interfaces/mdm.ts b/frontend/interfaces/mdm.ts index 25f0f0d7ec..1120e1ab62 100644 --- a/frontend/interfaces/mdm.ts +++ b/frontend/interfaces/mdm.ts @@ -22,8 +22,6 @@ export const MDM_ENROLLMENT_STATUS = { export type MdmEnrollmentStatus = keyof typeof MDM_ENROLLMENT_STATUS; -export type ProfileSummaryResponse = Record; - export interface IMdmStatusCardData { status: MdmEnrollmentStatus; hosts: number; @@ -74,16 +72,15 @@ export type MdmProfileStatus = "verified" | "verifying" | "pending" | "failed"; export type MacMdmProfileOperationType = "remove" | "install"; -export interface IHostMacMdmProfile { +export interface IHostMdmProfile { profile_id: number; name: string; - // identifier?: string; // TODO: add when API is updated to return this - operation_type: MacMdmProfileOperationType; + operation_type: MacMdmProfileOperationType | null; status: MdmProfileStatus; detail: string; } -export type FileVaultProfileStatus = +export type DiskEncryptionStatus = | "verified" | "verifying" | "action_required" @@ -91,9 +88,18 @@ export type FileVaultProfileStatus = | "failed" | "removing_enforcement"; -// // TODO: update when list profiles API returns identifier -// export const FLEET_FILEVAULT_PROFILE_IDENTIFIER = -// "com.fleetdm.fleet.mdm.filevault"; +/** Currently windows disk enxryption status will only be one of these four +values. In the future we may add more. */ +export type IWindowsDiskEncryptionStatus = Extract< + DiskEncryptionStatus, + "verified" | "verifying" | "enforcing" | "failed" +>; + +export const isWindowsDiskEncryptionStatus = ( + status: DiskEncryptionStatus +): status is IWindowsDiskEncryptionStatus => { + return !["action_required", "removing_enforcement"].includes(status); +}; export const FLEET_FILEVAULT_PROFILE_DISPLAY_NAME = "Disk encryption"; diff --git a/frontend/interfaces/team.ts b/frontend/interfaces/team.ts index 3c06c11c36..4487dba48d 100644 --- a/frontend/interfaces/team.ts +++ b/frontend/interfaces/team.ts @@ -44,6 +44,7 @@ export interface ITeam extends ITeamSummary { secrets?: IEnrollSecret[]; role?: UserRole; // role value is included when the team is in the context of a user mdm?: { + enable_disk_encryption: boolean; macos_updates: { minimum_version: string; deadline: string; diff --git a/frontend/pages/LoginSuccessfulPage/_styles.scss b/frontend/pages/LoginSuccessfulPage/_styles.scss index 07e1429fa7..b3b962d3c9 100644 --- a/frontend/pages/LoginSuccessfulPage/_styles.scss +++ b/frontend/pages/LoginSuccessfulPage/_styles.scss @@ -3,7 +3,7 @@ margin-top: 20px; padding: $pad-xxlarge; text-align: center; - border-radius: 10px; + border-radius: $border-radius-xlarge; z-index: 0; align-self: center; transform: translateY(80px); diff --git a/frontend/pages/ManageControlsPage/OSSettings/AggregateMacSettingsIndicators/AggregateMacSettingsIndicators.tsx b/frontend/pages/ManageControlsPage/OSSettings/AggregateMacSettingsIndicators/AggregateMacSettingsIndicators.tsx deleted file mode 100644 index 7c4b490601..0000000000 --- a/frontend/pages/ManageControlsPage/OSSettings/AggregateMacSettingsIndicators/AggregateMacSettingsIndicators.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import React from "react"; - -import paths from "router/paths"; -import { buildQueryStringFromParams } from "utilities/url"; -import { MdmProfileStatus, ProfileSummaryResponse } from "interfaces/mdm"; -import MacSettingsIndicator from "pages/hosts/details/MacSettingsIndicator"; - -import { IconNames } from "components/icons"; -import Spinner from "components/Spinner"; - -const baseClass = "aggregate-mac-settings-indicators"; - -interface IAggregateDisplayOption { - value: MdmProfileStatus; - text: string; - iconName: IconNames; - tooltipText: string; -} - -const AGGREGATE_STATUS_DISPLAY_OPTIONS: IAggregateDisplayOption[] = [ - { - value: "verified", - text: "Verified", - iconName: "success", - tooltipText: - "These hosts installed all configuration profiles. Fleet verified with osquery.", - }, - { - value: "verifying", - text: "Verifying", - iconName: "success-partial", - tooltipText: - "These hosts acknowledged all MDM commands to install configuration profiles. " + - "Fleet is verifying the profiles are installed with osquery.", - }, - { - value: "pending", - text: "Pending", - iconName: "pending-partial", - tooltipText: - "These hosts will receive MDM commands to install configuration profiles when the hosts come online.", - }, - { - value: "failed", - text: "Failed", - iconName: "error", - tooltipText: - "These hosts failed to install configuration profiles. Click on a host to view error(s).", - }, -]; - -interface AggregateMacSettingsIndicatorsProps { - isLoading: boolean; - teamId: number; - aggregateProfileStatusData?: ProfileSummaryResponse; -} - -const AggregateMacSettingsIndicators = ({ - isLoading, - teamId, - aggregateProfileStatusData, -}: AggregateMacSettingsIndicatorsProps) => { - const indicators = AGGREGATE_STATUS_DISPLAY_OPTIONS.map((status) => { - if (!aggregateProfileStatusData) return null; - - const { value, text, iconName, tooltipText } = status; - const count = aggregateProfileStatusData[value]; - - return ( - - ); - }); - - if (isLoading) { - return ( -
- -
- ); - } - - return
{indicators}
; -}; - -export default AggregateMacSettingsIndicators; diff --git a/frontend/pages/ManageControlsPage/OSSettings/AggregateMacSettingsIndicators/index.ts b/frontend/pages/ManageControlsPage/OSSettings/AggregateMacSettingsIndicators/index.ts deleted file mode 100644 index 1032f0ac50..0000000000 --- a/frontend/pages/ManageControlsPage/OSSettings/AggregateMacSettingsIndicators/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./AggregateMacSettingsIndicators"; diff --git a/frontend/pages/ManageControlsPage/OSSettings/OSSettings.tsx b/frontend/pages/ManageControlsPage/OSSettings/OSSettings.tsx index dddbffc048..9eb28475ba 100644 --- a/frontend/pages/ManageControlsPage/OSSettings/OSSettings.tsx +++ b/frontend/pages/ManageControlsPage/OSSettings/OSSettings.tsx @@ -4,12 +4,11 @@ import { useQuery } from "react-query"; import { AppContext } from "context/app"; import SideNav from "pages/admin/components/SideNav"; -import { ProfileSummaryResponse } from "interfaces/mdm"; import { API_NO_TEAM_ID, APP_CONTEXT_NO_TEAM_ID } from "interfaces/team"; import mdmAPI from "services/entities/mdm"; import OS_SETTINGS_NAV_ITEMS from "./OSSettingsNavItems"; -import AggregateMacSettingsIndicators from "./AggregateMacSettingsIndicators"; +import ProfileStatusAggregate from "./ProfileStatusAggregate"; import TurnOnMdmMessage from "../components/TurnOnMdmMessage"; const baseClass = "os-settings"; @@ -40,9 +39,10 @@ const OSSettings = ({ data: aggregateProfileStatusData, refetch: refetchAggregateProfileStatus, isLoading: isLoadingAggregateProfileStatus, - } = useQuery( + } = useQuery( ["aggregateProfileStatuses", teamId], - () => mdmAPI.getAggregateProfileStatuses(teamId), + () => + mdmAPI.getAggregateProfileStatuses(teamId, config?.mdm_enabled ?? false), { refetchOnWindowFocus: false, retry: false, @@ -50,7 +50,10 @@ const OSSettings = ({ ); // MDM is not on so show messaging for user to enable it. - if (!config?.mdm.enabled_and_configured) { + if ( + !config?.mdm.enabled_and_configured && + !config?.mdm.windows_enabled_and_configured + ) { return ; } @@ -67,7 +70,7 @@ const OSSettings = ({

Remotely enforce settings on macOS hosts assigned to this team.

- { + const generateFilterHostsByStatusLink = () => { + return `${paths.MANAGE_HOSTS}?${buildQueryStringFromParams({ + team_id: teamId, + macos_settings: statusValue, + })}`; + }; + + return ( + + ); +}; + +interface ProfileStatusAggregateProps { + isLoading: boolean; + teamId: number; + aggregateProfileStatusData?: ProfileStatusSummaryResponse; +} + +const ProfileStatusAggregate = ({ + isLoading, + teamId, + aggregateProfileStatusData, +}: ProfileStatusAggregateProps) => { + if (!aggregateProfileStatusData) return null; + + if (isLoading) { + return ( +
+ +
+ ); + } + + const indicators = AGGREGATE_STATUS_DISPLAY_OPTIONS.map((status) => { + const { value, text, iconName, tooltipText } = status; + const count = aggregateProfileStatusData[value]; + + return ( + + ); + }); + + return
{indicators}
; +}; + +export default ProfileStatusAggregate; diff --git a/frontend/pages/ManageControlsPage/OSSettings/ProfileStatusAggregate/ProfileStatusAggregateOptions.ts b/frontend/pages/ManageControlsPage/OSSettings/ProfileStatusAggregate/ProfileStatusAggregateOptions.ts new file mode 100644 index 0000000000..8dbe94abf8 --- /dev/null +++ b/frontend/pages/ManageControlsPage/OSSettings/ProfileStatusAggregate/ProfileStatusAggregateOptions.ts @@ -0,0 +1,43 @@ +import { MdmProfileStatus } from "interfaces/mdm"; +import { IndicatorStatus } from "components/StatusIndicatorWithIcon/StatusIndicatorWithIcon"; + +interface IAggregateDisplayOption { + value: MdmProfileStatus; + text: string; + iconName: IndicatorStatus; + tooltipText: string; +} + +const AGGREGATE_STATUS_DISPLAY_OPTIONS: IAggregateDisplayOption[] = [ + { + value: "verified", + text: "Verified", + iconName: "success", + tooltipText: + "These hosts applied all OS settings. Fleet verified with osquery.", + }, + { + value: "verifying", + text: "Verifying", + iconName: "successPartial", + tooltipText: + "These hosts acknowledged all MDM commands to apply OS settings. " + + "Fleet is verifying the OS settings are applied with osquery.", + }, + { + value: "pending", + text: "Pending", + iconName: "pendingPartial", + tooltipText: + "These hosts will receive MDM command to apply OS settings when the host come online.", + }, + { + value: "failed", + text: "Failed", + iconName: "error", + tooltipText: + "These host failed to apply the latest OS settings. Click on a host to view error(s).", + }, +]; + +export default AGGREGATE_STATUS_DISPLAY_OPTIONS; diff --git a/frontend/pages/ManageControlsPage/OSSettings/AggregateMacSettingsIndicators/_styles.scss b/frontend/pages/ManageControlsPage/OSSettings/ProfileStatusAggregate/_styles.scss similarity index 73% rename from frontend/pages/ManageControlsPage/OSSettings/AggregateMacSettingsIndicators/_styles.scss rename to frontend/pages/ManageControlsPage/OSSettings/ProfileStatusAggregate/_styles.scss index 7665954493..89744cf6cc 100644 --- a/frontend/pages/ManageControlsPage/OSSettings/AggregateMacSettingsIndicators/_styles.scss +++ b/frontend/pages/ManageControlsPage/OSSettings/ProfileStatusAggregate/_styles.scss @@ -1,16 +1,16 @@ -.aggregate-mac-settings-indicators { +.profile-status-aggregate { display: flex; height: 94px; border-top: 1px solid #e2e4ea; border-bottom: 1px solid #e2e4ea; border-left: 1px solid #e2e4ea; - border-radius: 6px; + border-radius: $border-radius-large; &__loading-spinner { margin: auto; } - .aggregate-mac-settings-indicator { + &__profile-status-count { flex-grow: 1; display: flex; @@ -29,13 +29,17 @@ font-weight: $regular; } - .settings-indicator { + .profile-status-indicator { flex-direction: column; } } - .aggregate-mac-settings-indicator:last-child { + &__profile-status-count:last-child { border-top-right-radius: 6px; border-bottom-right-radius: 6px; } + + &__status-indicator-value { + font-weight: $bold; + } } diff --git a/frontend/pages/ManageControlsPage/OSSettings/ProfileStatusAggregate/index.ts b/frontend/pages/ManageControlsPage/OSSettings/ProfileStatusAggregate/index.ts new file mode 100644 index 0000000000..a29bd5e10d --- /dev/null +++ b/frontend/pages/ManageControlsPage/OSSettings/ProfileStatusAggregate/index.ts @@ -0,0 +1 @@ +export { default } from "./ProfileStatusAggregate"; diff --git a/frontend/pages/ManageControlsPage/OSSettings/cards/DiskEncryption/DiskEncryption.tsx b/frontend/pages/ManageControlsPage/OSSettings/cards/DiskEncryption/DiskEncryption.tsx index 6edd4f35f5..c8cd1d2bc3 100644 --- a/frontend/pages/ManageControlsPage/OSSettings/cards/DiskEncryption/DiskEncryption.tsx +++ b/frontend/pages/ManageControlsPage/OSSettings/cards/DiskEncryption/DiskEncryption.tsx @@ -31,7 +31,7 @@ const DiskEncryption = ({ const defaultShowDiskEncryption = currentTeamId ? false - : config?.mdm.macos_settings.enable_disk_encryption ?? false; + : config?.mdm.enable_disk_encryption ?? false; const [isLoadingTeam, setIsLoadingTeam] = useState(true); @@ -67,8 +67,7 @@ const DiskEncryption = ({ enabled: currentTeamId !== 0, select: (res) => res.team, onSuccess: (res) => { - const enableDiskEncryption = - res.mdm?.macos_settings.enable_disk_encryption ?? false; + const enableDiskEncryption = res.mdm?.enable_disk_encryption ?? false; setDiskEncryptionEnabled(enableDiskEncryption); setShowAggregate(enableDiskEncryption); setIsLoadingTeam(false); @@ -100,6 +99,19 @@ const DiskEncryption = ({ setIsLoadingTeam(false); } + const createDescriptionText = () => { + // table is showing disk encryption status. + if (showAggregate) { + return "If turned on, hosts' disk encryption keys will be stored in Fleet. "; + } + + const isWindowsFeatureFlagEnabled = config?.mdm_enabled ?? false; + const dynamicText = isWindowsFeatureFlagEnabled + ? " and “BitLocker” on Windows" + : ""; + return `Also known as “FileVault” on macOS${dynamicText}. If turned on, hosts' disk encryption keys will be stored in Fleet. `; + }; + return (

Disk encryption

@@ -124,8 +136,7 @@ const DiskEncryption = ({ On

- Apple calls this “FileVault.” If turned on, hosts' disk - encryption keys will be stored in Fleet.{" "} + {createDescriptionText()} { + const { config } = useContext(AppContext); + const { data: diskEncryptionStatusData, error: diskEncryptionStatusError, - } = useQuery( + } = useQuery( ["disk-encryption-summary", currentTeamId], - () => mdmAPI.getDiskEncryptionAggregate(currentTeamId), + () => mdmAPI.getDiskEncryptionSummary(currentTeamId), { refetchOnWindowFocus: false, retry: false, } ); - const tableHeaders = generateTableHeaders(); - - const tableData = generateTableData(diskEncryptionStatusData, currentTeamId); + // TODO: WINDOWS FEATURE FLAG: remove this when windows feature flag is removed. + // this is used to conditianlly show "View all hosts" link in table cells. + const windowsFeatureFlagEnabled = config?.mdm_enabled ?? false; + const tableHeaders = generateTableHeaders(windowsFeatureFlagEnabled); + const tableData = generateTableData( + windowsFeatureFlagEnabled, + diskEncryptionStatusData, + currentTeamId + ); if (diskEncryptionStatusError) { return ; @@ -53,8 +59,7 @@ const DiskEncryptionTable = ({ currentTeamId }: IDiskEncryptionTableProps) => { isLoading={false} showMarkAllPages={false} isAllPagesSelected={false} - defaultSortHeader={DEFAULT_SORT_HEADER} - defaultSortDirection={DEFAULT_SORT_DIRECTION} + manualSortBy disableTableHeader disablePagination disableCount diff --git a/frontend/pages/ManageControlsPage/OSSettings/cards/DiskEncryption/components/DiskEncryptionTable/DiskEncryptionTableConfig.tsx b/frontend/pages/ManageControlsPage/OSSettings/cards/DiskEncryption/components/DiskEncryptionTable/DiskEncryptionTableConfig.tsx index 068459d69c..9a5b7baf5d 100644 --- a/frontend/pages/ManageControlsPage/OSSettings/cards/DiskEncryption/components/DiskEncryptionTable/DiskEncryptionTableConfig.tsx +++ b/frontend/pages/ManageControlsPage/OSSettings/cards/DiskEncryption/components/DiskEncryptionTable/DiskEncryptionTableConfig.tsx @@ -1,7 +1,11 @@ import React from "react"; -import { FileVaultProfileStatus } from "interfaces/mdm"; -import { IFileVaultSummaryResponse } from "services/entities/mdm"; +import { DiskEncryptionStatus } from "interfaces/mdm"; +import { + IDiskEncryptionStatusAggregate, + IDiskEncryptionSummaryResponse, +} from "services/entities/mdm"; +import { DISK_ENCRYPTION_QUERY_PARAM_NAME } from "services/entities/hosts"; import TextCell from "components/TableContainer/DataTable/TextCell"; import HeaderCell from "components/TableContainer/DataTable/HeaderCell"; @@ -12,7 +16,7 @@ import { IndicatorStatus } from "components/StatusIndicatorWithIcon/StatusIndica interface IStatusCellValue { displayName: string; statusName: IndicatorStatus; - value: FileVaultProfileStatus; + value: DiskEncryptionStatus; tooltip?: string | JSX.Element; } @@ -28,6 +32,7 @@ interface ICellProps { }; row: { original: { + includeWindows: boolean; status: IStatusCellValue; teamId: number; }; @@ -72,15 +77,53 @@ const defaultTableHeaders: IDataColumn[] = [ }, }, { - title: "Hosts", + title: "macOS hosts", Header: (cellProps: IHeaderProps) => ( ), - accessor: "hosts", + disableSortBy: true, + accessor: "macosHosts", + Cell: ({ + cell: { value: aggregateCount }, + row: { original }, + }: ICellProps) => { + return ( +

+ <>{val}} /> + {/* TODO: WINDOWS FEATURE FLAG: remove this conditional when windows mdm + is released. the view all UI will show in the windows column when we + release the feature. */} + {!original.includeWindows && ( + + )} +
+ ); + }, + }, +]; + +const windowsTableHeader: IDataColumn[] = [ + { + title: "Windows hosts", + Header: (cellProps: IHeaderProps) => ( + + ), + disableSortBy: true, + accessor: "windowsHosts", Cell: ({ cell: { value: aggregateCount }, row: { original }, @@ -91,7 +134,7 @@ const defaultTableHeaders: IDataColumn[] = [ @@ -101,15 +144,17 @@ const defaultTableHeaders: IDataColumn[] = [ }, ]; -type StatusNames = keyof IFileVaultSummaryResponse; - -type StatusEntry = [StatusNames, number]; - -export const generateTableHeaders = (): IDataColumn[] => { +// TODO: WINDOWS FEATURE FLAG: return all headers when windows feature flag is removed. +export const generateTableHeaders = ( + includeWindows: boolean +): IDataColumn[] => { + return includeWindows + ? [...defaultTableHeaders, ...windowsTableHeader] + : defaultTableHeaders; return defaultTableHeaders; }; -const STATUS_CELL_VALUES: Record = { +const STATUS_CELL_VALUES: Record = { verified: { displayName: "Verified", statusName: "success", @@ -122,8 +167,8 @@ const STATUS_CELL_VALUES: Record = { statusName: "successPartial", value: "verifying", tooltip: - "These hosts acknowledged the MDM command to install disk encryption profile. " + - "Fleet is verifying with osquery and retrieving the disk encryption key. This may take up to one hour.", + "These hosts acknowledged the MDM command to turn on disk encryption. Fleet is verifying with " + + "osquery and retrieving the disk encryption key. This may take up to one hour.", }, action_required: { displayName: "Action required (pending)", @@ -141,7 +186,7 @@ const STATUS_CELL_VALUES: Record = { statusName: "pendingPartial", value: "enforcing", tooltip: - "These hosts will receive the MDM command to install the disk encryption profile when the hosts come online.", + "These hosts will receive the MDM command to turn on disk encryption when the hosts come online.", }, failed: { displayName: "Failed", @@ -153,21 +198,41 @@ const STATUS_CELL_VALUES: Record = { statusName: "pendingPartial", value: "removing_enforcement", tooltip: - "These hosts will receive the MDM command to remove the disk encryption profile when the hosts come online.", + "These hosts will receive the MDM command to turn off disk encryption when the hosts come online.", }, }; +type StatusEntry = [DiskEncryptionStatus, IDiskEncryptionStatusAggregate]; + +// Order of the status column. We want the order to always be the same. +const STATUS_ORDER = [ + "verified", + "verifying", + "failed", + "action_required", + "enforcing", + "removing_enforcement", +] as const; + export const generateTableData = ( - data?: IFileVaultSummaryResponse, + // TODO: WINDOWS FEATURE FLAG: remove includeWindows when windows feature flag is removed. + // This is used to conditionally show "View all hosts" link in table cells. + includeWindows: boolean, + data?: IDiskEncryptionSummaryResponse, currentTeamId?: number ) => { if (!data) return []; - const entries = Object.entries(data) as StatusEntry[]; - return entries.map(([status, numHosts]) => ({ - // eslint-disable-next-line object-shorthand + const rowFromStatusEntry = ( + status: DiskEncryptionStatus, + statusAggregate: IDiskEncryptionStatusAggregate + ) => ({ + includeWindows, status: STATUS_CELL_VALUES[status], - hosts: numHosts, + macosHosts: statusAggregate.macos, + windowsHosts: statusAggregate.windows, teamId: currentTeamId, - })); + }); + + return STATUS_ORDER.map((status) => rowFromStatusEntry(status, data[status])); }; diff --git a/frontend/pages/ManageControlsPage/OSSettings/cards/DiskEncryption/components/DiskEncryptionTable/_styles.scss b/frontend/pages/ManageControlsPage/OSSettings/cards/DiskEncryption/components/DiskEncryptionTable/_styles.scss index ee3e1c025e..c2e35efe6b 100644 --- a/frontend/pages/ManageControlsPage/OSSettings/cards/DiskEncryption/components/DiskEncryptionTable/_styles.scss +++ b/frontend/pages/ManageControlsPage/OSSettings/cards/DiskEncryption/components/DiskEncryptionTable/_styles.scss @@ -1,7 +1,4 @@ .disk-encryption-table { - padding: $pad-xxlarge; - border: 1px solid $ui-fleet-black-10; - border-radius: $border-radius; margin-bottom: $pad-xxlarge; .data-table-block .data-table tbody td .w250 { diff --git a/frontend/pages/ManageControlsPage/components/TurnOnMdmMessage/TurnOnMdmMessage.tsx b/frontend/pages/ManageControlsPage/components/TurnOnMdmMessage/TurnOnMdmMessage.tsx index 545887a8f5..ce052c31e1 100644 --- a/frontend/pages/ManageControlsPage/components/TurnOnMdmMessage/TurnOnMdmMessage.tsx +++ b/frontend/pages/ManageControlsPage/components/TurnOnMdmMessage/TurnOnMdmMessage.tsx @@ -30,7 +30,7 @@ const TurnOnMdmMessage = ({ router }: ITurnOnMdmMessageProps) => { return ( diff --git a/frontend/pages/hosts/ManageHostsPage/HostsPageConfig.tsx b/frontend/pages/hosts/ManageHostsPage/HostsPageConfig.tsx index 2fbaac795a..b4102d321e 100644 --- a/frontend/pages/hosts/ManageHostsPage/HostsPageConfig.tsx +++ b/frontend/pages/hosts/ManageHostsPage/HostsPageConfig.tsx @@ -1,6 +1,7 @@ import React from "react"; import Icon from "components/Icon"; +import { DISK_ENCRYPTION_QUERY_PARAM_NAME } from "services/entities/hosts"; export const MANAGE_HOSTS_PAGE_FILTER_KEYS = [ "query", @@ -17,7 +18,7 @@ export const MANAGE_HOSTS_PAGE_FILTER_KEYS = [ "os_version", "munki_issue_id", "low_disk_space", - "macos_settings_disk_encryption", + DISK_ENCRYPTION_QUERY_PARAM_NAME, "bootstrap_package", ] as const; diff --git a/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx b/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx index ab6a3b4deb..6eedf30627 100644 --- a/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx +++ b/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx @@ -18,6 +18,7 @@ import labelsAPI, { ILabelsResponse } from "services/entities/labels"; import teamsAPI, { ILoadTeamsResponse } from "services/entities/teams"; import globalPoliciesAPI from "services/entities/global_policies"; import hostsAPI, { + DISK_ENCRYPTION_QUERY_PARAM_NAME, ILoadHostsQueryKey, ILoadHostsResponse, ISortOption, @@ -49,7 +50,7 @@ import { IOperatingSystemVersion } from "interfaces/operating_system"; import { IPolicy, IStoredPolicyResponse } from "interfaces/policy"; import { ITeam } from "interfaces/team"; import { IEmptyTableProps } from "interfaces/empty_table"; -import { FileVaultProfileStatus, BootstrapPackageStatus } from "interfaces/mdm"; +import { DiskEncryptionStatus, BootstrapPackageStatus } from "interfaces/mdm"; import sortUtils from "utilities/sort"; import { @@ -232,8 +233,8 @@ const ManageHostsPage = ({ ? parseInt(queryParams.low_disk_space, 10) : undefined; const missingHosts = queryParams?.status === "missing"; - const diskEncryptionStatus: FileVaultProfileStatus | undefined = - queryParams?.macos_settings_disk_encryption; + const diskEncryptionStatus: DiskEncryptionStatus | undefined = + queryParams?.[DISK_ENCRYPTION_QUERY_PARAM_NAME]; const bootstrapPackageStatus: BootstrapPackageStatus | undefined = queryParams?.bootstrap_package; @@ -558,7 +559,7 @@ const ManageHostsPage = ({ }; const handleChangeDiskEncryptionStatusFilter = ( - newStatus: FileVaultProfileStatus + newStatus: DiskEncryptionStatus ) => { handleResetPageIndex(); @@ -569,7 +570,7 @@ const ManageHostsPage = ({ routeParams, queryParams: { ...queryParams, - macos_settings_disk_encryption: newStatus, + [DISK_ENCRYPTION_QUERY_PARAM_NAME]: newStatus, page: 0, // resets page index }, }) @@ -768,7 +769,7 @@ const ManageHostsPage = ({ newQueryParams.os_version = osVersion; } else if (diskEncryptionStatus && isPremiumTier) { // Premium feature only - newQueryParams.macos_settings_disk_encryption = diskEncryptionStatus; + newQueryParams[DISK_ENCRYPTION_QUERY_PARAM_NAME] = diskEncryptionStatus; } else if (bootstrapPackageStatus && isPremiumTier) { newQueryParams.bootstrap_package = bootstrapPackageStatus; } diff --git a/frontend/pages/hosts/ManageHostsPage/components/CustomLabelGroupHeading/_styles.scss b/frontend/pages/hosts/ManageHostsPage/components/CustomLabelGroupHeading/_styles.scss index 38a1599708..d4cb6bed09 100644 --- a/frontend/pages/hosts/ManageHostsPage/components/CustomLabelGroupHeading/_styles.scss +++ b/frontend/pages/hosts/ManageHostsPage/components/CustomLabelGroupHeading/_styles.scss @@ -33,7 +33,7 @@ line-height: 1.5; background-color: $ui-light-grey; border: solid 1px $ui-fleet-black-10; - border-radius: 4px; + border-radius: $border-radius; font-size: $small; padding: 9.5px 12px 9.5px 36px; color: $core-fleet-blue; @@ -70,7 +70,7 @@ color: $core-vibrant-red; border: 1px solid $core-vibrant-red; box-sizing: border-box; - border-radius: 4px; + border-radius: $border-radius; &:focus { border-color: $ui-error; diff --git a/frontend/pages/hosts/ManageHostsPage/components/DiskEncryptionStatusFilter/DiskEncryptionStatusFilter.tsx b/frontend/pages/hosts/ManageHostsPage/components/DiskEncryptionStatusFilter/DiskEncryptionStatusFilter.tsx index 2405fd6d15..4e49123919 100644 --- a/frontend/pages/hosts/ManageHostsPage/components/DiskEncryptionStatusFilter/DiskEncryptionStatusFilter.tsx +++ b/frontend/pages/hosts/ManageHostsPage/components/DiskEncryptionStatusFilter/DiskEncryptionStatusFilter.tsx @@ -4,7 +4,7 @@ import { IDropdownOption } from "interfaces/dropdownOption"; // @ts-ignore import Dropdown from "components/forms/fields/Dropdown"; -import { FileVaultProfileStatus } from "interfaces/mdm"; +import { DiskEncryptionStatus } from "interfaces/mdm"; const baseClass = "disk-encryption-status-filter"; @@ -42,8 +42,8 @@ const DISK_ENCRYPTION_STATUS_OPTIONS: IDropdownOption[] = [ ]; interface IDiskEncryptionStatusFilterProps { - diskEncryptionStatus: FileVaultProfileStatus; - onChange: (value: FileVaultProfileStatus) => void; + diskEncryptionStatus: DiskEncryptionStatus; + onChange: (value: DiskEncryptionStatus) => void; } const DiskEncryptionStatusFilter = ({ diff --git a/frontend/pages/hosts/ManageHostsPage/components/FilterPill/_styles.scss b/frontend/pages/hosts/ManageHostsPage/components/FilterPill/_styles.scss index a7badf53a1..8f4a9c76a3 100644 --- a/frontend/pages/hosts/ManageHostsPage/components/FilterPill/_styles.scss +++ b/frontend/pages/hosts/ManageHostsPage/components/FilterPill/_styles.scss @@ -4,7 +4,7 @@ align-items: center; padding: 6px 12px; border: 1px solid $ui-fleet-black-25; - border-radius: 4px; + border-radius: $border-radius; box-shadow: none; color: $core-fleet-black; font-size: $xx-small; diff --git a/frontend/pages/hosts/ManageHostsPage/components/HostsFilterBlock/HostsFilterBlock.tsx b/frontend/pages/hosts/ManageHostsPage/components/HostsFilterBlock/HostsFilterBlock.tsx index 0e64c52c74..1ff7287cb3 100644 --- a/frontend/pages/hosts/ManageHostsPage/components/HostsFilterBlock/HostsFilterBlock.tsx +++ b/frontend/pages/hosts/ManageHostsPage/components/HostsFilterBlock/HostsFilterBlock.tsx @@ -7,7 +7,7 @@ import { IOperatingSystemVersion, } from "interfaces/operating_system"; import { - FileVaultProfileStatus, + DiskEncryptionStatus, BootstrapPackageStatus, IMdmSolution, MDM_ENROLLMENT_STATUS, @@ -15,7 +15,10 @@ import { import { IMunkiIssuesAggregate } from "interfaces/macadmins"; import { ISoftware } from "interfaces/software"; import { IPolicy } from "interfaces/policy"; -import { MacSettingsStatusQueryParam } from "services/entities/hosts"; +import { + DISK_ENCRYPTION_QUERY_PARAM_NAME, + MacSettingsStatusQueryParam, +} from "services/entities/hosts"; import { PLATFORM_LABEL_DISPLAY_NAMES, @@ -60,7 +63,7 @@ interface IHostsFilterBlockProps { osVersions?: IOperatingSystemVersion[]; softwareDetails: ISoftware | null; mdmSolutionDetails: IMdmSolution | null; - diskEncryptionStatus?: FileVaultProfileStatus; + diskEncryptionStatus?: DiskEncryptionStatus; bootstrapPackageStatus?: BootstrapPackageStatus; }; selectedLabel?: ILabel; @@ -68,9 +71,7 @@ interface IHostsFilterBlockProps { handleClearRouteParam: () => void; handleClearFilter: (omitParams: string[]) => void; onChangePoliciesFilter: (response: PolicyResponse) => void; - onChangeDiskEncryptionStatusFilter: ( - response: FileVaultProfileStatus - ) => void; + onChangeDiskEncryptionStatusFilter: (response: DiskEncryptionStatus) => void; onChangeBootstrapPackageStatusFilter: ( response: BootstrapPackageStatus ) => void; @@ -376,8 +377,8 @@ const HostsFilterBlock = ({ onChange={onChangeDiskEncryptionStatusFilter} /> handleClearFilter(["macos_settings_disk_encryption"])} + label="OS settings: Disk encryption" + onClear={() => handleClearFilter([DISK_ENCRYPTION_QUERY_PARAM_NAME])} /> ); diff --git a/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx b/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx index 285c0f8908..d5550026d5 100644 --- a/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx +++ b/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx @@ -417,6 +417,7 @@ const DeviceUserPage = ({ showRefetchSpinner={showRefetchSpinner} onRefetchHost={onRefetchHost} renderActionButtons={renderActionButtons} + osSettings={host?.mdm.os_settings} deviceUser /> @@ -489,6 +490,7 @@ const DeviceUserPage = ({ )} {showMacSettingsModal && ( diff --git a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx index 0493579f5e..48212d6a10 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx +++ b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx @@ -72,6 +72,7 @@ import HostActionDropdown from "./HostActionsDropdown/HostActionsDropdown"; import MacSettingsModal from "../MacSettingsModal"; import BootstrapPackageModal from "./modals/BootstrapPackageModal"; import SelectQueryModal from "./modals/SelectQueryModal"; +import { isSupportedPlatform } from "./modals/DiskEncryptionKeyModal/DiskEncryptionKeyModal"; const baseClass = "host-details"; @@ -725,6 +726,7 @@ const HostDetailsPage = ({ showRefetchSpinner={showRefetchSpinner} onRefetchHost={onRefetchHost} renderActionButtons={renderActionButtons} + osSettings={host?.mdm.os_settings} /> @@ -857,12 +860,15 @@ const HostDetailsPage = ({ {showUnenrollMdmModal && !!host && ( )} - {showDiskEncryptionModal && host && ( - setShowDiskEncryptionModal(false)} - /> - )} + {showDiskEncryptionModal && + host && + isSupportedPlatform(host.platform) && ( + setShowDiskEncryptionModal(false)} + /> + )} {showBootstrapPackageModal && bootstrapPackageData.details && bootstrapPackageData.name && ( diff --git a/frontend/pages/hosts/details/HostDetailsPage/modals/DiskEncryptionKeyModal/DiskEncryptionKeyModal.tsx b/frontend/pages/hosts/details/HostDetailsPage/modals/DiskEncryptionKeyModal/DiskEncryptionKeyModal.tsx index 8acdc6622e..1046822673 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/modals/DiskEncryptionKeyModal/DiskEncryptionKeyModal.tsx +++ b/frontend/pages/hosts/details/HostDetailsPage/modals/DiskEncryptionKeyModal/DiskEncryptionKeyModal.tsx @@ -9,15 +9,32 @@ import CustomLink from "components/CustomLink"; import Button from "components/buttons/Button"; import InputFieldHiddenContent from "components/forms/fields/InputFieldHiddenContent"; import DataError from "components/DataError"; +import { SupportedPlatform } from "interfaces/platform"; const baseClass = "disk-encryption-key-modal"; +// currently these are the only supported platforms for the disk encryption +// key modal. +export type ModalSupportedPlatform = Extract< + SupportedPlatform, + "darwin" | "windows" +>; + +// Checks to see if the platform is supported by the modal. +export const isSupportedPlatform = ( + platform: string +): platform is ModalSupportedPlatform => { + return ["darwin", "windows"].includes(platform); +}; + interface IDiskEncryptionKeyModal { + platform: ModalSupportedPlatform; hostId: number; onCancel: () => void; } const DiskEncryptionKeyModal = ({ + platform, hostId, onCancel, }: IDiskEncryptionKeyModal) => { @@ -33,6 +50,18 @@ const DiskEncryptionKeyModal = ({ select: (data) => data.encryption_key.key, }); + const isMacOS = platform === "darwin"; + const descriptionText = isMacOS + ? "The disk encryption key refers to the FileVault recovery key for macOS." + : "The disk encryption key refers to the BitLocker recovery key for Windows."; + + const recoveryText = isMacOS + ? "Use this key to log in to the host if you forgot the password." + : "Use this key to unlock the encrypted drive."; + const recoveryUrl = isMacOS + ? "https://fleetdm.com/docs/using-fleet/mdm-disk-encryption#reset-a-macos-hosts-password-using-the-disk-encryption-key" + : "https://fleetdm.com/docs/using-fleet/mdm-disk-encryption#unlock-a-windows-hosts-drive-using-the-disk-encryption-key"; + return ( {encryptionKeyError ? ( @@ -40,15 +69,12 @@ const DiskEncryptionKeyModal = ({ ) : ( <> +

{descriptionText}

- The disk encryption key refers to the FileVault recovery key for - macOS. -

-

- Use this key to log in to the host if you forgot the password.{" "} + {recoveryText}{" "}

diff --git a/frontend/pages/hosts/details/HostDetailsPage/modals/OSPolicyModal/_styles.scss b/frontend/pages/hosts/details/HostDetailsPage/modals/OSPolicyModal/_styles.scss index 7f2f5f1ff1..c0b6053f30 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/modals/OSPolicyModal/_styles.scss +++ b/frontend/pages/hosts/details/HostDetailsPage/modals/OSPolicyModal/_styles.scss @@ -42,7 +42,7 @@ &__copy-message { background-color: $ui-light-grey; border: solid 1px #e2e4ea; - border-radius: 10px; + border-radius: $border-radius-xlarge; padding: 2px 6px; } } diff --git a/frontend/pages/hosts/details/MacSettingsIndicator/index.ts b/frontend/pages/hosts/details/MacSettingsIndicator/index.ts deleted file mode 100644 index 47e9752064..0000000000 --- a/frontend/pages/hosts/details/MacSettingsIndicator/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./MacSettingsIndicator"; diff --git a/frontend/pages/hosts/details/MacSettingsModal/MacSettingsModal.tsx b/frontend/pages/hosts/details/MacSettingsModal/MacSettingsModal.tsx index a055bae715..eab9e92bbb 100644 --- a/frontend/pages/hosts/details/MacSettingsModal/MacSettingsModal.tsx +++ b/frontend/pages/hosts/details/MacSettingsModal/MacSettingsModal.tsx @@ -7,20 +7,28 @@ import MacSettingsTable from "./MacSettingsTable"; import { generateTableData } from "./MacSettingsTable/MacSettingsTableConfig"; interface IMacSettingsModalProps { - hostMDMData?: Pick; + platform?: string; + hostMDMData?: IHostMdmData; onClose: () => void; } const baseClass = "mac-settings-modal"; -const MacSettingsModal = ({ hostMDMData, onClose }: IMacSettingsModalProps) => { - const memoizedTableData = useMemo(() => generateTableData(hostMDMData), [ - hostMDMData, - ]); +const MacSettingsModal = ({ + platform, + hostMDMData, + onClose, +}: IMacSettingsModalProps) => { + const memoizedTableData = useMemo( + () => generateTableData(hostMDMData, platform), + [hostMDMData, platform] + ); + + if (!platform) return null; return ( innerProps.isDiskEncryptionProfile - ? "The host will receive the MDM command to install the disk encryption profile when the " + - "host comes online." + ? "The hosts will receive the MDM command to turn on disk encryption " + + "when the hosts come online." : "The host will receive the MDM command to install the configuration profile when the " + "host comes online.", }, @@ -56,8 +59,8 @@ const PROFILE_DISPLAY_CONFIG: ProfileDisplayConfig = { iconName: "success", tooltip: (innerProps) => innerProps.isDiskEncryptionProfile - ? "The host turned disk encryption on and " + - "sent their key to Fleet. Fleet verified with osquery." + ? "The host turned disk encryption on and sent the key to Fleet. " + + "Fleet verified with osquery." : "The host installed the configuration profile. Fleet verified with osquery.", }, verifying: { @@ -65,8 +68,9 @@ const PROFILE_DISPLAY_CONFIG: ProfileDisplayConfig = { iconName: "success-partial", tooltip: (innerProps) => innerProps.isDiskEncryptionProfile - ? "The host acknowledged the MDM command to install disk encryption profile. Fleet is " + - "verifying with osquery and retrieving the disk encryption key. This may take up to one hour." + ? "The host acknowledged the MDM command to turn on disk encryption. " + + "Fleet is verifying with osquery and retrieving the disk encryption key. " + + "This may take up to one hour." : "The host acknowledged the MDM command to install the configuration profile. Fleet is " + "verifying with osquery.", }, @@ -98,9 +102,41 @@ const PROFILE_DISPLAY_CONFIG: ProfileDisplayConfig = { }, }; +type WindowsDiskEncryptionDisplayConfig = Omit< + OperationTypeOption, + "action_required" +>; + +const WINDOWS_DISK_ENCRYPTION_DISPLAY_CONFIG: WindowsDiskEncryptionDisplayConfig = { + verified: { + statusText: "Verified", + iconName: "success", + tooltip: () => + "The host turned disk encryption on and sent the key to Fleet. Fleet verified with osquery.", + }, + verifying: { + statusText: "Verifying", + iconName: "success-partial", + tooltip: () => + "The host acknowledged the MDM command to turn on disk encryption. Fleet is verifying with osquery and retrieving " + + "the disk encryption key. This may take up to one hour.", + }, + pending: { + statusText: "Enforcing (pending)", + iconName: "pending-partial", + tooltip: () => + "The host will receive the MDM command to turn on disk encryption when the host comes online.", + }, + failed: { + statusText: "Failed", + iconName: "error", + tooltip: null, + }, +}; + interface IMacSettingStatusCellProps { status: MacSettingsTableStatusValue; - operationType: MacMdmProfileOperationType; + operationType: MacMdmProfileOperationType | null; profileName: string; } @@ -108,8 +144,18 @@ const MacSettingStatusCell = ({ status, operationType, profileName = "", -}: IMacSettingStatusCellProps): JSX.Element => { - const diplayOption = PROFILE_DISPLAY_CONFIG[operationType]?.[status]; +}: IMacSettingStatusCellProps) => { + let displayOption: ProfileDisplayOption = null; + + // windows hosts do not have an operation type at the moment and their display options are + // different than mac hosts. + if (!operationType && isMdmProfileStatus(status)) { + displayOption = WINDOWS_DISK_ENCRYPTION_DISPLAY_CONFIG[status]; + } + + if (operationType) { + displayOption = PROFILE_DISPLAY_CONFIG[operationType]?.[status]; + } const isDeviceUser = window.location.pathname .toLowerCase() @@ -118,8 +164,8 @@ const MacSettingStatusCell = ({ const isDiskEncryptionProfile = profileName === FLEET_FILEVAULT_PROFILE_DISPLAY_NAME; - if (diplayOption) { - const { statusText, iconName, tooltip } = diplayOption; + if (displayOption) { + const { statusText, iconName, tooltip } = displayOption; const tooltipId = uniqueId(); return ( diff --git a/frontend/pages/hosts/details/MacSettingsModal/MacSettingsTable/MacSettingsTableConfig.tsx b/frontend/pages/hosts/details/MacSettingsModal/MacSettingsTable/MacSettingsTableConfig.tsx index 39bd021222..7b2364af25 100644 --- a/frontend/pages/hosts/details/MacSettingsModal/MacSettingsTable/MacSettingsTableConfig.tsx +++ b/frontend/pages/hosts/details/MacSettingsModal/MacSettingsTable/MacSettingsTableConfig.tsx @@ -5,20 +5,27 @@ import { IHostMdmData } from "interfaces/host"; import { FLEET_FILEVAULT_PROFILE_DISPLAY_NAME, // FLEET_FILEVAULT_PROFILE_IDENTIFIER, - IHostMacMdmProfile, + IHostMdmProfile, MdmProfileStatus, + isWindowsDiskEncryptionStatus, } from "interfaces/mdm"; import { DEFAULT_EMPTY_CELL_VALUE } from "utilities/constants"; import TruncatedTextCell from "components/TableContainer/DataTable/TruncatedTextCell"; import MacSettingStatusCell from "./MacSettingStatusCell"; +import { generateWinDiskEncryptionProfile } from "../../helpers"; -export interface IMacSettingsTableRow - extends Omit { +export interface IMacSettingsTableRow extends Omit { status: MacSettingsTableStatusValue; } export type MacSettingsTableStatusValue = MdmProfileStatus | "action_required"; +export const isMdmProfileStatus = ( + status: string +): status is MdmProfileStatus => { + return status !== "action_required"; +}; + interface IHeaderProps { column: { title: string; @@ -92,20 +99,41 @@ const tableHeaders: IDataColumn[] = [ ]; export const generateTableData = ( - hostMDMData?: Pick + hostMDMData?: IHostMdmData, + platform?: string ) => { + if (!platform) return []; + let rows: IMacSettingsTableRow[] = []; if (!hostMDMData) { return rows; } + if ( + platform === "windows" && + hostMDMData.os_settings?.disk_encryption.status && + isWindowsDiskEncryptionStatus( + hostMDMData.os_settings.disk_encryption.status + ) + ) { + rows.push( + generateWinDiskEncryptionProfile( + hostMDMData.os_settings.disk_encryption.status + ) + ); + return rows; + } + const { profiles, macos_settings } = hostMDMData; + if (!profiles) { return rows; } - rows = profiles; - if (macos_settings?.disk_encryption === "action_required") { + if ( + platform === "darwin" && + macos_settings?.disk_encryption === "action_required" + ) { rows = profiles.map((p) => { // TODO: this is a brittle check for the filevault profile // it would be better to match on the identifier but it is not diff --git a/frontend/pages/hosts/details/MacSettingsIndicator/MacSettingsIndicator.tests.tsx b/frontend/pages/hosts/details/ProfileStatusIndicator/ProfileStatusIndicator.tests.tsx similarity index 87% rename from frontend/pages/hosts/details/MacSettingsIndicator/MacSettingsIndicator.tests.tsx rename to frontend/pages/hosts/details/ProfileStatusIndicator/ProfileStatusIndicator.tests.tsx index a71467903c..73044dd218 100644 --- a/frontend/pages/hosts/details/MacSettingsIndicator/MacSettingsIndicator.tests.tsx +++ b/frontend/pages/hosts/details/ProfileStatusIndicator/ProfileStatusIndicator.tests.tsx @@ -1,12 +1,15 @@ import React from "react"; import { fireEvent, render, screen } from "@testing-library/react"; -import MacSettingsIndicator from "./MacSettingsIndicator"; +import ProfileStatusIndicator from "./ProfileStatusIndicator"; -describe("MacSettingsIndicator", () => { +describe("ProfileStatusIndicator component", () => { it("Renders the text and icon", () => { const indicatorText = "test text"; render( - + ); const renderedIndicatorText = screen.getByText(indicatorText); const renderedIcon = screen.getByTestId("success-icon"); @@ -19,7 +22,7 @@ describe("MacSettingsIndicator", () => { const indicatorText = "test text"; const tooltipText = "test tooltip text"; render( - { document.body.appendChild(newDiv); }; render( - { diff --git a/frontend/pages/hosts/details/MacSettingsIndicator/MacSettingsIndicator.tsx b/frontend/pages/hosts/details/ProfileStatusIndicator/ProfileStatusIndicator.tsx similarity index 92% rename from frontend/pages/hosts/details/MacSettingsIndicator/MacSettingsIndicator.tsx rename to frontend/pages/hosts/details/ProfileStatusIndicator/ProfileStatusIndicator.tsx index 473745ee33..f3bbe6cd02 100644 --- a/frontend/pages/hosts/details/MacSettingsIndicator/MacSettingsIndicator.tsx +++ b/frontend/pages/hosts/details/ProfileStatusIndicator/ProfileStatusIndicator.tsx @@ -4,9 +4,9 @@ import { IconNames } from "components/icons"; import Icon from "components/Icon"; import Button from "components/buttons/Button"; -const baseClass = "settings-indicator"; +const baseClass = "profile-status-indicator"; -export interface IMacSettingsIndicator { +export interface IProfileStatusIndicatorProps { indicatorText: string; iconName: IconNames; onClick?: () => void; @@ -16,12 +16,12 @@ export interface IMacSettingsIndicator { }; } -const MacSettingsIndicator = ({ +const ProfileStatusIndicator = ({ indicatorText, iconName, onClick, tooltip, -}: IMacSettingsIndicator): JSX.Element => { +}: IProfileStatusIndicatorProps) => { const getIndicatorTextWrapped = () => { if (onClick && tooltip?.tooltipText) { return ( @@ -103,4 +103,4 @@ const MacSettingsIndicator = ({ ); }; -export default MacSettingsIndicator; +export default ProfileStatusIndicator; diff --git a/frontend/pages/hosts/details/MacSettingsIndicator/_styles.scss b/frontend/pages/hosts/details/ProfileStatusIndicator/_styles.scss similarity index 88% rename from frontend/pages/hosts/details/MacSettingsIndicator/_styles.scss rename to frontend/pages/hosts/details/ProfileStatusIndicator/_styles.scss index fce8265c95..a7ed40ad91 100644 --- a/frontend/pages/hosts/details/MacSettingsIndicator/_styles.scss +++ b/frontend/pages/hosts/details/ProfileStatusIndicator/_styles.scss @@ -1,4 +1,4 @@ -.settings-indicator { +.profile-status-indicator { display: flex; gap: 4px; diff --git a/frontend/pages/hosts/details/ProfileStatusIndicator/index.ts b/frontend/pages/hosts/details/ProfileStatusIndicator/index.ts new file mode 100644 index 0000000000..99de4100ca --- /dev/null +++ b/frontend/pages/hosts/details/ProfileStatusIndicator/index.ts @@ -0,0 +1 @@ +export { default } from "./ProfileStatusIndicator"; diff --git a/frontend/pages/hosts/details/cards/HostSummary/HostSummary.tsx b/frontend/pages/hosts/details/cards/HostSummary/HostSummary.tsx index f18194844c..45be0eaba5 100644 --- a/frontend/pages/hosts/details/cards/HostSummary/HostSummary.tsx +++ b/frontend/pages/hosts/details/cards/HostSummary/HostSummary.tsx @@ -1,7 +1,12 @@ import React from "react"; - import ReactTooltip from "react-tooltip"; -import { IHostMacMdmProfile, BootstrapPackageStatus } from "interfaces/mdm"; + +import { + IHostMdmProfile, + BootstrapPackageStatus, + isWindowsDiskEncryptionStatus, +} from "interfaces/mdm"; +import { IOSSettings } from "interfaces/host"; import getHostStatusTooltipText from "pages/hosts/helpers"; import TooltipWrapper from "components/TooltipWrapper"; @@ -9,6 +14,7 @@ import Button from "components/buttons/Button"; import Icon from "components/Icon/Icon"; import DiskSpaceGraph from "components/DiskSpaceGraph"; import HumanTimeDiffWithDateTip from "components/HumanTimeDiffWithDateTip"; +import PremiumFeatureIconWithTooltip from "components/PremiumFeatureIconWithTooltip"; import { getHostDiskEncryptionTooltipMessage, humanHostMemory, @@ -16,10 +22,11 @@ import { } from "utilities/helpers"; import { DEFAULT_EMPTY_CELL_VALUE } from "utilities/constants"; import StatusIndicator from "components/StatusIndicator"; -import PremiumFeatureIconWithTooltip from "components/PremiumFeatureIconWithTooltip"; + import MacSettingsIndicator from "./MacSettingsIndicator"; import HostSummaryIndicator from "./HostSummaryIndicator"; import BootstrapPackageIndicator from "./BootstrapPackageIndicator/BootstrapPackageIndicator"; +import { generateWinDiskEncryptionProfile } from "../../helpers"; const baseClass = "host-summary"; @@ -38,7 +45,7 @@ interface IHostSummaryProps { toggleOSPolicyModal?: () => void; toggleMacSettingsModal?: () => void; toggleBootstrapPackageModal?: () => void; - hostMdmProfiles?: IHostMacMdmProfile[]; + hostMdmProfiles?: IHostMdmProfile[]; mdmName?: string; showRefetchSpinner: boolean; onRefetchHost: ( @@ -46,6 +53,7 @@ interface IHostSummaryProps { ) => void; renderActionButtons: () => JSX.Element | null; deviceUser?: boolean; + osSettings?: IOSSettings; } const HostSummary = ({ @@ -64,8 +72,9 @@ const HostSummary = ({ onRefetchHost, renderActionButtons, deviceUser, + osSettings, }: IHostSummaryProps): JSX.Element => { - const { status, id, platform } = titleData; + const { status, platform } = titleData; const renderRefetch = () => { const isOnline = titleData.status === "online"; @@ -179,6 +188,22 @@ const HostSummary = ({ }; const renderSummary = () => { + // 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. + if ( + platform === "windows" && + osSettings?.disk_encryption?.status && + isWindowsDiskEncryptionStatus(osSettings.disk_encryption.status) + ) { + const winDiskEncryptionProfile: IHostMdmProfile = generateWinDiskEncryptionProfile( + osSettings.disk_encryption.status + ); + hostMdmProfiles = hostMdmProfiles + ? [...hostMdmProfiles, winDiskEncryptionProfile] + : [winDiskEncryptionProfile]; + } + return (
@@ -198,12 +223,15 @@ const HostSummary = ({ {isPremiumTier && renderHostTeam()} - {platform === "darwin" && + {/* Rendering of OS Settings data */} + {(platform === "darwin" || platform === "windows") && isPremiumTier && - mdmName === "Fleet" && // show if 1 - host is enrolled in Fleet MDM, and + // TODO: API INTEGRATION: change this when we figure out why the API is + // returning "Fleet" or "FleetDM" for the MDM name. + mdmName?.includes("Fleet") && // show if 1 - host is enrolled in Fleet MDM, and hostMdmProfiles && hostMdmProfiles.length > 0 && ( // 2 - host has at least one setting (profile) enforced - + { const statuses = hostMacSettings.map((setting) => setting.status); if (statuses.includes("failed")) { @@ -68,7 +68,7 @@ const getMacProfileStatus = ( }; interface IMacSettingsIndicatorProps { - profiles: IHostMacMdmProfile[]; + profiles: IHostMdmProfile[]; onClick?: () => void; } const MacSettingsIndicator = ({ diff --git a/frontend/pages/hosts/details/cards/Policies/HostPoliciesTable/PolicyFailingCount/PolicyFailingCount.tsx b/frontend/pages/hosts/details/cards/Policies/HostPoliciesTable/PolicyFailingCount/PolicyFailingCount.tsx index ecb1f1b7c5..24a184a0d1 100644 --- a/frontend/pages/hosts/details/cards/Policies/HostPoliciesTable/PolicyFailingCount/PolicyFailingCount.tsx +++ b/frontend/pages/hosts/details/cards/Policies/HostPoliciesTable/PolicyFailingCount/PolicyFailingCount.tsx @@ -2,6 +2,7 @@ import { IHostPolicy } from "interfaces/policy"; import React from "react"; import Icon from "components/Icon/Icon"; +import InfoBanner from "components/InfoBanner"; const baseClass = "policy-failing-count"; @@ -18,7 +19,7 @@ const PolicyFailingCount = ({ }, 0); return failCount ? ( -
+
This device is failing @@ -27,10 +28,10 @@ const PolicyFailingCount = ({

Click a policy below to see if there are steps you can take to resolve the issue - {failCount > 1 ? "s" : ""}.{" "} + {failCount > 1 ? "s" : ""}. {deviceUser && " Once resolved, click “Refetch” above to confirm."}

-
+
) : null; }; diff --git a/frontend/pages/hosts/details/cards/Policies/HostPoliciesTable/PolicyFailingCount/_styles.scss b/frontend/pages/hosts/details/cards/Policies/HostPoliciesTable/PolicyFailingCount/_styles.scss index f3543ccc47..36ac989fdb 100644 --- a/frontend/pages/hosts/details/cards/Policies/HostPoliciesTable/PolicyFailingCount/_styles.scss +++ b/frontend/pages/hosts/details/cards/Policies/HostPoliciesTable/PolicyFailingCount/_styles.scss @@ -1,24 +1,11 @@ .policy-failing-count { - font-size: $x-small; - background-color: $ui-off-white; - border: solid 1px $ui-fleet-black-50; - box-sizing: border-box; - border-radius: 10px; - overflow: auto; - margin-bottom: $pad-large; - padding: $pad-large; - padding-bottom: $pad-small; - - p { - padding-left: $pad-large; - margin-top: $pad-medium; - margin-bottom: $pad-medium; - } - &__count { display: flex; - align-content: center; - align-items: center; font-weight: $bold; + gap: $pad-small; + } + + p { + margin-left: $pad-large; // Align second line with first line and not with icon } } diff --git a/frontend/pages/hosts/details/cards/Policies/_styles.scss b/frontend/pages/hosts/details/cards/Policies/_styles.scss index 01a500f359..eb7e5d8f6a 100644 --- a/frontend/pages/hosts/details/cards/Policies/_styles.scss +++ b/frontend/pages/hosts/details/cards/Policies/_styles.scss @@ -1,10 +1,6 @@ .section--policies { .info-banner { margin-bottom: 1rem; - p { - font-size: 0.875rem; - margin: 0; - } } .table-container__header { display: none; diff --git a/frontend/pages/hosts/details/cards/Software/SoftwareVulnCount/SoftwareVulnCount.tsx b/frontend/pages/hosts/details/cards/Software/SoftwareVulnCount/SoftwareVulnCount.tsx index 4b398201f4..83dd3936d2 100644 --- a/frontend/pages/hosts/details/cards/Software/SoftwareVulnCount/SoftwareVulnCount.tsx +++ b/frontend/pages/hosts/details/cards/Software/SoftwareVulnCount/SoftwareVulnCount.tsx @@ -2,6 +2,7 @@ import React from "react"; import { ISoftware } from "interfaces/software"; import Icon from "components/Icon/Icon"; +import InfoBanner from "components/InfoBanner"; const baseClass = "software-vuln-count"; @@ -18,14 +19,20 @@ const SoftwareVulnCount = ({ return software.vulnerabilities?.length ? sum + 1 : sum; }, 0); return vulnCount ? ( -
+
{vulnCount === 1 ? "1 software item with vulnerabilities detected" : `${vulnCount} software items with vulnerabilities detected`}
-
+ {!deviceUser && ( +

+ Click a vulnerable item below to see the associated Common + Vulnerabilites and Exposures (CVEs). +

+ )} + ) : ( <> ); diff --git a/frontend/pages/hosts/details/cards/Software/SoftwareVulnCount/_styles.scss b/frontend/pages/hosts/details/cards/Software/SoftwareVulnCount/_styles.scss index 9562f27e07..654886d39f 100644 --- a/frontend/pages/hosts/details/cards/Software/SoftwareVulnCount/_styles.scss +++ b/frontend/pages/hosts/details/cards/Software/SoftwareVulnCount/_styles.scss @@ -1,25 +1,11 @@ .software-vuln-count { - font-size: $x-small; - background-color: $ui-off-white; - border: solid 1px $ui-fleet-black-50; - box-sizing: border-box; - border-radius: 10px; - overflow: auto; - margin-bottom: $pad-large; - padding: $pad-large; - - p { - padding-left: $pad-large; - margin-top: $pad-medium; - margin-bottom: 0; - } - &__count { display: flex; - align-content: center; - align-items: center; - font-size: $x-small; font-weight: $bold; gap: $pad-small; } + + p { + margin-left: $pad-large; // Align second line with first line and not with icon + } } diff --git a/frontend/pages/hosts/details/cards/Software/_styles.scss b/frontend/pages/hosts/details/cards/Software/_styles.scss index e1dc55e7c4..ceaae0b407 100644 --- a/frontend/pages/hosts/details/cards/Software/_styles.scss +++ b/frontend/pages/hosts/details/cards/Software/_styles.scss @@ -1,4 +1,7 @@ .section--software { + .info-banner { + margin-bottom: 1rem; + } .text-muted { color: $ui-fleet-black-50; } diff --git a/frontend/pages/hosts/details/helpers.ts b/frontend/pages/hosts/details/helpers.ts new file mode 100644 index 0000000000..2c1283be18 --- /dev/null +++ b/frontend/pages/hosts/details/helpers.ts @@ -0,0 +1,33 @@ +/** Helpers used across the host details and my device pages and components. */ + +import { + IHostMdmProfile, + IWindowsDiskEncryptionStatus, + MdmProfileStatus, +} from "interfaces/mdm"; + +const convertWinDiskEncryptionStatusToProfileStatus = ( + diskEncryptionStatus: IWindowsDiskEncryptionStatus +): MdmProfileStatus => { + return diskEncryptionStatus === "enforcing" + ? "pending" + : diskEncryptionStatus; +}; + +/** + * Manually generates a profile for the windows disk encryption status. We need + * this as we don't have a windows disk encryption profile in the `profiles` + * attribute coming back from the GET /hosts/:id API response. + */ +// eslint-disable-next-line import/prefer-default-export +export const generateWinDiskEncryptionProfile = ( + diskEncryptionStatus: IWindowsDiskEncryptionStatus +): IHostMdmProfile => { + return { + profile_id: 0, // This s the only type of profile that can have this number + name: "Disk Encryption", + status: convertWinDiskEncryptionStatusToProfileStatus(diskEncryptionStatus), + detail: "", + operation_type: null, + }; +}; diff --git a/frontend/pages/policies/ManagePoliciesPage/_styles.scss b/frontend/pages/policies/ManagePoliciesPage/_styles.scss index d8e20f56b2..bf18675d94 100644 --- a/frontend/pages/policies/ManagePoliciesPage/_styles.scss +++ b/frontend/pages/policies/ManagePoliciesPage/_styles.scss @@ -101,7 +101,7 @@ align-items: center; padding-left: $pad-medium; padding-right: $pad-medium; - border-radius: 4px; + border-radius: $border-radius; font-size: $x-small; font-family: "Inter", sans-serif; font-weight: $bold; diff --git a/frontend/pages/policies/ManagePoliciesPage/components/AddPolicyModal/_styles.scss b/frontend/pages/policies/ManagePoliciesPage/components/AddPolicyModal/_styles.scss index be5cf1dbb7..376aacc859 100644 --- a/frontend/pages/policies/ManagePoliciesPage/components/AddPolicyModal/_styles.scss +++ b/frontend/pages/policies/ManagePoliciesPage/components/AddPolicyModal/_styles.scss @@ -25,7 +25,7 @@ font-size: $x-small; font-weight: $bold; padding: 2px 4px; - border-radius: 4px; + border-radius: $border-radius; margin-left: 0.5rem; position: relative; top: -2px; diff --git a/frontend/pages/policies/ManagePoliciesPage/components/ManageAutomationsModal/_styles.scss b/frontend/pages/policies/ManagePoliciesPage/components/ManageAutomationsModal/_styles.scss index ec85f7f7a9..467d05f39e 100644 --- a/frontend/pages/policies/ManagePoliciesPage/components/ManageAutomationsModal/_styles.scss +++ b/frontend/pages/policies/ManagePoliciesPage/components/ManageAutomationsModal/_styles.scss @@ -4,7 +4,7 @@ background-color: $ui-off-white; color: $core-fleet-blue; border: 1px solid $ui-fleet-black-10; - border-radius: 4px; + border-radius: $border-radius; padding: 7px $pad-medium; margin: $pad-large 0 0 44px; } diff --git a/frontend/pages/policies/PolicyPage/_styles.scss b/frontend/pages/policies/PolicyPage/_styles.scss index 1eb28ded67..f069352178 100644 --- a/frontend/pages/policies/PolicyPage/_styles.scss +++ b/frontend/pages/policies/PolicyPage/_styles.scss @@ -59,7 +59,7 @@ background-color: $core-white; border: none; box-shadow: inset 0 0 0 1px $ui-fleet-black-25; - border-radius: 6px; + border-radius: $border-radius-large; cursor: pointer; display: flex; align-items: center; diff --git a/frontend/pages/policies/PolicyPage/components/PolicyQueriesErrorsTable/_styles.scss b/frontend/pages/policies/PolicyPage/components/PolicyQueriesErrorsTable/_styles.scss index 4e110ad1dd..24618c22f9 100644 --- a/frontend/pages/policies/PolicyPage/components/PolicyQueriesErrorsTable/_styles.scss +++ b/frontend/pages/policies/PolicyPage/components/PolicyQueriesErrorsTable/_styles.scss @@ -3,7 +3,7 @@ &__wrapper { border: 1px solid $ui-fleet-black-10; - border-radius: 4px; + border-radius: $border-radius; overflow: hidden; margin-top: $pad-medium; } diff --git a/frontend/pages/policies/PolicyPage/components/PolicyQueriesTable/_styles.scss b/frontend/pages/policies/PolicyPage/components/PolicyQueriesTable/_styles.scss index d0d5d05d28..65fa20a8a5 100644 --- a/frontend/pages/policies/PolicyPage/components/PolicyQueriesTable/_styles.scss +++ b/frontend/pages/policies/PolicyPage/components/PolicyQueriesTable/_styles.scss @@ -3,7 +3,7 @@ &__wrapper { border: 1px solid $ui-fleet-black-10; - border-radius: 4px; + border-radius: $border-radius; overflow: hidden; margin-top: $pad-medium; } diff --git a/frontend/pages/queries/ManageQueriesPage/components/ManageAutomationsModal/_styles.scss b/frontend/pages/queries/ManageQueriesPage/components/ManageAutomationsModal/_styles.scss index a0e403d935..4acc671a11 100644 --- a/frontend/pages/queries/ManageQueriesPage/components/ManageAutomationsModal/_styles.scss +++ b/frontend/pages/queries/ManageQueriesPage/components/ManageAutomationsModal/_styles.scss @@ -11,7 +11,7 @@ flex-direction: column; align-items: flex-start; align-self: stretch; - border-radius: 4px; + border-radius: $border-radius; border: 1px solid $ui-fleet-black-10; } diff --git a/frontend/pages/software/ManageSoftwarePage/components/ManageAutomationsModal/_styles.scss b/frontend/pages/software/ManageSoftwarePage/components/ManageAutomationsModal/_styles.scss index 5bb83b84f0..2f6297388a 100644 --- a/frontend/pages/software/ManageSoftwarePage/components/ManageAutomationsModal/_styles.scss +++ b/frontend/pages/software/ManageSoftwarePage/components/ManageAutomationsModal/_styles.scss @@ -4,7 +4,7 @@ background-color: $ui-off-white; color: $core-fleet-blue; border: 1px solid $ui-fleet-black-10; - border-radius: 4px; + border-radius: $border-radius; padding: 7px $pad-medium; margin: $pad-large 0 0 44px; } diff --git a/frontend/services/entities/host_count.ts b/frontend/services/entities/host_count.ts index 9e4bba000b..5511dcfc10 100644 --- a/frontend/services/entities/host_count.ts +++ b/frontend/services/entities/host_count.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ import sendRequest from "services"; import endpoints from "utilities/endpoints"; -import { FileVaultProfileStatus, BootstrapPackageStatus } from "interfaces/mdm"; +import { DiskEncryptionStatus, BootstrapPackageStatus } from "interfaces/mdm"; import { HostStatus } from "interfaces/host"; import { buildQueryStringFromParams, @@ -43,7 +43,7 @@ export interface IHostCountLoadOptions { osId?: number; osName?: string; osVersion?: string; - diskEncryptionStatus?: FileVaultProfileStatus; + diskEncryptionStatus?: DiskEncryptionStatus; bootstrapPackageStatus?: BootstrapPackageStatus; } diff --git a/frontend/services/entities/hosts.ts b/frontend/services/entities/hosts.ts index b8abf7061b..e4d95353d5 100644 --- a/frontend/services/entities/hosts.ts +++ b/frontend/services/entities/hosts.ts @@ -11,7 +11,7 @@ import { import { SelectedPlatform } from "interfaces/platform"; import { ISoftware } from "interfaces/software"; import { - FileVaultProfileStatus, + DiskEncryptionStatus, BootstrapPackageStatus, IMdmSolution, } from "interfaces/mdm"; @@ -29,6 +29,11 @@ export interface ILoadHostsResponse { mobile_device_management_solution: IMdmSolution; } +// the source of truth for the filter option names. +// there are used on many other pages but we define them here. +// TODO: add other filter options here. +export const DISK_ENCRYPTION_QUERY_PARAM_NAME = "os_settings_disk_encryption"; + export interface ILoadHostsQueryKey extends ILoadHostsOptions { scope: "hosts"; } @@ -57,7 +62,7 @@ export interface ILoadHostsOptions { device_mapping?: boolean; columns?: string; visibleColumns?: string; - diskEncryptionStatus?: FileVaultProfileStatus; + diskEncryptionStatus?: DiskEncryptionStatus; bootstrapPackageStatus?: BootstrapPackageStatus; } @@ -83,7 +88,7 @@ export interface IExportHostsOptions { device_mapping?: boolean; columns?: string; visibleColumns?: string; - diskEncryptionStatus?: FileVaultProfileStatus; + diskEncryptionStatus?: DiskEncryptionStatus; } export interface IActionByFilter { diff --git a/frontend/services/entities/mdm.ts b/frontend/services/entities/mdm.ts index 55416203dc..41301f3d3b 100644 --- a/frontend/services/entities/mdm.ts +++ b/frontend/services/entities/mdm.ts @@ -1,19 +1,61 @@ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ -import { FileVaultProfileStatus } from "interfaces/mdm"; +import { DiskEncryptionStatus, MdmProfileStatus } from "interfaces/mdm"; import { APP_CONTEXT_NO_TEAM_ID } from "interfaces/team"; import sendRequest from "services"; import endpoints from "utilities/endpoints"; import { buildQueryStringFromParams } from "utilities/url"; -export type IFileVaultSummaryResponse = Record; - export interface IEulaMetadataResponse { name: string; token: string; created_at: string; } -export default { +export type ProfileStatusSummaryResponse = Record; + +export interface IDiskEncryptionStatusAggregate { + macos: number; + windows: number; +} + +export type IDiskEncryptionSummaryResponse = Record< + DiskEncryptionStatus, + IDiskEncryptionStatusAggregate +>; + +// This function combines the profile status summary and the disk encryption summary +// to generate the aggregate profile status summary. We are doing this as a temporary +// solution until we have the API that will return the aggregate profile status summary +// from one call. +// TODO: API INTEGRATION: remove when API is implemented that returns windows +// data in the aggregate profile status summary. +const generateCombinedProfileStatusSummary = ( + profileStatuses: ProfileStatusSummaryResponse, + diskEncryptionSummary: IDiskEncryptionSummaryResponse +): ProfileStatusSummaryResponse => { + const { verified, verifying, failed, pending } = profileStatuses; + const { + verified: verifiedDiskEncryption, + verifying: verifyingDiskEncryption, + failed: failedDiskEncryption, + action_required: actionRequiredDiskEncryption, + enforcing: enforcingDiskEncryption, + removing_enforcement: removingEnforcementDiskEncryption, + } = diskEncryptionSummary; + + return { + verified: verified + verifiedDiskEncryption.windows, + verifying: verifying + verifyingDiskEncryption.windows, + failed: failed + failedDiskEncryption.windows, + pending: + pending + + actionRequiredDiskEncryption.windows + + enforcingDiskEncryption.windows + + removingEnforcementDiskEncryption.windows, + }; +}; + +const mdmService = { downloadDeviceUserEnrollmentProfile: (token: string) => { const { DEVICE_USER_MDM_ENROLLMENT_PROFILE } = endpoints; return sendRequest("GET", DEVICE_USER_MDM_ENROLLMENT_PROFILE(token)); @@ -72,24 +114,51 @@ export default { return sendRequest("DELETE", MDM_PROFILE(profileId)); }, - getAggregateProfileStatuses: (teamId = APP_CONTEXT_NO_TEAM_ID) => { + // TODO: API INTEGRATION: we need to rework this when we create API call that + // will return the aggregate statuses for windows included in the response. + // Currently to get windows data included we will need to make a separate call. + // We will likely change this to go back to single "getProfileStatusSummary" API call. + getAggregateProfileStatuses: async ( + teamId = APP_CONTEXT_NO_TEAM_ID, + // TODO: WINDOWS FEATURE FLAG: remove when we windows feature is released. + includeWindows: boolean + ) => { + // if we are not including windows we can just call the existing profile summary API + if (!includeWindows) { + return mdmService.getProfileStatusSummary(teamId); + } + + // otherwise we have to make two calls and combine the results. + return mdmService + .getAggregateProfileStatusesWithWindows(teamId) + .then((res) => generateCombinedProfileStatusSummary(...res)); + }, + + getAggregateProfileStatusesWithWindows: async (teamId: number) => { + return Promise.all([ + mdmService.getProfileStatusSummary(teamId), + mdmService.getDiskEncryptionSummary(teamId), + ]); + }, + + getProfileStatusSummary: (teamId = APP_CONTEXT_NO_TEAM_ID) => { const path = `${ endpoints.MDM_PROFILES_AGGREGATE_STATUSES }?${buildQueryStringFromParams({ team_id: teamId })}`; - return sendRequest("GET", path); }, - getDiskEncryptionAggregate: (teamId?: number) => { - let { MDM_APPLE_DISK_ENCRYPTION_AGGREGATE: path } = endpoints; + getDiskEncryptionSummary: (teamId?: number) => { + let { MDM_DISK_ENCRYPTION_SUMMARY: path } = endpoints; if (teamId) { path = `${path}?${buildQueryStringFromParams({ team_id: teamId })}`; } - return sendRequest("GET", path); }, + // TODO: API INTEGRATION: change when API is implemented that works for windows + // disk encryption too. updateAppleMdmSettings: (enableDiskEncryption: boolean, teamId?: number) => { const { MDM_UPDATE_APPLE_SETTINGS: teamsEndpoint, @@ -98,7 +167,9 @@ export default { if (teamId === 0) { return sendRequest("PATCH", noTeamsEndpoint, { mdm: { + // TODO: API INTEGRATION: remove macos_settings when API change is merged in. macos_settings: { enable_disk_encryption: enableDiskEncryption }, + // enable_disk_encryption: enableDiskEncryption, }, }); } @@ -179,3 +250,5 @@ export default { }); }, }; + +export default mdmService; diff --git a/frontend/styles/var/_global.scss b/frontend/styles/var/_global.scss index 0c772c21e8..7876654c4c 100644 --- a/frontend/styles/var/_global.scss +++ b/frontend/styles/var/_global.scss @@ -1,2 +1,3 @@ $border-radius: 4px; $border-radius-large: 6px; +$border-radius-xlarge: 10px; diff --git a/frontend/utilities/endpoints.ts b/frontend/utilities/endpoints.ts index e1a3880b99..c78504a634 100644 --- a/frontend/utilities/endpoints.ts +++ b/frontend/utilities/endpoints.ts @@ -51,7 +51,7 @@ export default { MDM_PROFILE: (id: number) => `/${API_VERSION}/fleet/mdm/apple/profiles/${id}`, MDM_UPDATE_APPLE_SETTINGS: `/${API_VERSION}/fleet/mdm/apple/settings`, MDM_PROFILES_AGGREGATE_STATUSES: `/${API_VERSION}/fleet/mdm/apple/profiles/summary`, - MDM_APPLE_DISK_ENCRYPTION_AGGREGATE: `/${API_VERSION}/fleet/mdm/apple/filevault/summary`, + MDM_DISK_ENCRYPTION_SUMMARY: `/${API_VERSION}/fleet/mdm/disk_encryption/summary`, MDM_APPLE_SSO: `/${API_VERSION}/fleet/mdm/sso`, MDM_APPLE_ENROLLMENT_PROFILE: (token: string, ref?: string) => { const query = new URLSearchParams({ token }); diff --git a/frontend/utilities/url/index.ts b/frontend/utilities/url/index.ts index c535c80131..d6eb7b6c89 100644 --- a/frontend/utilities/url/index.ts +++ b/frontend/utilities/url/index.ts @@ -1,6 +1,10 @@ -import { FileVaultProfileStatus, BootstrapPackageStatus } from "interfaces/mdm"; import { isEmpty, reduce, omitBy, Dictionary } from "lodash"; -import { MacSettingsStatusQueryParam } from "services/entities/hosts"; + +import { DiskEncryptionStatus, BootstrapPackageStatus } from "interfaces/mdm"; +import { + DISK_ENCRYPTION_QUERY_PARAM_NAME, + MacSettingsStatusQueryParam, +} from "services/entities/hosts"; type QueryValues = string | number | boolean | undefined | null; export type QueryParams = Record; @@ -24,7 +28,7 @@ interface IMutuallyExclusiveHostParams { osId?: number; osName?: string; osVersion?: string; - diskEncryptionStatus?: FileVaultProfileStatus; + diskEncryptionStatus?: DiskEncryptionStatus; bootstrapPackageStatus?: BootstrapPackageStatus; } @@ -123,7 +127,7 @@ export const reconcileMutuallyExclusiveHostParams = ({ case !!lowDiskSpaceHosts: return { low_disk_space: lowDiskSpaceHosts }; case !!diskEncryptionStatus: - return { macos_settings_disk_encryption: diskEncryptionStatus }; + return { [DISK_ENCRYPTION_QUERY_PARAM_NAME]: diskEncryptionStatus }; case !!bootstrapPackageStatus: return { bootstrap_package: bootstrapPackageStatus }; default: diff --git a/go.mod b/go.mod index 7913243bd9..48a36f9559 100644 --- a/go.mod +++ b/go.mod @@ -272,6 +272,7 @@ require ( github.com/rcrowley/go-metrics v0.0.0-20200313005456-10cdbea86bc0 // indirect github.com/rogpeppe/go-internal v1.8.1 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/scjalliance/comshim v0.0.0-20230315213746-5e51f40bd3b9 // indirect github.com/secure-systems-lab/go-securesystemslib v0.4.0 // indirect github.com/sergi/go-diff v1.2.0 // indirect github.com/slack-go/slack v0.9.4 // indirect diff --git a/go.sum b/go.sum index ca02b6bc31..9c99a41dc3 100644 --- a/go.sum +++ b/go.sum @@ -1080,6 +1080,8 @@ github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/scjalliance/comshim v0.0.0-20190308082608-cf06d2532c4e/go.mod h1:9Tc1SKnfACJb9N7cw2eyuI6xzy845G7uZONBsi5uPEA= +github.com/scjalliance/comshim v0.0.0-20230315213746-5e51f40bd3b9 h1:rc/CcqLH3lh8n+csdOuDfP+NuykE0U6AeYSJJHKDgSg= +github.com/scjalliance/comshim v0.0.0-20230315213746-5e51f40bd3b9/go.mod h1:a/83NAfUXvEuLpmxDssAXxgUgrEy12MId3Wd7OTs76s= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= github.com/sebdah/goldie v1.0.0 h1:9GNhIat69MSlz/ndaBg48vl9dF5fI+NBB6kfOxgfkMc= github.com/sebdah/goldie v1.0.0/go.mod h1:jXP4hmWywNEwZzhMuv2ccnqTSFpuq8iyQhtQdkkZBH4= diff --git a/handbook/business-operations/README.md b/handbook/business-operations/README.md index 5c6f8c710e..17a9a35690 100644 --- a/handbook/business-operations/README.md +++ b/handbook/business-operations/README.md @@ -35,6 +35,7 @@ Certain new team members, especially in go-to-market (GTM) roles, will need paid | 🐋 SC | ✅ | ✅ | ❌ | ❌ | ✅ | 🫧 SDR | ✅ | ✅ | ✅ | ❌ | ❌ | ⚗️ PM | ❌ | ❌ | ❌ | ✅ | ✅ +| ⚗️ PD | ❌ | ❌ | ❌ | ✅ | ✅ | 🔦 CEO | ✅ | ✅ | ✅ | ✅ | ✅ | Other roles | ❌ | ❌ | ❌ | ❌ | ❌ diff --git a/handbook/ceo.md b/handbook/ceo.md index 864baefee6..e74b1e3aa9 100644 --- a/handbook/ceo.md +++ b/handbook/ceo.md @@ -17,7 +17,7 @@ The CEO is the [directly responsible individual](https://fleetdm.com/handbook/co - 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 [🐈‍⬛#g-ceo kanban board](https://app.zenhub.com/workspaces/-g-ceo-645b0eab68a4d40c0795ff61/board?sprints=none) (confidential) for this team, including pending tasks and requests. - **Do not add events to the CEO's calendar.** events added directly to the CEO's calendar will be declined and removed. Even if the CEO asks you to set up a meeting or add him to a call, please [get scheduling help from the Apprentice](#schedule-time-with-the-ceo). -- **For personal or extremely urgent requests** that cannot wait one business day, send a Slack direct message (DM) to `@mikermcneil` right away. +- **For personal or extremely urgent requests** that cannot wait one business day, send a Slack direct message (DM) to `@mikermcneil` right away 🎵 - If you mention the CEO or reply from within a Slack thread, he [will not read your message](#why-not-mention-the-ceo-in-slack-threads). - **If you're a hiring manager**, you can [schedule a CEO interview](https://github.com/fleetdm/confidential/issues/new?assignees=sampfluger88&labels=%23g-ceo&projects=&template=&title=CEO%20interview%3a%20%7BCANDIDATE_NAME%7D&body=-%20[%20]%20I%20followed%20all%20the%20steps%20in%20https%3A%2F%2Ffleetdm.com%2Fhandbook%2Fcompany%2Fleadership%23hiring-a-new-team-member%20before%20submitting%20this%20issue.) - **If you're in Business Operations**, you can [request warehoused equipment be shipped from Fleet IT](#request-equipment-from-fleet-it). diff --git a/handbook/company/open-positions.yml b/handbook/company/open-positions.yml index 233d074e7d..5aaa55deae 100644 --- a/handbook/company/open-positions.yml +++ b/handbook/company/open-positions.yml @@ -220,6 +220,25 @@ - ➕ Bonus: Experienced with Go. - 💭 3-5 years' of experience in cloud infrastructure (AWS/GCP/Azure). - 🦉 Proficient in infrastructure as code and container deployments. +- jobTitle: ⚗️ Product Manager, Security + department: Product + hiringManagerName: Mo Zhu + hiringManagerLinkedInUrl: linkedin.com/in/mo-zhu + hiringManagerGithubUsername: zhumo + responsibilities: | + - 🤝 Deeply understand customer needs and workflows through direct conversations + - 🛣️ Use gained customer understanding to inform product direction and strategy + - 💬 Translate between technical and business needs, orally and in writing + - 📝 Write user stories and requirements to guide design and engineering + - 🛫 Oversee product development lifecycle from concept to release to documentation and support + - 🎁 Develop go-to-market strategies and launch plans for new cybersecurity products, including pricing, positioning, and messaging. + - 🔎 Conduct competitive product analysis + - 👯 Communicate and build strong relationships with cross-functional teams and stakeholders + experience: | + - ⚗️ 2+ years experience as a product manager at a software technology firm + - ⚙️ 2+ years experience as a technical contributor at a software technology firm + - 🏃 Ability to work in a fast-paced startup environment + - 🔒 Bonus: experience in cybersecurity # Note: commenting out this open position because the page link did not exist in the current version of the company handbook page. (2023-08-31) # - jobTitle: 🐋 Solutions Consultant diff --git a/handbook/company/pricing-features-table.yml b/handbook/company/pricing-features-table.yml index 775b50b171..ad63dd38a3 100644 --- a/handbook/company/pricing-features-table.yml +++ b/handbook/company/pricing-features-table.yml @@ -1,193 +1,268 @@ +- categoryName: Other + features: + - industryName: File integrity monitoring (FIM) # Short industry phrase + friendlyName: Detect changes to critical files # Short, Fleet one-liner for the feature, written in the imperative mood. (If easy to do, base this off of the words that an actual customer is saying.) + description: Specify files to monitor for changes or deletions, then log those events to your SIEM or data lake, including key information such as filepath and checksum. # Clear Mr. Rogers description + documentationUrl: https://fleetdm.com/guides/osquery-evented-tables-overview#file-integrity-monitoring-fim # URL of the single-best page within the docs which serves as a "jumping-off point" for this feature. + screenshotSrc: "" # A screenshot of the single, best, simplifying, obvious example + tier: Free # Either "Free" or "Premium" + usualDepartment: Security # or omit if there isn't a particular departmental leaning we've noticed + productCategories: [Endpoint operations] # or omit if this isn't associated with a single product category + dri: mikermcneil #GitHub user name + demos: + - description: A top gaming company needed a way to monitor critical files on production Debian servers. + quote: The FIM features are kind of a top priority. + moreInfoUrl: https://docs.google.com/document/d/1pE9U-1E4YDiy6h4TorszrTOiFAauFiORikSUFUqW7Pk/edit + cues: + - description: Monitor critical files on production Debian servers + - description: Detect illicit activity + moreInfoUrl: https://www.beyondtrust.com/resources/glossary/file-integrity-monitoring + - description: Pinpoint unintended changes + moreInfoUrl: https://www.beyondtrust.com/resources/glossary/file-integrity-monitoring + - description: Verify update status and monitoring system health + moreInfoUrl: https://www.beyondtrust.com/resources/glossary/file-integrity-monitoring + - description: Meet compliance mandates + moreInfoUrl: https://www.beyondtrust.com/resources/glossary/file-integrity-monitoring + - industryName: Human-endpoint mapping + friendlyName: See who logs in on every computer + description: Identify who logs in to any system, including login history and current sessions. Look up any host by the email address of the person using it. + documentationUrl: "" # todo + screenshotSrc: "" + tier: Free + productCategories: [Endpoint operations] + dri: mikermcneil + demos: + - description: Security engineers at a top gaming company wanted to get demographics off their macOS, Windows, and Linux machines about who the user is and who's logged in. + moreInfoUrl: https://docs.google.com/document/d/1qFYtMoKh3zyERLhbErJOEOo2me6Bc7KOOkjKn482Sqc/edit + cues: + - description: Human-to-device mapping + - description: Look up computer by ActiveDirectory account + - description: Find device by Google Chrome user + - description: Check user login history + moreInfoUrl: https://www.lepide.com/how-to/audit-who-logged-into-a-computer-and-when.html#:~:text=To%20find%20out%20the%20details,logs%20in%20%E2%80%9CWindows%20Logs%E2%80%9D. + - description: See currently logged in users + moreInfoUrl: https://www.top-password.com/blog/see-currently-logged-in-users-in-windows/ + - description: Get demographics off of our machines about who the user is and who's logged in + moreInfoUrl: https://docs.google.com/document/d/1qFYtMoKh3zyERLhbErJOEOo2me6Bc7KOOkjKn482Sqc/edit + - description: See what servers someone is logged-in on + moreInfoUrl: https://community.spiceworks.com/topic/138171-is-there-a-way-to-see-what-servers-someone-is-logged-in-on + - industryName: REST API + friendlyName: Automate any feature + description: "" + documentationUrl: https://fleetdm.com/docs/rest-api/rest-api + screenshotSrc: "" + tier: Free + dri: rachaelshaw + - industryName: Command line tool (CLI) + tier: Free - categoryName: Device management features: - - name: User-initiated enrollment of macOS computers + - industryName: User-initiated enrollment of macOS computers tier: Free - comingSoon: false - - name: Remotely enforce macOS settings + usualDepartment: IT + productCategories: [Device management] + - industryName: Remotely enforce macOS settings tier: Free - comingSoon: false - - name: Low-level macOS MDM commands (e.g. remote restart) + usualDepartment: IT + productCategories: [Device management] + - industryName: Low-level macOS MDM commands (e.g. remote restart) tier: Free - comingSoon: false - - name: Native macOS update reminders + usualDepartment: IT + productCategories: [Device management] + - industryName: Native macOS update reminders tier: Free - comingSoon: false - - name: Zero-touch setup for macOS computers + usualDepartment: IT + productCategories: [Device management] + - industryName: Zero-touch setup for macOS computers tier: Premium - comingSoon: false - - name: Safely execute custom scripts (macOS, Windows, and Linux) + usualDepartment: IT + productCategories: [Device management] + - industryName: Safely execute custom scripts (macOS, Windows, and Linux) tier: Premium - comingSoon: false - - name: End-user macOS update reminders (via Nudge) + productCategories: [Device management, Endpoint operations] + - industryName: End-user macOS update reminders (via Nudge) tier: Premium - comingSoon: false - - name: Encrypt macOS hard disks with FileVault + usualDepartment: IT + productCategories: [Device management] + - industryName: Encrypt macOS hard disks with FileVault tier: Premium - comingSoon: false - - name: Manage queued MDM commands on macOS + usualDepartment: IT + productCategories: [Device management] + - industryName: Manage queued MDM commands on macOS tier: Premium - comingSoon: true - - name: Remotely lock and wipe macOS computers + comingSoonOn: 2023-12-31 + usualDepartment: IT + productCategories: [Device management] + - industryName: Remotely lock and wipe macOS computers tier: Premium - comingSoon: false - - name: Update apps on macOS computers + usualDepartment: IT + productCategories: [Device management] + - industryName: Update apps on macOS computers tier: Premium - comingSoon: true - - name: Puppet integration # « Map macOS settings to computers with Puppet module + comingSoonOn: 2024-03-31 + usualDepartment: IT + productCategories: [Device management] + - industryName: Puppet integration + friendlyName: Map macOS settings to computers with Puppet module tier: Premium - comingSoon: false - - name: Interactive MDM migration # « end-user initiated MDM migration, with interactive UI + usualDepartment: IT + productCategories: [Device management] + - industryName: Interactive MDM migration # « end-user initiated MDM migration, with interactive UI tier: Premium - comingSoon: false + usualDepartment: IT + productCategories: [Device management] - categoryName: Support features: - - name: Public issue tracker (GitHub) + - industryName: Public issue tracker (GitHub) tier: Free - comingSoon: false - - name: Community Slack channel + - industryName: Community Slack channel tier: Free - comingSoon: false - - name: Unlimited email support (confidential) + - industryName: Unlimited email support (confidential) tier: Premium - comingSoon: false - - name: Phone and video call support + - industryName: Phone and video call support tier: Premium - comingSoon: false - categoryName: Inventory management features: - - name: Secure REST API + - industryName: Device inventory dashboard tier: Free - comingSoon: false - - name: Command line tool (CLI) + - industryName: Browse installed software packages tier: Free - comingSoon: false - - name: Realtime device inventory dashboard + - industryName: Search devices by IP, serial, hostname, UUID tier: Free - comingSoon: false - - name: Browse installed software packages - tier: Free - comingSoon: false - - name: Search devices by IP, serial, hostname, UUID - tier: Free - comingSoon: false - - name: Target and configure specific groups of devices + - industryName: Target and configure specific groups of devices tier: Premium - comingSoon: false - - name: Aggregate insights for groups of devices + - industryName: Generate reports for groups of devices tier: Premium - comingSoon: false - categoryName: Collaboration features: - - name: Shareable device health reports + - industryName: Shareable device health reports tier: Free - comingSoon: false - - name: Versionable queries and config (GitOps) + - industryName: Versionable queries and config (GitOps) tier: Free - comingSoon: false - - name: Human-to-device mapping + demos: + - description: A top financial services company needed to set up rolling deployments for changes to osquery agents running on their production servers. + moreInfoUrl: https://docs.google.com/document/d/1UdzZMyBLbs9SUXfSXN2x2wZQCbjZZUetYlNWH6-ryqQ/edit#heading=h.2lh6ehprpvl6 + - industryName: Scope transparency tier: Free - comingSoon: false - - name: Scope transparency - tier: Free - comingSoon: false + moreInfoUrl: https://fleetdm.com/transparency - categoryName: Security and compliance features: - - name: Single sign on (SSO, SAML) + - industryName: Single sign on (SSO, SAML) tier: Free - comingSoon: false - - name: Report on disk encryption status (FileVault) + - industryName: Disk encryption + friendlyName: Ensure hard disks are encrypted + description: Encrypt hard disks of macOS and Windows computers, manage escrowed encryption keys, and report on disk encryption status (FileVault, BitLocker). tier: Free - comingSoon: false - - name: Audit queries and user activities + cues: + - description: Report on disk encryption status + - description: Encrypt hard disks on macOS with FileVault + - description: Escrow FileVault keys on macOS + - description: Encrypt hard disks on Windows with BitLocker + - industryName: Audit queries and user activities tier: Free - comingSoon: false - - name: Grant API-only access + usualDepartment: Security + - industryName: Grant API-only access tier: Free - comingSoon: false - - name: Role-based access control - tier: Free - comingSoon: false - - name: Ship logs to Splunk, Snowflake, and more - tier: Free - comingSoon: false - - name: Programmable audit log + - industryName: Programmable audit log tier: Premium - comingSoon: false - - name: Just-in-time (JIT) provisioning + usualDepartment: Security + cues: + - description: Export activity of Fleet admins to your SIEM or data lake + - industryName: Just-in-time (JIT) provisioning tier: Premium - comingSoon: false - - name: Automated user role sync via Okta, AD, or any IDP + - industryName: Automated user role sync via Okta, AD, or any IDP tier: Premium - comingSoon: false - - name: Vanta integration + cue: + - description: Automatically set admin access to Fleet based on your IDP + - industryName: Vanta integration tier: Premium - comingSoon: false - - name: Trigger a workflow based on a failing policy + - industryName: Trigger a workflow based on a failing policy tier: Premium - comingSoon: true - - name: Granular role-based access control + - industryName: Role-based access control tier: Premium - comingSoon: false - categoryName: Monitoring features: - - name: Schedule and automate custom queries + - industryName: Schedule and automate custom queries tier: Free - comingSoon: false - - name: Detect vulnerable software + usualDepartment: Security + cues: + - description: Ship logs to Splunk, Snowflake, and more + - description: Export the data to other systems + moreInfoUrl: https://docs.google.com/document/d/1pE9U-1E4YDiy6h4TorszrTOiFAauFiORikSUFUqW7Pk/edit + - description: Export data to a third-party SIEM tool + moreInfoUrl: https://www.websense.com/content/support/library/web/hosted/admin_guide/siem_integration_explain.aspx + - industryName: Detect vulnerable software tier: Free - comingSoon: false - - name: Query performance monitoring + usualDepartment: Security + productCategories: [Vulnerability management] + demos: + - description: A top gaming company wanted to replace Qualys for infrastructure vulnerability detection. + quote: So we have some stuff today through Qualys, but it's just not very good. A lot of it is...it's just really noisy. I'm trying to find out specifically, actually what packages are installed where, and then the ability to live query them. + moreInfoUrl: https://docs.google.com/document/d/1JWtRsW1FUTCkZEESJj9-CvXjLXK4219by-C6vvVVyBY/edit + - industryName: Query performance monitoring tier: Free - comingSoon: false - - name: Standard query and policy library + demos: + - description: A top software company needed to understand the performance impact of osquery queries before running them on all of their production Linux servers. + moreInfoUrl: https://docs.google.com/document/d/1WzMc8GJCRU6tTBb6gLsSTzFysqtXO8CtP2sXMPKgYSk/edit?disco=AAAA6xuVxGg + - description: A top software company wanted to detect regressions when adding/changing queries and fail builds if queries were too expensive. + moreInfoUrl: https://docs.google.com/document/d/1WzMc8GJCRU6tTBb6gLsSTzFysqtXO8CtP2sXMPKgYSk/edit?disco=AAAA6xuVxGg + - industryName: Device trust tier: Free - comingSoon: false - - name: Policy and vulnerability automations (webhook, Zendesk, JIRA, ServiceNow*) + cue: + - description: Standard query and policy library + - description: Beyondcorp + - description: Zero trust + - description: Conditional access + - industryName: Policy and vulnerability automations (webhook, Zendesk, JIRA, ServiceNow*) tier: Free - comingSoon: false - - name: Detect and surface issues with devices (policies) + - industryName: Detect and surface issues with devices (policies) tier: Free - comingSoon: false - - name: Mark policies as critical + - industryName: Mark policies as critical tier: Premium - comingSoon: false - - name: Vulnerability scores (EPSS and CVSS) + - industryName: Vulnerability scores (EPSS and CVSS) tier: Premium - comingSoon: false - - name: CISA known exploited vulnerabilities + usualDepartment: Security + productCategories: [Vulnerability management] + - industryName: CISA known exploited vulnerabilities tier: Premium - comingSoon: false - - name: End-user self-service + usualDepartment: Security + productCategories: [Vulnerability management] + - industryName: End-user self-service tier: Premium - comingSoon: false + usualDepartment: IT + productCategories: [Device management, Endpoint operations] - categoryName: Data outputs features: - - name: Flexible log destinations (AWS Kinesis, Lambda, GCP, Kafka) + - industryName: Flexible log destinations (AWS Kinesis, Lambda, GCP, Kafka) tier: Free - comingSoon: false - - name: File carving (AWS S3) + usualDepartment: Security + productCategories: [Endpoint operations] + - industryName: File carving (AWS S3) tier: Free - comingSoon: false + usualDepartment: Security + productCategories: [Endpoint operations] - categoryName: Deployment features: - - name: Self-hosted + - industryName: Self-hosted tier: Free - comingSoon: false - - name: Deployment tools (Helm, Terraform) + cues: + - description: Self-managed + - description: Host it yourself + - industryName: Deployment tools (Terraform, Helm) tier: Free - comingSoon: false - - name: Configure osquery startup flags remotely + - industryName: Configure osquery startup flags remotely tier: Free - comingSoon: false - - name: Auto-update osquery agents + usualDepartment: Security + productCategories: [Endpoint operations] + - industryName: Auto-update osquery agents tier: Free - comingSoon: false - - name: Self-managed auto-update registry + productCategories: [Endpoint operations] + - industryName: Self-managed auto-update registry tier: Premium - comingSoon: false - - name: Manage osquery extensions remotely + usualDepartment: Security + productCategories: [Endpoint operations] + - industryName: Manage osquery extensions remotely tier: Premium - comingSoon: false - - name: Managed Cloud + productCategories: [Endpoint operations] + - industryName: Managed Cloud tier: Premium - comingSoon: false diff --git a/handbook/company/why-this-way.md b/handbook/company/why-this-way.md index 962c707227..071ea4a7c4 100644 --- a/handbook/company/why-this-way.md +++ b/handbook/company/why-this-way.md @@ -13,7 +13,7 @@ Fleet's source code, website, documentation, company handbook, and internal tool Meanwhile, the [company behind Fleet](https://twitter.com/fleetctl) is built on the [open-core](https://www.heavybit.com/library/video/commercial-open-source-business-strategies) business model. Openness is one of our core [values](https://fleetdm.com/handbook/company#values), and everything we do is [public by default](https://handbook.gitlab.com/handbook/values/#public-by-default). Even the [company handbook](https://fleetdm.com/handbook) is open to the world. -Is open-source collaboration _really_ worth all that? Is it any good? +Is open-source collaboration _all that_? Is it any good? Here are some of the reasons we build in the open: diff --git a/handbook/engineering/Load-testing.md b/handbook/engineering/Load-testing.md index bcdab25be4..ac93f16565 100644 --- a/handbook/engineering/Load-testing.md +++ b/handbook/engineering/Load-testing.md @@ -58,7 +58,7 @@ After the hosts have been enrolled, you can add `-only_already_enrolled` to make ## Infrastructure setup -The deployment of Fleet was done through the loadtesting [terraform maintained in the repo](https://github.com/fleetdm/fleet/tree/main/tools/loadtesting/terraform) with the following command: +The deployment of Fleet was done through the loadtesting [terraform maintained in the repo](https://github.com/fleetdm/fleet/tree/main/infrastructure/loadtesting/terraform) with the following command: ```bash terraform apply -var tag= diff --git a/handbook/engineering/README.md b/handbook/engineering/README.md index df35137430..975185961c 100644 --- a/handbook/engineering/README.md +++ b/handbook/engineering/README.md @@ -632,6 +632,17 @@ If the bug does not meet the criteria of a critical bug, the EM will determine i When fixing the bug, if the proposed solution requires changes that would affect the user experience (UI, API, or CLI), notify the EM and PM to align on the acceptability of the change. +Engineering teams coordinate on bug fixes with the product team during the joint sprint kick-off review. If one team is at capacity and a bug needs attention, another team can step in to assist by following these steps: + +For MDM support on CX bugs: +- Remove the `#g-cx` label and add `#g-mdm` label. +- Add `~assisting g-cx` to clarify the bug’s origin. + +For CX support on MDM bugs: +- Remove the `#g-mdm` label and add `#g-cx` label. +- Add `~assisting g-mdm` to clarify the bug’s origin. + + Fleet [always prioritizes bugs](https://fleetdm.com/handbook/product#prioritizing-improvements) into a release within six weeks. If a bug is not prioritized in the current release, and it is not prioritized in the next release, it is removed from the "Sprint backlog" and placed back in the "Product drafting" column with the `:product` label. Product will determine if the bug should be closed as accepted behavior, or if further drafting is necessary. #### Awaiting QA diff --git a/handbook/engineering/scaling-fleet.md b/handbook/engineering/scaling-fleet.md index 125e2f95f8..0738737028 100644 --- a/handbook/engineering/scaling-fleet.md +++ b/handbook/engineering/scaling-fleet.md @@ -69,7 +69,7 @@ However, this database feature doesn’t come without a cost. The one to focus o The TLDR is: understand very well how a table will be used. If we do bulk inserts/updates, InnoDB might lock more than you anticipate and cause issues. This is not an argument to not do bulk inserts/updates, but to be very careful when you add a foreign key. -In particular, host_id is a foreign key we’ve been skipping in all the new additional host data tables, which is not something that comes for free, as with that, [we have to keep the data consistent by hand with cleanups](https://github.com/fleetdm/fleet/blob/main/server/datastore/mysql/hosts.go#L309-L309). +In particular, host_id is a foreign key we’ve been skipping in all the new additional host data tables, which is not something that comes for free, as with that, [we have to keep the data consistent by hand with cleanups](https://github.com/fleetdm/fleet/blob/71a237042a9c39a45bc8f9c76465e5ff6039eba9/server/datastore/mysql/hosts.go#L444). ### In this section diff --git a/handbook/marketing/README.md b/handbook/marketing/README.md index faac5f7210..84fcd33b61 100644 --- a/handbook/marketing/README.md +++ b/handbook/marketing/README.md @@ -36,7 +36,7 @@ Fleet's community programs are rooted in several areas; created to nurture commu ### Social media Fleet's largest asset is our user community, the people actually using Fleet. Public conversations on social media create valuable opportunities for contributors to answer technical questions and collect feedback. -Fleet [does not self-promote](https://www.audible.com/pd/The-Impact-Equation-Audiobook/B00AR1VFBU). (Great brands are [magnanimous](https://en.wikipedia.org/wiki/Magnanimity).) +Fleet [does not self-promote](https://www.audible.com/pd/The-Impact-Equation-Audiobook/B00AR1VFBU). (Great brands are [magnanimous](https://en.wikipedia.org/wiki/Magnanimity).) In fact, conversations are already happening in our social spaces that open up opportunities for Fleet to [engage with the community](https://fleetdm.com/handbook/marketing#engage-with-the-community). Here are some topics for social media posts: - Fleet the product @@ -47,11 +47,13 @@ Here are some topics for social media posts: - Industry news about device management - Upcoming events, interviews, and podcasts - ### Ads Fleet uses advertising to spread awareness through a broader audience and foster greater engagement within user communities. The more people actively using Fleet, or contributing, the better Fleet will be. +### Events +It's important for Fleet to engage at events. This provides an opportunity to directly engage with potential users and contributors, build relationships, gather feedback, and create a stronger sense of community and trust. + ## Responsibilities ### Optimize ads through experimentation @@ -116,6 +118,31 @@ Any changes to the current running ads visible to a user, including designs, key 2. Compare existing ads against the newly proposed ad within the corresponding ad platform. ([Google Ads](https://ads.google.com/home/), [LinkedIn Campaign Manager](https://www.linkedin.com/campaignmanager/), etc.) 3. If your change is approved, Field Marketer makes changes and creates a calendar reminder to check performance two weeks from the date changes were made. + +### Engage with the community +Public conversations on social media create valuable opportunities for contributors to answer technical questions and collect feedback. + +Here are some links that filter relevant conversations on each platform: +- [LinkedIn](https://www.linkedin.com/search/results/content/?datePosted=%22past-week%22&keywords=osquery%20OR%20%22fleet%20device%20management%22%20OR%20%22fleetdm%22%20OR%20%22github.com%2Ffleetdm%2Ffleet%22%20OR%20%22fleetdm.com%22&origin=FACETED_SEARCH&sid=oxR) +- [Twitter](https://twitter.com/search?q=%22osquery%22%20OR%20%22github.com%2Fosquery%2Fosquery%22%20OR%20%22github.com%2Ffleetdm%2Ffleet%22%20OR%20%22github.com%2Fkolide%2Ffleet%22%20OR%20%22fleetdm%22%20OR%20%22fleet%20device%20management%22%20OR%20%22nanomdm%22%20OR%20%22micromdm%22%20OR%20%22swiftDialog%22&src=typed_query&f=live) + +1. Find conversations that are relevant to Fleet on both LinkedIn and Twitter +2. Reply to threads looking for solutions Fleet can solve with helpful information. If additional information is needed, find help in [#help-engineering](https://fleetdm.slack.com/archives/C019WG4GH0A) for accurate information. +3. Leave a like on threads and posts that are interesting, cool, celebratory, funny, etc. within our communities. +4. If a post is helpful to our audience, reshare it. + +### Book an event +For an event to be considered, booked, and scheduled, we follow the event issue template. + +1. Create a [new GitHub issue for the #g-marketing board](https://fleetdm.com/handbook/marketing#contact-us) and select the "Event-preparation" template.. +2. Drag the issue into the "🗓 Ideas for future events" column. + +Once approval has been received, move the event into the "🗓 Planned events" column. + +### Review ongoing events +Check the "🗓 Planned events" column in [#g-marketing board](https://app.zenhub.com/workspaces/g-marketing-64e6c8e2d35c7f001a457b7f/board) and continue to work through steps in each event's issue. + + ## Rituals diff --git a/handbook/marketing/marketing.rituals.yml b/handbook/marketing/marketing.rituals.yml index 7c43d7e48f..2cb4d35329 100644 --- a/handbook/marketing/marketing.rituals.yml +++ b/handbook/marketing/marketing.rituals.yml @@ -21,3 +21,24 @@ description: "Complete draft orders." # example of a longer thing: description: "[Prioritizing next sprint](https://fleetdm.com/handbook/company/communication)" moreInfoUrl: "https://fleetdm.com/handbook/marketing#process-pending-swag-requests-from-the-website" #URL used to highlight "description:" test in table dri: "drewbakerfdm" # DRI for ritual (assignee if autoIssue) (TODO display GitHub proflie pic instead of name or title) +- + task: "Engage with the community" + startedOn: "2023-09-20" + frequency: "Daily" + description: "Find relevant conversations with the community and contribute" + moreInfoUrl: "https://fleetdm.com/handbook/marketing#engage-with-the-community" + dri: "drewbakerfdm" +- + task: "Review ongoing events" + startedOn: "2023-10-02" + frequency: "Daily" + description: "Check 🗓️ Planned events and complete steps in each issue" + moreInfoUrl: "https://fleetdm.com/handbook/marketing#review-ongoing-events" + dri: "drewbakerfdm" +- + task: "Book an event" + startedOn: "2023-10-02" + frequency: "Weekly" + description: "Populate 🗓️ Ideas for future events" + moreInfoUrl: "https://fleetdm.com/handbook/marketing#book-an-event" + dri: "drewbakerfdm" diff --git a/handbook/product/README.md b/handbook/product/README.md index 4471388fe0..30eaf80477 100644 --- a/handbook/product/README.md +++ b/handbook/product/README.md @@ -77,9 +77,9 @@ For external contributors: please consider opening an issue with reference scree Once the draft has been approved, it moves to the "Settled" column on the drafting board. -Before assigning an engineering manager for [estimation](https://fleetdm.com/handbook/engineering#sprint-ceremonies), the product team should ensure the product section of the user story [checklist](https://github.com/fleetdm/fleet/issues/new?assignees=&labels=story&projects=&template=story.md&title=) is complete. +Before assigning an engineering manager to [estimate](https://fleetdm.com/handbook/engineering#sprint-ceremonies) a user story, the product designer ensures the product section of the user story [checklist](https://github.com/fleetdm/fleet/issues/new?assignees=&labels=story&projects=&template=story.md&title=) is complete. -> The story's designer is responsible for ensuring the checklist has been completed, the requirements section is consistent with the Figma, and the group engineering manager has been assigned. +Once a bug has gone through design and is considered "Settled", the designer removes the `:product` label and moves the issue to the 'To be scheduled' column on the "Bugs" board. The product manager then prioritizes the bug into the "Sprint backlog" and assigns the group engineering manager. Learn https://fleetdm.com/handbook/company/development-groups#making-changes diff --git a/infrastructure/dogfood/terraform/aws/variables.tf b/infrastructure/dogfood/terraform/aws/variables.tf index 615976ac2e..09388cd560 100644 --- a/infrastructure/dogfood/terraform/aws/variables.tf +++ b/infrastructure/dogfood/terraform/aws/variables.tf @@ -56,7 +56,7 @@ variable "database_name" { variable "fleet_image" { description = "the name of the container image to run" - default = "fleetdm/fleet:v4.38.0" + default = "fleetdm/fleet:v4.38.1" } variable "software_inventory" { diff --git a/infrastructure/dogfood/terraform/gcp/variables.tf b/infrastructure/dogfood/terraform/gcp/variables.tf index 9306fe0904..7523062127 100644 --- a/infrastructure/dogfood/terraform/gcp/variables.tf +++ b/infrastructure/dogfood/terraform/gcp/variables.tf @@ -68,5 +68,5 @@ variable "redis_mem" { } variable "image" { - default = "fleet:v4.38.0" + default = "fleet:v4.38.1" } diff --git a/infrastructure/loadtesting/terraform/readme.md b/infrastructure/loadtesting/terraform/readme.md index bf25be2b06..e46cd0be42 100644 --- a/infrastructure/loadtesting/terraform/readme.md +++ b/infrastructure/loadtesting/terraform/readme.md @@ -1,7 +1,7 @@ ## Terraform for Loadtesting Environment The interface into this code is designed to be minimal. -If you require changes beyond whats described here, contact @zwinnerman-fleetdm. +If you require changes beyond whats described here, contact #g-infra. ### Deploying your code to the loadtesting environment diff --git a/infrastructure/sandbox/JITProvisioner/jitprovisioner.tf b/infrastructure/sandbox/JITProvisioner/jitprovisioner.tf index 71842e3cd5..fe78454409 100644 --- a/infrastructure/sandbox/JITProvisioner/jitprovisioner.tf +++ b/infrastructure/sandbox/JITProvisioner/jitprovisioner.tf @@ -206,7 +206,7 @@ resource "random_uuid" "jitprovisioner" { # Use the local to make the trigger work. locals { - fleet_tag = "v4.38.0" + fleet_tag = "v4.38.1" } resource "null_resource" "standard-query-library" { diff --git a/infrastructure/sandbox/PreProvisioner/lambda/deploy_terraform/main.tf b/infrastructure/sandbox/PreProvisioner/lambda/deploy_terraform/main.tf index d56cbdbb94..458d6677cc 100644 --- a/infrastructure/sandbox/PreProvisioner/lambda/deploy_terraform/main.tf +++ b/infrastructure/sandbox/PreProvisioner/lambda/deploy_terraform/main.tf @@ -165,7 +165,7 @@ resource "helm_release" "main" { set { name = "imageTag" - value = "v4.38.0" + value = "v4.38.1" } set { diff --git a/orbit/changes/12842-orbit-bitlocker-management b/orbit/changes/12842-orbit-bitlocker-management new file mode 100644 index 0000000000..97d7e6fe1e --- /dev/null +++ b/orbit/changes/12842-orbit-bitlocker-management @@ -0,0 +1 @@ +* Adding support to manage Bitlocker operations through Orbit notifications diff --git a/orbit/cmd/orbit/orbit.go b/orbit/cmd/orbit/orbit.go index 3b91e700f9..3e9f56df12 100644 --- a/orbit/cmd/orbit/orbit.go +++ b/orbit/cmd/orbit/orbit.go @@ -622,6 +622,7 @@ func main() { const ( renewEnrollmentProfileCommandFrequency = time.Hour windowsMDMEnrollmentCommandFrequency = time.Hour + windowsMDMBitlockerCommandFrequency = time.Hour ) configFetcher := update.ApplyRenewEnrollmentProfileConfigFetcherMiddleware(orbitClient, renewEnrollmentProfileCommandFrequency, fleetURL) configFetcher = update.ApplyRunScriptsConfigFetcherMiddleware(configFetcher, c.Bool("enable-scripts"), orbitClient) @@ -638,6 +639,7 @@ func main() { configFetcher = update.ApplySwiftDialogDownloaderMiddleware(configFetcher, updateRunner) case "windows": configFetcher = update.ApplyWindowsMDMEnrollmentFetcherMiddleware(configFetcher, windowsMDMEnrollmentCommandFrequency, orbitHostInfo.HardwareUUID, orbitClient) + configFetcher = update.ApplyWindowsMDMBitlockerFetcherMiddleware(configFetcher, windowsMDMBitlockerCommandFrequency, orbitClient) } const orbitFlagsUpdateInterval = 30 * time.Second diff --git a/orbit/pkg/bitlocker/bitlocker_management.go b/orbit/pkg/bitlocker/bitlocker_management.go new file mode 100644 index 0000000000..e210568927 --- /dev/null +++ b/orbit/pkg/bitlocker/bitlocker_management.go @@ -0,0 +1,17 @@ +package bitlocker + +// Encryption Status +type EncryptionStatus struct { + ProtectionStatusDesc string + ConversionStatusDesc string + EncryptionPercentage string + EncryptionFlags string + WipingStatusDesc string + WipingPercentage string +} + +// Volume Encryption Status +type VolumeStatus struct { + DriveVolume string + Status *EncryptionStatus +} diff --git a/orbit/pkg/bitlocker/bitlocker_management_notwindows.go b/orbit/pkg/bitlocker/bitlocker_management_notwindows.go new file mode 100644 index 0000000000..4263ba270e --- /dev/null +++ b/orbit/pkg/bitlocker/bitlocker_management_notwindows.go @@ -0,0 +1,19 @@ +//go:build !windows + +package bitlocker + +func GetRecoveryKeys(targetVolume string) (map[string]string, error) { + return nil, nil +} + +func EncryptVolume(targetVolume string) (string, error) { + return "", nil +} + +func DecryptVolume(targetVolume string) error { + return nil +} + +func GetEncryptionStatus() ([]VolumeStatus, error) { + return nil, nil +} diff --git a/orbit/pkg/bitlocker/bitlocker_management_windows.go b/orbit/pkg/bitlocker/bitlocker_management_windows.go new file mode 100644 index 0000000000..4d9bb36838 --- /dev/null +++ b/orbit/pkg/bitlocker/bitlocker_management_windows.go @@ -0,0 +1,573 @@ +//go:build windows + +package bitlocker + +import ( + "fmt" + "syscall" + + "github.com/go-ole/go-ole" + "github.com/go-ole/go-ole/oleutil" + "github.com/scjalliance/comshim" +) + +// Encryption Methods +// https://docs.microsoft.com/en-us/windows/win32/secprov/getencryptionmethod-win32-encryptablevolume +type EncryptionMethod int32 + +const ( + None EncryptionMethod = iota + AES128WithDiffuser + AES256WithDiffuser + AES128 + AES256 + HardwareEncryption + XtsAES128 + XtsAES256 +) + +// Encryption Flags +// https://docs.microsoft.com/en-us/windows/win32/secprov/encrypt-win32-encryptablevolume +type EncryptionFlag int32 + +const ( + EncryptDataOnly EncryptionFlag = 0x00000001 + EncryptDemandWipe EncryptionFlag = 0x00000002 + EncryptSynchronous EncryptionFlag = 0x00010000 + + // Error Codes + ERROR_IO_DEVICE int32 = -2147023779 + FVE_E_EDRIVE_INCOMPATIBLE_VOLUME int32 = -2144272206 + FVE_E_NO_TPM_WITH_PASSPHRASE int32 = -2144272212 + FVE_E_PASSPHRASE_TOO_LONG int32 = -2144272214 + FVE_E_POLICY_PASSPHRASE_NOT_ALLOWED int32 = -2144272278 + FVE_E_NOT_DECRYPTED int32 = -2144272327 + FVE_E_INVALID_PASSWORD_FORMAT int32 = -2144272331 + FVE_E_BOOTABLE_CDDVD int32 = -2144272336 + FVE_E_PROTECTOR_EXISTS int32 = -2144272335 +) + +// DiscoveryVolumeType specifies the type of discovery volume to be used by Prepare. +// https://docs.microsoft.com/en-us/windows/win32/secprov/preparevolume-win32-encryptablevolume +type DiscoveryVolumeType string + +const ( + // VolumeTypeNone indicates no discovery volume. This value creates a native BitLocker volume. + VolumeTypeNone DiscoveryVolumeType = "" + // VolumeTypeDefault indicates the default behavior. + VolumeTypeDefault DiscoveryVolumeType = "" + // VolumeTypeFAT32 creates a FAT32 discovery volume. + VolumeTypeFAT32 DiscoveryVolumeType = "FAT32" +) + +// ForceEncryptionType specifies the encryption type to be used when calling Prepare on the volume. +// https://docs.microsoft.com/en-us/windows/win32/secprov/preparevolume-win32-encryptablevolume +type ForceEncryptionType int32 + +const ( + // EncryptionTypeUnspecified indicates that the encryption type is not specified. + EncryptionTypeUnspecified ForceEncryptionType = 0 + // EncryptionTypeSoftware specifies software encryption. + EncryptionTypeSoftware ForceEncryptionType = 1 + // EncryptionTypeHardware specifies hardware encryption. + EncryptionTypeHardware ForceEncryptionType = 2 +) + +func encryptErrHandler(val int32) error { + switch val { + case ERROR_IO_DEVICE: + return fmt.Errorf("an I/O error has occurred during encryption; the device may need to be reset") + case FVE_E_EDRIVE_INCOMPATIBLE_VOLUME: + return fmt.Errorf("the drive specified does not support hardware-based encryption") + case FVE_E_NO_TPM_WITH_PASSPHRASE: + return fmt.Errorf("a TPM key protector cannot be added because a password protector exists on the drive") + case FVE_E_PASSPHRASE_TOO_LONG: + return fmt.Errorf("the passphrase cannot exceed 256 characters") + case FVE_E_POLICY_PASSPHRASE_NOT_ALLOWED: + return fmt.Errorf("group Policy settings do not permit the creation of a password") + case FVE_E_NOT_DECRYPTED: + return fmt.Errorf("the drive must be fully decrypted to complete this operation") + case FVE_E_INVALID_PASSWORD_FORMAT: + return fmt.Errorf("the format of the recovery password provided is invalid") + case FVE_E_BOOTABLE_CDDVD: + return fmt.Errorf("bitLocker Drive Encryption detected bootable media (CD or DVD) in the computer") + case FVE_E_PROTECTOR_EXISTS: + return fmt.Errorf("key protector cannot be added; only one key protector of this type is allowed for this drive") + default: + return fmt.Errorf("error code returned during encryption: %d", val) + } +} + +///////////////////////////////////////////////////// +// Volume represents a Bitlocker encryptable volume +///////////////////////////////////////////////////// + +type Volume struct { + letter string + handle *ole.IDispatch + wmiIntf *ole.IDispatch + wmiSvc *ole.IDispatch +} + +// bitlockerClose frees all resources associated with a volume. +func (v *Volume) bitlockerClose() { + if v.handle != nil { + v.handle.Release() + } + + if v.wmiIntf != nil { + v.wmiIntf.Release() + } + + if v.wmiSvc != nil { + v.wmiSvc.Release() + } + + comshim.Done() +} + +// encrypt encrypts the volume +// Example: vol.encrypt(bitlocker.XtsAES256, bitlocker.EncryptDataOnly) +// https://docs.microsoft.com/en-us/windows/win32/secprov/encrypt-win32-encryptablevolume +func (v *Volume) encrypt(method EncryptionMethod, flags EncryptionFlag) error { + resultRaw, err := oleutil.CallMethod(v.handle, "Encrypt", int32(method), int32(flags)) + if err != nil { + return fmt.Errorf("encrypt(%s): %w", v.letter, err) + } else if val, ok := resultRaw.Value().(int32); val != 0 || !ok { + return fmt.Errorf("encrypt(%s): %w", v.letter, encryptErrHandler(val)) + } + + return nil +} + +// decrypt encrypts the volume +// Example: vol.decrypt() +// https://learn.microsoft.com/en-us/windows/win32/secprov/decrypt-win32-encryptablevolume +func (v *Volume) decrypt() error { + resultRaw, err := oleutil.CallMethod(v.handle, "Decrypt") + if err != nil { + return fmt.Errorf("decrypt(%s): %w", v.letter, err) + } else if val, ok := resultRaw.Value().(int32); val != 0 || !ok { + return fmt.Errorf("decrypt(%s): %w", v.letter, encryptErrHandler(val)) + } + + return nil +} + +// prepareVolume prepares a new Bitlocker Volume. This should be called BEFORE any key protectors are added. +// Example: vol.prepareVolume(bitlocker.VolumeTypeDefault, bitlocker.EncryptionTypeHardware) +// https://docs.microsoft.com/en-us/windows/win32/secprov/preparevolume-win32-encryptablevolume +func (v *Volume) prepareVolume(volType DiscoveryVolumeType, encType ForceEncryptionType) error { + resultRaw, err := oleutil.CallMethod(v.handle, "PrepareVolume", string(volType), int32(encType)) + if err != nil { + return fmt.Errorf("prepareVolume(%s): %w", v.letter, err) + } else if val, ok := resultRaw.Value().(int32); val != 0 || !ok { + return fmt.Errorf("prepareVolume(%s): %w", v.letter, encryptErrHandler(val)) + } + return nil +} + +// protectWithNumericalPassword adds a numerical password key protector. +// Leave password as a blank string to have one auto-generated by Windows +// https://docs.microsoft.com/en-us/windows/win32/secprov/protectkeywithnumericalpassword-win32-encryptablevolume +func (v *Volume) protectWithNumericalPassword() (string, error) { + var volumeKeyProtectorID ole.VARIANT + ole.VariantInit(&volumeKeyProtectorID) + var resultRaw *ole.VARIANT + var err error + + resultRaw, err = oleutil.CallMethod(v.handle, "ProtectKeyWithNumericalPassword", nil, nil, &volumeKeyProtectorID) + if err != nil { + return "", fmt.Errorf("ProtectKeyWithNumericalPassword(%s): %w", v.letter, err) + } else if val, ok := resultRaw.Value().(int32); val != 0 || !ok { + return "", fmt.Errorf("ProtectKeyWithNumericalPassword(%s): %w", v.letter, encryptErrHandler(val)) + } + + var recoveryKey ole.VARIANT + ole.VariantInit(&recoveryKey) + resultRaw, err = oleutil.CallMethod(v.handle, "GetKeyProtectorNumericalPassword", volumeKeyProtectorID.ToString(), &recoveryKey) + + if err != nil { + return "", fmt.Errorf("GetKeyProtectorNumericalPassword(%s): %w", v.letter, err) + } else if val, ok := resultRaw.Value().(int32); val != 0 || !ok { + return "", fmt.Errorf("GetKeyProtectorNumericalPassword(%s): %w", v.letter, encryptErrHandler(val)) + } + + return recoveryKey.ToString(), nil +} + +// protectWithPassphrase adds a passphrase key protector +// https://docs.microsoft.com/en-us/windows/win32/secprov/protectkeywithpassphrase-win32-encryptablevolume +func (v *Volume) protectWithPassphrase(passphrase string) (string, error) { + var volumeKeyProtectorID ole.VARIANT + ole.VariantInit(&volumeKeyProtectorID) + + resultRaw, err := oleutil.CallMethod(v.handle, "ProtectKeyWithPassphrase", nil, passphrase, &volumeKeyProtectorID) + if err != nil { + return "", fmt.Errorf("protectWithPassphrase(%s): %w", v.letter, err) + } else if val, ok := resultRaw.Value().(int32); val != 0 || !ok { + return "", fmt.Errorf("protectWithPassphrase(%s): %w", v.letter, encryptErrHandler(val)) + } + + return volumeKeyProtectorID.ToString(), nil +} + +// protectWithTPM adds the TPM key protector +// https://docs.microsoft.com/en-us/windows/win32/secprov/protectkeywithtpm-win32-encryptablevolume +func (v *Volume) protectWithTPM(platformValidationProfile *[]uint8) error { + var volumeKeyProtectorID ole.VARIANT + ole.VariantInit(&volumeKeyProtectorID) + var resultRaw *ole.VARIANT + var err error + + if platformValidationProfile == nil { + resultRaw, err = oleutil.CallMethod(v.handle, "ProtectKeyWithTPM", nil, nil, &volumeKeyProtectorID) + } else { + resultRaw, err = oleutil.CallMethod(v.handle, "ProtectKeyWithTPM", nil, *platformValidationProfile, &volumeKeyProtectorID) + } + if err != nil { + return fmt.Errorf("protectKeyWithTPM(%s): %w", v.letter, err) + } else if val, ok := resultRaw.Value().(int32); val != 0 || !ok { + return fmt.Errorf("protectKeyWithTPM(%s): %w", v.letter, encryptErrHandler(val)) + } + + return nil +} + +// getBitlockerStatus returns the current status of the volume +// https://learn.microsoft.com/en-us/windows/win32/secprov/getprotectionstatus-win32-encryptablevolume +func (v *Volume) getBitlockerStatus() (*EncryptionStatus, error) { + var ( + conversionStatus int32 + encryptionPercentage int32 + encryptionFlags int32 + wipingStatus int32 + wipingPercentage int32 + precisionFactor int32 = 4 + protectionStatus int32 + ) + + resultRaw, err := oleutil.CallMethod(v.handle, "GetConversionStatus", &conversionStatus, &encryptionPercentage, &encryptionFlags, &wipingStatus, &wipingPercentage, precisionFactor) + if err != nil { + return nil, fmt.Errorf("GetConversionStatus(%s): %w", v.letter, err) + } else if val, ok := resultRaw.Value().(int32); val != 0 || !ok { + return nil, fmt.Errorf("GetConversionStatus(%s): %w", v.letter, encryptErrHandler(val)) + } + + resultRaw, err = oleutil.CallMethod(v.handle, "GetProtectionStatus", &protectionStatus) + if err != nil { + return nil, fmt.Errorf("GetProtectionStatus(%s): %w", v.letter, err) + } else if val, ok := resultRaw.Value().(int32); val != 0 || !ok { + return nil, fmt.Errorf("GetProtectionStatus(%s): %w", v.letter, encryptErrHandler(val)) + } + + // Creating the encryption status struct + encStatus := &EncryptionStatus{ + ProtectionStatusDesc: getProtectionStatusDescription(fmt.Sprintf("%d", protectionStatus)), + ConversionStatusDesc: getConversionStatusDescription(fmt.Sprintf("%d", conversionStatus)), + EncryptionPercentage: intToPercentage(encryptionPercentage), + EncryptionFlags: fmt.Sprintf("%d", encryptionFlags), + WipingStatusDesc: getWipingStatusDescription(fmt.Sprintf("%d", wipingStatus)), + WipingPercentage: intToPercentage(wipingPercentage), + } + + return encStatus, nil +} + +// getProtectorsKeys returns the recovery keys for the volume +// https://learn.microsoft.com/en-us/windows/win32/secprov/getkeyprotectornumericalpassword-win32-encryptablevolume +func (v *Volume) getProtectorsKeys() (map[string]string, error) { + keys, err := getKeyProtectors(v.handle) + if err != nil { + return nil, fmt.Errorf("getKeyProtectors: %w", err) + } + + recoveryKeys := make(map[string]string) + for _, k := range keys { + var recoveryKey ole.VARIANT + ole.VariantInit(&recoveryKey) + recoveryKeyResultRaw, err := oleutil.CallMethod(v.handle, "GetKeyProtectorNumericalPassword", k, &recoveryKey) + if err != nil { + continue // No recovery key for this protector + } else if val, ok := recoveryKeyResultRaw.Value().(int32); val != 0 || !ok { + continue // No recovery key for this protector + } + recoveryKeys[k] = recoveryKey.ToString() + } + + return recoveryKeys, nil +} + +///////////////////////////////////////////////////// +// Helper functions +///////////////////////////////////////////////////// + +// bitlockerConnect connects to an encryptable volume in order to manage it. +func bitlockerConnect(driveLetter string) (Volume, error) { + comshim.Add(1) + v := Volume{letter: driveLetter} + + unknown, err := oleutil.CreateObject("WbemScripting.SWbemLocator") + if err != nil { + comshim.Done() + return v, fmt.Errorf("createObject: %w", err) + } + defer unknown.Release() + + v.wmiIntf, err = unknown.QueryInterface(ole.IID_IDispatch) + if err != nil { + comshim.Done() + return v, fmt.Errorf("queryInterface: %w", err) + } + serviceRaw, err := oleutil.CallMethod(v.wmiIntf, "ConnectServer", nil, `\\.\ROOT\CIMV2\Security\MicrosoftVolumeEncryption`) + if err != nil { + v.bitlockerClose() + return v, fmt.Errorf("connectServer: %w", err) + } + v.wmiSvc = serviceRaw.ToIDispatch() + + raw, err := oleutil.CallMethod(v.wmiSvc, "ExecQuery", "SELECT * FROM Win32_EncryptableVolume WHERE DriveLetter = '"+driveLetter+"'") + if err != nil { + v.bitlockerClose() + return v, fmt.Errorf("execQuery: %w", err) + } + result := raw.ToIDispatch() + defer result.Release() + + itemRaw, err := oleutil.CallMethod(result, "ItemIndex", 0) + if err != nil { + v.bitlockerClose() + return v, fmt.Errorf("failed to fetch result row while processing BitLocker info: %w", err) + } + v.handle = itemRaw.ToIDispatch() + + return v, nil +} + +// getConversionStatusDescription returns the current status of the volume +// https://learn.microsoft.com/en-us/windows/win32/secprov/getconversionstatus-win32-encryptablevolume +func getConversionStatusDescription(input string) string { + switch input { + case "0": + return "FullyDecrypted" + case "1": + return "FullyEncrypted" + case "2": + return "EncryptionInProgress" + case "3": + return "DecryptionInProgress" + case "4": + return "EncryptionPaused" + case "5": + return "DecryptionPaused" + } + + return "Status " + input +} + +// getWipingStatusDescription returns the current wiping status of the volume +// https://learn.microsoft.com/en-us/windows/win32/secprov/getconversionstatus-win32-encryptablevolume +func getWipingStatusDescription(input string) string { + switch input { + case "0": + return "FreeSpaceNotWiped" + case "1": + return "FreeSpaceWiped" + case "2": + return "FreeSpaceWipingInProgress" + case "3": + return "FreeSpaceWipingPaused" + } + + return "Status " + input +} + +// getProtectionStatusDescription returns the current protection status of the volume +// https://learn.microsoft.com/en-us/windows/win32/secprov/getprotectionstatus-win32-encryptablevolume +func getProtectionStatusDescription(input string) string { + switch input { + case "0": + return "Unprotected" + case "1": + return "Protected" + case "2": + return "Unknown" + } + + return "Status " + input +} + +// intToPercentage converts an int to a percentage string +func intToPercentage(num int32) string { + percentage := float64(num) / 10000.0 + return fmt.Sprintf("%.2f%%", percentage) +} + +// getKeyProtectors returns the key protectors for the volume +// https://learn.microsoft.com/en-us/windows/win32/secprov/getkeyprotectors-win32-encryptablevolume +func getKeyProtectors(item *ole.IDispatch) ([]string, error) { + kp := []string{} + var keyProtectorResults ole.VARIANT + ole.VariantInit(&keyProtectorResults) + + keyIDResultRaw, err := oleutil.CallMethod(item, "GetKeyProtectors", 3, &keyProtectorResults) + if err != nil { + return nil, fmt.Errorf("unable to get Key Protectors while getting BitLocker info. %s", err.Error()) + } else if val, ok := keyIDResultRaw.Value().(int32); val != 0 || !ok { + return nil, fmt.Errorf("unable to get Key Protectors while getting BitLocker info. Return code %d", val) + } + + keyProtectorValues := keyProtectorResults.ToArray().ToValueArray() + for _, keyIDItemRaw := range keyProtectorValues { + keyIDItem, ok := keyIDItemRaw.(string) + if !ok { + return nil, fmt.Errorf("keyProtectorID wasn't a string") + } + kp = append(kp, keyIDItem) + } + + return kp, nil +} + +// bitsToDrives converts a bit map to a list of drives +func bitsToDrives(bitMap uint32) (drives []string) { + availableDrives := []string{"A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z"} + + for i := range availableDrives { + if bitMap&1 == 1 { + drives = append(drives, availableDrives[i]+":") + } + bitMap >>= 1 + } + + return +} + +func getLogicalVolumes() ([]string, error) { + kernel32, err := syscall.LoadLibrary("kernel32.dll") + if err != nil { + return nil, fmt.Errorf("failed to load kernel32.dll: %v", err) + } + defer syscall.FreeLibrary(kernel32) + + getLogicalDrivesHandle, err := syscall.GetProcAddress(kernel32, "GetLogicalDrives") + if err != nil { + return nil, fmt.Errorf("failed to get procedure address: %v", err) + } + + ret, _, callErr := syscall.SyscallN(uintptr(getLogicalDrivesHandle), 0, 0, 0, 0) + if callErr != 0 { + return nil, fmt.Errorf("syscall to GetLogicalDrives failed: %v", callErr) + } + + return bitsToDrives(uint32(ret)), nil +} + +func getBitlockerStatus(targetVolume string) (*EncryptionStatus, error) { + // Connect to the volume + vol, err := bitlockerConnect(targetVolume) + if err != nil { + return nil, fmt.Errorf("there was an error connecting to the volume - error: %v", err) + } + defer vol.bitlockerClose() + + // Get volume status + status, err := vol.getBitlockerStatus() + if err != nil { + return nil, fmt.Errorf("there was an error starting decryption - error: %v", err) + } + + return status, nil +} + +///////////////////////////////////////////////////// +// Bitlocker Management interface implementation +///////////////////////////////////////////////////// + +func GetRecoveryKeys(targetVolume string) (map[string]string, error) { + // Connect to the volume + vol, err := bitlockerConnect(targetVolume) + if err != nil { + return nil, fmt.Errorf("there was an error connecting to the volume - error: %v", err) + } + defer vol.bitlockerClose() + + // Get recovery keys + keys, err := vol.getProtectorsKeys() + if err != nil { + return nil, fmt.Errorf("there was an error retreving protection keys: %v", err) + } + + return keys, nil +} + +func EncryptVolume(targetVolume string) (string, error) { + // Connect to the volume + vol, err := bitlockerConnect(targetVolume) + if err != nil { + return "", fmt.Errorf("there was an error connecting to the volume - error: %v", err) + } + defer vol.bitlockerClose() + + // Prepare for encryption + if err := vol.prepareVolume(VolumeTypeDefault, EncryptionTypeSoftware); err != nil { + return "", fmt.Errorf("there was an error preparing the volume for encryption - error: %v", err) + } + + // Add a recovery protector + recoveryKey, err := vol.protectWithNumericalPassword() + if err != nil { + return "", fmt.Errorf("there was an error adding a recovery protector - error: %v", err) + } + + // Protect with TPM + if err := vol.protectWithTPM(nil); err != nil { + return "", fmt.Errorf("there was an error protecting with TPM - error: %v", err) + } + + // Start encryption + if err := vol.encrypt(XtsAES256, EncryptDataOnly); err != nil { + return "", fmt.Errorf("there was an error starting encryption - error: %v", err) + } + + return recoveryKey, nil +} + +func DecryptVolume(targetVolume string) error { + // Connect to the volume + vol, err := bitlockerConnect(targetVolume) + if err != nil { + return fmt.Errorf("there was an error connecting to the volume - error: %v", err) + } + defer vol.bitlockerClose() + + // Start decryption + if err := vol.decrypt(); err != nil { + return fmt.Errorf("there was an error starting decryption - error: %v", err) + } + + return nil +} + +func GetEncryptionStatus() ([]VolumeStatus, error) { + drives, err := getLogicalVolumes() + if err != nil { + return nil, fmt.Errorf("logical volumen enumeration %v", err) + } + + // iterate drives + var volumeStatus []VolumeStatus + for _, drive := range drives { + status, err := getBitlockerStatus(drive) + if err == nil { + // Skipping errors on purpose + driveStatus := VolumeStatus{ + DriveVolume: drive, + Status: status, + } + volumeStatus = append(volumeStatus, driveStatus) + } + } + + return volumeStatus, nil +} diff --git a/orbit/pkg/update/execwinapi_stub.go b/orbit/pkg/update/execwinapi_stub.go index e4957bc2cd..50d6a9a414 100644 --- a/orbit/pkg/update/execwinapi_stub.go +++ b/orbit/pkg/update/execwinapi_stub.go @@ -9,3 +9,7 @@ func RunWindowsMDMEnrollment(args WindowsMDMEnrollmentArgs) error { func RunWindowsMDMUnenrollment(args WindowsMDMEnrollmentArgs) error { return nil } + +func IsRunningOnWindowsServer() (bool, error) { + return false, nil +} diff --git a/orbit/pkg/update/execwinapi_windows.go b/orbit/pkg/update/execwinapi_windows.go index ca28089dab..3c0988a0fc 100644 --- a/orbit/pkg/update/execwinapi_windows.go +++ b/orbit/pkg/update/execwinapi_windows.go @@ -174,3 +174,17 @@ func generateWindowsMDMAccessTokenPayload(args WindowsMDMEnrollmentArgs) ([]byte pld.Payload.OrbitNodeKey = args.OrbitNodeKey return json.Marshal(pld) } + +// IsRunningOnWindowsServer determines if the process is running on a Windows server. Exported so it can be used across packages. +func IsRunningOnWindowsServer() (bool, error) { + installType, err := readInstallationType() + if err != nil { + return false, err + } + + if strings.ToLower(installType) == "server" { + return true, nil + } + + return false, nil +} diff --git a/orbit/pkg/update/notifications.go b/orbit/pkg/update/notifications.go index 18076ba922..e07c6648cc 100644 --- a/orbit/pkg/update/notifications.go +++ b/orbit/pkg/update/notifications.go @@ -7,6 +7,7 @@ import ( "sync/atomic" "time" + "github.com/fleetdm/fleet/v4/orbit/pkg/bitlocker" "github.com/fleetdm/fleet/v4/orbit/pkg/profiles" "github.com/fleetdm/fleet/v4/orbit/pkg/scripts" "github.com/fleetdm/fleet/v4/server/fleet" @@ -397,3 +398,119 @@ func (h *runScriptsConfigFetcher) GetConfig() (*fleet.OrbitConfig, error) { } return cfg, err } + +type DiskEncryptionKeySetter interface { + SetOrUpdateDiskEncryptionKey(diskEncryptionStatus fleet.OrbitHostDiskEncryptionKeyPayload) error +} + +type execEncryptVolumeFunc func(string) (string, error) + +type windowsMDMBitlockerConfigFetcher struct { + // Fetcher is the OrbitConfigFetcher that will be wrapped. It is responsible + // for actually returning the orbit configuration or an error. + Fetcher OrbitConfigFetcher + + // Frequency is the minimum amount of time that must pass between two + // executions of the windows MDM enrollment attempt. + Frequency time.Duration + + // Bitlocker Operation Results + EncryptionResult DiskEncryptionKeySetter + + // tracks last time the enrollment command was executed + lastEnrollRun time.Time + + // ensures only one script execution runs at a time + mu sync.Mutex + + // for tests, to be able to mock API commands. If nil, will use + // EncryptVolume + execEncryptVolumeFn execEncryptVolumeFunc +} + +func ApplyWindowsMDMBitlockerFetcherMiddleware( + fetcher OrbitConfigFetcher, + frequency time.Duration, + encryptionResult DiskEncryptionKeySetter, +) OrbitConfigFetcher { + return &windowsMDMBitlockerConfigFetcher{ + Fetcher: fetcher, + Frequency: frequency, + EncryptionResult: encryptionResult, + } +} + +// GetConfig calls the wrapped Fetcher's GetConfig method, and if the fleet +// server set the "EnforceBitLockerEncryption" flag to true, executes the command +// to attempt BitlockerEncryption (or not, if the device is a Windows Server). +func (w *windowsMDMBitlockerConfigFetcher) GetConfig() (*fleet.OrbitConfig, error) { + cfg, err := w.Fetcher.GetConfig() + if err == nil && cfg.Notifications.EnforceBitLockerEncryption { + if w.mu.TryLock() { + defer w.mu.Unlock() + + w.attemptBitlockerEncryption(cfg.Notifications) + } + } + + return cfg, err +} + +func (w *windowsMDMBitlockerConfigFetcher) attemptBitlockerEncryption(notifs fleet.OrbitConfigNotifications) { + // do not trigger Bitlocker encryption if running on a Windwos server + isWindowsServer, err := IsRunningOnWindowsServer() + if err != nil { + log.Error().Err(err).Msg("checking if the host is a Windows server") + return + } + + if isWindowsServer { + log.Debug().Msg("device is a Windows Server, encryption is not going to be performed") + return + } + + if time.Since(w.lastEnrollRun) <= w.Frequency { + log.Debug().Msg("skipped encryption process, last run was too recent") + return + } + + // Performing Bitlocker encryption operation against C: volume + + // We are supporting only C: volume for now + targetVolume := "C:" + + // Performing actual encryption + + // Getting Bitlocker encryption mock operation function if any + fn := w.execEncryptVolumeFn + if fn == nil { + // Otherwise, using the real one + fn = bitlocker.EncryptVolume + } + recoveryKey, err := fn(targetVolume) + + // Getting Bitlocker encryption operation error message if any + bitlockerError := "" + if err != nil { + bitlockerError = err.Error() + } + + // Update Fleet Server with encryption result + payload := fleet.OrbitHostDiskEncryptionKeyPayload{ + EncryptionKey: []byte(recoveryKey), + ClientError: bitlockerError, + } + + if err != nil { + log.Error().Err(err).Msg("failed to encrypt the volume") + return + } + + err = w.EncryptionResult.SetOrUpdateDiskEncryptionKey(payload) + if err != nil { + log.Error().Err(err).Msg("failed to send encryption result to Fleet Server") + return + } + + w.lastEnrollRun = time.Now() +} diff --git a/orbit/pkg/update/notifications_test.go b/orbit/pkg/update/notifications_test.go index c4512b12f2..901dcd5276 100644 --- a/orbit/pkg/update/notifications_test.go +++ b/orbit/pkg/update/notifications_test.go @@ -573,3 +573,67 @@ func TestRunScripts(t *testing.T) { require.Contains(t, logBuf.String(), "running scripts [c] succeeded") }) } + +type mockDiskEncryptionKeySetter struct{} + +func (m mockDiskEncryptionKeySetter) SetOrUpdateDiskEncryptionKey(diskEncryptionStatus fleet.OrbitHostDiskEncryptionKeyPayload) error { + return nil +} + +func TestBitlockerOperations(t *testing.T) { + var logBuf bytes.Buffer + + oldLog := log.Logger + log.Logger = log.Output(&logBuf) + t.Cleanup(func() { log.Logger = oldLog }) + + var ( + shouldEncrypt = true + shouldReturnError = false + ) + + fetcher := &dummyConfigFetcher{ + cfg: &fleet.OrbitConfig{ + Notifications: fleet.OrbitConfigNotifications{ + EnforceBitLockerEncryption: shouldEncrypt, + }, + }, + } + + enrollFetcher := &windowsMDMBitlockerConfigFetcher{ + Fetcher: fetcher, + Frequency: time.Hour, // doesn't matter for this test + EncryptionResult: mockDiskEncryptionKeySetter{}, + execEncryptVolumeFn: func(string) (string, error) { + if shouldReturnError { + return "", errors.New("error") + } + + return "123456", nil + }, + } + + t.Run("bitlocker encryption is performed", func(t *testing.T) { + shouldEncrypt = true + shouldReturnError = false + cfg, err := enrollFetcher.GetConfig() + require.NoError(t, err) // the dummy fetcher never returns an error + require.Equal(t, fetcher.cfg, cfg) // the bitlocker wrapper properly returns the expected config + }) + + t.Run("bitlocker encryption is not performed", func(t *testing.T) { + shouldEncrypt = false + shouldReturnError = false + cfg, err := enrollFetcher.GetConfig() + require.NoError(t, err) // the dummy fetcher never returns an error + require.Equal(t, fetcher.cfg, cfg) // the bitlocker wrapper properly returns the expected config + }) + + t.Run("bitlocker encryption returns an error", func(t *testing.T) { + shouldEncrypt = true + shouldReturnError = true + cfg, err := enrollFetcher.GetConfig() + require.NoError(t, err) // the dummy fetcher never returns an error + require.Equal(t, fetcher.cfg, cfg) // the bitlocker wrapper properly returns the expected config + }) +} diff --git a/pkg/optjson/optjson.go b/pkg/optjson/optjson.go index ec045070c9..a665b3bb52 100644 --- a/pkg/optjson/optjson.go +++ b/pkg/optjson/optjson.go @@ -53,3 +53,42 @@ func (s *String) UnmarshalJSON(data []byte) error { s.Valid = true return nil } + +// Bool represents an optional boolean value. +type Bool struct { + Set bool + Valid bool + Value bool +} + +func SetBool(b bool) Bool { + return Bool{Set: true, Valid: true, Value: b} +} + +func (b Bool) MarshalJSON() ([]byte, error) { + if !b.Valid { + return []byte("null"), nil + } + return json.Marshal(b.Value) +} + +func (b *Bool) UnmarshalJSON(data []byte) error { + // If this method was called, the value was set. + b.Set = true + b.Valid = false + + if bytes.Equal(data, []byte("null")) { + // The key was set to null, blank the value + b.Value = false + return nil + } + + // The key isn't set to null + var v bool + if err := json.Unmarshal(data, &v); err != nil { + return err + } + b.Value = v + b.Valid = true + return nil +} diff --git a/pkg/optjson/optjson_test.go b/pkg/optjson/optjson_test.go index 868963d4a0..264834a637 100644 --- a/pkg/optjson/optjson_test.go +++ b/pkg/optjson/optjson_test.go @@ -88,3 +88,84 @@ func TestString(t *testing.T) { } }) } + +func TestBool(t *testing.T) { + t.Run("plain string", func(t *testing.T) { + cases := []struct { + data string + wantErr string + wantRes Bool + marshalAs string + }{ + {`true`, "", Bool{Set: true, Valid: true, Value: true}, `true`}, + {`null`, "", Bool{Set: true, Valid: false, Value: false}, `null`}, + {`123`, "cannot unmarshal number into Go value of type bool", Bool{Set: true, Valid: false, Value: false}, `null`}, + {`{"v": "foo"}`, "cannot unmarshal object into Go value of type bool", Bool{Set: true, Valid: false, Value: false}, `null`}, + } + + for _, c := range cases { + t.Run(c.data, func(t *testing.T) { + var s Bool + err := json.Unmarshal([]byte(c.data), &s) + + if c.wantErr != "" { + require.Error(t, err) + require.ErrorContains(t, err, c.wantErr) + } else { + require.NoError(t, err) + } + require.Equal(t, c.wantRes, s) + + b, err := json.Marshal(s) + require.NoError(t, err) + require.Equal(t, c.marshalAs, string(b)) + }) + } + }) + + t.Run("struct", func(t *testing.T) { + type N struct { + B2 Bool `json:"b2"` + } + type T struct { + I int `json:"i"` + B Bool `json:"b"` + N N `json:"n"` + } + + cases := []struct { + data string + wantErr string + wantRes T + marshalAs string + }{ + {`{}`, "", T{}, `{"i": 0, "b": null, "n": {"b2": null}}`}, + {`{"x": "nope"}`, "", T{}, `{"i": 0, "b": null, "n": {"b2": null}}`}, + {`{"i": 1, "b": true}`, "", T{I: 1, B: Bool{Set: true, Valid: true, Value: true}}, `{"i": 1, "b": true, "n": {"b2": null}}`}, + {`{"i": 1, "b": null, "n": {}}`, "", T{I: 1, B: Bool{Set: true, Valid: false, Value: false}}, `{"i": 1, "b": null, "n": {"b2": null}}`}, + {`{"i": 1, "b": false, "n": {"b2": true}}`, "", T{I: 1, B: Bool{Set: true, Valid: true, Value: false}, N: N{B2: Bool{Set: true, Valid: true, Value: true}}}, `{"i": 1, "b": false, "n": {"b2": true}}`}, + {`{"i": 1, "b": true, "n": {"b2": null}}`, "", T{I: 1, B: Bool{Set: true, Valid: true, Value: true}, N: N{B2: Bool{Set: true, Valid: false, Value: false}}}, `{"i": 1, "b": true, "n": {"b2": null}}`}, + {`{"i": 1, "b": ""}`, "cannot unmarshal string into Go struct", T{I: 1, B: Bool{Set: true, Valid: false, Value: false}}, `{"i": 1, "b": null, "n": {"b2": null}}`}, + {`{"i": 1, "n": {"b2": 123}}`, "cannot unmarshal number into Go struct", T{I: 1, N: N{B2: Bool{Set: true, Valid: false, Value: false}}}, `{"i": 1, "b": null, "n": {"b2": null}}`}, + } + + for _, c := range cases { + t.Run(c.data, func(t *testing.T) { + var tt T + err := json.Unmarshal([]byte(c.data), &tt) + + if c.wantErr != "" { + require.Error(t, err) + require.ErrorContains(t, err, c.wantErr) + } else { + require.NoError(t, err) + } + require.Equal(t, c.wantRes, tt) + + b, err := json.Marshal(tt) + require.NoError(t, err) + require.JSONEq(t, c.marshalAs, string(b)) + }) + } + }) +} diff --git a/pkg/rawjson/rawjson.go b/pkg/rawjson/rawjson.go new file mode 100644 index 0000000000..d6bb2189a8 --- /dev/null +++ b/pkg/rawjson/rawjson.go @@ -0,0 +1,55 @@ +package rawjson + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" +) + +// CombineRoots "concatenates" two JSON objects into a single object. +// +// By virtue of its implementation it: +// +// - Doesn't take into account nested keys +// - Assumes the JSON string is well formed and was marshaled by the standard +// library +func CombineRoots(a, b json.RawMessage) (json.RawMessage, error) { + if err := validate(a); err != nil { + return nil, fmt.Errorf("validating first object: %w", err) + } + + if err := validate(b); err != nil { + return nil, fmt.Errorf("validating second object: %w", err) + } + + emptyObject := []byte{'{', '}'} + if bytes.Equal(a, emptyObject) { + return b, nil + } + if bytes.Equal(b, emptyObject) { + return a, nil + } + + // remove '}' from the first object and add a trailing ',' + combined := append(a[:len(a)-1], ',') + // remove '{' from the second object and combine the two + combined = append(combined, b[1:]...) + return combined, nil +} + +func validate(j json.RawMessage) error { + if len(j) < 2 { + return errors.New("incomplete json object") + } + + if j[0] != '{' || j[len(j)-1] != '}' { + return errors.New("json object must be surrounded by '{' and '}'") + } + + if len(j) > 2 && j[len(j)-2] == ',' { + return errors.New("trailing comma at the end of the object") + } + + return nil +} diff --git a/pkg/rawjson/rawjson_test.go b/pkg/rawjson/rawjson_test.go new file mode 100644 index 0000000000..03b38a3dfa --- /dev/null +++ b/pkg/rawjson/rawjson_test.go @@ -0,0 +1,104 @@ +package rawjson + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCombineRoots(t *testing.T) { + tests := []struct { + name string + a json.RawMessage + b json.RawMessage + want json.RawMessage + wantErr string + }{ + { + name: "both empty", + a: []byte("{}"), + b: []byte("{}"), + want: []byte("{}"), + }, + { + name: "first incomplete", + a: []byte("{"), + b: []byte("{}"), + wantErr: "incomplete json object", + }, + { + name: "second incomplete", + a: []byte("{}"), + b: []byte("{"), + wantErr: "incomplete json object", + }, + { + name: "first empty array", + a: []byte{}, + b: []byte("{}"), + wantErr: "incomplete json object", + }, + { + name: "second empty array", + a: []byte("{}"), + b: []byte{}, + wantErr: "incomplete json object", + }, + { + name: "first empty", + a: []byte("{}"), + b: []byte(`{"key":"value"}`), + want: []byte(`{"key":"value"}`), + }, + { + name: "second empty", + a: []byte(`{"key":"value"}`), + b: []byte("{}"), + want: []byte(`{"key":"value"}`), + }, + { + name: "both with data", + a: []byte(`{"key1":"value1"}`), + b: []byte(`{"key2":"value2"}`), + want: []byte(`{"key1":"value1","key2":"value2"}`), + }, + { + name: "first incomplete", + a: []byte(`{"key1":"value1"`), + b: []byte(`{"key2":"value2"}`), + wantErr: "json object must be surrounded by '{' and '}'", + }, + { + name: "second incomplete", + a: []byte(`{"key2":"value2"}`), + b: []byte(`{"key1":"value1"`), + wantErr: "json object must be surrounded by '{' and '}'", + }, + { + name: "first trailing comma", + a: []byte(`{"key1":"value1",}`), + b: []byte(`{"key2":"value2"}`), + wantErr: "trailing comma at the end of the object", + }, + { + name: "second trailing comma", + a: []byte(`{"key1":"value1"}`), + b: []byte(`{"key2":"value2",}`), + wantErr: "trailing comma at the end of the object", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := CombineRoots(tt.a, tt.b) + if tt.wantErr != "" { + require.ErrorContains(t, err, tt.wantErr) + require.Nil(t, got) + } else { + require.NoError(t, err) + require.Equal(t, tt.want, got) + } + }) + } +} diff --git a/server/datastore/mysql/app_configs.go b/server/datastore/mysql/app_configs.go index 0da61f93a3..c14340fa12 100644 --- a/server/datastore/mysql/app_configs.go +++ b/server/datastore/mysql/app_configs.go @@ -213,3 +213,18 @@ func (ds *Datastore) AggregateEnrollSecretPerTeam(ctx context.Context) ([]*fleet } return secrets, nil } + +func (ds *Datastore) getConfigEnableDiskEncryption(ctx context.Context, teamID *uint) (bool, error) { + if teamID != nil && *teamID > 0 { + tc, err := ds.TeamMDMConfig(ctx, *teamID) + if err != nil { + return false, err + } + return tc.EnableDiskEncryption, nil + } + ac, err := ds.AppConfig(ctx) + if err != nil { + return false, err + } + return ac.MDM.EnableDiskEncryption.Value, nil +} diff --git a/server/datastore/mysql/app_configs_test.go b/server/datastore/mysql/app_configs_test.go index 1580d40f25..66c14b157b 100644 --- a/server/datastore/mysql/app_configs_test.go +++ b/server/datastore/mysql/app_configs_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + "github.com/fleetdm/fleet/v4/pkg/optjson" "github.com/fleetdm/fleet/v4/server/ptr" "github.com/fleetdm/fleet/v4/server/fleet" @@ -30,6 +31,7 @@ func TestAppConfig(t *testing.T) { {"AggregateEnrollSecretPerTeam", testAggregateEnrollSecretPerTeam}, {"Defaults", testAppConfigDefaults}, {"Backwards Compatibility", testAppConfigBackwardsCompatibility}, + {"GetConfigEnableDiskEncryption", testGetConfigEnableDiskEncryption}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -309,7 +311,6 @@ func testAppConfigEnrollSecretRoundtrip(t *testing.T, ds *Datastore) { secrets, err = ds.GetEnrollSecrets(context.Background(), nil) require.NoError(t, err) require.Len(t, secrets, 2) - } func testAppConfigEnrollSecretUniqueness(t *testing.T, ds *Datastore) { @@ -431,3 +432,48 @@ func testAggregateEnrollSecretPerTeam(t *testing.T, ds *Datastore) { {TeamID: ptr.Uint(3), Secret: "team_3_secret_1"}, }, agg) } + +func testGetConfigEnableDiskEncryption(t *testing.T, ds *Datastore) { + ctx := context.Background() + defer TruncateTables(t, ds) + + ac, err := ds.AppConfig(ctx) + require.NoError(t, err) + require.False(t, ac.MDM.EnableDiskEncryption.Value) + + enabled, err := ds.getConfigEnableDiskEncryption(ctx, nil) + require.NoError(t, err) + require.False(t, enabled) + + // Enable disk encryption for no team + ac.MDM.EnableDiskEncryption = optjson.SetBool(true) + err = ds.SaveAppConfig(ctx, ac) + require.NoError(t, err) + ac, err = ds.AppConfig(ctx) + require.NoError(t, err) + require.True(t, ac.MDM.EnableDiskEncryption.Value) + + enabled, err = ds.getConfigEnableDiskEncryption(ctx, nil) + require.NoError(t, err) + require.True(t, enabled) + + // Create team + team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1"}) + require.NoError(t, err) + + tm, err := ds.Team(ctx, team1.ID) + require.NoError(t, err) + require.NotNil(t, tm) + require.False(t, tm.Config.MDM.EnableDiskEncryption) + + enabled, err = ds.getConfigEnableDiskEncryption(ctx, &team1.ID) + require.NoError(t, err) + require.False(t, enabled) + + // Enable disk encryption for the team + tm.Config.MDM.EnableDiskEncryption = true + tm, err = ds.SaveTeam(ctx, tm) + require.NoError(t, err) + require.NotNil(t, tm) + require.True(t, tm.Config.MDM.EnableDiskEncryption) +} diff --git a/server/datastore/mysql/apple_mdm.go b/server/datastore/mysql/apple_mdm.go index cb1133ac61..1e95d96f14 100644 --- a/server/datastore/mysql/apple_mdm.go +++ b/server/datastore/mysql/apple_mdm.go @@ -2082,7 +2082,7 @@ func (ds *Datastore) GetMDMIdPAccount(ctx context.Context, uuid string) (*fleet. return &acct, nil } -func subqueryDiskEncryptionVerifying() (string, []interface{}) { +func subqueryFileVaultVerifying() (string, []interface{}) { sql := ` SELECT 1 FROM host_mdm_apple_profiles hmap @@ -2100,7 +2100,7 @@ func subqueryDiskEncryptionVerifying() (string, []interface{}) { return sql, args } -func subqueryDiskEncryptionVerified() (string, []interface{}) { +func subqueryFileVaultVerified() (string, []interface{}) { sql := ` SELECT 1 FROM host_mdm_apple_profiles hmap @@ -2118,7 +2118,7 @@ func subqueryDiskEncryptionVerified() (string, []interface{}) { return sql, args } -func subqueryDiskEncryptionActionRequired() (string, []interface{}) { +func subqueryFileVaultActionRequired() (string, []interface{}) { sql := ` SELECT 1 FROM host_mdm_apple_profiles hmap @@ -2138,7 +2138,7 @@ func subqueryDiskEncryptionActionRequired() (string, []interface{}) { return sql, args } -func subqueryDiskEncryptionEnforcing() (string, []interface{}) { +func subqueryFileVaultEnforcing() (string, []interface{}) { sql := ` SELECT 1 FROM host_mdm_apple_profiles hmap @@ -2168,7 +2168,7 @@ func subqueryDiskEncryptionEnforcing() (string, []interface{}) { return sql, args } -func subqueryDiskEncryptionFailed() (string, []interface{}) { +func subqueryFileVaultFailed() (string, []interface{}) { sql := ` SELECT 1 FROM host_mdm_apple_profiles hmap @@ -2180,7 +2180,7 @@ func subqueryDiskEncryptionFailed() (string, []interface{}) { return sql, args } -func subqueryDiskEncryptionRemovingEnforcement() (string, []interface{}) { +func subqueryFileVaultRemovingEnforcement() (string, []interface{}) { sql := ` SELECT 1 FROM host_mdm_apple_profiles hmap @@ -2224,20 +2224,20 @@ FROM hosts h LEFT JOIN host_disk_encryption_keys hdek ON h.id = hdek.host_id WHERE - %s` + h.platform = 'darwin' AND %s` var args []interface{} - subqueryVerified, subqueryVerifiedArgs := subqueryDiskEncryptionVerified() + subqueryVerified, subqueryVerifiedArgs := subqueryFileVaultVerified() args = append(args, subqueryVerifiedArgs...) - subqueryVerifying, subqueryVerifyingArgs := subqueryDiskEncryptionVerifying() + subqueryVerifying, subqueryVerifyingArgs := subqueryFileVaultVerifying() args = append(args, subqueryVerifyingArgs...) - subqueryActionRequired, subqueryActionRequiredArgs := subqueryDiskEncryptionActionRequired() + subqueryActionRequired, subqueryActionRequiredArgs := subqueryFileVaultActionRequired() args = append(args, subqueryActionRequiredArgs...) - subqueryEnforcing, subqueryEnforcingArgs := subqueryDiskEncryptionEnforcing() + subqueryEnforcing, subqueryEnforcingArgs := subqueryFileVaultEnforcing() args = append(args, subqueryEnforcingArgs...) - subqueryFailed, subqueryFailedArgs := subqueryDiskEncryptionFailed() + subqueryFailed, subqueryFailedArgs := subqueryFileVaultFailed() args = append(args, subqueryFailedArgs...) - subqueryRemovingEnforcement, subqueryRemovingEnforcementArgs := subqueryDiskEncryptionRemovingEnforcement() + subqueryRemovingEnforcement, subqueryRemovingEnforcementArgs := subqueryFileVaultRemovingEnforcement() args = append(args, subqueryRemovingEnforcementArgs...) teamFilter := "h.team_id IS NULL" diff --git a/server/datastore/mysql/apple_mdm_test.go b/server/datastore/mysql/apple_mdm_test.go index 3c9173b63e..2d5977334b 100644 --- a/server/datastore/mysql/apple_mdm_test.go +++ b/server/datastore/mysql/apple_mdm_test.go @@ -782,7 +782,7 @@ func testUpdateHostTablesOnMDMUnenroll(t *testing.T, ds *Datastore) { var hostID uint err = sqlx.GetContext(context.Background(), ds.reader(context.Background()), &hostID, `SELECT id FROM hosts WHERE uuid = ?`, testUUID) require.NoError(t, err) - err = ds.SetOrUpdateHostDiskEncryptionKey(ctx, hostID, "asdf") + err = ds.SetOrUpdateHostDiskEncryptionKey(ctx, hostID, "asdf", "", nil) require.NoError(t, err) key, err := ds.GetHostDiskEncryptionKey(ctx, hostID) @@ -1474,7 +1474,7 @@ func upsertHostCPs( func testAggregateMacOSSettingsStatusWithFileVault(t *testing.T, ds *Datastore) { ctx := context.Background() - checkListHosts := func(status fleet.MacOSSettingsStatus, teamID *uint, expected []*fleet.Host) bool { + checkListHosts := func(status fleet.OSSettingsStatus, teamID *uint, expected []*fleet.Host) bool { expectedIDs := []uint{} for _, h := range expected { expectedIDs = append(expectedIDs, h.ID) @@ -1556,7 +1556,7 @@ func testAggregateMacOSSettingsStatusWithFileVault(t *testing.T, ds *Datastore) require.Equal(t, uint(0), res.Verifying) require.Equal(t, uint(0), res.Verified) - err = ds.SetOrUpdateHostDiskEncryptionKey(ctx, hosts[0].ID, "foo") + err = ds.SetOrUpdateHostDiskEncryptionKey(ctx, hosts[0].ID, "foo", "", nil) require.NoError(t, err) res, err = ds.GetMDMAppleHostsProfilesSummary(ctx, nil) require.NoError(t, err) @@ -1596,7 +1596,7 @@ func testAggregateMacOSSettingsStatusWithFileVault(t *testing.T, ds *Datastore) require.Equal(t, uint(0), res.Verifying) require.Equal(t, uint(1), res.Verified) // hosts[0] now has filevault fully enforced and verified - err = ds.SetOrUpdateHostDiskEncryptionKey(ctx, hosts[1].ID, "bar") + err = ds.SetOrUpdateHostDiskEncryptionKey(ctx, hosts[1].ID, "bar", "", nil) require.NoError(t, err) err = ds.SetHostsDiskEncryptionKeyStatus(ctx, []uint{hosts[1].ID}, false, time.Now().Add(1*time.Hour)) require.NoError(t, err) @@ -1619,10 +1619,10 @@ func testAggregateMacOSSettingsStatusWithFileVault(t *testing.T, ds *Datastore) require.Equal(t, uint(1), res.Verified) // check that list hosts by status matches summary - require.True(t, checkListHosts(fleet.MacOSSettingsPending, nil, hosts[2:])) - require.True(t, checkListHosts(fleet.MacOSSettingsFailed, nil, []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsVerifying, nil, hosts[1:2])) - require.True(t, checkListHosts(fleet.MacOSSettingsVerified, nil, hosts[0:1])) + require.True(t, checkListHosts(fleet.OSSettingsPending, nil, hosts[2:])) + require.True(t, checkListHosts(fleet.OSSettingsFailed, nil, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsVerifying, nil, hosts[1:2])) + require.True(t, checkListHosts(fleet.OSSettingsVerified, nil, hosts[0:1])) // create a team team, err := ds.NewTeam(ctx, &fleet.Team{Name: "test"}) @@ -1662,7 +1662,7 @@ func testAggregateMacOSSettingsStatusWithFileVault(t *testing.T, ds *Datastore) require.Equal(t, uint(0), res.Verifying) require.Equal(t, uint(0), res.Verified) - err = ds.SetOrUpdateHostDiskEncryptionKey(ctx, hosts[9].ID, "baz") + err = ds.SetOrUpdateHostDiskEncryptionKey(ctx, hosts[9].ID, "baz", "", nil) require.NoError(t, err) err = ds.SetHostsDiskEncryptionKeyStatus(ctx, []uint{hosts[9].ID}, true, time.Now().Add(1*time.Hour)) require.NoError(t, err) @@ -1675,10 +1675,10 @@ func testAggregateMacOSSettingsStatusWithFileVault(t *testing.T, ds *Datastore) require.Equal(t, uint(0), res.Verified) // check that list hosts by status matches summary - require.True(t, checkListHosts(fleet.MacOSSettingsPending, &team.ID, []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsFailed, &team.ID, []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsVerifying, &team.ID, hosts[9:10])) - require.True(t, checkListHosts(fleet.MacOSSettingsVerified, &team.ID, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsPending, &team.ID, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsFailed, &team.ID, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsVerifying, &team.ID, hosts[9:10])) + require.True(t, checkListHosts(fleet.OSSettingsVerified, &team.ID, []*fleet.Host{})) upsertHostCPs(hosts[9:10], append(teamCPs, fvTeam), fleet.MDMAppleOperationTypeInstall, &fleet.MDMAppleDeliveryVerified, ctx, ds, t) res, err = ds.GetMDMAppleHostsProfilesSummary(ctx, &team.ID) @@ -1701,10 +1701,10 @@ func testAggregateMacOSSettingsStatusWithFileVault(t *testing.T, ds *Datastore) require.Equal(t, uint(0), res.Verified) // check that list hosts by status matches summary - require.True(t, checkListHosts(fleet.MacOSSettingsPending, &team.ID, hosts[9:10])) - require.True(t, checkListHosts(fleet.MacOSSettingsFailed, &team.ID, []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsVerifying, &team.ID, []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsVerified, &team.ID, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsPending, &team.ID, hosts[9:10])) + require.True(t, checkListHosts(fleet.OSSettingsFailed, &team.ID, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsVerifying, &team.ID, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsVerified, &team.ID, []*fleet.Host{})) // set decryptable back to true for hosts[9] err = ds.SetHostsDiskEncryptionKeyStatus(ctx, []uint{hosts[9].ID}, true, time.Now().Add(1*time.Hour)) @@ -1718,21 +1718,22 @@ func testAggregateMacOSSettingsStatusWithFileVault(t *testing.T, ds *Datastore) require.Equal(t, uint(1), res.Verified) // hosts[9] goes back to verified // check that list hosts by status matches summary - require.True(t, checkListHosts(fleet.MacOSSettingsPending, &team.ID, []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsFailed, &team.ID, []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsVerifying, &team.ID, []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsVerified, &team.ID, hosts[9:10])) + require.True(t, checkListHosts(fleet.OSSettingsPending, &team.ID, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsFailed, &team.ID, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsVerifying, &team.ID, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsVerified, &team.ID, hosts[9:10])) } func testMDMAppleHostsProfilesStatus(t *testing.T, ds *Datastore) { ctx := context.Background() - checkListHosts := func(status fleet.MacOSSettingsStatus, teamID *uint, expected []*fleet.Host) bool { + checkFilterHostsByMacOSSettings := func(status fleet.OSSettingsStatus, teamID *uint, expected []*fleet.Host) bool { expectedIDs := []uint{} for _, h := range expected { expectedIDs = append(expectedIDs, h.ID) } + // check that list hosts by macos settings status matches summary gotHosts, err := ds.ListHosts(ctx, fleet.TeamFilter{User: &fleet.User{GlobalRole: ptr.String("admin")}}, fleet.HostListOptions{MacOSSettingsFilter: status, TeamFilter: teamID}) gotIDs := []uint{} for _, h := range gotHosts { @@ -1742,6 +1743,26 @@ func testMDMAppleHostsProfilesStatus(t *testing.T, ds *Datastore) { return assert.NoError(t, err) && assert.Len(t, gotHosts, len(expected)) && assert.ElementsMatch(t, expectedIDs, gotIDs) } + // check that list hosts by os settings status matches summary + checkFilterHostsByOSSettings := func(status fleet.OSSettingsStatus, teamID *uint, expected []*fleet.Host) bool { + expectedIDs := []uint{} + for _, h := range expected { + expectedIDs = append(expectedIDs, h.ID) + } + + gotHosts, err := ds.ListHosts(ctx, fleet.TeamFilter{User: &fleet.User{GlobalRole: ptr.String("admin")}}, fleet.HostListOptions{OSSettingsFilter: status, TeamFilter: teamID}) + gotIDs := []uint{} + for _, h := range gotHosts { + gotIDs = append(gotIDs, h.ID) + } + + return assert.NoError(t, err) && assert.Len(t, gotHosts, len(expected)) && assert.ElementsMatch(t, expectedIDs, gotIDs) + } + + checkListHosts := func(status fleet.OSSettingsStatus, teamID *uint, expected []*fleet.Host) bool { + return checkFilterHostsByMacOSSettings(status, teamID, expected) && checkFilterHostsByOSSettings(status, teamID, expected) + } + var hosts []*fleet.Host for i := 0; i < 10; i++ { h := test.NewHost(t, ds, fmt.Sprintf("foo.local.%d", i), "1.1.1.1", @@ -1766,14 +1787,14 @@ func testMDMAppleHostsProfilesStatus(t *testing.T, ds *Datastore) { require.Equal(t, uint(0), res.Failed) require.Equal(t, uint(0), res.Verifying) require.Equal(t, uint(0), res.Verified) - require.True(t, checkListHosts(fleet.MacOSSettingsPending, nil, hosts)) - require.True(t, checkListHosts(fleet.MacOSSettingsFailed, nil, []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsVerifying, nil, []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsVerified, nil, []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsPending, ptr.Uint(0), hosts)) - require.True(t, checkListHosts(fleet.MacOSSettingsFailed, ptr.Uint(0), []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsVerifying, ptr.Uint(0), []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsVerified, ptr.Uint(0), []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsPending, nil, hosts)) + require.True(t, checkListHosts(fleet.OSSettingsFailed, nil, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsVerifying, nil, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsVerified, nil, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsPending, ptr.Uint(0), hosts)) + require.True(t, checkListHosts(fleet.OSSettingsFailed, ptr.Uint(0), []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsVerifying, ptr.Uint(0), []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsVerified, ptr.Uint(0), []*fleet.Host{})) // all hosts pending install of all profiles upsertHostCPs(hosts, noTeamCPs, fleet.MDMAppleOperationTypeInstall, &fleet.MDMAppleDeliveryPending, ctx, ds, t) @@ -1784,14 +1805,14 @@ func testMDMAppleHostsProfilesStatus(t *testing.T, ds *Datastore) { require.Equal(t, uint(0), res.Failed) require.Equal(t, uint(0), res.Verifying) require.Equal(t, uint(0), res.Verified) - require.True(t, checkListHosts(fleet.MacOSSettingsPending, nil, hosts)) - require.True(t, checkListHosts(fleet.MacOSSettingsFailed, nil, []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsVerifying, nil, []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsVerified, nil, []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsPending, ptr.Uint(0), hosts)) - require.True(t, checkListHosts(fleet.MacOSSettingsFailed, ptr.Uint(0), []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsVerifying, ptr.Uint(0), []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsVerified, ptr.Uint(0), []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsPending, nil, hosts)) + require.True(t, checkListHosts(fleet.OSSettingsFailed, nil, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsVerifying, nil, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsVerified, nil, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsPending, ptr.Uint(0), hosts)) + require.True(t, checkListHosts(fleet.OSSettingsFailed, ptr.Uint(0), []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsVerifying, ptr.Uint(0), []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsVerified, ptr.Uint(0), []*fleet.Host{})) // hosts[0] and hosts[1] failed one profile upsertHostCPs(hosts[0:2], noTeamCPs[0:1], fleet.MDMAppleOperationTypeInstall, &fleet.MDMAppleDeliveryFailed, ctx, ds, t) @@ -1810,14 +1831,14 @@ func testMDMAppleHostsProfilesStatus(t *testing.T, ds *Datastore) { require.Equal(t, uint(2), res.Failed) // only count one failure per host (hosts[0] failed two profiles but only counts once) require.Equal(t, uint(0), res.Verifying) require.Equal(t, uint(0), res.Verified) - require.True(t, checkListHosts(fleet.MacOSSettingsPending, nil, hosts[2:])) - require.True(t, checkListHosts(fleet.MacOSSettingsFailed, nil, hosts[0:2])) - require.True(t, checkListHosts(fleet.MacOSSettingsVerifying, nil, []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsVerified, nil, []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsPending, ptr.Uint(0), hosts[2:])) - require.True(t, checkListHosts(fleet.MacOSSettingsFailed, ptr.Uint(0), hosts[0:2])) - require.True(t, checkListHosts(fleet.MacOSSettingsVerifying, ptr.Uint(0), []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsVerified, ptr.Uint(0), []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsPending, nil, hosts[2:])) + require.True(t, checkListHosts(fleet.OSSettingsFailed, nil, hosts[0:2])) + require.True(t, checkListHosts(fleet.OSSettingsVerifying, nil, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsVerified, nil, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsPending, ptr.Uint(0), hosts[2:])) + require.True(t, checkListHosts(fleet.OSSettingsFailed, ptr.Uint(0), hosts[0:2])) + require.True(t, checkListHosts(fleet.OSSettingsVerifying, ptr.Uint(0), []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsVerified, ptr.Uint(0), []*fleet.Host{})) // hosts[0:3] installed a third profile upsertHostCPs(hosts[0:3], noTeamCPs[2:3], fleet.MDMAppleOperationTypeInstall, &fleet.MDMAppleDeliveryVerifying, ctx, ds, t) @@ -1828,14 +1849,14 @@ func testMDMAppleHostsProfilesStatus(t *testing.T, ds *Datastore) { require.Equal(t, uint(2), res.Failed) // no change require.Equal(t, uint(0), res.Verifying) // no change, host must apply all profiles count as latest require.Equal(t, uint(0), res.Verified) - require.True(t, checkListHosts(fleet.MacOSSettingsPending, nil, hosts[2:])) - require.True(t, checkListHosts(fleet.MacOSSettingsFailed, nil, hosts[0:2])) - require.True(t, checkListHosts(fleet.MacOSSettingsVerifying, nil, []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsVerified, nil, []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsPending, ptr.Uint(0), hosts[2:])) - require.True(t, checkListHosts(fleet.MacOSSettingsFailed, ptr.Uint(0), hosts[0:2])) - require.True(t, checkListHosts(fleet.MacOSSettingsVerifying, ptr.Uint(0), []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsVerified, ptr.Uint(0), []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsPending, nil, hosts[2:])) + require.True(t, checkListHosts(fleet.OSSettingsFailed, nil, hosts[0:2])) + require.True(t, checkListHosts(fleet.OSSettingsVerifying, nil, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsVerified, nil, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsPending, ptr.Uint(0), hosts[2:])) + require.True(t, checkListHosts(fleet.OSSettingsFailed, ptr.Uint(0), hosts[0:2])) + require.True(t, checkListHosts(fleet.OSSettingsVerifying, ptr.Uint(0), []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsVerified, ptr.Uint(0), []*fleet.Host{})) // hosts[6] deletes all its profiles tx, err := ds.writer(ctx).BeginTxx(ctx, nil) @@ -1850,14 +1871,14 @@ func testMDMAppleHostsProfilesStatus(t *testing.T, ds *Datastore) { require.Equal(t, uint(2), res.Failed) // no change require.Equal(t, uint(0), res.Verifying) // no change, host must apply all profiles count as latest require.Equal(t, uint(0), res.Verified) - require.True(t, checkListHosts(fleet.MacOSSettingsPending, nil, pendingHosts)) - require.True(t, checkListHosts(fleet.MacOSSettingsFailed, nil, hosts[0:2])) - require.True(t, checkListHosts(fleet.MacOSSettingsVerifying, nil, []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsVerified, nil, []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsPending, ptr.Uint(0), pendingHosts)) - require.True(t, checkListHosts(fleet.MacOSSettingsFailed, ptr.Uint(0), hosts[0:2])) - require.True(t, checkListHosts(fleet.MacOSSettingsVerifying, ptr.Uint(0), []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsVerified, ptr.Uint(0), []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsPending, nil, pendingHosts)) + require.True(t, checkListHosts(fleet.OSSettingsFailed, nil, hosts[0:2])) + require.True(t, checkListHosts(fleet.OSSettingsVerifying, nil, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsVerified, nil, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsPending, ptr.Uint(0), pendingHosts)) + require.True(t, checkListHosts(fleet.OSSettingsFailed, ptr.Uint(0), hosts[0:2])) + require.True(t, checkListHosts(fleet.OSSettingsVerifying, ptr.Uint(0), []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsVerified, ptr.Uint(0), []*fleet.Host{})) // hosts[9] installed all profiles but one is with status nil (pending) upsertHostCPs(hosts[9:10], noTeamCPs[:9], fleet.MDMAppleOperationTypeInstall, &fleet.MDMAppleDeliveryVerifying, ctx, ds, t) @@ -1870,14 +1891,14 @@ func testMDMAppleHostsProfilesStatus(t *testing.T, ds *Datastore) { require.Equal(t, uint(2), res.Failed) // no change require.Equal(t, uint(0), res.Verifying) // no change, host must apply all profiles count as latest require.Equal(t, uint(0), res.Verified) - require.True(t, checkListHosts(fleet.MacOSSettingsPending, nil, pendingHosts)) - require.True(t, checkListHosts(fleet.MacOSSettingsFailed, nil, hosts[0:2])) - require.True(t, checkListHosts(fleet.MacOSSettingsVerifying, nil, []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsVerified, nil, []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsPending, ptr.Uint(0), pendingHosts)) - require.True(t, checkListHosts(fleet.MacOSSettingsFailed, ptr.Uint(0), hosts[0:2])) - require.True(t, checkListHosts(fleet.MacOSSettingsVerifying, ptr.Uint(0), []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsVerified, ptr.Uint(0), []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsPending, nil, pendingHosts)) + require.True(t, checkListHosts(fleet.OSSettingsFailed, nil, hosts[0:2])) + require.True(t, checkListHosts(fleet.OSSettingsVerifying, nil, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsVerified, nil, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsPending, ptr.Uint(0), pendingHosts)) + require.True(t, checkListHosts(fleet.OSSettingsFailed, ptr.Uint(0), hosts[0:2])) + require.True(t, checkListHosts(fleet.OSSettingsVerifying, ptr.Uint(0), []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsVerified, ptr.Uint(0), []*fleet.Host{})) // hosts[9] installed all profiles upsertHostCPs(hosts[9:10], noTeamCPs, fleet.MDMAppleOperationTypeInstall, &fleet.MDMAppleDeliveryVerifying, ctx, ds, t) @@ -1889,14 +1910,14 @@ func testMDMAppleHostsProfilesStatus(t *testing.T, ds *Datastore) { require.Equal(t, uint(2), res.Failed) // no change require.Equal(t, uint(1), res.Verifying) // add one host that has installed all profiles require.Equal(t, uint(0), res.Verified) - require.True(t, checkListHosts(fleet.MacOSSettingsPending, nil, pendingHosts)) - require.True(t, checkListHosts(fleet.MacOSSettingsFailed, nil, hosts[0:2])) - require.True(t, checkListHosts(fleet.MacOSSettingsVerifying, nil, hosts[9:10])) - require.True(t, checkListHosts(fleet.MacOSSettingsVerified, nil, []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsPending, ptr.Uint(0), pendingHosts)) - require.True(t, checkListHosts(fleet.MacOSSettingsFailed, ptr.Uint(0), hosts[0:2])) - require.True(t, checkListHosts(fleet.MacOSSettingsVerifying, ptr.Uint(0), hosts[9:10])) - require.True(t, checkListHosts(fleet.MacOSSettingsVerified, ptr.Uint(0), []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsPending, nil, pendingHosts)) + require.True(t, checkListHosts(fleet.OSSettingsFailed, nil, hosts[0:2])) + require.True(t, checkListHosts(fleet.OSSettingsVerifying, nil, hosts[9:10])) + require.True(t, checkListHosts(fleet.OSSettingsVerified, nil, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsPending, ptr.Uint(0), pendingHosts)) + require.True(t, checkListHosts(fleet.OSSettingsFailed, ptr.Uint(0), hosts[0:2])) + require.True(t, checkListHosts(fleet.OSSettingsVerifying, ptr.Uint(0), hosts[9:10])) + require.True(t, checkListHosts(fleet.OSSettingsVerified, ptr.Uint(0), []*fleet.Host{})) // create a team tm, err := ds.NewTeam(ctx, &fleet.Team{Name: "rocket"}) @@ -1908,10 +1929,10 @@ func testMDMAppleHostsProfilesStatus(t *testing.T, ds *Datastore) { require.Equal(t, uint(0), res.Failed) // no profiles yet require.Equal(t, uint(0), res.Verifying) // no profiles yet require.Equal(t, uint(0), res.Verified) // no profiles yet - require.True(t, checkListHosts(fleet.MacOSSettingsPending, &tm.ID, []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsFailed, &tm.ID, []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsVerifying, &tm.ID, []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsVerified, &tm.ID, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsPending, &tm.ID, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsFailed, &tm.ID, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsVerifying, &tm.ID, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsVerified, &tm.ID, []*fleet.Host{})) // transfer hosts[9] to new team err = ds.AddHostsToTeam(ctx, &tm.ID, []uint{hosts[9].ID}) @@ -1926,14 +1947,14 @@ func testMDMAppleHostsProfilesStatus(t *testing.T, ds *Datastore) { require.Equal(t, uint(len(hosts)-4), res.Pending) // hosts[9] is still not pending, transferred to team require.Equal(t, uint(2), res.Failed) // no change require.Equal(t, uint(0), res.Verifying) // hosts[9] was transferred so this is now zero - require.True(t, checkListHosts(fleet.MacOSSettingsPending, nil, pendingHosts)) - require.True(t, checkListHosts(fleet.MacOSSettingsFailed, nil, hosts[0:2])) - require.True(t, checkListHosts(fleet.MacOSSettingsVerifying, nil, []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsVerified, nil, []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsPending, ptr.Uint(0), pendingHosts)) - require.True(t, checkListHosts(fleet.MacOSSettingsFailed, ptr.Uint(0), hosts[0:2])) - require.True(t, checkListHosts(fleet.MacOSSettingsVerifying, ptr.Uint(0), []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsVerified, ptr.Uint(0), []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsPending, nil, pendingHosts)) + require.True(t, checkListHosts(fleet.OSSettingsFailed, nil, hosts[0:2])) + require.True(t, checkListHosts(fleet.OSSettingsVerifying, nil, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsVerified, nil, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsPending, ptr.Uint(0), pendingHosts)) + require.True(t, checkListHosts(fleet.OSSettingsFailed, ptr.Uint(0), hosts[0:2])) + require.True(t, checkListHosts(fleet.OSSettingsVerifying, ptr.Uint(0), []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsVerified, ptr.Uint(0), []*fleet.Host{})) res, err = ds.GetMDMAppleHostsProfilesSummary(ctx, &tm.ID) // get summary for new team require.NoError(t, err) @@ -1942,10 +1963,10 @@ func testMDMAppleHostsProfilesStatus(t *testing.T, ds *Datastore) { require.Equal(t, uint(0), res.Failed) require.Equal(t, uint(0), res.Verifying) require.Equal(t, uint(0), res.Verified) - require.True(t, checkListHosts(fleet.MacOSSettingsPending, &tm.ID, hosts[9:10])) - require.True(t, checkListHosts(fleet.MacOSSettingsFailed, &tm.ID, []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsVerifying, &tm.ID, []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsVerified, &tm.ID, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsPending, &tm.ID, hosts[9:10])) + require.True(t, checkListHosts(fleet.OSSettingsFailed, &tm.ID, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsVerifying, &tm.ID, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsVerified, &tm.ID, []*fleet.Host{})) // create somes config profiles for the new team var teamCPs []*fleet.MDMAppleConfigProfile @@ -1964,10 +1985,10 @@ func testMDMAppleHostsProfilesStatus(t *testing.T, ds *Datastore) { require.Equal(t, uint(0), res.Failed) require.Equal(t, uint(0), res.Verifying) require.Equal(t, uint(0), res.Verified) - require.True(t, checkListHosts(fleet.MacOSSettingsPending, &tm.ID, hosts[9:10])) - require.True(t, checkListHosts(fleet.MacOSSettingsFailed, &tm.ID, []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsVerifying, &tm.ID, []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsVerified, &tm.ID, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsPending, &tm.ID, hosts[9:10])) + require.True(t, checkListHosts(fleet.OSSettingsFailed, &tm.ID, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsVerifying, &tm.ID, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsVerified, &tm.ID, []*fleet.Host{})) // hosts[9] successfully removed old profiles upsertHostCPs(hosts[9:10], noTeamCPs, fleet.MDMAppleOperationTypeRemove, &fleet.MDMAppleDeliveryVerifying, ctx, ds, t) @@ -1978,10 +1999,10 @@ func testMDMAppleHostsProfilesStatus(t *testing.T, ds *Datastore) { require.Equal(t, uint(0), res.Failed) require.Equal(t, uint(1), res.Verifying) // hosts[9] is verifying all new profiles require.Equal(t, uint(0), res.Verified) - require.True(t, checkListHosts(fleet.MacOSSettingsPending, &tm.ID, []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsFailed, &tm.ID, []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsVerifying, &tm.ID, hosts[9:10])) - require.True(t, checkListHosts(fleet.MacOSSettingsVerified, &tm.ID, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsPending, &tm.ID, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsFailed, &tm.ID, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsVerifying, &tm.ID, hosts[9:10])) + require.True(t, checkListHosts(fleet.OSSettingsVerified, &tm.ID, []*fleet.Host{})) // verify one profile on hosts[9] upsertHostCPs(hosts[9:10], teamCPs[0:1], fleet.MDMAppleOperationTypeInstall, &fleet.MDMAppleDeliveryVerified, ctx, ds, t) @@ -1992,10 +2013,10 @@ func testMDMAppleHostsProfilesStatus(t *testing.T, ds *Datastore) { require.Equal(t, uint(0), res.Failed) require.Equal(t, uint(1), res.Verifying) // hosts[9] is still verifying other profiles require.Equal(t, uint(0), res.Verified) - require.True(t, checkListHosts(fleet.MacOSSettingsPending, &tm.ID, []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsFailed, &tm.ID, []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsVerifying, &tm.ID, hosts[9:10])) - require.True(t, checkListHosts(fleet.MacOSSettingsVerified, &tm.ID, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsPending, &tm.ID, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsFailed, &tm.ID, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsVerifying, &tm.ID, hosts[9:10])) + require.True(t, checkListHosts(fleet.OSSettingsVerified, &tm.ID, []*fleet.Host{})) // verify the other profiles on hosts[9] upsertHostCPs(hosts[9:10], teamCPs[1:], fleet.MDMAppleOperationTypeInstall, &fleet.MDMAppleDeliveryVerified, ctx, ds, t) @@ -2006,10 +2027,10 @@ func testMDMAppleHostsProfilesStatus(t *testing.T, ds *Datastore) { require.Equal(t, uint(0), res.Failed) require.Equal(t, uint(0), res.Verifying) require.Equal(t, uint(1), res.Verified) // hosts[9] is all verified - require.True(t, checkListHosts(fleet.MacOSSettingsPending, &tm.ID, []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsFailed, &tm.ID, []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsVerifying, &tm.ID, []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsVerified, &tm.ID, hosts[9:10])) + require.True(t, checkListHosts(fleet.OSSettingsPending, &tm.ID, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsFailed, &tm.ID, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsVerifying, &tm.ID, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsVerified, &tm.ID, hosts[9:10])) // confirm no changes in summary for profiles with no team res, err = ds.GetMDMAppleHostsProfilesSummary(ctx, ptr.Uint(0)) // team id zero represents no team @@ -2020,14 +2041,14 @@ func testMDMAppleHostsProfilesStatus(t *testing.T, ds *Datastore) { require.Equal(t, uint(2), res.Failed) // two failed hosts require.Equal(t, uint(0), res.Verifying) // hosts[9] transferred to new team so is not counted under no team require.Equal(t, uint(0), res.Verified) - require.True(t, checkListHosts(fleet.MacOSSettingsPending, nil, pendingHosts)) - require.True(t, checkListHosts(fleet.MacOSSettingsFailed, nil, hosts[0:2])) - require.True(t, checkListHosts(fleet.MacOSSettingsVerifying, nil, []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsVerified, nil, []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsPending, ptr.Uint(0), pendingHosts)) - require.True(t, checkListHosts(fleet.MacOSSettingsFailed, ptr.Uint(0), hosts[0:2])) - require.True(t, checkListHosts(fleet.MacOSSettingsVerifying, ptr.Uint(0), []*fleet.Host{})) - require.True(t, checkListHosts(fleet.MacOSSettingsVerified, ptr.Uint(0), []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsPending, nil, pendingHosts)) + require.True(t, checkListHosts(fleet.OSSettingsFailed, nil, hosts[0:2])) + require.True(t, checkListHosts(fleet.OSSettingsVerifying, nil, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsVerified, nil, []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsPending, ptr.Uint(0), pendingHosts)) + require.True(t, checkListHosts(fleet.OSSettingsFailed, ptr.Uint(0), hosts[0:2])) + require.True(t, checkListHosts(fleet.OSSettingsVerifying, ptr.Uint(0), []*fleet.Host{})) + require.True(t, checkListHosts(fleet.OSSettingsVerified, ptr.Uint(0), []*fleet.Host{})) } func testMDMAppleIdPAccount(t *testing.T, ds *Datastore) { @@ -2166,7 +2187,7 @@ func testDeleteMDMAppleProfilesForHost(t *testing.T, ds *Datastore) { } func createDiskEncryptionRecord(ctx context.Context, ds *Datastore, t *testing.T, hostId uint, key string, decryptable bool, threshold time.Time) { - err := ds.SetOrUpdateHostDiskEncryptionKey(ctx, hostId, key) + err := ds.SetOrUpdateHostDiskEncryptionKey(ctx, hostId, key, "", nil) require.NoError(t, err) err = ds.SetHostsDiskEncryptionKeyStatus(ctx, []uint{hostId}, decryptable, threshold) require.NoError(t, err) diff --git a/server/datastore/mysql/hosts.go b/server/datastore/mysql/hosts.go index 10124e68e0..0ad9c6e005 100644 --- a/server/datastore/mysql/hosts.go +++ b/server/datastore/mysql/hosts.go @@ -887,7 +887,11 @@ func (ds *Datastore) ListHosts(ctx context.Context, filter fleet.TeamFilter, opt } leftJoinFailingPolicies := !useHostPaginationOptim - sql, params = ds.applyHostFilters(opt, sql, filter, params, leftJoinFailingPolicies) + + sql, params, err := ds.applyHostFilters(ctx, opt, sql, filter, params, leftJoinFailingPolicies) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "list hosts: apply host filters") + } hosts := []*fleet.Host{} if err := sqlx.SelectContext(ctx, ds.reader(ctx), &hosts, sql, params...); err != nil { @@ -906,7 +910,7 @@ func (ds *Datastore) ListHosts(ctx context.Context, filter fleet.TeamFilter, opt } // TODO(Sarah): Do we need to reconcile mutually exclusive filters? -func (ds *Datastore) applyHostFilters(opt fleet.HostListOptions, sql string, filter fleet.TeamFilter, params []interface{}, leftJoinFailingPolicies bool) (string, []interface{}) { +func (ds *Datastore) applyHostFilters(ctx context.Context, opt fleet.HostListOptions, sql string, filter fleet.TeamFilter, params []interface{}, leftJoinFailingPolicies bool) (string, []interface{}, error) { opt.OrderKey = defaultHostColumnTableAlias(opt.OrderKey) deviceMappingJoin := `LEFT JOIN ( @@ -1004,12 +1008,20 @@ func (ds *Datastore) applyHostFilters(opt fleet.HostListOptions, sql string, fil sql, params = filterHostsByMDM(sql, opt, params) sql, params = filterHostsByMacOSSettingsStatus(sql, opt, params) sql, params = filterHostsByMacOSDiskEncryptionStatus(sql, opt, params) + if enableDiskEncryption, err := ds.getConfigEnableDiskEncryption(ctx, opt.TeamFilter); err != nil { + return "", nil, err + } else if opt.OSSettingsFilter.IsValid() { + sql, params = ds.filterHostsByOSSettingsStatus(sql, opt, params, enableDiskEncryption) + } else if opt.OSSettingsDiskEncryptionFilter.IsValid() { + sql, params = ds.filterHostsByOSSettingsDiskEncryptionStatus(sql, opt, params, enableDiskEncryption) + } + sql, params = filterHostsByMDMBootstrapPackageStatus(sql, opt, params) sql, params = filterHostsByOS(sql, opt, params) sql, params, _ = hostSearchLike(sql, params, opt.MatchQuery, hostSearchColumns...) sql, params = appendListOptionsWithCursorToSQL(sql, params, &opt.ListOptions) - return sql, params + return sql, params, nil } func filterHostsByTeam(sql string, opt fleet.HostListOptions, params []interface{}) (string, []interface{}) { @@ -1115,13 +1127,13 @@ func filterHostsByMacOSSettingsStatus(sql string, opt fleet.HostListOptions, par var subquery string var subqueryParams []interface{} switch opt.MacOSSettingsFilter { - case fleet.MacOSSettingsFailed: + case fleet.OSSettingsFailed: subquery, subqueryParams = subqueryHostsMacOSSettingsStatusFailing() - case fleet.MacOSSettingsPending: + case fleet.OSSettingsPending: subquery, subqueryParams = subqueryHostsMacOSSettingsStatusPending() - case fleet.MacOSSettingsVerifying: + case fleet.OSSettingsVerifying: subquery, subqueryParams = subqueryHostsMacOSSetttingsStatusVerifying() - case fleet.MacOSSettingsVerified: + case fleet.OSSettingsVerified: subquery, subqueryParams = subqueryHostsMacOSSetttingsStatusVerified() } if subquery != "" { @@ -1140,22 +1152,131 @@ func filterHostsByMacOSDiskEncryptionStatus(sql string, opt fleet.HostListOption var subqueryParams []interface{} switch opt.MacOSSettingsDiskEncryptionFilter { case fleet.DiskEncryptionVerified: - subquery, subqueryParams = subqueryDiskEncryptionVerified() + subquery, subqueryParams = subqueryFileVaultVerified() case fleet.DiskEncryptionVerifying: - subquery, subqueryParams = subqueryDiskEncryptionVerifying() + subquery, subqueryParams = subqueryFileVaultVerifying() case fleet.DiskEncryptionActionRequired: - subquery, subqueryParams = subqueryDiskEncryptionActionRequired() + subquery, subqueryParams = subqueryFileVaultActionRequired() case fleet.DiskEncryptionEnforcing: - subquery, subqueryParams = subqueryDiskEncryptionEnforcing() + subquery, subqueryParams = subqueryFileVaultEnforcing() case fleet.DiskEncryptionFailed: - subquery, subqueryParams = subqueryDiskEncryptionFailed() + subquery, subqueryParams = subqueryFileVaultFailed() case fleet.DiskEncryptionRemovingEnforcement: - subquery, subqueryParams = subqueryDiskEncryptionRemovingEnforcement() + subquery, subqueryParams = subqueryFileVaultRemovingEnforcement() } return sql + fmt.Sprintf(` AND EXISTS (%s)`, subquery), append(params, subqueryParams...) } +func (ds *Datastore) filterHostsByOSSettingsStatus(sql string, opt fleet.HostListOptions, params []interface{}, isDiskEncryptionEnabled bool) (string, []interface{}) { + if !opt.OSSettingsFilter.IsValid() { + return sql, params + } + + sqlFmt := ` AND h.platform IN('windows', 'darwin')` + if opt.TeamFilter == nil { + // macOS settings filter is not compatible with the "all teams" option so append the "no + // team" filter here (note that filterHostsByTeam applies the "no team" filter if TeamFilter == 0) + sqlFmt += ` AND h.team_id IS NULL` + } + sqlFmt += ` AND ((h.platform = 'windows' AND (%s)) OR (h.platform = 'darwin' AND (%s)))` + + var subqueryMacOS string + var subqueryParams []interface{} + whereWindows := "FALSE" + whereMacOS := "FALSE" + + switch opt.OSSettingsFilter { + case fleet.OSSettingsFailed: + subqueryMacOS, subqueryParams = subqueryHostsMacOSSettingsStatusFailing() + if isDiskEncryptionEnabled { + whereWindows = ds.whereBitLockerStatus(fleet.DiskEncryptionFailed) + } + case fleet.OSSettingsPending: + subqueryMacOS, subqueryParams = subqueryHostsMacOSSettingsStatusPending() + if isDiskEncryptionEnabled { + whereWindows = ds.whereBitLockerStatus(fleet.DiskEncryptionEnforcing) + } + case fleet.OSSettingsVerifying: + subqueryMacOS, subqueryParams = subqueryHostsMacOSSetttingsStatusVerifying() + if isDiskEncryptionEnabled { + whereWindows = ds.whereBitLockerStatus(fleet.DiskEncryptionVerifying) + } + case fleet.OSSettingsVerified: + subqueryMacOS, subqueryParams = subqueryHostsMacOSSetttingsStatusVerified() + if isDiskEncryptionEnabled { + whereWindows = ds.whereBitLockerStatus(fleet.DiskEncryptionVerified) + } + } + + if subqueryMacOS != "" { + whereMacOS = "EXISTS (" + subqueryMacOS + ")" + } + + return sql + fmt.Sprintf(sqlFmt, whereWindows, whereMacOS), append(params, subqueryParams...) +} + +func (ds *Datastore) filterHostsByOSSettingsDiskEncryptionStatus(sql string, opt fleet.HostListOptions, params []interface{}, enableDiskEncryption bool) (string, []interface{}) { + if !opt.OSSettingsDiskEncryptionFilter.IsValid() { + return sql, params + } + + sqlFmt := " AND h.platform IN('windows', 'darwin')" + // TODO: Should we add no team filter here? It isn't included for the FileVault filter but is + // for the general macOS settings filter. + if opt.TeamFilter == nil { + // macOS settings filter is not compatible with the "all teams" option so append the "no + // team" filter here (note that filterHostsByTeam applies the "no team" filter if TeamFilter == 0) + sqlFmt += ` AND h.team_id IS NULL` + } + sqlFmt += ` AND ((h.platform = 'windows' AND %s) OR (h.platform = 'darwin' AND %s))` + + var subqueryMacOS string + var subqueryParams []interface{} + whereWindows := "FALSE" + whereMacOS := "FALSE" + + switch opt.OSSettingsDiskEncryptionFilter { + case fleet.DiskEncryptionVerified: + if enableDiskEncryption { + whereWindows = ds.whereBitLockerStatus(fleet.DiskEncryptionVerified) + } + subqueryMacOS, subqueryParams = subqueryFileVaultVerified() + + case fleet.DiskEncryptionVerifying: + if enableDiskEncryption { + whereWindows = ds.whereBitLockerStatus(fleet.DiskEncryptionVerifying) + } + subqueryMacOS, subqueryParams = subqueryFileVaultVerifying() + + case fleet.DiskEncryptionActionRequired: + // Windows hosts cannot be action required status in the current implementation. + subqueryMacOS, subqueryParams = subqueryFileVaultActionRequired() + + case fleet.DiskEncryptionEnforcing: + if enableDiskEncryption { + whereWindows = ds.whereBitLockerStatus(fleet.DiskEncryptionEnforcing) + } + subqueryMacOS, subqueryParams = subqueryFileVaultEnforcing() + + case fleet.DiskEncryptionFailed: + if enableDiskEncryption { + whereWindows = ds.whereBitLockerStatus(fleet.DiskEncryptionFailed) + } + subqueryMacOS, subqueryParams = subqueryFileVaultFailed() + + case fleet.DiskEncryptionRemovingEnforcement: + // Windows hosts cannot be removing enforcement status in the current implementation. + subqueryMacOS, subqueryParams = subqueryFileVaultRemovingEnforcement() + } + + if subqueryMacOS != "" { + whereMacOS = "EXISTS (" + subqueryMacOS + ")" + } + + return sql + fmt.Sprintf(sqlFmt, whereWindows, whereMacOS), append(params, subqueryParams...) +} + func filterHostsByMDMBootstrapPackageStatus(sql string, opt fleet.HostListOptions, params []interface{}) (string, []interface{}) { if opt.MDMBootstrapPackageFilter == nil || !opt.MDMBootstrapPackageFilter.IsValid() { return sql, params @@ -1210,7 +1331,11 @@ func (ds *Datastore) CountHosts(ctx context.Context, filter fleet.TeamFilter, op leftJoinFailingPolicies := false var params []interface{} - sql, params = ds.applyHostFilters(opt, sql, filter, params, leftJoinFailingPolicies) + + sql, params, err := ds.applyHostFilters(ctx, opt, sql, filter, params, leftJoinFailingPolicies) + if err != nil { + return 0, ctxerr.Wrap(ctx, err, "count hosts: apply host filters") + } var count int if err := sqlx.GetContext(ctx, ds.reader(ctx), &count, sql, params...); err != nil { @@ -1742,13 +1867,14 @@ func (ds *Datastore) LoadHostByNodeKey(ctx context.Context, nodeKey string) (*fl type hostWithMDMInfo struct { fleet.Host - HostID *uint `db:"host_id"` - Enrolled *bool `db:"enrolled"` - ServerURL *string `db:"server_url"` - InstalledFromDep *bool `db:"installed_from_dep"` - IsServer *bool `db:"is_server"` - MDMID *uint `db:"mdm_id"` - Name *string `db:"name"` + HostID *uint `db:"host_id"` + Enrolled *bool `db:"enrolled"` + ServerURL *string `db:"server_url"` + InstalledFromDep *bool `db:"installed_from_dep"` + IsServer *bool `db:"is_server"` + MDMID *uint `db:"mdm_id"` + Name *string `db:"name"` + EncryptionKeyAvailable *bool `db:"encryption_key_available"` } // LoadHostByOrbitNodeKey loads the whole host identified by the node key. @@ -1804,7 +1930,9 @@ func (ds *Datastore) LoadHostByOrbitNodeKey(ctx context.Context, nodeKey string) COALESCE(hm.is_server, false) AS is_server, COALESCE(mdms.name, ?) AS name, COALESCE(hdek.reset_requested, false) AS disk_encryption_reset_requested, + COALESCE(hdek.decryptable, false) as encryption_key_available, IF(hdep.host_id AND ISNULL(hdep.deleted_at), true, false) AS dep_assigned_to_fleet, + hd.encrypted as disk_encryption_enabled, t.name as team_name FROM hosts h @@ -1824,6 +1952,10 @@ func (ds *Datastore) LoadHostByOrbitNodeKey(ctx context.Context, nodeKey string) host_disk_encryption_keys hdek ON hdek.host_id = h.id + LEFT OUTER JOIN + host_disks hd + ON + hd.host_id = h.id LEFT OUTER JOIN teams t ON @@ -1846,6 +1978,10 @@ func (ds *Datastore) LoadHostByOrbitNodeKey(ctx context.Context, nodeKey string) MDMID: hostWithMDM.MDMID, Name: *hostWithMDM.Name, } + + host.MDM = fleet.MDMHostData{ + EncryptionKeyAvailable: *hostWithMDM.EncryptionKeyAvailable, + } } return &host, nil case errors.Is(err, sql.ErrNoRows): @@ -3013,19 +3149,30 @@ func (ds *Datastore) SetOrUpdateHostDisksEncryption(ctx context.Context, hostID ) } -func (ds *Datastore) SetOrUpdateHostDiskEncryptionKey(ctx context.Context, hostID uint, encryptedBase64Key string) error { +func (ds *Datastore) SetOrUpdateHostDiskEncryptionKey(ctx context.Context, hostID uint, encryptedBase64Key, clientError string, decryptable *bool) error { _, err := ds.writer(ctx).ExecContext(ctx, ` - INSERT INTO host_disk_encryption_keys (host_id, base64_encrypted) - VALUES (?, ?) - ON DUPLICATE KEY UPDATE - /* if the key has changed, NULLify this value so it can be calculated again */ - decryptable = IF(base64_encrypted = VALUES(base64_encrypted), decryptable, NULL), - base64_encrypted = VALUES(base64_encrypted) - `, hostID, encryptedBase64Key) +INSERT INTO host_disk_encryption_keys + (host_id, base64_encrypted, client_error, decryptable) +VALUES + (?, ?, ?, ?) +ON DUPLICATE KEY UPDATE + /* if the key has changed, set decrypted to its initial value so it can be calculated again if necessary (if null) */ + decryptable = IF(base64_encrypted = VALUES(base64_encrypted), decryptable, VALUES(decryptable)), + base64_encrypted = VALUES(base64_encrypted), + client_error = VALUES(client_error) +`, hostID, encryptedBase64Key, clientError, decryptable) return err } func (ds *Datastore) GetUnverifiedDiskEncryptionKeys(ctx context.Context) ([]fleet.HostDiskEncryptionKey, error) { + // NOTE(mna): currently we only verify encryption keys for macOS, + // Windows/bitlocker uses a different approach where orbit sends the + // encryption key and we encrypt it server-side with the WSTEP certificate, + // so it is always decryptable once received. + // + // To avoid sending Windows-related keys to verify as part of this call, we + // only return rows that have a non-empty encryption key (for Windows, the + // key is blanked if an error occurred trying to retrieve it on the host). var keys []fleet.HostDiskEncryptionKey err := sqlx.SelectContext(ctx, ds.reader(ctx), &keys, ` SELECT @@ -3035,7 +3182,8 @@ func (ds *Datastore) GetUnverifiedDiskEncryptionKeys(ctx context.Context) ([]fle FROM host_disk_encryption_keys WHERE - decryptable IS NULL + decryptable IS NULL AND + base64_encrypted != '' `) return keys, err } diff --git a/server/datastore/mysql/hosts_test.go b/server/datastore/mysql/hosts_test.go index 58367713b1..0a89f4f32e 100644 --- a/server/datastore/mysql/hosts_test.go +++ b/server/datastore/mysql/hosts_test.go @@ -21,6 +21,7 @@ import ( "github.com/fleetdm/fleet/v4/server" "github.com/fleetdm/fleet/v4/server/config" "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig" "github.com/fleetdm/fleet/v4/server/ptr" "github.com/fleetdm/fleet/v4/server/test" "github.com/google/uuid" @@ -111,7 +112,7 @@ func TestHosts(t *testing.T) { {"HostsListBySoftwareChangedAt", testHostsListBySoftwareChangedAt}, {"HostsListByOperatingSystemID", testHostsListByOperatingSystemID}, {"HostsListByOSNameAndVersion", testHostsListByOSNameAndVersion}, - {"HostsListByDiskEncryptionStatus", testHostsListDiskEncryptionStatus}, + {"HostsListByDiskEncryptionStatus", testHostsListMacOSSettingsDiskEncryptionStatus}, {"HostsListFailingPolicies", printReadsInTest(testHostsListFailingPolicies)}, {"HostsExpiration", testHostsExpiration}, {"HostsAllPackStats", testHostsAllPackStats}, @@ -722,8 +723,13 @@ func testHostListOptionsTeamFilter(t *testing.T, ds *Datastore) { var hosts []*fleet.Host for i := 0; i < 10; i++ { + var opts []test.NewHostOption + switch i { + case 5, 6: + opts = append(opts, test.WithPlatform("windows")) + } h := test.NewHost(t, ds, fmt.Sprintf("foo.local.%d", i), "1.1.1.1", - fmt.Sprintf("%d", i), fmt.Sprintf("%d", i), time.Now()) + fmt.Sprintf("%d", i), fmt.Sprintf("%d", i), time.Now(), opts...) hosts = append(hosts, h) } userFilter := fleet.TeamFilter{User: test.UserAdmin} @@ -763,12 +769,12 @@ func testHostListOptionsTeamFilter(t *testing.T, ds *Datastore) { Checksum: []byte("csum"), }, })) - listHostsCheckCount(t, ds, userFilter, fleet.HostListOptions{TeamFilter: &team1.ID, MacOSSettingsFilter: fleet.MacOSSettingsVerifying}, 1) // hosts[0] - listHostsCheckCount(t, ds, userFilter, fleet.HostListOptions{TeamFilter: &team2.ID, MacOSSettingsFilter: fleet.MacOSSettingsVerifying}, 0) // wrong team + listHostsCheckCount(t, ds, userFilter, fleet.HostListOptions{TeamFilter: &team1.ID, MacOSSettingsFilter: fleet.OSSettingsVerifying}, 1) // hosts[0] + listHostsCheckCount(t, ds, userFilter, fleet.HostListOptions{TeamFilter: &team2.ID, MacOSSettingsFilter: fleet.OSSettingsVerifying}, 0) // wrong team // macos settings filter does not support "all teams" so teamIDFilterNil acts the same as teamIDFilterZero - listHostsCheckCount(t, ds, userFilter, fleet.HostListOptions{TeamFilter: teamIDFilterZero, MacOSSettingsFilter: fleet.MacOSSettingsVerifying}, 0) // no team - listHostsCheckCount(t, ds, userFilter, fleet.HostListOptions{TeamFilter: teamIDFilterNil, MacOSSettingsFilter: fleet.MacOSSettingsVerifying}, 0) // no team - listHostsCheckCount(t, ds, userFilter, fleet.HostListOptions{MacOSSettingsFilter: fleet.MacOSSettingsVerifying}, 0) // no team + listHostsCheckCount(t, ds, userFilter, fleet.HostListOptions{TeamFilter: teamIDFilterZero, MacOSSettingsFilter: fleet.OSSettingsVerifying}, 0) // no team + listHostsCheckCount(t, ds, userFilter, fleet.HostListOptions{TeamFilter: teamIDFilterNil, MacOSSettingsFilter: fleet.OSSettingsVerifying}, 0) // no team + listHostsCheckCount(t, ds, userFilter, fleet.HostListOptions{MacOSSettingsFilter: fleet.OSSettingsVerifying}, 0) // no team require.NoError(t, ds.BulkUpsertMDMAppleHostProfiles(context.Background(), []*fleet.MDMAppleBulkUpsertHostProfilePayload{ { @@ -781,12 +787,39 @@ func testHostListOptionsTeamFilter(t *testing.T, ds *Datastore) { Checksum: []byte("csum"), }, })) - listHostsCheckCount(t, ds, userFilter, fleet.HostListOptions{TeamFilter: &team1.ID, MacOSSettingsFilter: fleet.MacOSSettingsVerifying}, 1) // hosts[0] - listHostsCheckCount(t, ds, userFilter, fleet.HostListOptions{TeamFilter: &team2.ID, MacOSSettingsFilter: fleet.MacOSSettingsVerifying}, 0) // wrong team + listHostsCheckCount(t, ds, userFilter, fleet.HostListOptions{TeamFilter: &team1.ID, MacOSSettingsFilter: fleet.OSSettingsVerifying}, 1) // hosts[0] + listHostsCheckCount(t, ds, userFilter, fleet.HostListOptions{TeamFilter: &team2.ID, MacOSSettingsFilter: fleet.OSSettingsVerifying}, 0) // wrong team // macos settings filter does not support "all teams" so both teamIDFilterNil acts the same as teamIDFilterZero - listHostsCheckCount(t, ds, userFilter, fleet.HostListOptions{TeamFilter: teamIDFilterZero, MacOSSettingsFilter: fleet.MacOSSettingsVerifying}, 1) // hosts[9] - listHostsCheckCount(t, ds, userFilter, fleet.HostListOptions{TeamFilter: teamIDFilterNil, MacOSSettingsFilter: fleet.MacOSSettingsVerifying}, 1) // hosts[9] - listHostsCheckCount(t, ds, userFilter, fleet.HostListOptions{MacOSSettingsFilter: fleet.MacOSSettingsVerifying}, 1) // hosts[9] + listHostsCheckCount(t, ds, userFilter, fleet.HostListOptions{TeamFilter: teamIDFilterZero, MacOSSettingsFilter: fleet.OSSettingsVerifying}, 1) // hosts[9] + listHostsCheckCount(t, ds, userFilter, fleet.HostListOptions{TeamFilter: teamIDFilterNil, MacOSSettingsFilter: fleet.OSSettingsVerifying}, 1) // hosts[9] + listHostsCheckCount(t, ds, userFilter, fleet.HostListOptions{MacOSSettingsFilter: fleet.OSSettingsVerifying}, 1) // hosts[9] + + // test team filter in combination with os settings filter + listHostsCheckCount(t, ds, userFilter, fleet.HostListOptions{TeamFilter: &team1.ID, OSSettingsFilter: fleet.OSSettingsVerifying}, 1) // hosts[0] + listHostsCheckCount(t, ds, userFilter, fleet.HostListOptions{TeamFilter: &team2.ID, OSSettingsFilter: fleet.OSSettingsVerifying}, 0) // wrong team + // os settings filter does not support "all teams" so teamIDFilterNil acts the same as teamIDFilterZero + listHostsCheckCount(t, ds, userFilter, fleet.HostListOptions{TeamFilter: teamIDFilterZero, OSSettingsFilter: fleet.OSSettingsVerifying}, 1) // hosts[9] + listHostsCheckCount(t, ds, userFilter, fleet.HostListOptions{TeamFilter: teamIDFilterNil, OSSettingsFilter: fleet.OSSettingsVerifying}, 1) // hosts[9] + listHostsCheckCount(t, ds, userFilter, fleet.HostListOptions{OSSettingsFilter: fleet.OSSettingsVerifying}, 1) + + // test team filter in combination with os settings disk encryptionfilter + require.NoError(t, ds.BulkUpsertMDMAppleHostProfiles(context.Background(), []*fleet.MDMAppleBulkUpsertHostProfilePayload{ + { + ProfileID: 1, + ProfileIdentifier: mobileconfig.FleetFileVaultPayloadIdentifier, + HostUUID: hosts[8].UUID, // hosts[8] is assgined to no team + CommandUUID: "command-uuid-3", + OperationType: fleet.MDMAppleOperationTypeInstall, + Status: &fleet.MDMAppleDeliveryPending, + Checksum: []byte("disk-encryption-csum"), + }, + })) + listHostsCheckCount(t, ds, userFilter, fleet.HostListOptions{TeamFilter: &team1.ID, OSSettingsDiskEncryptionFilter: fleet.DiskEncryptionEnforcing}, 0) // hosts[0] + listHostsCheckCount(t, ds, userFilter, fleet.HostListOptions{TeamFilter: &team2.ID, OSSettingsDiskEncryptionFilter: fleet.DiskEncryptionEnforcing}, 0) // wrong team + // os settings filter does not support "all teams" so teamIDFilterNil acts the same as teamIDFilterZero + listHostsCheckCount(t, ds, userFilter, fleet.HostListOptions{TeamFilter: teamIDFilterZero, OSSettingsDiskEncryptionFilter: fleet.DiskEncryptionEnforcing}, 1) // hosts[8] + listHostsCheckCount(t, ds, userFilter, fleet.HostListOptions{TeamFilter: teamIDFilterNil, OSSettingsDiskEncryptionFilter: fleet.DiskEncryptionEnforcing}, 1) // hosts[8] + listHostsCheckCount(t, ds, userFilter, fleet.HostListOptions{OSSettingsDiskEncryptionFilter: fleet.DiskEncryptionEnforcing}, 1) // hosts[8] } func testHostsListFilterAdditional(t *testing.T, ds *Datastore) { @@ -2920,7 +2953,7 @@ func testHostsListByOSNameAndVersion(t *testing.T, ds *Datastore) { } } -func testHostsListDiskEncryptionStatus(t *testing.T, ds *Datastore) { +func testHostsListMacOSSettingsDiskEncryptionStatus(t *testing.T, ds *Datastore) { ctx := context.Background() // seed hosts @@ -5740,7 +5773,7 @@ func testHostsDeleteHosts(t *testing.T, ds *Datastore) { err = ds.SetOrUpdateHostOrbitInfo(context.Background(), host.ID, "1.1.0") require.NoError(t, err) // set an encryption key - err = ds.SetOrUpdateHostDiskEncryptionKey(context.Background(), host.ID, "TESTKEY") + err = ds.SetOrUpdateHostDiskEncryptionKey(context.Background(), host.ID, "TESTKEY", "", nil) require.NoError(t, err) // set an mdm profile prof, err := ds.NewMDMAppleConfigProfile(context.Background(), *configProfileForTest(t, "N1", "I1", "U1")) @@ -6586,23 +6619,26 @@ func testHostsLoadHostByOrbitNodeKey(t *testing.T, ds *Datastore) { require.NoError(t, err) require.Equal(t, hFleet.ID, loadFleet.ID) require.False(t, loadFleet.MDMInfo.IsServer) + + // fill in disk encryption information + require.NoError(t, ds.SetOrUpdateHostDisksEncryption(context.Background(), hFleet.ID, true)) + err = ds.SetOrUpdateHostDiskEncryptionKey(ctx, hFleet.ID, "test-key", "", nil) + require.NoError(t, err) + err = ds.SetHostsDiskEncryptionKeyStatus(ctx, []uint{hFleet.ID}, true, time.Now()) + require.NoError(t, err) + loadFleet, err = ds.LoadHostByOrbitNodeKey(ctx, *hFleet.OrbitNodeKey) + require.NoError(t, err) + require.True(t, loadFleet.MDM.EncryptionKeyAvailable) + require.NoError(t, err) + require.NotNil(t, loadFleet.DiskEncryptionEnabled) + require.True(t, *loadFleet.DiskEncryptionEnabled) } -func checkEncryptionKeyStatus(t *testing.T, ds *Datastore, hostID uint, expected *bool) { - ExecAdhocSQL(t, ds, func(tx sqlx.ExtContext) error { - var actual *bool - - row := tx.QueryRowxContext( - context.Background(), - "SELECT decryptable FROM host_disk_encryption_keys WHERE host_id = ?", - hostID, - ) - - err := row.Scan(&actual) - require.NoError(t, err) - require.Equal(t, expected, actual) - return nil - }) +func checkEncryptionKeyStatus(t *testing.T, ds *Datastore, hostID uint, expectedKey string, expectedDecryptable *bool) { + got, err := ds.GetHostDiskEncryptionKey(context.Background(), hostID) + require.NoError(t, err) + require.Equal(t, expectedKey, got.Base64Encrypted) + require.Equal(t, expectedDecryptable, got.Decryptable) } func testHostsSetOrUpdateHostDisksEncryptionKey(t *testing.T, ds *Datastore) { @@ -6632,49 +6668,81 @@ func testHostsSetOrUpdateHostDisksEncryptionKey(t *testing.T, ds *Datastore) { PrimaryMac: "30-65-EC-6F-C4-59", }) require.NoError(t, err) - - err = ds.SetOrUpdateHostDiskEncryptionKey(context.Background(), host.ID, "AAA") + host3, err := ds.NewHost(context.Background(), &fleet.Host{ + DetailUpdatedAt: time.Now(), + LabelUpdatedAt: time.Now(), + PolicyUpdatedAt: time.Now(), + SeenTime: time.Now(), + NodeKey: ptr.String("3"), + UUID: "3", + OsqueryHostID: ptr.String("3"), + Hostname: "foo.local3", + PrimaryIP: "192.168.1.3", + PrimaryMac: "30-65-EC-6F-C4-60", + }) require.NoError(t, err) - err = ds.SetOrUpdateHostDiskEncryptionKey(context.Background(), host2.ID, "BBB") + err = ds.SetOrUpdateHostDiskEncryptionKey(context.Background(), host.ID, "AAA", "", nil) require.NoError(t, err) - checkEncryptionKey := func(hostID uint, expected string) { - actual, err := ds.GetHostDiskEncryptionKey(context.Background(), hostID) - require.NoError(t, err) - require.Equal(t, expected, actual.Base64Encrypted) - } + err = ds.SetOrUpdateHostDiskEncryptionKey(context.Background(), host2.ID, "BBB", "", nil) + require.NoError(t, err) h, err := ds.Host(context.Background(), host.ID) require.NoError(t, err) - checkEncryptionKey(h.ID, "AAA") + checkEncryptionKeyStatus(t, ds, h.ID, "AAA", nil) h, err = ds.Host(context.Background(), host2.ID) require.NoError(t, err) - checkEncryptionKey(h.ID, "BBB") + checkEncryptionKeyStatus(t, ds, h.ID, "BBB", nil) - err = ds.SetOrUpdateHostDiskEncryptionKey(context.Background(), host2.ID, "CCC") + err = ds.SetOrUpdateHostDiskEncryptionKey(context.Background(), host2.ID, "CCC", "", nil) require.NoError(t, err) h, err = ds.Host(context.Background(), host2.ID) require.NoError(t, err) - checkEncryptionKey(h.ID, "CCC") + checkEncryptionKeyStatus(t, ds, h.ID, "CCC", nil) // setting the encryption key to an existing value doesn't change its // encryption status err = ds.SetHostsDiskEncryptionKeyStatus(context.Background(), []uint{host.ID}, true, time.Now().Add(time.Hour)) require.NoError(t, err) - checkEncryptionKeyStatus(t, ds, host.ID, ptr.Bool(true)) + checkEncryptionKeyStatus(t, ds, host.ID, "AAA", ptr.Bool(true)) // same key doesn't change encryption status - err = ds.SetOrUpdateHostDiskEncryptionKey(context.Background(), host.ID, "AAA") + err = ds.SetOrUpdateHostDiskEncryptionKey(context.Background(), host.ID, "AAA", "", nil) require.NoError(t, err) - checkEncryptionKeyStatus(t, ds, host.ID, ptr.Bool(true)) + checkEncryptionKeyStatus(t, ds, host.ID, "AAA", ptr.Bool(true)) // different key resets encryption status - err = ds.SetOrUpdateHostDiskEncryptionKey(context.Background(), host.ID, "XZY") + err = ds.SetOrUpdateHostDiskEncryptionKey(context.Background(), host.ID, "XZY", "", nil) require.NoError(t, err) - checkEncryptionKeyStatus(t, ds, host.ID, nil) + checkEncryptionKeyStatus(t, ds, host.ID, "XZY", nil) + + // set the key with an initial decrypted status of true + err = ds.SetOrUpdateHostDiskEncryptionKey(context.Background(), host3.ID, "abc", "", ptr.Bool(true)) + require.NoError(t, err) + checkEncryptionKeyStatus(t, ds, host3.ID, "abc", ptr.Bool(true)) + + // same key, provided decrypted status is ignored (stored one is kept) + err = ds.SetOrUpdateHostDiskEncryptionKey(context.Background(), host3.ID, "abc", "", ptr.Bool(false)) + require.NoError(t, err) + checkEncryptionKeyStatus(t, ds, host3.ID, "abc", ptr.Bool(true)) + + // client error, key is removed and decrypted status is nulled + err = ds.SetOrUpdateHostDiskEncryptionKey(context.Background(), host3.ID, "", "fail", nil) + require.NoError(t, err) + checkEncryptionKeyStatus(t, ds, host3.ID, "", nil) + + // new key, provided decrypted status is applied + err = ds.SetOrUpdateHostDiskEncryptionKey(context.Background(), host3.ID, "def", "", ptr.Bool(true)) + require.NoError(t, err) + checkEncryptionKeyStatus(t, ds, host3.ID, "def", ptr.Bool(true)) + + // different key, provided decrypted status is applied + err = ds.SetOrUpdateHostDiskEncryptionKey(context.Background(), host3.ID, "ghi", "", ptr.Bool(false)) + require.NoError(t, err) + checkEncryptionKeyStatus(t, ds, host3.ID, "ghi", ptr.Bool(false)) } func testHostsSetDiskEncryptionKeyStatus(t *testing.T, ds *Datastore) { @@ -6692,7 +6760,7 @@ func testHostsSetDiskEncryptionKeyStatus(t *testing.T, ds *Datastore) { PrimaryMac: "30-65-EC-6F-C4-58", }) require.NoError(t, err) - err = ds.SetOrUpdateHostDiskEncryptionKey(ctx, host.ID, "TESTKEY") + err = ds.SetOrUpdateHostDiskEncryptionKey(ctx, host.ID, "TESTKEY", "", nil) require.NoError(t, err) host2, err := ds.NewHost(context.Background(), &fleet.Host{ @@ -6709,7 +6777,7 @@ func testHostsSetDiskEncryptionKeyStatus(t *testing.T, ds *Datastore) { }) require.NoError(t, err) - err = ds.SetOrUpdateHostDiskEncryptionKey(ctx, host2.ID, "TESTKEY") + err = ds.SetOrUpdateHostDiskEncryptionKey(ctx, host2.ID, "TESTKEY", "", nil) require.NoError(t, err) threshold := time.Now().Add(time.Hour) @@ -6717,31 +6785,31 @@ func testHostsSetDiskEncryptionKeyStatus(t *testing.T, ds *Datastore) { // empty set err = ds.SetHostsDiskEncryptionKeyStatus(ctx, []uint{}, false, threshold) require.NoError(t, err) - checkEncryptionKeyStatus(t, ds, host.ID, nil) - checkEncryptionKeyStatus(t, ds, host2.ID, nil) + checkEncryptionKeyStatus(t, ds, host.ID, "TESTKEY", nil) + checkEncryptionKeyStatus(t, ds, host2.ID, "TESTKEY", nil) // keys that changed after the provided threshold are not updated err = ds.SetHostsDiskEncryptionKeyStatus(ctx, []uint{host.ID, host2.ID}, true, threshold.Add(-24*time.Hour)) require.NoError(t, err) - checkEncryptionKeyStatus(t, ds, host.ID, nil) - checkEncryptionKeyStatus(t, ds, host2.ID, nil) + checkEncryptionKeyStatus(t, ds, host.ID, "TESTKEY", nil) + checkEncryptionKeyStatus(t, ds, host2.ID, "TESTKEY", nil) // single host err = ds.SetHostsDiskEncryptionKeyStatus(ctx, []uint{host.ID}, true, threshold) require.NoError(t, err) - checkEncryptionKeyStatus(t, ds, host.ID, ptr.Bool(true)) - checkEncryptionKeyStatus(t, ds, host2.ID, nil) + checkEncryptionKeyStatus(t, ds, host.ID, "TESTKEY", ptr.Bool(true)) + checkEncryptionKeyStatus(t, ds, host2.ID, "TESTKEY", nil) // multiple hosts err = ds.SetHostsDiskEncryptionKeyStatus(ctx, []uint{host.ID, host2.ID}, true, threshold) require.NoError(t, err) - checkEncryptionKeyStatus(t, ds, host.ID, ptr.Bool(true)) - checkEncryptionKeyStatus(t, ds, host2.ID, ptr.Bool(true)) + checkEncryptionKeyStatus(t, ds, host.ID, "TESTKEY", ptr.Bool(true)) + checkEncryptionKeyStatus(t, ds, host2.ID, "TESTKEY", ptr.Bool(true)) err = ds.SetHostsDiskEncryptionKeyStatus(ctx, []uint{host.ID, host2.ID}, false, threshold) require.NoError(t, err) - checkEncryptionKeyStatus(t, ds, host.ID, ptr.Bool(false)) - checkEncryptionKeyStatus(t, ds, host2.ID, ptr.Bool(false)) + checkEncryptionKeyStatus(t, ds, host.ID, "TESTKEY", ptr.Bool(false)) + checkEncryptionKeyStatus(t, ds, host2.ID, "TESTKEY", ptr.Bool(false)) } func testHostsGetUnverifiedDiskEncryptionKeys(t *testing.T, ds *Datastore) { @@ -6773,9 +6841,9 @@ func testHostsGetUnverifiedDiskEncryptionKeys(t *testing.T, ds *Datastore) { }) require.NoError(t, err) - err = ds.SetOrUpdateHostDiskEncryptionKey(ctx, host.ID, "TESTKEY") + err = ds.SetOrUpdateHostDiskEncryptionKey(ctx, host.ID, "TESTKEY", "", nil) require.NoError(t, err) - err = ds.SetOrUpdateHostDiskEncryptionKey(ctx, host2.ID, "TESTKEY") + err = ds.SetOrUpdateHostDiskEncryptionKey(ctx, host2.ID, "TESTKEY", "", nil) require.NoError(t, err) keys, err := ds.GetUnverifiedDiskEncryptionKeys(ctx) @@ -6794,6 +6862,17 @@ func testHostsGetUnverifiedDiskEncryptionKeys(t *testing.T, ds *Datastore) { keys, err = ds.GetUnverifiedDiskEncryptionKeys(ctx) require.NoError(t, err) require.Len(t, keys, 1) + require.Equal(t, host2.ID, keys[0].HostID) + + // update key of host 1 to empty with a client error, should not be reported + // by GetUnverifiedDiskEncryptionKeys + err = ds.SetOrUpdateHostDiskEncryptionKey(ctx, host.ID, "", "failed", nil) + require.NoError(t, err) + + keys, err = ds.GetUnverifiedDiskEncryptionKeys(ctx) + require.NoError(t, err) + require.Len(t, keys, 1) + require.Equal(t, host2.ID, keys[0].HostID) err = ds.SetHostsDiskEncryptionKeyStatus(ctx, []uint{host.ID, host2.ID}, false, threshold) require.NoError(t, err) @@ -6992,7 +7071,7 @@ func testHostsEncryptionKeyRawDecryption(t *testing.T, ds *Datastore) { require.Equal(t, -1, *got.MDM.TestGetRawDecryptable()) // create the encryption key row, but unknown decryptable - err = ds.SetOrUpdateHostDiskEncryptionKey(ctx, host.ID, "abc") + err = ds.SetOrUpdateHostDiskEncryptionKey(ctx, host.ID, "abc", "", nil) require.NoError(t, err) got, err = ds.Host(ctx, host.ID) diff --git a/server/datastore/mysql/labels.go b/server/datastore/mysql/labels.go index 551ad04635..2573a53da5 100644 --- a/server/datastore/mysql/labels.go +++ b/server/datastore/mysql/labels.go @@ -552,10 +552,13 @@ func (ds *Datastore) ListHostsInLabel(ctx context.Context, filter fleet.TeamFilt query := fmt.Sprintf(queryFmt, hostMDMSelect, failingPoliciesSelect, deviceMappingSelect, hostMDMJoin, failingPoliciesJoin, deviceMappingJoin) - query, params := ds.applyHostLabelFilters(filter, lid, query, opt) + query, params, err := ds.applyHostLabelFilters(ctx, filter, lid, query, opt) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "applying label query filters") + } hosts := []*fleet.Host{} - err := sqlx.SelectContext(ctx, ds.reader(ctx), &hosts, query, params...) + err = sqlx.SelectContext(ctx, ds.reader(ctx), &hosts, query, params...) if err != nil { return nil, ctxerr.Wrap(ctx, err, "selecting label query executions") } @@ -563,7 +566,7 @@ func (ds *Datastore) ListHostsInLabel(ctx context.Context, filter fleet.TeamFilt } // NOTE: the hosts table must be aliased to `h` in the query passed to this function. -func (ds *Datastore) applyHostLabelFilters(filter fleet.TeamFilter, lid uint, query string, opt fleet.HostListOptions) (string, []interface{}) { +func (ds *Datastore) applyHostLabelFilters(ctx context.Context, filter fleet.TeamFilter, lid uint, query string, opt fleet.HostListOptions) (string, []interface{}, error) { params := []interface{}{lid} if opt.ListOptions.OrderKey == "display_name" { @@ -582,26 +585,33 @@ func (ds *Datastore) applyHostLabelFilters(filter fleet.TeamFilter, lid uint, qu query, params = filterHostsByMacOSSettingsStatus(query, opt, params) query, params = filterHostsByMacOSDiskEncryptionStatus(query, opt, params) query, params = filterHostsByMDMBootstrapPackageStatus(query, opt, params) + if enableDiskEncryption, err := ds.getConfigEnableDiskEncryption(ctx, opt.TeamFilter); err != nil { + return "", nil, err + } else if opt.OSSettingsFilter.IsValid() { + query, params = ds.filterHostsByOSSettingsStatus(query, opt, params, enableDiskEncryption) + } else if opt.OSSettingsDiskEncryptionFilter.IsValid() { + query, params = ds.filterHostsByOSSettingsDiskEncryptionStatus(query, opt, params, enableDiskEncryption) + } query, params = searchLike(query, params, opt.MatchQuery, hostSearchColumns...) query, params = appendListOptionsWithCursorToSQL(query, params, &opt.ListOptions) - return query, params + return query, params, nil } func (ds *Datastore) CountHostsInLabel(ctx context.Context, filter fleet.TeamFilter, lid uint, opt fleet.HostListOptions) (int, error) { query := `SELECT count(*) FROM label_membership lm JOIN hosts h ON (lm.host_id = h.id) LEFT JOIN host_seen_times hst ON (h.id=hst.host_id) + LEFT JOIN host_disks hd ON (h.id=hd.host_id) ` query += hostMDMJoin - if opt.LowDiskSpaceFilter != nil { - query += ` LEFT JOIN host_disks hd ON (h.id=hd.host_id) ` + query, params, err := ds.applyHostLabelFilters(ctx, filter, lid, query, opt) + if err != nil { + return 0, err } - query, params := ds.applyHostLabelFilters(filter, lid, query, opt) - var count int if err := sqlx.GetContext(ctx, ds.reader(ctx), &count, query, params...); err != nil { return 0, ctxerr.Wrap(ctx, err, "count hosts") diff --git a/server/datastore/mysql/labels_test.go b/server/datastore/mysql/labels_test.go index db4ef80494..377cf22432 100644 --- a/server/datastore/mysql/labels_test.go +++ b/server/datastore/mysql/labels_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + "github.com/fleetdm/fleet/v4/pkg/optjson" "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/ptr" "github.com/fleetdm/fleet/v4/server/test" @@ -66,6 +67,7 @@ func TestLabels(t *testing.T) { {"ListHostsInLabelFailingPolicies", testListHostsInLabelFailingPolicies}, {"ListHostsInLabelDiskEncryptionStatus", testListHostsInLabelDiskEncryptionStatus}, {"HostMemberOfAllLabels", testHostMemberOfAllLabels}, + {"ListHostsInLabelOSSettings", testLabelsListHostsInLabelOSSettings}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -497,12 +499,12 @@ func testLabelsListHostsInLabelAndTeamFilter(deferred bool, t *testing.T, db *Da Checksum: []byte("csum"), }, })) - listHostsInLabelCheckCount(t, db, userFilter, l1.ID, fleet.HostListOptions{TeamFilter: &team1.ID, MacOSSettingsFilter: fleet.MacOSSettingsVerifying}, 1) // h1 - listHostsInLabelCheckCount(t, db, userFilter, l1.ID, fleet.HostListOptions{TeamFilter: &team2.ID, MacOSSettingsFilter: fleet.MacOSSettingsVerifying}, 0) // wrong team + listHostsInLabelCheckCount(t, db, userFilter, l1.ID, fleet.HostListOptions{TeamFilter: &team1.ID, MacOSSettingsFilter: fleet.OSSettingsVerifying}, 1) // h1 + listHostsInLabelCheckCount(t, db, userFilter, l1.ID, fleet.HostListOptions{TeamFilter: &team2.ID, MacOSSettingsFilter: fleet.OSSettingsVerifying}, 0) // wrong team // macos settings filter does not support "all teams" so teamIDFilterNil acts the same as teamIDFilterZero - listHostsInLabelCheckCount(t, db, userFilter, l1.ID, fleet.HostListOptions{TeamFilter: teamIDFilterZero, MacOSSettingsFilter: fleet.MacOSSettingsVerifying}, 0) // no team - listHostsInLabelCheckCount(t, db, userFilter, l1.ID, fleet.HostListOptions{TeamFilter: teamIDFilterNil, MacOSSettingsFilter: fleet.MacOSSettingsVerifying}, 0) // no team - listHostsInLabelCheckCount(t, db, userFilter, l1.ID, fleet.HostListOptions{MacOSSettingsFilter: fleet.MacOSSettingsVerifying}, 0) // no team + listHostsInLabelCheckCount(t, db, userFilter, l1.ID, fleet.HostListOptions{TeamFilter: teamIDFilterZero, MacOSSettingsFilter: fleet.OSSettingsVerifying}, 0) // no team + listHostsInLabelCheckCount(t, db, userFilter, l1.ID, fleet.HostListOptions{TeamFilter: teamIDFilterNil, MacOSSettingsFilter: fleet.OSSettingsVerifying}, 0) // no team + listHostsInLabelCheckCount(t, db, userFilter, l1.ID, fleet.HostListOptions{MacOSSettingsFilter: fleet.OSSettingsVerifying}, 0) // no team require.NoError(t, db.BulkUpsertMDMAppleHostProfiles(context.Background(), []*fleet.MDMAppleBulkUpsertHostProfilePayload{ { @@ -515,12 +517,12 @@ func testLabelsListHostsInLabelAndTeamFilter(deferred bool, t *testing.T, db *Da Checksum: []byte("csum"), }, })) - listHostsInLabelCheckCount(t, db, userFilter, l1.ID, fleet.HostListOptions{TeamFilter: &team1.ID, MacOSSettingsFilter: fleet.MacOSSettingsVerifying}, 1) // h1 - listHostsInLabelCheckCount(t, db, userFilter, l1.ID, fleet.HostListOptions{TeamFilter: &team2.ID, MacOSSettingsFilter: fleet.MacOSSettingsVerifying}, 0) // wrong team + listHostsInLabelCheckCount(t, db, userFilter, l1.ID, fleet.HostListOptions{TeamFilter: &team1.ID, MacOSSettingsFilter: fleet.OSSettingsVerifying}, 1) // h1 + listHostsInLabelCheckCount(t, db, userFilter, l1.ID, fleet.HostListOptions{TeamFilter: &team2.ID, MacOSSettingsFilter: fleet.OSSettingsVerifying}, 0) // wrong team // macos settings filter does not support "all teams" so both teamIDFilterNil acts the same as teamIDFilterZero - listHostsInLabelCheckCount(t, db, userFilter, l1.ID, fleet.HostListOptions{TeamFilter: teamIDFilterZero, MacOSSettingsFilter: fleet.MacOSSettingsVerifying}, 1) // h2 - listHostsInLabelCheckCount(t, db, userFilter, l1.ID, fleet.HostListOptions{TeamFilter: teamIDFilterNil, MacOSSettingsFilter: fleet.MacOSSettingsVerifying}, 1) // h2 - listHostsInLabelCheckCount(t, db, userFilter, l1.ID, fleet.HostListOptions{MacOSSettingsFilter: fleet.MacOSSettingsVerifying}, 1) // h2 + listHostsInLabelCheckCount(t, db, userFilter, l1.ID, fleet.HostListOptions{TeamFilter: teamIDFilterZero, MacOSSettingsFilter: fleet.OSSettingsVerifying}, 1) // h2 + listHostsInLabelCheckCount(t, db, userFilter, l1.ID, fleet.HostListOptions{TeamFilter: teamIDFilterNil, MacOSSettingsFilter: fleet.OSSettingsVerifying}, 1) // h2 + listHostsInLabelCheckCount(t, db, userFilter, l1.ID, fleet.HostListOptions{MacOSSettingsFilter: fleet.OSSettingsVerifying}, 1) // h2 } func testLabelsBuiltIn(t *testing.T, db *Datastore) { @@ -1329,3 +1331,97 @@ func testHostMemberOfAllLabels(t *testing.T, ds *Datastore) { }) } } + +func testLabelsListHostsInLabelOSSettings(t *testing.T, db *Datastore) { + h1, err := db.NewHost(context.Background(), &fleet.Host{ + DetailUpdatedAt: time.Now(), + LabelUpdatedAt: time.Now(), + PolicyUpdatedAt: time.Now(), + SeenTime: time.Now(), + OsqueryHostID: ptr.String("1"), + NodeKey: ptr.String("1"), + UUID: "1", + Hostname: "foo.local", + Platform: "windows", + }) + require.NoError(t, err) + + h2, err := db.NewHost(context.Background(), &fleet.Host{ + DetailUpdatedAt: time.Now(), + LabelUpdatedAt: time.Now(), + PolicyUpdatedAt: time.Now(), + SeenTime: time.Now(), + OsqueryHostID: ptr.String("2"), + NodeKey: ptr.String("2"), + UUID: "2", + Hostname: "bar.local", + Platform: "windows", + }) + require.NoError(t, err) + h3, err := db.NewHost(context.Background(), &fleet.Host{ + DetailUpdatedAt: time.Now(), + LabelUpdatedAt: time.Now(), + PolicyUpdatedAt: time.Now(), + SeenTime: time.Now(), + OsqueryHostID: ptr.String("3"), + NodeKey: ptr.String("3"), + UUID: "3", + Hostname: "baz.local", + Platform: "centos", + }) + require.NoError(t, err) + + l1 := &fleet.LabelSpec{ + ID: 1, + Name: "label foo", + Query: "query1", + } + err = db.ApplyLabelSpecs(context.Background(), []*fleet.LabelSpec{l1}) + require.Nil(t, err) + + filter := fleet.TeamFilter{User: test.UserAdmin} + // add all hosts to label + for _, h := range []*fleet.Host{h1, h2, h3} { + require.NoError(t, db.RecordLabelQueryExecutions(context.Background(), h, map[uint]*bool{l1.ID: ptr.Bool(true)}, time.Now(), false)) + } + + // turn on disk encryption + ac, err := db.AppConfig(context.Background()) + require.NoError(t, err) + ac.MDM.EnableDiskEncryption = optjson.SetBool(true) + require.NoError(t, db.SaveAppConfig(context.Background(), ac)) + + // add two hosts to MDM to enforce disk encryption, fleet doesn't enforce settings on centos so h3 is not included + for _, h := range []*fleet.Host{h1, h2} { + require.NoError(t, db.SetOrUpdateMDMData(context.Background(), h.ID, false, true, "https://example.com", false, fleet.WellKnownMDMFleet)) + } + // add disk encryption key for h1 + require.NoError(t, db.SetOrUpdateHostDiskEncryptionKey(context.Background(), h1.ID, "test-key", "", ptr.Bool(true))) + // add disk encryption for h1 + require.NoError(t, db.SetOrUpdateHostDisksEncryption(context.Background(), h1.ID, true)) + + checkHosts := func(t *testing.T, gotHosts []*fleet.Host, expectedIDs []uint) { + require.Len(t, gotHosts, len(expectedIDs)) + for _, h := range gotHosts { + require.Contains(t, expectedIDs, h.ID) + } + } + + // baseline no filter + hosts := listHostsInLabelCheckCount(t, db, filter, l1.ID, fleet.HostListOptions{}, 3) + checkHosts(t, hosts, []uint{h1.ID, h2.ID, h3.ID}) + + t.Run("os_settings", func(t *testing.T) { + hosts = listHostsInLabelCheckCount(t, db, filter, l1.ID, fleet.HostListOptions{OSSettingsDiskEncryptionFilter: fleet.DiskEncryptionVerified}, 1) + checkHosts(t, hosts, []uint{h1.ID}) + hosts = listHostsInLabelCheckCount(t, db, filter, l1.ID, fleet.HostListOptions{OSSettingsDiskEncryptionFilter: fleet.DiskEncryptionEnforcing}, 1) + checkHosts(t, hosts, []uint{h2.ID}) + }) + + t.Run("os_settings_disk_encryption", func(t *testing.T) { + hosts = listHostsInLabelCheckCount(t, db, filter, l1.ID, fleet.HostListOptions{OSSettingsFilter: fleet.OSSettingsVerified}, 1) + checkHosts(t, hosts, []uint{h1.ID}) + hosts = listHostsInLabelCheckCount(t, db, filter, l1.ID, fleet.HostListOptions{OSSettingsFilter: fleet.OSSettingsPending}, 1) + checkHosts(t, hosts, []uint{h2.ID}) + }) +} diff --git a/server/datastore/mysql/microsoft_mdm.go b/server/datastore/mysql/microsoft_mdm.go index 863f9c290f..7dd3a696fc 100644 --- a/server/datastore/mysql/microsoft_mdm.go +++ b/server/datastore/mysql/microsoft_mdm.go @@ -3,13 +3,16 @@ package mysql import ( "context" "database/sql" + "errors" + "fmt" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/go-kit/kit/log/level" "github.com/jmoiron/sqlx" ) -// MDMWindowsGetEnrolledDevice receives a Windows MDM device id and returns the device information. +// MDMWindowsGetEnrolledDevice receives a Windows MDM HW Device id and returns the device information. func (ds *Datastore) MDMWindowsGetEnrolledDevice(ctx context.Context, mdmDeviceHWID string) (*fleet.MDMWindowsEnrolledDevice, error) { stmt := `SELECT mdm_device_id, @@ -36,6 +39,33 @@ func (ds *Datastore) MDMWindowsGetEnrolledDevice(ctx context.Context, mdmDeviceH return &winMDMDevice, nil } +// MDMWindowsGetEnrolledDeviceWithDeviceID receives a Windows MDM device id and returns the device information. +func (ds *Datastore) MDMWindowsGetEnrolledDeviceWithDeviceID(ctx context.Context, mdmDeviceID string) (*fleet.MDMWindowsEnrolledDevice, error) { + stmt := `SELECT + mdm_device_id, + mdm_hardware_id, + device_state, + device_type, + device_name, + enroll_type, + enroll_user_id, + enroll_proto_version, + enroll_client_version, + not_in_oobe, + created_at, + updated_at + FROM mdm_windows_enrollments WHERE mdm_device_id = ?` + + var winMDMDevice fleet.MDMWindowsEnrolledDevice + if err := sqlx.GetContext(ctx, ds.reader(ctx), &winMDMDevice, stmt, mdmDeviceID); err != nil { + if err == sql.ErrNoRows { + return nil, ctxerr.Wrap(ctx, notFound("MDMWindowsGetEnrolledDeviceWithDeviceID").WithMessage(mdmDeviceID)) + } + return nil, ctxerr.Wrap(ctx, err, "get MDMWindowsGetEnrolledDeviceWithDeviceID") + } + return &winMDMDevice, nil +} + // MDMWindowsInsertEnrolledDevice inserts a new MDMWindowsEnrolledDevice in the database func (ds *Datastore) MDMWindowsInsertEnrolledDevice(ctx context.Context, device *fleet.MDMWindowsEnrolledDevice) error { stmt := ` @@ -74,7 +104,8 @@ func (ds *Datastore) MDMWindowsInsertEnrolledDevice(ctx context.Context, device return nil } -// MDMWindowsDeleteEnrolledDevice deletes a give MDMWindowsEnrolledDevice entry from the database using the device id. +// MDMWindowsDeleteEnrolledDevice deletes a give MDMWindowsEnrolledDevice entry from the database +// using the HW Device ID. func (ds *Datastore) MDMWindowsDeleteEnrolledDevice(ctx context.Context, mdmDeviceHWID string) error { stmt := "DELETE FROM mdm_windows_enrollments WHERE mdm_hardware_id = ?" @@ -90,3 +121,202 @@ func (ds *Datastore) MDMWindowsDeleteEnrolledDevice(ctx context.Context, mdmDevi return ctxerr.Wrap(ctx, notFound("MDMWindowsEnrolledDevice")) } + +// MDMWindowsDeleteEnrolledDeviceWithDeviceID deletes a give MDMWindowsEnrolledDevice entry from the database using the device id. +func (ds *Datastore) MDMWindowsDeleteEnrolledDeviceWithDeviceID(ctx context.Context, mdmDeviceID string) error { + stmt := "DELETE FROM mdm_windows_enrollments WHERE mdm_device_id = ?" + + res, err := ds.writer(ctx).ExecContext(ctx, stmt, mdmDeviceID) + if err != nil { + return ctxerr.Wrap(ctx, err, "delete MDMWindowsDeleteEnrolledDeviceWithDeviceID") + } + + deleted, _ := res.RowsAffected() + if deleted == 1 { + return nil + } + + return ctxerr.Wrap(ctx, notFound("MDMWindowsDeleteEnrolledDeviceWithDeviceID")) +} + +// whereBitLockerStatus returns a string suitable for inclusion within a SQL WHERE clause to filter by +// the given status. The caller is responsible for ensuring the status is valid. In the case of an invalid +// status, the function will return the string "FALSE". The caller should also ensure that the query in +// which this is used joins the following tables with the specified aliases: +// - host_disk_encryption_keys: hdek +// - host_mdm: hmdm +// - host_disks: hd +func (ds *Datastore) whereBitLockerStatus(status fleet.DiskEncryptionStatus) string { + const ( + whereNotServer = `(hmdm.is_server IS NOT NULL AND hmdm.is_server = 0)` + whereKeyAvailable = `(hdek.base64_encrypted IS NOT NULL AND hdek.base64_encrypted != '' AND hdek.decryptable IS NOT NULL AND hdek.decryptable = 1)` + whereEncrypted = `(hd.encrypted IS NOT NULL AND hd.encrypted = 1)` + whereHostDisksUpdated = `(hd.updated_at IS NOT NULL AND hdek.updated_at IS NOT NULL AND hd.updated_at >= hdek.updated_at)` + whereClientError = `(hdek.client_error IS NOT NULL AND hdek.client_error != '')` + withinGracePeriod = `(hdek.updated_at IS NOT NULL AND hdek.updated_at >= DATE_SUB(NOW(), INTERVAL 1 HOUR))` + ) + + // TODO: what if windows sends us a key for an already encrypted volumne? could it get stuck + // in pending or verifying? should we modify SetOrUpdateHostDiskEncryption to ensure that we + // increment the updated_at timestamp on the host_disks table for all encrypted volumes + // host_disks if the hdek timestamp is newer? What about SetOrUpdateHostDiskEncryptionKey? + + switch status { + case fleet.DiskEncryptionVerified: + return whereNotServer + ` +AND NOT ` + whereClientError + ` +AND ` + whereKeyAvailable + ` +AND ` + whereEncrypted + ` +AND ` + whereHostDisksUpdated + + case fleet.DiskEncryptionVerifying: + // Possible verifying scenarios: + // - we have the key and host_disks already encrypted before the key but hasn't been updated yet + // - we have the key and host_disks reported unencrypted during the 1-hour grace period after key was updated + return whereNotServer + ` +AND NOT ` + whereClientError + ` +AND ` + whereKeyAvailable + ` +AND ( + (` + whereEncrypted + ` AND NOT ` + whereHostDisksUpdated + `) + OR (NOT ` + whereEncrypted + ` AND ` + whereHostDisksUpdated + ` AND ` + withinGracePeriod + `) +)` + + case fleet.DiskEncryptionEnforcing: + // Possible enforcing scenarios: + // - we don't have the key + // - we have the key and host_disks reported unencrypted before the key was updated or outside the 1-hour grace period after key was updated + return whereNotServer + ` +AND NOT ` + whereClientError + ` +AND ( + NOT ` + whereKeyAvailable + ` + OR (` + whereKeyAvailable + ` + AND (NOT ` + whereEncrypted + ` + AND (NOT ` + whereHostDisksUpdated + ` OR NOT ` + withinGracePeriod + `) + ) + ) +)` + + case fleet.DiskEncryptionFailed: + return whereNotServer + ` AND ` + whereClientError + + default: + level.Debug(ds.logger).Log("msg", "unknown bitlocker status", "status", status) + return "FALSE" + } +} + +func (ds *Datastore) GetMDMWindowsBitLockerSummary(ctx context.Context, teamID *uint) (*fleet.MDMWindowsBitLockerSummary, error) { + enabled, err := ds.getConfigEnableDiskEncryption(ctx, teamID) + if err != nil { + return nil, err + } + if !enabled { + return &fleet.MDMWindowsBitLockerSummary{}, nil + } + + // Note action_required and removing_enforcement are not applicable to Windows hosts + sqlFmt := ` +SELECT + COUNT(if((%s), 1, NULL)) AS verified, + COUNT(if((%s), 1, NULL)) AS verifying, + 0 AS action_required, + COUNT(if((%s), 1, NULL)) AS enforcing, + COUNT(if((%s), 1, NULL)) AS failed, + 0 AS removing_enforcement +FROM + hosts h + LEFT JOIN host_disk_encryption_keys hdek ON h.id = hdek.host_id + LEFT JOIN host_mdm hmdm ON h.id = hmdm.host_id + LEFT JOIN host_disks hd ON h.id = hd.host_id +WHERE + h.platform = 'windows' AND hmdm.is_server = 0 AND %s` + + var args []interface{} + teamFilter := "h.team_id IS NULL" + if teamID != nil && *teamID > 0 { + teamFilter = "h.team_id = ?" + args = append(args, *teamID) + } + + var res fleet.MDMWindowsBitLockerSummary + stmt := fmt.Sprintf( + sqlFmt, + ds.whereBitLockerStatus(fleet.DiskEncryptionVerified), + ds.whereBitLockerStatus(fleet.DiskEncryptionVerifying), + ds.whereBitLockerStatus(fleet.DiskEncryptionEnforcing), + ds.whereBitLockerStatus(fleet.DiskEncryptionFailed), + teamFilter, + ) + if err := sqlx.GetContext(ctx, ds.reader(ctx), &res, stmt, args...); err != nil { + return nil, err + } + + return &res, nil +} + +func (ds *Datastore) GetMDMWindowsBitLockerStatus(ctx context.Context, host *fleet.Host) (*fleet.DiskEncryptionStatus, error) { + if host == nil { + return nil, errors.New("host cannot be nil") + } + + if host.Platform != "windows" { + // Generally, the caller should have already checked this, but just in case we log and + // return nil + level.Debug(ds.logger).Log("msg", "cannot get bitlocker status for non-windows host", "host_id", host.ID) + return nil, nil + } + + if host.MDMInfo != nil && host.MDMInfo.IsServer { + // It is currently expected that server hosts do not have a bitlocker status so we can skip + // the query and return nil. We log for potential debugging in case this changes in the future. + level.Debug(ds.logger).Log("msg", "no bitlocker status for server host", "host_id", host.ID) + return nil, nil + } + + enabled, err := ds.getConfigEnableDiskEncryption(ctx, host.TeamID) + if err != nil { + return nil, err + } + if !enabled { + return nil, nil + } + + // Note action_required and removing_enforcement are not applicable to Windows hosts + stmt := fmt.Sprintf(` +SELECT + CASE + WHEN (%s) THEN '%s' + WHEN (%s) THEN '%s' + WHEN (%s) THEN '%s' + WHEN (%s) THEN '%s' + END AS status +FROM + host_mdm hmdm + LEFT JOIN host_disk_encryption_keys hdek ON hmdm.host_id = hdek.host_id + LEFT JOIN host_disks hd ON hmdm.host_id = hd.host_id +WHERE + hmdm.host_id = ?`, + ds.whereBitLockerStatus(fleet.DiskEncryptionVerified), + fleet.DiskEncryptionVerified, + ds.whereBitLockerStatus(fleet.DiskEncryptionVerifying), + fleet.DiskEncryptionVerifying, + ds.whereBitLockerStatus(fleet.DiskEncryptionEnforcing), + fleet.DiskEncryptionEnforcing, + ds.whereBitLockerStatus(fleet.DiskEncryptionFailed), + fleet.DiskEncryptionFailed, + ) + + var des fleet.DiskEncryptionStatus + if err := sqlx.GetContext(ctx, ds.reader(ctx), &des, stmt, host.ID); err != nil { + if err == sql.ErrNoRows { + // At this point we know disk encryption is enabled so if we don't have a record for the + // host then we treat it as enforcing and log for potential debugging + level.Debug(ds.logger).Log("msg", "no bitlocker status found for host", "host_id", host.ID) + des = fleet.DiskEncryptionEnforcing + return &des, nil + } + return nil, err + } + + return &des, nil +} diff --git a/server/datastore/mysql/microsoft_mdm_test.go b/server/datastore/mysql/microsoft_mdm_test.go index 00e2a15265..e1fe97b2c3 100644 --- a/server/datastore/mysql/microsoft_mdm_test.go +++ b/server/datastore/mysql/microsoft_mdm_test.go @@ -3,9 +3,15 @@ package mysql import ( "context" // nolint:gosec // used only to hash for efficient comparisons "testing" + "time" + "github.com/fleetdm/fleet/v4/pkg/optjson" "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig" + "github.com/fleetdm/fleet/v4/server/ptr" + "github.com/fleetdm/fleet/v4/server/test" "github.com/google/uuid" + "github.com/jmoiron/sqlx" "github.com/stretchr/testify/require" ) @@ -66,4 +72,387 @@ func testMDMWindowsEnrolledDevice(t *testing.T, ds *Datastore) { err = ds.MDMWindowsDeleteEnrolledDevice(ctx, enrolledDevice.MDMHardwareID) require.ErrorAs(t, err, &nfe) + + // Test using device ID instead of hardware ID + err = ds.MDMWindowsInsertEnrolledDevice(ctx, enrolledDevice) + require.NoError(t, err) + + err = ds.MDMWindowsInsertEnrolledDevice(ctx, enrolledDevice) + require.ErrorAs(t, err, &ae) + + gotEnrolledDevice, err = ds.MDMWindowsGetEnrolledDeviceWithDeviceID(ctx, enrolledDevice.MDMDeviceID) + require.NoError(t, err) + require.NotZero(t, gotEnrolledDevice.CreatedAt) + require.Equal(t, enrolledDevice.MDMDeviceID, gotEnrolledDevice.MDMDeviceID) + require.Equal(t, enrolledDevice.MDMHardwareID, gotEnrolledDevice.MDMHardwareID) + + err = ds.MDMWindowsDeleteEnrolledDeviceWithDeviceID(ctx, enrolledDevice.MDMDeviceID) + require.NoError(t, err) + + _, err = ds.MDMWindowsGetEnrolledDeviceWithDeviceID(ctx, enrolledDevice.MDMDeviceID) + require.ErrorAs(t, err, &nfe) + + err = ds.MDMWindowsDeleteEnrolledDevice(ctx, enrolledDevice.MDMHardwareID) + require.ErrorAs(t, err, &nfe) +} + +func TestMDMWindowsDiskEncryption(t *testing.T) { + ds := CreateMySQLDS(t) + ctx := context.Background() + + checkBitLockerSummary := func(t *testing.T, teamID *uint, expected fleet.MDMWindowsBitLockerSummary) { + bls, err := ds.GetMDMWindowsBitLockerSummary(ctx, teamID) + require.NoError(t, err) + require.NotNil(t, bls) + require.Equal(t, expected, *bls) + } + + checkListHostsFilterOSSettings := func(t *testing.T, teamID *uint, status fleet.OSSettingsStatus, expectedIDs []uint) { + gotHosts, err := ds.ListHosts(ctx, fleet.TeamFilter{User: test.UserAdmin}, fleet.HostListOptions{TeamFilter: teamID, OSSettingsFilter: status}) + require.NoError(t, err) + require.Len(t, gotHosts, len(expectedIDs)) + for _, h := range gotHosts { + require.Contains(t, expectedIDs, h.ID) + } + } + + checkListHostsFilterDiskEncryption := func(t *testing.T, teamID *uint, status fleet.DiskEncryptionStatus, expectedIDs []uint) { + gotHosts, err := ds.ListHosts(ctx, fleet.TeamFilter{User: test.UserAdmin}, fleet.HostListOptions{TeamFilter: teamID, OSSettingsDiskEncryptionFilter: status}) + require.NoError(t, err) + require.Len(t, gotHosts, len(expectedIDs), "status: %s", status) + for _, h := range gotHosts { + require.Contains(t, expectedIDs, h.ID) + } + } + + checkHostBitLockerStatus := func(t *testing.T, expected fleet.DiskEncryptionStatus, hostIDs []uint) { + for _, id := range hostIDs { + h, err := ds.Host(ctx, id) + require.NoError(t, err) + require.NotNil(t, h) + bls, err := ds.GetMDMWindowsBitLockerStatus(ctx, h) + require.NoError(t, err) + require.NotNil(t, bls) + require.Equal(t, expected, *bls) + } + } + + type hostIDsByStatus map[fleet.DiskEncryptionStatus][]uint + + checkExpected := func(t *testing.T, teamID *uint, expected hostIDsByStatus) { + for _, status := range []fleet.DiskEncryptionStatus{ + fleet.DiskEncryptionVerified, + fleet.DiskEncryptionVerifying, + fleet.DiskEncryptionFailed, + fleet.DiskEncryptionEnforcing, + fleet.DiskEncryptionRemovingEnforcement, + fleet.DiskEncryptionActionRequired, + } { + hostIDs, ok := expected[status] + if !ok { + hostIDs = []uint{} + } + checkListHostsFilterDiskEncryption(t, teamID, status, hostIDs) + checkHostBitLockerStatus(t, status, hostIDs) + } + + checkBitLockerSummary(t, teamID, fleet.MDMWindowsBitLockerSummary{ + Verified: uint(len(expected[fleet.DiskEncryptionVerified])), + Verifying: uint(len(expected[fleet.DiskEncryptionVerifying])), + Failed: uint(len(expected[fleet.DiskEncryptionFailed])), + Enforcing: uint(len(expected[fleet.DiskEncryptionEnforcing])), + RemovingEnforcement: uint(len(expected[fleet.DiskEncryptionRemovingEnforcement])), + ActionRequired: uint(len(expected[fleet.DiskEncryptionActionRequired])), + }) + + checkListHostsFilterOSSettings(t, teamID, fleet.OSSettingsVerified, expected[fleet.DiskEncryptionVerified]) + checkListHostsFilterOSSettings(t, teamID, fleet.OSSettingsVerifying, expected[fleet.DiskEncryptionVerifying]) + checkListHostsFilterOSSettings(t, teamID, fleet.OSSettingsFailed, expected[fleet.DiskEncryptionFailed]) + var expectedPending []uint + expectedPending = append(expectedPending, expected[fleet.DiskEncryptionEnforcing]...) + expectedPending = append(expectedPending, expected[fleet.DiskEncryptionRemovingEnforcement]...) + expectedPending = append(expectedPending, expected[fleet.DiskEncryptionActionRequired]...) + checkListHostsFilterOSSettings(t, teamID, fleet.OSSettingsPending, expectedPending) + } + + updateHostDisks := func(t *testing.T, hostID uint, encrypted bool, updated_at time.Time) { + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + stmt := `UPDATE host_disks SET encrypted = ?, updated_at = ? where host_id = ?` + _, err := q.ExecContext(ctx, stmt, encrypted, updated_at, hostID) + return err + }) + } + + setKeyUpdatedAt := func(t *testing.T, hostID uint, keyUpdatedAt time.Time) { + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + stmt := `UPDATE host_disk_encryption_keys SET updated_at = ? where host_id = ?` + _, err := q.ExecContext(ctx, stmt, keyUpdatedAt, hostID) + return err + }) + } + + // Create some hosts + var hosts []*fleet.Host + for i := 0; i < 10; i++ { + p := "windows" + if i >= 5 { + p = "darwin" + } + u := uuid.New().String() + h, err := ds.NewHost(ctx, &fleet.Host{ + DetailUpdatedAt: time.Now(), + LabelUpdatedAt: time.Now(), + PolicyUpdatedAt: time.Now(), + SeenTime: time.Now(), + NodeKey: &u, + UUID: u, + Hostname: u, + Platform: p, + }) + require.NoError(t, err) + require.NotNil(t, h) + hosts = append(hosts, h) + + require.NoError(t, ds.SetOrUpdateMDMData(ctx, h.ID, false, true, "https://example.com", false, fleet.WellKnownMDMFleet)) + } + + t.Run("Disk encryption disabled", func(t *testing.T) { + ac, err := ds.AppConfig(ctx) + require.NoError(t, err) + require.False(t, ac.MDM.EnableDiskEncryption.Value) + + checkExpected(t, nil, hostIDsByStatus{}) // no hosts are counted because disk encryption is not enabled + }) + + t.Run("Disk encryption enabled", func(t *testing.T) { + ac, err := ds.AppConfig(ctx) + require.NoError(t, err) + ac.MDM.EnableDiskEncryption = optjson.SetBool(true) + require.NoError(t, ds.SaveAppConfig(ctx, ac)) + ac, err = ds.AppConfig(ctx) + require.NoError(t, err) + require.True(t, ac.MDM.EnableDiskEncryption.Value) + + t.Run("Bitlocker enforcing status", func(t *testing.T) { + // all windows hosts are counted as enforcing because they have not reported any disk encryption status yet + checkExpected(t, nil, hostIDsByStatus{ + fleet.DiskEncryptionEnforcing: []uint{hosts[0].ID, hosts[1].ID, hosts[2].ID, hosts[3].ID, hosts[4].ID}, + }) + + require.NoError(t, ds.SetOrUpdateHostDiskEncryptionKey(ctx, hosts[0].ID, "test-key", "", ptr.Bool(true))) + checkExpected(t, nil, hostIDsByStatus{ + // status is still pending because hosts_disks hasn't been updated yet + fleet.DiskEncryptionEnforcing: []uint{hosts[0].ID, hosts[1].ID, hosts[2].ID, hosts[3].ID, hosts[4].ID}, + }) + + require.NoError(t, ds.SetOrUpdateHostDisksEncryption(ctx, hosts[0].ID, true)) + checkExpected(t, nil, hostIDsByStatus{ + fleet.DiskEncryptionVerified: []uint{hosts[0].ID}, + fleet.DiskEncryptionEnforcing: []uint{hosts[1].ID, hosts[2].ID, hosts[3].ID, hosts[4].ID}, + }) + + cases := []struct { + name string + hostDisksEncrypted bool + reportedAfterKey bool + expectedWithinGracePeriod fleet.DiskEncryptionStatus + expectedOutsideGracePeriod fleet.DiskEncryptionStatus + }{ + { + name: "encrypted reported after key", + hostDisksEncrypted: true, + reportedAfterKey: true, + expectedWithinGracePeriod: fleet.DiskEncryptionVerified, + expectedOutsideGracePeriod: fleet.DiskEncryptionVerified, + }, + { + name: "encrypted reported before key", + hostDisksEncrypted: true, + reportedAfterKey: false, + expectedWithinGracePeriod: fleet.DiskEncryptionVerifying, + expectedOutsideGracePeriod: fleet.DiskEncryptionVerifying, + }, + { + name: "not encrypted reported before key", + hostDisksEncrypted: false, + reportedAfterKey: false, + expectedWithinGracePeriod: fleet.DiskEncryptionEnforcing, + expectedOutsideGracePeriod: fleet.DiskEncryptionEnforcing, + }, + { + name: "not encrypted reported after key", + hostDisksEncrypted: false, + reportedAfterKey: true, + expectedWithinGracePeriod: fleet.DiskEncryptionVerifying, + expectedOutsideGracePeriod: fleet.DiskEncryptionEnforcing, + }, + } + + testHostID := hosts[0].ID + otherWindowsHostIDs := []uint{hosts[1].ID, hosts[2].ID, hosts[3].ID, hosts[4].ID} + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + var keyUpdatedAt, hostDisksUpdatedAt time.Time + + t.Run("within grace period", func(t *testing.T) { + expected := make(hostIDsByStatus) + if c.expectedWithinGracePeriod == fleet.DiskEncryptionEnforcing { + expected[fleet.DiskEncryptionEnforcing] = append([]uint{testHostID}, otherWindowsHostIDs...) + } else { + expected[c.expectedWithinGracePeriod] = []uint{testHostID} + expected[fleet.DiskEncryptionEnforcing] = otherWindowsHostIDs + } + + keyUpdatedAt = time.Now().Add(-10 * time.Minute) + setKeyUpdatedAt(t, testHostID, keyUpdatedAt) + + if c.reportedAfterKey { + hostDisksUpdatedAt = keyUpdatedAt.Add(5 * time.Minute) + } else { + hostDisksUpdatedAt = keyUpdatedAt.Add(-5 * time.Minute) + } + updateHostDisks(t, testHostID, c.hostDisksEncrypted, hostDisksUpdatedAt) + + checkExpected(t, nil, expected) + }) + + t.Run("outside grace period", func(t *testing.T) { + expected := make(hostIDsByStatus) + if c.expectedOutsideGracePeriod == fleet.DiskEncryptionEnforcing { + expected[fleet.DiskEncryptionEnforcing] = append([]uint{testHostID}, otherWindowsHostIDs...) + } else { + expected[c.expectedOutsideGracePeriod] = []uint{testHostID} + expected[fleet.DiskEncryptionEnforcing] = otherWindowsHostIDs + } + + keyUpdatedAt = time.Now().Add(-2 * time.Hour) + setKeyUpdatedAt(t, testHostID, keyUpdatedAt) + + if c.reportedAfterKey { + hostDisksUpdatedAt = keyUpdatedAt.Add(5 * time.Minute) + } else { + hostDisksUpdatedAt = keyUpdatedAt.Add(-5 * time.Minute) + } + updateHostDisks(t, testHostID, c.hostDisksEncrypted, hostDisksUpdatedAt) + + checkExpected(t, nil, expected) + }) + }) + } + }) + + // ensure hosts[0] is set to verified for the rest of the tests + require.NoError(t, ds.SetOrUpdateHostDiskEncryptionKey(ctx, hosts[0].ID, "test-key", "", ptr.Bool(true))) + require.NoError(t, ds.SetOrUpdateHostDisksEncryption(ctx, hosts[0].ID, true)) + checkExpected(t, nil, hostIDsByStatus{ + fleet.DiskEncryptionVerified: []uint{hosts[0].ID}, + fleet.DiskEncryptionEnforcing: []uint{hosts[1].ID, hosts[2].ID, hosts[3].ID, hosts[4].ID}, + }) + + t.Run("BitLocker failed status", func(t *testing.T) { + // TODO: Update test to use methods to set windows disk encryption when they are implemented + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + _, err := q.ExecContext(ctx, + `INSERT INTO host_disk_encryption_keys (host_id, decryptable, client_error) VALUES (?, ?, ?)`, + hosts[1].ID, + false, + "test-error") + return err + }) + + checkExpected(t, nil, hostIDsByStatus{ + fleet.DiskEncryptionVerified: []uint{hosts[0].ID}, + fleet.DiskEncryptionFailed: []uint{hosts[1].ID}, + fleet.DiskEncryptionEnforcing: []uint{hosts[2].ID, hosts[3].ID, hosts[4].ID}, + }) + }) + + t.Run("BitLocker team filtering", func(t *testing.T) { + // Test team filtering + team, err := ds.NewTeam(ctx, &fleet.Team{Name: "team"}) + require.NoError(t, err) + + tm, err := ds.Team(ctx, team.ID) + require.NoError(t, err) + require.NotNil(t, tm) + require.False(t, tm.Config.MDM.EnableDiskEncryption) // disk encryption is not enabled for team + + // Transfer hosts[2] to the team + require.NoError(t, ds.AddHostsToTeam(ctx, &team.ID, []uint{hosts[2].ID})) + + // Check the summary for the team + checkExpected(t, &team.ID, hostIDsByStatus{}) // disk encryption is not enabled for team so hosts[2] is not counted + + // Check the summary for no team + checkExpected(t, nil, hostIDsByStatus{ + fleet.DiskEncryptionVerified: []uint{hosts[0].ID}, + fleet.DiskEncryptionFailed: []uint{hosts[1].ID}, + fleet.DiskEncryptionEnforcing: []uint{hosts[3].ID, hosts[4].ID}, // hosts[2] is no longer included in the no team summary + }) + + // Enable disk encryption for the team + tm.Config.MDM.EnableDiskEncryption = true + tm, err = ds.SaveTeam(ctx, tm) + require.NoError(t, err) + require.NotNil(t, tm) + require.True(t, tm.Config.MDM.EnableDiskEncryption) + + // Check the summary for the team + checkExpected(t, &team.ID, hostIDsByStatus{ + fleet.DiskEncryptionEnforcing: []uint{hosts[2].ID}, // disk encryption is enabled for team so hosts[2] is counted + }) + + // Check the summary for no team (should be unchanged) + checkExpected(t, nil, hostIDsByStatus{ + fleet.DiskEncryptionVerified: []uint{hosts[0].ID}, + fleet.DiskEncryptionFailed: []uint{hosts[1].ID}, + fleet.DiskEncryptionEnforcing: []uint{hosts[3].ID, hosts[4].ID}, + }) + }) + + t.Run("BitLocker Windows server excluded", func(t *testing.T) { + require.NoError(t, ds.SetOrUpdateMDMData(ctx, + hosts[3].ID, + true, // set is_server to true for hosts[3] + true, "https://example.com", false, fleet.WellKnownMDMFleet)) + + // Check Windows servers not counted + checkExpected(t, nil, hostIDsByStatus{ + fleet.DiskEncryptionVerified: []uint{hosts[0].ID}, + fleet.DiskEncryptionFailed: []uint{hosts[1].ID}, + fleet.DiskEncryptionEnforcing: []uint{hosts[4].ID}, // hosts[3] is not counted + }) + }) + + t.Run("OS settings filters include Windows and macOS hosts", func(t *testing.T) { + // Make macOS host fail disk encryption + require.NoError(t, ds.BulkUpsertMDMAppleHostProfiles(ctx, []*fleet.MDMAppleBulkUpsertHostProfilePayload{ + { + HostUUID: hosts[5].UUID, + ProfileIdentifier: mobileconfig.FleetFileVaultPayloadIdentifier, + ProfileName: "Disk encryption", + ProfileID: 1, + CommandUUID: uuid.New().String(), + OperationType: fleet.MDMAppleOperationTypeInstall, + Status: &fleet.MDMAppleDeliveryFailed, + Checksum: []byte("checksum"), + }, + })) + + // Check that BitLocker summary does not include macOS hosts + checkBitLockerSummary(t, nil, fleet.MDMWindowsBitLockerSummary{ + Verified: 1, + Verifying: 0, + Failed: 1, + Enforcing: 1, + RemovingEnforcement: 0, + ActionRequired: 0, + }) + + // Check that filtered lists do include macOS hosts + checkListHostsFilterDiskEncryption(t, nil, fleet.DiskEncryptionFailed, []uint{hosts[1].ID, hosts[5].ID}) + checkListHostsFilterOSSettings(t, nil, fleet.OSSettingsFailed, []uint{hosts[1].ID, hosts[5].ID}) + }) + }) } diff --git a/server/datastore/mysql/migrations/tables/20230918221115_MoveDiskEncryptionSetting.go b/server/datastore/mysql/migrations/tables/20230918221115_MoveDiskEncryptionSetting.go new file mode 100644 index 0000000000..793432c209 --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20230918221115_MoveDiskEncryptionSetting.go @@ -0,0 +1,32 @@ +package tables + +import ( + "database/sql" + "fmt" +) + +func init() { + MigrationClient.AddMigration(Up_20230918221115, Down_20230918221115) +} + +func Up_20230918221115(tx *sql.Tx) error { + stmt := ` +UPDATE teams +SET + config = JSON_SET(config, '$.mdm.enable_disk_encryption', + JSON_EXTRACT(config, '$.mdm.macos_settings.enable_disk_encryption')), + config = JSON_REMOVE(config, '$.mdm.macos_settings.enable_disk_encryption') +WHERE + JSON_EXTRACT(config, '$.mdm.macos_settings.enable_disk_encryption') IS NOT NULL; + ` + + if _, err := tx.Exec(stmt); err != nil { + return fmt.Errorf("move team mdm.macos_settings.enable_disk_encryption setting to mdm.enable_disk_encryption: %w", err) + } + + return nil +} + +func Down_20230918221115(tx *sql.Tx) error { + return nil +} diff --git a/server/datastore/mysql/migrations/tables/20230918221115_MoveDiskEncryptionSetting_test.go b/server/datastore/mysql/migrations/tables/20230918221115_MoveDiskEncryptionSetting_test.go new file mode 100644 index 0000000000..90538968f8 --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20230918221115_MoveDiskEncryptionSetting_test.go @@ -0,0 +1,67 @@ +package tables + +import ( + "encoding/json" + "testing" + + "github.com/jmoiron/sqlx" + "github.com/stretchr/testify/require" +) + +func TestUp_20230918221115(t *testing.T) { + db := applyUpToPrev(t) + + dataStmts := ` + INSERT INTO teams VALUES + (1,'2023-07-21 20:32:42','Team 1','','{\"mdm\": {\"macos_setup\": {\"bootstrap_package\": null, \"macos_setup_assistant\": null, \"enable_end_user_authentication\": false}, \"macos_updates\": {\"deadline\": null, \"minimum_version\": null}, \"macos_settings\": {\"custom_settings\": null, \"enable_disk_encryption\": false}}, \"features\": {\"enable_host_users\": true, \"enable_software_inventory\": true}, \"integrations\": {\"jira\": null, \"zendesk\": null}, \"agent_options\": {\"config\": {\"options\": {\"pack_delimiter\": \"/\", \"logger_tls_period\": 10, \"distributed_plugin\": \"tls\", \"disable_distributed\": false, \"logger_tls_endpoint\": \"/api/osquery/log\", \"distributed_interval\": 10, \"distributed_tls_max_attempts\": 3}, \"decorators\": {\"load\": [\"SELECT uuid AS host_uuid FROM system_info;\", \"SELECT hostname AS hostname FROM system_info;\"]}}, \"overrides\": {}}, \"webhook_settings\": {\"failing_policies_webhook\": {\"policy_ids\": null, \"destination_url\": \"\", \"host_batch_size\": 0, \"enable_failing_policies_webhook\": false}}}'), + (2,'2023-07-21 20:32:47','Team 2','','{\"mdm\": {\"macos_setup\": {\"bootstrap_package\": null, \"macos_setup_assistant\": null, \"enable_end_user_authentication\": false}, \"macos_updates\": {\"deadline\": null, \"minimum_version\": null}, \"macos_settings\": {\"custom_settings\": null, \"enable_disk_encryption\": true}}, \"features\": {\"enable_host_users\": true, \"enable_software_inventory\": true}, \"integrations\": {\"jira\": null, \"zendesk\": null}, \"agent_options\": {\"config\": {\"options\": {\"pack_delimiter\": \"/\", \"logger_tls_period\": 10, \"distributed_plugin\": \"tls\", \"disable_distributed\": false, \"logger_tls_endpoint\": \"/api/osquery/log\", \"distributed_interval\": 10, \"distributed_tls_max_attempts\": 3}, \"decorators\": {\"load\": [\"SELECT uuid AS host_uuid FROM system_info;\", \"SELECT hostname AS hostname FROM system_info;\"]}}, \"overrides\": {}}, \"webhook_settings\": {\"failing_policies_webhook\": {\"policy_ids\": null, \"destination_url\": \"\", \"host_batch_size\": 0, \"enable_failing_policies_webhook\": false}}}'); + ` + _, err := db.Exec(dataStmts) + require.NoError(t, err) + + var rawConfigs []json.RawMessage + err = sqlx.Select(db, &rawConfigs, "SELECT config FROM teams ORDER BY id") + require.NoError(t, err) + + var wantConfigs []map[string]any + for _, c := range rawConfigs { + var wantConfig map[string]any + err = json.Unmarshal(c, &wantConfig) + require.NoError(t, err) + wantConfigs = append(wantConfigs, wantConfig) + } + + applyNext(t, db) + + rawConfigs = []json.RawMessage{} + err = sqlx.Select(db, &rawConfigs, "SELECT JSON_EXTRACT(config, '$') FROM teams ORDER BY id") + require.NoError(t, err) + + var gotConfigs []map[string]any + for _, c := range rawConfigs { + var gotConfig map[string]any + err = json.Unmarshal(c, &gotConfig) + require.NoError(t, err) + gotConfigs = append(gotConfigs, gotConfig) + } + + // simulate the ideal behavior with the oldConfigs + for i, config := range wantConfigs { + if mdmMap, ok := config["mdm"].(map[string]interface{}); ok { + // Delete 'mdm.macos_settings.enable_disk_encryption' + if macosSettings, ok := mdmMap["macos_settings"].(map[string]interface{}); ok { + delete(macosSettings, "enable_disk_encryption") + } + + // Set 'mdm.enable_disk_encryption' + if i == 0 { + mdmMap["enable_disk_encryption"] = false + } else { + mdmMap["enable_disk_encryption"] = true + } + } + wantConfigs[i] = config + } + + require.ElementsMatch(t, wantConfigs, gotConfigs) +} diff --git a/server/datastore/mysql/schema.sql b/server/datastore/mysql/schema.sql index 93c1e2703d..932d1d9a28 100644 --- a/server/datastore/mysql/schema.sql +++ b/server/datastore/mysql/schema.sql @@ -40,7 +40,7 @@ CREATE TABLE `app_config_json` ( UNIQUE KEY `id` (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; -INSERT INTO `app_config_json` VALUES (1,'{\"mdm\": {\"macos_setup\": {\"bootstrap_package\": null, \"macos_setup_assistant\": null, \"enable_end_user_authentication\": false}, \"macos_updates\": {\"deadline\": null, \"minimum_version\": null}, \"macos_settings\": {\"custom_settings\": null, \"enable_disk_encryption\": false}, \"macos_migration\": {\"mode\": \"\", \"enable\": false, \"webhook_url\": \"\"}, \"apple_bm_default_team\": \"\", \"apple_bm_terms_expired\": false, \"enabled_and_configured\": false, \"end_user_authentication\": {\"idp_name\": \"\", \"metadata\": \"\", \"entity_id\": \"\", \"issuer_uri\": \"\", \"metadata_url\": \"\"}, \"windows_enabled_and_configured\": false, \"apple_bm_enabled_and_configured\": false}, \"features\": {\"enable_host_users\": true, \"enable_software_inventory\": false}, \"org_info\": {\"org_name\": \"\", \"contact_url\": \"\", \"org_logo_url\": \"\", \"org_logo_url_light_background\": \"\"}, \"integrations\": {\"jira\": null, \"zendesk\": null}, \"sso_settings\": {\"idp_name\": \"\", \"metadata\": \"\", \"entity_id\": \"\", \"enable_sso\": false, \"issuer_uri\": \"\", \"metadata_url\": \"\", \"idp_image_url\": \"\", \"enable_jit_role_sync\": false, \"enable_sso_idp_login\": false, \"enable_jit_provisioning\": false}, \"agent_options\": {\"config\": {\"options\": {\"logger_plugin\": \"tls\", \"pack_delimiter\": \"/\", \"logger_tls_period\": 10, \"distributed_plugin\": \"tls\", \"disable_distributed\": false, \"logger_tls_endpoint\": \"/api/osquery/log\", \"distributed_interval\": 10, \"distributed_tls_max_attempts\": 3}, \"decorators\": {\"load\": [\"SELECT uuid AS host_uuid FROM system_info;\", \"SELECT hostname AS hostname FROM system_info;\"]}}, \"overrides\": {}}, \"fleet_desktop\": {\"transparency_url\": \"\"}, \"smtp_settings\": {\"port\": 587, \"domain\": \"\", \"server\": \"\", \"password\": \"\", \"user_name\": \"\", \"configured\": false, \"enable_smtp\": false, \"enable_ssl_tls\": true, \"sender_address\": \"\", \"enable_start_tls\": true, \"verify_ssl_certs\": true, \"authentication_type\": \"0\", \"authentication_method\": \"0\"}, \"server_settings\": {\"server_url\": \"\", \"enable_analytics\": false, \"deferred_save_host\": false, \"live_query_disabled\": false}, \"webhook_settings\": {\"interval\": \"0s\", \"host_status_webhook\": {\"days_count\": 0, \"destination_url\": \"\", \"host_percentage\": 0, \"enable_host_status_webhook\": false}, \"vulnerabilities_webhook\": {\"destination_url\": \"\", \"host_batch_size\": 0, \"enable_vulnerabilities_webhook\": false}, \"failing_policies_webhook\": {\"policy_ids\": null, \"destination_url\": \"\", \"host_batch_size\": 0, \"enable_failing_policies_webhook\": false}}, \"host_expiry_settings\": {\"host_expiry_window\": 0, \"host_expiry_enabled\": false}, \"vulnerability_settings\": {\"databases_path\": \"\"}}','2020-01-01 01:01:01','2020-01-01 01:01:01'); +INSERT INTO `app_config_json` VALUES (1,'{\"mdm\": {\"macos_setup\": {\"bootstrap_package\": null, \"macos_setup_assistant\": null, \"enable_end_user_authentication\": false}, \"macos_updates\": {\"deadline\": null, \"minimum_version\": null}, \"macos_settings\": {\"custom_settings\": null}, \"macos_migration\": {\"mode\": \"\", \"enable\": false, \"webhook_url\": \"\"}, \"apple_bm_default_team\": \"\", \"apple_bm_terms_expired\": false, \"enable_disk_encryption\": false, \"enabled_and_configured\": false, \"end_user_authentication\": {\"idp_name\": \"\", \"metadata\": \"\", \"entity_id\": \"\", \"issuer_uri\": \"\", \"metadata_url\": \"\"}, \"windows_enabled_and_configured\": false, \"apple_bm_enabled_and_configured\": false}, \"features\": {\"enable_host_users\": true, \"enable_software_inventory\": false}, \"org_info\": {\"org_name\": \"\", \"contact_url\": \"\", \"org_logo_url\": \"\", \"org_logo_url_light_background\": \"\"}, \"integrations\": {\"jira\": null, \"zendesk\": null}, \"sso_settings\": {\"idp_name\": \"\", \"metadata\": \"\", \"entity_id\": \"\", \"enable_sso\": false, \"issuer_uri\": \"\", \"metadata_url\": \"\", \"idp_image_url\": \"\", \"enable_jit_role_sync\": false, \"enable_sso_idp_login\": false, \"enable_jit_provisioning\": false}, \"agent_options\": {\"config\": {\"options\": {\"logger_plugin\": \"tls\", \"pack_delimiter\": \"/\", \"logger_tls_period\": 10, \"distributed_plugin\": \"tls\", \"disable_distributed\": false, \"logger_tls_endpoint\": \"/api/osquery/log\", \"distributed_interval\": 10, \"distributed_tls_max_attempts\": 3}, \"decorators\": {\"load\": [\"SELECT uuid AS host_uuid FROM system_info;\", \"SELECT hostname AS hostname FROM system_info;\"]}}, \"overrides\": {}}, \"fleet_desktop\": {\"transparency_url\": \"\"}, \"smtp_settings\": {\"port\": 587, \"domain\": \"\", \"server\": \"\", \"password\": \"\", \"user_name\": \"\", \"configured\": false, \"enable_smtp\": false, \"enable_ssl_tls\": true, \"sender_address\": \"\", \"enable_start_tls\": true, \"verify_ssl_certs\": true, \"authentication_type\": \"0\", \"authentication_method\": \"0\"}, \"server_settings\": {\"server_url\": \"\", \"enable_analytics\": false, \"deferred_save_host\": false, \"live_query_disabled\": false}, \"webhook_settings\": {\"interval\": \"0s\", \"host_status_webhook\": {\"days_count\": 0, \"destination_url\": \"\", \"host_percentage\": 0, \"enable_host_status_webhook\": false}, \"vulnerabilities_webhook\": {\"destination_url\": \"\", \"host_batch_size\": 0, \"enable_vulnerabilities_webhook\": false}, \"failing_policies_webhook\": {\"policy_ids\": null, \"destination_url\": \"\", \"host_batch_size\": 0, \"enable_failing_policies_webhook\": false}}, \"host_expiry_settings\": {\"host_expiry_window\": 0, \"host_expiry_enabled\": false}, \"vulnerability_settings\": {\"databases_path\": \"\"}}','2020-01-01 01:01:01','2020-01-01 01:01:01'); /*!40101 SET @saved_cs_client = @@character_set_client */; /*!40101 SET character_set_client = utf8 */; CREATE TABLE `carve_blocks` ( @@ -685,9 +685,9 @@ CREATE TABLE `migration_status_tables` ( `tstamp` timestamp NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `id` (`id`) -) ENGINE=InnoDB AUTO_INCREMENT=209 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +) ENGINE=InnoDB AUTO_INCREMENT=210 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'); +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,20230918221115,1,'2020-01-01 01:01:01'); /*!40101 SET @saved_cs_client = @@character_set_client */; /*!40101 SET character_set_client = utf8 */; CREATE TABLE `mobile_device_management_solutions` ( diff --git a/server/fleet/app.go b/server/fleet/app.go index 6a77190739..17016a7e3e 100644 --- a/server/fleet/app.go +++ b/server/fleet/app.go @@ -13,7 +13,9 @@ import ( "time" "github.com/fleetdm/fleet/v4/pkg/optjson" + "github.com/fleetdm/fleet/v4/pkg/rawjson" "github.com/fleetdm/fleet/v4/server/config" + "github.com/fleetdm/fleet/v4/server/ptr" ) // SMTP settings names returned from API, these map to SMTPAuthType and @@ -157,12 +159,23 @@ type MDM struct { // with the similarly named macOS-specific fields. WindowsEnabledAndConfigured bool `json:"windows_enabled_and_configured"` + EnableDiskEncryption optjson.Bool `json:"enable_disk_encryption"` + ///////////////////////////////////////////////////////////////// // WARNING: If you add to this struct make sure it's taken into // account in the AppConfig Clone implementation! ///////////////////////////////////////////////////////////////// } +// AtLeastOnePlatformEnabledAndConfigured returns true if at least one supported platform +// (macOS or Windows) has MDM enabled and configured. +func (m MDM) AtLeastOnePlatformEnabledAndConfigured() bool { + // explicitly check for the feature flag to account for the edge case of: + // 1. FF enabled, windows is turned on + // 2. FF disabled on server restart + return m.EnabledAndConfigured || (config.IsMDMFeatureFlagEnabled() && m.WindowsEnabledAndConfigured) +} + // versionStringRegex is used to validate that a version string is in the x.y.z // format only (no prerelease or build metadata). var versionStringRegex = regexp.MustCompile(`^\d+(\.\d+)?(\.\d+)?$`) @@ -222,10 +235,8 @@ type MacOSSettings struct { // // NOTE: These are only present here for informational purposes. // (The source of truth for profiles is in MySQL.) - CustomSettings []string `json:"custom_settings"` - // EnableDiskEncryption enables disk encryption on hosts such that the hosts' - // disk encryption keys will be stored in Fleet. - EnableDiskEncryption bool `json:"enable_disk_encryption"` + CustomSettings []string `json:"custom_settings"` + DeprecatedEnableDiskEncryption *bool `json:"enable_disk_encryption,omitempty"` // NOTE: make sure to update the ToMap/FromMap methods when adding/updating fields. } @@ -233,7 +244,7 @@ type MacOSSettings struct { func (s MacOSSettings) ToMap() map[string]interface{} { return map[string]interface{}{ "custom_settings": s.CustomSettings, - "enable_disk_encryption": s.EnableDiskEncryption, + "enable_disk_encryption": s.DeprecatedEnableDiskEncryption, } } @@ -274,11 +285,11 @@ func (s *MacOSSettings) FromMap(m map[string]interface{}) (map[string]bool, erro // error, must be a bool return nil, &json.UnmarshalTypeError{ Value: fmt.Sprintf("%T", v), - Type: reflect.TypeOf(s.EnableDiskEncryption), + Type: reflect.TypeOf(s.DeprecatedEnableDiskEncryption).Elem(), Field: "macos_settings.enable_disk_encryption", } } - s.EnableDiskEncryption = b + s.DeprecatedEnableDiskEncryption = ptr.Bool(b) } return set, nil @@ -344,7 +355,8 @@ type AppConfig struct { SMTPSettings *SMTPSettings `json:"smtp_settings,omitempty"` HostExpirySettings HostExpirySettings `json:"host_expiry_settings"` // Features allows to globally enable or disable features - Features Features `json:"features"` + Features Features `json:"features"` + DeprecatedHostSettings *Features `json:"host_settings,omitempty"` // AgentOptions holds osquery configuration. // // This field is a pointer to avoid returning this information to non-global-admins. @@ -392,12 +404,6 @@ func (c *AppConfig) Obfuscate() { } } -// legacyConfig holds settings that have been replaced, superceded or -// deprecated by other AppConfig settings. -type legacyConfig struct { - HostSettings *Features `json:"host_settings"` -} - // Clone implements cloner. func (c *AppConfig) Clone() (interface{}, error) { return c.Copy(), nil @@ -509,6 +515,31 @@ func (e *EnrichedAppConfig) UnmarshalJSON(data []byte) error { return nil } +// MarshalJSON implements the json.Marshaler interface to make sure we serialize +// both AppConfig and enrichedAppConfigFields properly: +// +// - If this function is not defined, AppConfig.MarshalJSON gets promoted and +// will be called instead. +// - If we try to unmarshal everything in one go, AppConfig.MarshalJSON doesn't get +// called. +func (e *EnrichedAppConfig) MarshalJSON() ([]byte, error) { + // Marshal only the enriched fields + enrichedData, err := json.Marshal(e.enrichedAppConfigFields) + if err != nil { + return nil, err + } + + // Marshal the base AppConfig + appConfigData, err := json.Marshal(e.AppConfig) + if err != nil { + return nil, err + } + + // we need to marshal and combine both groups separately because + // AppConfig has a custom marshaler. + return rawjson.CombineRoots(enrichedData, appConfigData) +} + type Duration struct { time.Duration } @@ -628,16 +659,13 @@ func (c *AppConfig) DidUnmarshalLegacySettings() []string { return c.didUnmarsha func (c *AppConfig) UnmarshalJSON(b []byte) error { // Define a new type, this is to prevent infinite recursion when // unmarshalling the AppConfig struct. - type cfgStructUnmarshal AppConfig + type aliasConfig AppConfig compatConfig := struct { - *legacyConfig - *cfgStructUnmarshal + *aliasConfig }{ - &legacyConfig{}, - (*cfgStructUnmarshal)(c), + (*aliasConfig)(c), } - c.didUnmarshalLegacySettings = nil decoder := json.NewDecoder(bytes.NewReader(b)) if c.strictDecoding { decoder.DisallowUnknownFields() @@ -649,16 +677,56 @@ func (c *AppConfig) UnmarshalJSON(b []byte) error { return errors.New("unexpected extra tokens found in config") } + c.assignDeprecatedFields() + + return nil +} + +func (c AppConfig) MarshalJSON() ([]byte, error) { + // Define a new type, this is to prevent infinite recursion when + // marshalling the AppConfig struct. + c.assignDeprecatedFields() + + // requirements are that if this value is not set, defaults to false. + // The default mashaler of optjson.Bool will convert this to `null` if + // it's not valid. + if !c.MDM.EnableDiskEncryption.Valid { + c.MDM.EnableDiskEncryption = optjson.SetBool(false) + } + + type aliasConfig AppConfig + aa := aliasConfig(c) + return json.Marshal(aa) +} + +func (c *AppConfig) assignDeprecatedFields() { + c.didUnmarshalLegacySettings = nil // Define and assign legacy settings to new fields. // This has the drawback of legacy fields taking precedence over new fields // if both are defined. - if compatConfig.legacyConfig.HostSettings != nil { + // + // TODO: with optjson + the new approach we're using to handle legacy + // fields, legacy fields don't have to take precedence over new fields. + // Is it worth changing this behavior for `host_settings`/`features` at this point? + if c.DeprecatedHostSettings != nil { c.didUnmarshalLegacySettings = append(c.didUnmarshalLegacySettings, "host_settings") - c.Features = *compatConfig.legacyConfig.HostSettings + c.Features = *c.DeprecatedHostSettings } - sort.Strings(c.didUnmarshalLegacySettings) - return nil + // if disk encryption is not set in the root config + // try to read the value from the legacy config + if !c.MDM.EnableDiskEncryption.Valid { + if c.MDM.MacOSSettings.DeprecatedEnableDiskEncryption != nil { + c.didUnmarshalLegacySettings = append(c.didUnmarshalLegacySettings, "mdm.macos_settings.enable_disk_encryption") + c.MDM.EnableDiskEncryption = optjson.SetBool(*c.MDM.MacOSSettings.DeprecatedEnableDiskEncryption) + } + } + + // ensure the legacy configs are always nil + c.DeprecatedHostSettings = nil + c.MDM.MacOSSettings.DeprecatedEnableDiskEncryption = nil + + sort.Strings(c.didUnmarshalLegacySettings) } // OrgInfo contains general info about the organization using Fleet. diff --git a/server/fleet/app_test.go b/server/fleet/app_test.go index 268eb2277a..bc7a4173d3 100644 --- a/server/fleet/app_test.go +++ b/server/fleet/app_test.go @@ -1,6 +1,7 @@ package fleet import ( + "encoding/json" "testing" "github.com/fleetdm/fleet/v4/pkg/optjson" @@ -159,3 +160,154 @@ func TestMacOSMigrationModeIsValid(t *testing.T) { require.False(t, (MacOSMigrationMode("")).IsValid()) require.False(t, (MacOSMigrationMode("foo")).IsValid()) } + +func TestAppConfigDeprecatedFields(t *testing.T) { + cases := []struct { + msg string + in json.RawMessage + wantFeatures Features + wantDiskEncryption bool + }{ + {"both empty", json.RawMessage(`{}`), Features{}, false}, + {"only one feature set", json.RawMessage(`{"host_settings": {"enable_host_users": true}}`), Features{EnableHostUsers: true}, false}, + { + "a feature and disk encryption set", + json.RawMessage(`{"host_settings": {"enable_host_users": true}, "mdm": {"macos_settings": {"enable_disk_encryption": true}}}`), + Features{EnableHostUsers: true}, + true, + }, + { + "features legacy and new setting set", + json.RawMessage(`{"host_settings": {"enable_host_users": true}, "features": {"enable_host_users": false}}`), + Features{EnableHostUsers: true}, + false, + }, + { + "disk encryption legacy and new setting set", + json.RawMessage(`{"mdm": {"enable_disk_encryption": false, "macos_settings": {"enable_disk_encryption": true}}}`), + Features{}, + false, + }, + } + + for _, c := range cases { + t.Run(c.msg, func(t *testing.T) { + ac := AppConfig{} + err := json.Unmarshal(c.in, &ac) + require.NoError(t, err) + require.Nil(t, ac.DeprecatedHostSettings) + require.Nil(t, ac.MDM.MacOSSettings.DeprecatedEnableDiskEncryption) + require.Equal(t, c.wantFeatures, ac.Features) + require.Equal(t, c.wantDiskEncryption, ac.MDM.EnableDiskEncryption.Value) + + // marshalling the fields again doesn't contain deprecated fields + acJSON, err := json.Marshal(ac) + require.NoError(t, err) + var resultMap map[string]interface{} + err = json.Unmarshal(acJSON, &resultMap) + require.NoError(t, err) + + // host_settings is not present + _, exists := resultMap["host_settings"] + require.False(t, exists) + + // mdm.macos_settings.enable_disk_encryption is not present + mdm, ok := resultMap["mdm"].(map[string]interface{}) + require.True(t, ok) + macosSettings, ok := mdm["macos_settings"].(map[string]interface{}) + require.True(t, ok) + _, exists = macosSettings["enable_disk_encryption"] + require.False(t, exists) + + diskEncryption, exists := mdm["enable_disk_encryption"] + require.True(t, exists) + require.EqualValues(t, c.wantDiskEncryption, diskEncryption) + + }) + } + +} + +func TestAtLeastOnePlatformEnabledAndConfigured(t *testing.T) { + tests := []struct { + name string + macOSEnabledAndConfigured bool + windowsEnabledAndConfigured bool + isMDMFeatureFlagEnabled bool + expectedResult bool + }{ + { + name: "None enabled, feature flag disabled", + macOSEnabledAndConfigured: false, + windowsEnabledAndConfigured: false, + isMDMFeatureFlagEnabled: false, + expectedResult: false, + }, + { + name: "MacOS enabled, feature flag disabled", + macOSEnabledAndConfigured: true, + windowsEnabledAndConfigured: false, + isMDMFeatureFlagEnabled: false, + expectedResult: true, + }, + { + name: "Windows enabled, feature flag disabled", + macOSEnabledAndConfigured: false, + windowsEnabledAndConfigured: true, + isMDMFeatureFlagEnabled: false, + expectedResult: false, + }, + { + name: "Both enabled, feature flag disabled", + macOSEnabledAndConfigured: true, + windowsEnabledAndConfigured: true, + isMDMFeatureFlagEnabled: false, + expectedResult: true, + }, + { + name: "None enabled, feature flag enabled", + macOSEnabledAndConfigured: false, + windowsEnabledAndConfigured: false, + isMDMFeatureFlagEnabled: true, + expectedResult: false, + }, + { + name: "MacOS enabled, feature flag enabled", + macOSEnabledAndConfigured: true, + windowsEnabledAndConfigured: false, + isMDMFeatureFlagEnabled: true, + expectedResult: true, + }, + { + name: "Windows enabled, feature flag enabled", + macOSEnabledAndConfigured: false, + windowsEnabledAndConfigured: true, + isMDMFeatureFlagEnabled: true, + expectedResult: true, + }, + { + name: "Both enabled, feature flag enabled", + macOSEnabledAndConfigured: true, + windowsEnabledAndConfigured: true, + isMDMFeatureFlagEnabled: true, + expectedResult: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.isMDMFeatureFlagEnabled { + t.Setenv("FLEET_DEV_MDM_ENABLED", "1") + } else { + t.Setenv("FLEET_DEV_MDM_ENABLED", "0") + } + + mdm := MDM{ + EnabledAndConfigured: test.macOSEnabledAndConfigured, + WindowsEnabledAndConfigured: test.windowsEnabledAndConfigured, + } + result := mdm.AtLeastOnePlatformEnabledAndConfigured() + require.Equal(t, test.expectedResult, result) + }) + } +} diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index 13db70dc96..2aa92b13d9 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -694,12 +694,12 @@ type Datastore interface { SetOrUpdateHostDisksEncryption(ctx context.Context, hostID uint, encrypted bool) error // SetOrUpdateHostDiskEncryptionKey sets the base64, encrypted key for // a host - SetOrUpdateHostDiskEncryptionKey(ctx context.Context, hostID uint, encryptedBase64Key string) error + SetOrUpdateHostDiskEncryptionKey(ctx context.Context, hostID uint, encryptedBase64Key, clientError string, decryptable *bool) error // GetUnverifiedDiskEncryptionKeys returns all the encryption keys that // are collected but their decryptable status is not known yet (ie: // we're able to decrypt the key using a private key in the server) GetUnverifiedDiskEncryptionKeys(ctx context.Context) ([]HostDiskEncryptionKey, error) - // SetHostDiskEncryptionKeyStatus sets the encryptable status for the set + // SetHostsDiskEncryptionKeyStatus sets the encryptable status for the set // of encription keys provided SetHostsDiskEncryptionKeyStatus(ctx context.Context, hostIDs []uint, encryptable bool, threshold time.Time) error // GetHostDiskEncryptionKey returns the encryption key information for a given host @@ -1023,12 +1023,26 @@ type Datastore interface { // WSTEPAssociateCertHash associates a certificate hash with a device. WSTEPAssociateCertHash(ctx context.Context, deviceUUID string, hash string) error - // MDMWindowsGetEnrolledDevice receives a Windows MDM device id and returns the device information. + // MDMWindowsGetEnrolledDevice receives a Windows MDM HW device id and returns the device information. MDMWindowsGetEnrolledDevice(ctx context.Context, mdmDeviceID string) (*MDMWindowsEnrolledDevice, error) // MDMWindowsInsertEnrolledDevice inserts a new MDMWindowsEnrolledDevice in the database MDMWindowsInsertEnrolledDevice(ctx context.Context, device *MDMWindowsEnrolledDevice) error - // MDMWindowsDeleteEnrolledDevice deletes a give MDMWindowsEnrolledDevice entry from the database using the device id. + // MDMWindowsDeleteEnrolledDevice deletes a give MDMWindowsEnrolledDevice entry from the database using the HW device id. MDMWindowsDeleteEnrolledDevice(ctx context.Context, mdmDeviceID string) error + // MDMWindowsGetEnrolledDeviceWithDeviceID receives a Windows MDM device id and returns the device information + MDMWindowsGetEnrolledDeviceWithDeviceID(ctx context.Context, mdmDeviceID string) (*MDMWindowsEnrolledDevice, error) + // MDMWindowsDeleteEnrolledDeviceWithDeviceID deletes a give MDMWindowsEnrolledDevice entry from the database using the device id + MDMWindowsDeleteEnrolledDeviceWithDeviceID(ctx context.Context, mdmDeviceID string) error + + // GetMDMWindowsBitLockerSummary summarizes the current state of Windows disk encryption on + // each Windows host in the specified team (or, if no team is specified, each host that is not assigned + // to any team). + GetMDMWindowsBitLockerSummary(ctx context.Context, teamID *uint) (*MDMWindowsBitLockerSummary, error) + // GetMDMWindowsBitLockerStatus returns the disk encryption status for a given host + // + // Note that the returned status will be nil if the host is reported to be a Windows + // server or if disk encryption is disabled for the host's team (or no team, as applicable). + GetMDMWindowsBitLockerStatus(ctx context.Context, host *Host) (*DiskEncryptionStatus, error) /////////////////////////////////////////////////////////////////////////////// // Host Script Results diff --git a/server/fleet/hosts.go b/server/fleet/hosts.go index 87a401f71b..9e22fa92c3 100644 --- a/server/fleet/hosts.go +++ b/server/fleet/hosts.go @@ -53,20 +53,20 @@ const ( MDMEnrollStatusEnrolled = MDMEnrollStatus("enrolled") // combination of "manual" and "automatic" ) -// MacOSSettingsStatus defines the possible statuses of the host's macOS settings, which is derived from the -// status of MDM configuration profiles applied to the host. -type MacOSSettingsStatus string +// OSSettingsStatus defines the possible statuses of the host's OS settings, which is derived from the +// status of MDM configuration profiles and non-profile settings applied the host. +type OSSettingsStatus string const ( - MacOSSettingsVerified MacOSSettingsStatus = "verified" - MacOSSettingsVerifying MacOSSettingsStatus = "verifying" - MacOSSettingsPending MacOSSettingsStatus = "pending" - MacOSSettingsFailed MacOSSettingsStatus = "failed" + OSSettingsVerified OSSettingsStatus = "verified" + OSSettingsVerifying OSSettingsStatus = "verifying" + OSSettingsPending OSSettingsStatus = "pending" + OSSettingsFailed OSSettingsStatus = "failed" ) -func (s MacOSSettingsStatus) IsValid() bool { +func (s OSSettingsStatus) IsValid() bool { switch s { - case MacOSSettingsFailed, MacOSSettingsPending, MacOSSettingsVerifying, MacOSSettingsVerified: + case OSSettingsFailed, OSSettingsPending, OSSettingsVerifying, OSSettingsVerified: return true default: return false @@ -139,12 +139,19 @@ type HostListOptions struct { // MacOSSettingsFilter filters the hosts by the status of MDM configuration profiles // applied to the hosts. - MacOSSettingsFilter MacOSSettingsStatus + MacOSSettingsFilter OSSettingsStatus // MacOSSettingsDiskEncryptionFilter filters the hosts by the status of the disk encryption // MDM profile. MacOSSettingsDiskEncryptionFilter DiskEncryptionStatus + // OSSettingsFilter filters the hosts by the status of MDM configuration profiles and + // non-profile settings applied to the hosts. + OSSettingsFilter OSSettingsStatus + // OSSettingsDiskEncryptionFilter filters the hosts by the status of the disk encryption + // OS setting. + OSSettingsDiskEncryptionFilter DiskEncryptionStatus + // MDMBootstrapPackageFilter filters the hosts by the status of the MDM bootstrap package. MDMBootstrapPackageFilter *MDMBootstrapPackageStatus @@ -186,7 +193,9 @@ func (h HostListOptions) Empty() bool { h.MDMNameFilter == nil && h.MDMEnrollmentStatusFilter == "" && h.MunkiIssueIDFilter == nil && - h.LowDiskSpaceFilter == nil + h.LowDiskSpaceFilter == nil && + h.OSSettingsFilter == "" && + h.OSSettingsDiskEncryptionFilter == "" } type HostUser struct { @@ -336,6 +345,12 @@ type MDMHostData struct { // gets filled. rawDecryptable *int + // OSSettings contains information related to operating systems settings that are managed for + // MDM-enrolled hosts. + // + // Note: Additional information for macOS hosts is currently stored in MacOSSettings. + OSSettings *HostMDMOSSettings `json:"os_settings,omitempty" db:"-" csv:"-"` + // Profiles is a list of HostMDMProfiles for the host. Note that as for many // other host fields, it is not filled in by all host-returning datastore methods. // @@ -358,6 +373,14 @@ type MDMHostData struct { MacOSSetup *HostMDMMacOSSetup `json:"macos_setup,omitempty" db:"-" csv:"-"` } +type HostMDMOSSettings struct { + DiskEncryption HostMDMDiskEncryption `json:"disk_encryption" db:"-" csv:"-"` +} + +type HostMDMDiskEncryption struct { + Status *DiskEncryptionStatus `json:"status" db:"-" csv:"-"` +} + type DiskEncryptionStatus string const ( @@ -411,11 +434,11 @@ type HostMDMMacOSSetup struct { BootstrapPackageName string `db:"bootstrap_package_name" json:"bootstrap_package_name" csv:"-"` } -// DetermineDiskEncryptionStatus determines the disk encryption status for the +// DetermineMacOSDiskEncryptionStatus determines the disk encryption status for the // host based on the file-vault profile in its list of profiles and whether its // disk encryption key is available and decryptable. The file-vault profile // identifier is received as argument to avoid a circular dependency. -func (d *MDMHostData) DetermineDiskEncryptionStatus(profiles []HostMDMAppleProfile, fileVaultIdentifier string) { +func (d *MDMHostData) DetermineMacOSDiskEncryptionStatus(profiles []HostMDMAppleProfile, fileVaultIdentifier string) { var settings MDMHostMacOSSettings var fvprof *HostMDMAppleProfile @@ -577,6 +600,24 @@ func (h *Host) IsEligibleForWindowsMDMUnenrollment() bool { (h.MDMInfo == nil || !h.MDMInfo.IsServer) } +// IsEligibleForBitLockerEncryption checks if the host needs to enforce disk +// encryption using Fleet MDM features. +// +// Note: the *Host structs needs disk encryption data and MDM data filled in to +// perform the check. +func (h *Host) IsEligibleForBitLockerEncryption() bool { + isServer := h.MDMInfo != nil && h.MDMInfo.IsServer + isWindows := h.FleetPlatform() == "windows" + needsEncryption := h.DiskEncryptionEnabled != nil && !*h.DiskEncryptionEnabled + encryptedWithoutKey := h.DiskEncryptionEnabled != nil && *h.DiskEncryptionEnabled && !h.MDM.EncryptionKeyAvailable + + return isWindows && + h.IsOsqueryEnrolled() && + h.MDMInfo.IsFleetEnrolled() && + !isServer && + (needsEncryption || encryptedWithoutKey) +} + // DisplayName returns ComputerName if it isn't empty. Otherwise, it returns Hostname if it isn't // empty. If Hostname is empty and both HardwareSerial and HardwareModel are not empty, it returns a // composite string with HardwareModel and HardwareSerial. If all else fails, it returns an empty @@ -829,6 +870,9 @@ func (h *HostMDM) IsManualFleetEnrolled() bool { // it is in enrolled state for Fleet MDM, regardless of automatic or manual // enrollment method. func (h *HostMDM) IsFleetEnrolled() bool { + if h == nil { + return false + } return h.IsDEPFleetEnrolled() || h.IsManualFleetEnrolled() } diff --git a/server/fleet/hosts_test.go b/server/fleet/hosts_test.go index 2458e8d7f5..2bba30a7d7 100644 --- a/server/fleet/hosts_test.go +++ b/server/fleet/hosts_test.go @@ -6,6 +6,7 @@ import ( "time" "github.com/WatchBeam/clock" + "github.com/fleetdm/fleet/v4/server/ptr" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -240,3 +241,53 @@ func TestIsDEPCapable(t *testing.T) { require.Equal(t, tc.expected, tc.hostMDM.IsDEPCapable()) } } + +func TestIsEligibleForBitLockerEncryption(t *testing.T) { + require.False(t, (&Host{}).IsEligibleForBitLockerEncryption()) + + hostThatNeedsEnforcement := Host{ + Platform: "windows", + OsqueryHostID: ptr.String("test"), + MDMInfo: &HostMDM{ + Name: WellKnownMDMFleet, + Enrolled: true, + IsServer: false, + InstalledFromDep: true, + }, + MDM: MDMHostData{ + EncryptionKeyAvailable: false, + }, + DiskEncryptionEnabled: ptr.Bool(false), + } + require.True(t, hostThatNeedsEnforcement.IsEligibleForBitLockerEncryption()) + + // macOS hosts are not elegible + hostThatNeedsEnforcement.Platform = "darwin" + require.False(t, hostThatNeedsEnforcement.IsEligibleForBitLockerEncryption()) + hostThatNeedsEnforcement.Platform = "windows" + require.True(t, hostThatNeedsEnforcement.IsEligibleForBitLockerEncryption()) + + // hosts with disk encryption already enabled are elegible only if we + // can't decrypt the key + hostThatNeedsEnforcement.DiskEncryptionEnabled = ptr.Bool(true) + require.True(t, hostThatNeedsEnforcement.IsEligibleForBitLockerEncryption()) + hostThatNeedsEnforcement.MDM.EncryptionKeyAvailable = true + require.False(t, hostThatNeedsEnforcement.IsEligibleForBitLockerEncryption()) + + hostThatNeedsEnforcement.DiskEncryptionEnabled = ptr.Bool(false) + hostThatNeedsEnforcement.MDM.EncryptionKeyAvailable = false + require.True(t, hostThatNeedsEnforcement.IsEligibleForBitLockerEncryption()) + + // hosts without MDMinfo are not elegible + oldMDMInfo := hostThatNeedsEnforcement.MDMInfo + hostThatNeedsEnforcement.MDMInfo = nil + require.False(t, hostThatNeedsEnforcement.IsEligibleForBitLockerEncryption()) + hostThatNeedsEnforcement.MDMInfo = oldMDMInfo + require.True(t, hostThatNeedsEnforcement.IsEligibleForBitLockerEncryption()) + + // hosts that are not enrolled in MDM are not elegible + hostThatNeedsEnforcement.MDMInfo.Enrolled = false + require.False(t, hostThatNeedsEnforcement.IsEligibleForBitLockerEncryption()) + hostThatNeedsEnforcement.MDMInfo.Enrolled = true + require.True(t, hostThatNeedsEnforcement.IsEligibleForBitLockerEncryption()) +} diff --git a/server/fleet/mdm.go b/server/fleet/mdm.go index 9bac698299..9a68a6d6b6 100644 --- a/server/fleet/mdm.go +++ b/server/fleet/mdm.go @@ -135,3 +135,16 @@ type HostMDMProfileRetryCount struct { ProfileIdentifier string `db:"profile_identifier"` Retries uint `db:"retries"` } + +type MDMPlatformsCounts struct { + MacOS uint `db:"macos" json:"macos"` + Windows uint `db:"windows" json:"windows"` +} +type MDMDiskEncryptionSummary struct { + Verified MDMPlatformsCounts `db:"verified" json:"verified"` + Verifying MDMPlatformsCounts `db:"verifying" json:"verifying"` + ActionRequired MDMPlatformsCounts `db:"action_required" json:"action_required"` + Enforcing MDMPlatformsCounts `db:"enforcing" json:"enforcing"` + Failed MDMPlatformsCounts `db:"failed" json:"failed"` + RemovingEnforcement MDMPlatformsCounts `db:"removing_enforcement" json:"removing_enforcement"` +} diff --git a/server/fleet/orbit.go b/server/fleet/orbit.go index 77ecd683f0..b8c2b2fb65 100644 --- a/server/fleet/orbit.go +++ b/server/fleet/orbit.go @@ -29,6 +29,10 @@ type OrbitConfigNotifications struct { // execution on that host. The scripts pending execution are those that // haven't received a result yet. PendingScriptExecutionIDs []string `json:"pending_script_execution_ids,omitempty"` + + // EnforceBitLockerEncryption is sent as true if Windows MDM is + // enabled and the device should encrypt its disk volumes with BitLocker. + EnforceBitLockerEncryption bool `json:"enforce_bitlocker_encryption,omitempty"` } type OrbitConfig struct { @@ -81,3 +85,9 @@ func (es *Extensions) FilterByHostPlatform(hostPlatform string) { } } } + +// OrbitHostDiskEncryptionKeyPayload contains the disk encryption key for a host. +type OrbitHostDiskEncryptionKeyPayload struct { + EncryptionKey []byte `json:"encryption_key"` + ClientError string `json:"client_error"` +} diff --git a/server/fleet/service.go b/server/fleet/service.go index 9554c78154..b9cb37544a 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -711,6 +711,11 @@ type Service interface { // error can be raised to the user. VerifyMDMWindowsConfigured(ctx context.Context) error + // VerifyMDMAppleOrWindowsConfigured verifies that the server is configured + // for either Apple or Windows MDM. If an error is returned, authorization is + // skipped so the error can be raised to the user. + VerifyMDMAppleOrWindowsConfigured(ctx context.Context) error + MDMAppleUploadBootstrapPackage(ctx context.Context, name string, pkg io.Reader, teamID uint) error GetMDMAppleBootstrapPackageBytes(ctx context.Context, token string) (*MDMAppleBootstrapPackage, error) @@ -790,6 +795,17 @@ type Service interface { // GetMDMWindowsTOSContent returns TOS content GetMDMWindowsTOSContent(ctx context.Context, redirectUri string, reqID string) (string, error) + // Set or update the disk encryption key for a host. + SetOrUpdateDiskEncryptionKey(ctx context.Context, encryptionKey, clientError string) error + + /////////////////////////////////////////////////////////////////////////////// + // Common MDM + + // GetMDMDiskEncryptionSummary returns the current disk encryption status of all macOS and + // Windows hosts in the specified team (or, if no team is specified, each host that is not + // assigned to any team). + GetMDMDiskEncryptionSummary(ctx context.Context, teamID *uint) (*MDMDiskEncryptionSummary, error) + /////////////////////////////////////////////////////////////////////////////// // Host Script Execution diff --git a/server/fleet/teams.go b/server/fleet/teams.go index 4c6ba72e03..191ef0f3b1 100644 --- a/server/fleet/teams.go +++ b/server/fleet/teams.go @@ -5,6 +5,8 @@ import ( "encoding/json" "fmt" "time" + + "github.com/fleetdm/fleet/v4/pkg/optjson" ) const ( @@ -29,9 +31,10 @@ type TeamPayload struct { // need to be able which part of the MDM config was provided in the request, // so the fields are pointers to structs. type TeamPayloadMDM struct { - MacOSUpdates *MacOSUpdates `json:"macos_updates"` - MacOSSettings *MacOSSettings `json:"macos_settings"` - MacOSSetup *MacOSSetup `json:"macos_setup"` + EnableDiskEncryption optjson.Bool `json:"enable_disk_encryption"` + MacOSUpdates *MacOSUpdates `json:"macos_updates"` + MacOSSettings *MacOSSettings `json:"macos_settings"` + MacOSSetup *MacOSSetup `json:"macos_setup"` } // Team is the data representation for the "Team" concept (group of hosts and @@ -143,13 +146,16 @@ type TeamWebhookSettings struct { } type TeamMDM struct { - MacOSUpdates MacOSUpdates `json:"macos_updates"` - MacOSSettings MacOSSettings `json:"macos_settings"` - MacOSSetup MacOSSetup `json:"macos_setup"` + EnableDiskEncryption bool `json:"enable_disk_encryption"` + MacOSUpdates MacOSUpdates `json:"macos_updates"` + MacOSSettings MacOSSettings `json:"macos_settings"` + MacOSSetup MacOSSetup `json:"macos_setup"` // NOTE: TeamSpecMDM must be kept in sync with TeamMDM. } type TeamSpecMDM struct { + EnableDiskEncryption optjson.Bool `json:"enable_disk_encryption"` + MacOSUpdates MacOSUpdates `json:"macos_updates"` // A map is used for the macos settings so that we can easily detect if its @@ -364,7 +370,9 @@ func TeamSpecFromTeam(t *Team) (*TeamSpec, error) { var mdmSpec TeamSpecMDM mdmSpec.MacOSUpdates = t.Config.MDM.MacOSUpdates mdmSpec.MacOSSettings = t.Config.MDM.MacOSSettings.ToMap() + delete(mdmSpec.MacOSSettings, "enable_disk_encryption") mdmSpec.MacOSSetup = t.Config.MDM.MacOSSetup + mdmSpec.EnableDiskEncryption = optjson.SetBool(t.Config.MDM.EnableDiskEncryption) return &TeamSpec{ Name: t.Name, AgentOptions: agentOptions, diff --git a/server/fleet/windows_mdm.go b/server/fleet/windows_mdm.go new file mode 100644 index 0000000000..0a72f5aea7 --- /dev/null +++ b/server/fleet/windows_mdm.go @@ -0,0 +1,16 @@ +package fleet + +// MDMWindowsBitLockerSummary reports the number of Windows hosts being managed by Fleet with +// BitLocker. Each host may be counted in only one of six mutually-exclusive categories: +// Verified, Verifying, ActionRequired, Enforcing, Failed, RemovingEnforcement. +// +// Note that it is expected that each of Verifying, ActionRequired, and RemovingEnforcement will be +// zero because these states are not in Fleet's current implementation of BitLocker management. +type MDMWindowsBitLockerSummary struct { + Verified uint `json:"verified" db:"verified"` + Verifying uint `json:"verifying" db:"verifying"` + ActionRequired uint `json:"action_required" db:"action_required"` + Enforcing uint `json:"enforcing" db:"enforcing"` + Failed uint `json:"failed" db:"failed"` + RemovingEnforcement uint `json:"removing_enforcement" db:"removing_enforcement"` +} diff --git a/server/mdm/apple/cert.go b/server/mdm/apple/cert.go index 8b06ded4d8..aa300c4596 100644 --- a/server/mdm/apple/cert.go +++ b/server/mdm/apple/cert.go @@ -2,12 +2,10 @@ package apple_mdm import ( "bytes" - "crypto" "crypto/rand" "crypto/rsa" "crypto/x509" "crypto/x509/pkix" - "encoding/base64" "encoding/json" "fmt" "io/ioutil" @@ -17,7 +15,6 @@ import ( "github.com/micromdm/nanodep/tokenpki" "github.com/micromdm/scep/v2/depot" - "go.mozilla.org/pkcs7" ) const ( @@ -160,17 +157,3 @@ func NewDEPKeyPairPEM() ([]byte, []byte, error) { return publicKeyPEM, privateKeyPEM, nil } - -func DecryptBase64CMS(p7Base64 string, cert *x509.Certificate, key crypto.PrivateKey) ([]byte, error) { - p7Bytes, err := base64.StdEncoding.DecodeString(p7Base64) - if err != nil { - return nil, err - } - - p7, err := pkcs7.Parse(p7Bytes) - if err != nil { - return nil, err - } - - return p7.Decrypt(cert, key) -} diff --git a/server/mdm/mdm.go b/server/mdm/mdm.go new file mode 100644 index 0000000000..79a55dd50a --- /dev/null +++ b/server/mdm/mdm.go @@ -0,0 +1,25 @@ +package mdm + +import ( + "crypto" + "crypto/x509" + "encoding/base64" + + "go.mozilla.org/pkcs7" +) + +// DecryptBase64CMS decrypts a base64 encoded pkcs7-encrypted value using the +// provided certificate and private key. +func DecryptBase64CMS(p7Base64 string, cert *x509.Certificate, key crypto.PrivateKey) ([]byte, error) { + p7Bytes, err := base64.StdEncoding.DecodeString(p7Base64) + if err != nil { + return nil, err + } + + p7, err := pkcs7.Parse(p7Bytes) + if err != nil { + return nil, err + } + + return p7.Decrypt(cert, key) +} diff --git a/server/mdm/apple/cert_test.go b/server/mdm/mdm_test.go similarity index 99% rename from server/mdm/apple/cert_test.go rename to server/mdm/mdm_test.go index f6289e9923..35f151ac1d 100644 --- a/server/mdm/apple/cert_test.go +++ b/server/mdm/mdm_test.go @@ -1,4 +1,4 @@ -package apple_mdm +package mdm import ( "crypto/tls" diff --git a/server/mdm/microsoft/microsoft_mdm.go b/server/mdm/microsoft/microsoft_mdm.go index 552c8ffc97..8a52f6bc37 100644 --- a/server/mdm/microsoft/microsoft_mdm.go +++ b/server/mdm/microsoft/microsoft_mdm.go @@ -1,7 +1,11 @@ package microsoft_mdm import ( + "crypto/x509" + "encoding/base64" + "github.com/fleetdm/fleet/v4/server/mdm/internal/commonmdm" + "go.mozilla.org/pkcs7" ) const ( @@ -174,7 +178,7 @@ const ( WstepRenewRetryInterval = "4" // The PROVIDER-ID paramer specifies the server identifier for a management server used in the current management session - DocProvisioningAppProviderID = "FleetDM" + DocProvisioningAppProviderID = "Fleet" // The NAME parameter is used in the APPLICATION characteristic to specify a user readable application identity DocProvisioningAppName = DocProvisioningAppProviderID @@ -275,3 +279,14 @@ func ResolveWindowsMDMAuth(serverURL string) (string, error) { func ResolveWindowsMDMManagement(serverURL string) (string, error) { return commonmdm.ResolveURL(serverURL, MDE2ManagementPath, false) } + +// Encrypt uses pkcs7 to encrypt a raw value using the provided certificate. +// The returned encrypted value is base64-encoded. +func Encrypt(rawValue string, cert *x509.Certificate) (string, error) { + encrypted, err := pkcs7.Encrypt([]byte(rawValue), []*x509.Certificate{cert}) + if err != nil { + return "", err + } + b64Enc := base64.StdEncoding.EncodeToString(encrypted) + return b64Enc, nil +} diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index 1b049e6020..ecc0b814e3 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -480,7 +480,7 @@ type SetOrUpdateHostDisksSpaceFunc func(ctx context.Context, hostID uint, gigsAv type SetOrUpdateHostDisksEncryptionFunc func(ctx context.Context, hostID uint, encrypted bool) error -type SetOrUpdateHostDiskEncryptionKeyFunc func(ctx context.Context, hostID uint, encryptedBase64Key string) error +type SetOrUpdateHostDiskEncryptionKeyFunc func(ctx context.Context, hostID uint, encryptedBase64Key string, clientError string, decryptable *bool) error type GetUnverifiedDiskEncryptionKeysFunc func(ctx context.Context) ([]fleet.HostDiskEncryptionKey, error) @@ -674,6 +674,14 @@ type MDMWindowsInsertEnrolledDeviceFunc func(ctx context.Context, device *fleet. type MDMWindowsDeleteEnrolledDeviceFunc func(ctx context.Context, mdmDeviceID string) error +type MDMWindowsGetEnrolledDeviceWithDeviceIDFunc func(ctx context.Context, mdmDeviceID string) (*fleet.MDMWindowsEnrolledDevice, error) + +type MDMWindowsDeleteEnrolledDeviceWithDeviceIDFunc func(ctx context.Context, mdmDeviceID string) error + +type GetMDMWindowsBitLockerSummaryFunc func(ctx context.Context, teamID *uint) (*fleet.MDMWindowsBitLockerSummary, error) + +type GetMDMWindowsBitLockerStatusFunc func(ctx context.Context, host *fleet.Host) (*fleet.DiskEncryptionStatus, error) + type NewHostScriptExecutionRequestFunc func(ctx context.Context, request *fleet.HostScriptRequestPayload) (*fleet.HostScriptResult, error) type SetHostScriptExecutionResultFunc func(ctx context.Context, result *fleet.HostScriptResultPayload) error @@ -1667,6 +1675,18 @@ type DataStore struct { MDMWindowsDeleteEnrolledDeviceFunc MDMWindowsDeleteEnrolledDeviceFunc MDMWindowsDeleteEnrolledDeviceFuncInvoked bool + MDMWindowsGetEnrolledDeviceWithDeviceIDFunc MDMWindowsGetEnrolledDeviceWithDeviceIDFunc + MDMWindowsGetEnrolledDeviceWithDeviceIDFuncInvoked bool + + MDMWindowsDeleteEnrolledDeviceWithDeviceIDFunc MDMWindowsDeleteEnrolledDeviceWithDeviceIDFunc + MDMWindowsDeleteEnrolledDeviceWithDeviceIDFuncInvoked bool + + GetMDMWindowsBitLockerSummaryFunc GetMDMWindowsBitLockerSummaryFunc + GetMDMWindowsBitLockerSummaryFuncInvoked bool + + GetMDMWindowsBitLockerStatusFunc GetMDMWindowsBitLockerStatusFunc + GetMDMWindowsBitLockerStatusFuncInvoked bool + NewHostScriptExecutionRequestFunc NewHostScriptExecutionRequestFunc NewHostScriptExecutionRequestFuncInvoked bool @@ -3299,11 +3319,11 @@ func (s *DataStore) SetOrUpdateHostDisksEncryption(ctx context.Context, hostID u return s.SetOrUpdateHostDisksEncryptionFunc(ctx, hostID, encrypted) } -func (s *DataStore) SetOrUpdateHostDiskEncryptionKey(ctx context.Context, hostID uint, encryptedBase64Key string) error { +func (s *DataStore) SetOrUpdateHostDiskEncryptionKey(ctx context.Context, hostID uint, encryptedBase64Key string, clientError string, decryptable *bool) error { s.mu.Lock() s.SetOrUpdateHostDiskEncryptionKeyFuncInvoked = true s.mu.Unlock() - return s.SetOrUpdateHostDiskEncryptionKeyFunc(ctx, hostID, encryptedBase64Key) + return s.SetOrUpdateHostDiskEncryptionKeyFunc(ctx, hostID, encryptedBase64Key, clientError, decryptable) } func (s *DataStore) GetUnverifiedDiskEncryptionKeys(ctx context.Context) ([]fleet.HostDiskEncryptionKey, error) { @@ -3957,11 +3977,11 @@ func (s *DataStore) WSTEPAssociateCertHash(ctx context.Context, deviceUUID strin return s.WSTEPAssociateCertHashFunc(ctx, deviceUUID, hash) } -func (s *DataStore) MDMWindowsGetEnrolledDevice(ctx context.Context, mdmDeviceID string) (*fleet.MDMWindowsEnrolledDevice, error) { +func (s *DataStore) MDMWindowsGetEnrolledDevice(ctx context.Context, mdmDeviceHWID string) (*fleet.MDMWindowsEnrolledDevice, error) { s.mu.Lock() s.MDMWindowsGetEnrolledDeviceFuncInvoked = true s.mu.Unlock() - return s.MDMWindowsGetEnrolledDeviceFunc(ctx, mdmDeviceID) + return s.MDMWindowsGetEnrolledDeviceFunc(ctx, mdmDeviceHWID) } func (s *DataStore) MDMWindowsInsertEnrolledDevice(ctx context.Context, device *fleet.MDMWindowsEnrolledDevice) error { @@ -3971,11 +3991,39 @@ func (s *DataStore) MDMWindowsInsertEnrolledDevice(ctx context.Context, device * return s.MDMWindowsInsertEnrolledDeviceFunc(ctx, device) } -func (s *DataStore) MDMWindowsDeleteEnrolledDevice(ctx context.Context, mdmDeviceID string) error { +func (s *DataStore) MDMWindowsDeleteEnrolledDevice(ctx context.Context, mdmDeviceHWID string) error { s.mu.Lock() s.MDMWindowsDeleteEnrolledDeviceFuncInvoked = true s.mu.Unlock() - return s.MDMWindowsDeleteEnrolledDeviceFunc(ctx, mdmDeviceID) + return s.MDMWindowsDeleteEnrolledDeviceFunc(ctx, mdmDeviceHWID) +} + +func (s *DataStore) MDMWindowsGetEnrolledDeviceWithDeviceID(ctx context.Context, mdmDeviceID string) (*fleet.MDMWindowsEnrolledDevice, error) { + s.mu.Lock() + s.MDMWindowsGetEnrolledDeviceWithDeviceIDFuncInvoked = true + s.mu.Unlock() + return s.MDMWindowsGetEnrolledDeviceWithDeviceIDFunc(ctx, mdmDeviceID) +} + +func (s *DataStore) MDMWindowsDeleteEnrolledDeviceWithDeviceID(ctx context.Context, mdmDeviceID string) error { + s.mu.Lock() + s.MDMWindowsDeleteEnrolledDeviceWithDeviceIDFuncInvoked = true + s.mu.Unlock() + return s.MDMWindowsDeleteEnrolledDeviceWithDeviceIDFunc(ctx, mdmDeviceID) +} + +func (s *DataStore) GetMDMWindowsBitLockerSummary(ctx context.Context, teamID *uint) (*fleet.MDMWindowsBitLockerSummary, error) { + s.mu.Lock() + s.GetMDMWindowsBitLockerSummaryFuncInvoked = true + s.mu.Unlock() + return s.GetMDMWindowsBitLockerSummaryFunc(ctx, teamID) +} + +func (s *DataStore) GetMDMWindowsBitLockerStatus(ctx context.Context, host *fleet.Host) (*fleet.DiskEncryptionStatus, error) { + s.mu.Lock() + s.GetMDMWindowsBitLockerStatusFuncInvoked = true + s.mu.Unlock() + return s.GetMDMWindowsBitLockerStatusFunc(ctx, host) } func (s *DataStore) NewHostScriptExecutionRequest(ctx context.Context, request *fleet.HostScriptRequestPayload) (*fleet.HostScriptResult, error) { diff --git a/server/service/appconfig.go b/server/service/appconfig.go index 0b279e5b8a..a3008831c1 100644 --- a/server/service/appconfig.go +++ b/server/service/appconfig.go @@ -14,6 +14,7 @@ import ( "net/http" "net/url" + "github.com/fleetdm/fleet/v4/pkg/rawjson" "github.com/fleetdm/fleet/v4/server/authz" "github.com/fleetdm/fleet/v4/server/config" authz_ctx "github.com/fleetdm/fleet/v4/server/contexts/authz" @@ -78,6 +79,31 @@ func (r *appConfigResponse) UnmarshalJSON(data []byte) error { return nil } +// MarshalJSON implements the json.Marshaler interface to make sure we serialize +// both AppConfig and responseFields properly: +// +// - If this function is not defined, AppConfig.MarshalJSON gets promoted and +// will be called instead. +// - If we try to unmarshal everything in one go, AppConfig.MarshalJSON doesn't get +// called. +func (r appConfigResponse) MarshalJSON() ([]byte, error) { + // Marshal only the response fields + responseData, err := json.Marshal(r.appConfigResponseFields) + if err != nil { + return nil, err + } + + // Marshal the base AppConfig + appConfigData, err := json.Marshal(r.AppConfig) + if err != nil { + return nil, err + } + + // we need to marshal and combine both groups separately because + // AppConfig has a custom marshaler. + return rawjson.CombineRoots(responseData, appConfigData) +} + func (r appConfigResponse) error() error { return r.Err } func getAppConfigEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { @@ -340,6 +366,15 @@ func (svc *Service) ModifyAppConfig(ctx context.Context, p []byte, applyOpts fle } } + // TODO: move this logic to the AppConfig unmarshaller? we need to do + // this because we unmarshal twice into appConfig: + // + // 1. To get the JSON value from the database + // 2. To update fields with the incoming values + if newAppConfig.MDM.EnableDiskEncryption.Valid { + appConfig.MDM.EnableDiskEncryption = newAppConfig.MDM.EnableDiskEncryption + } + fleet.ValidateEnabledVulnerabilitiesIntegrations(appConfig.WebhookSettings.VulnerabilitiesWebhook, appConfig.Integrations, invalid) fleet.ValidateEnabledFailingPoliciesIntegrations(appConfig.WebhookSettings.FailingPoliciesWebhook, appConfig.Integrations, invalid) fleet.ValidateEnabledHostStatusIntegrations(appConfig.WebhookSettings.HostStatusWebhook, invalid) @@ -502,22 +537,24 @@ func (svc *Service) ModifyAppConfig(ctx context.Context, p []byte, applyOpts fle } } - if oldAppConfig.MDM.MacOSSettings.EnableDiskEncryption != appConfig.MDM.MacOSSettings.EnableDiskEncryption { - var act fleet.ActivityDetails - if appConfig.MDM.MacOSSettings.EnableDiskEncryption { - act = fleet.ActivityTypeEnabledMacosDiskEncryption{} - if err := svc.EnterpriseOverrides.MDMAppleEnableFileVaultAndEscrow(ctx, nil); err != nil { - return nil, ctxerr.Wrap(ctx, err, "enable no-team filevault and escrow") + if appConfig.MDM.EnableDiskEncryption.Valid && oldAppConfig.MDM.EnableDiskEncryption.Value != appConfig.MDM.EnableDiskEncryption.Value { + if oldAppConfig.MDM.EnabledAndConfigured { + var act fleet.ActivityDetails + if appConfig.MDM.EnableDiskEncryption.Value { + act = fleet.ActivityTypeEnabledMacosDiskEncryption{} + if err := svc.EnterpriseOverrides.MDMAppleEnableFileVaultAndEscrow(ctx, nil); err != nil { + return nil, ctxerr.Wrap(ctx, err, "enable no-team filevault and escrow") + } + } else { + act = fleet.ActivityTypeDisabledMacosDiskEncryption{} + if err := svc.EnterpriseOverrides.MDMAppleDisableFileVaultAndEscrow(ctx, nil); err != nil { + return nil, ctxerr.Wrap(ctx, err, "disable no-team filevault and escrow") + } } - } else { - act = fleet.ActivityTypeDisabledMacosDiskEncryption{} - if err := svc.EnterpriseOverrides.MDMAppleDisableFileVaultAndEscrow(ctx, nil); err != nil { - return nil, ctxerr.Wrap(ctx, err, "disable no-team filevault and escrow") + if err := svc.ds.NewActivity(ctx, authz.UserFromContext(ctx), act); err != nil { + return nil, ctxerr.Wrap(ctx, err, "create activity for app config macos disk encryption") } } - if err := svc.ds.NewActivity(ctx, authz.UserFromContext(ctx), act); err != nil { - return nil, ctxerr.Wrap(ctx, err, "create activity for app config macos disk encryption") - } } mdmEnableEndUserAuthChanged := oldAppConfig.MDM.MacOSSetup.EnableEndUserAuthentication != appConfig.MDM.MacOSSetup.EnableEndUserAuthentication @@ -565,7 +602,7 @@ func (svc *Service) validateMDM( mdm *fleet.MDM, invalid *fleet.InvalidArgumentError, ) { - if mdm.MacOSSettings.EnableDiskEncryption && !license.IsPremium() { + if mdm.EnableDiskEncryption.Value && !license.IsPremium() { invalid.Append("macos_settings.enable_disk_encryption", ErrMissingLicense.Error()) } if oldMdm.MacOSSetup.MacOSSetupAssistant.Value != mdm.MacOSSetup.MacOSSetupAssistant.Value && !license.IsPremium() { @@ -586,11 +623,6 @@ func (svc *Service) validateMDM( `Couldn't update macos_settings because MDM features aren't turned on in Fleet. Use fleetctl generate mdm-apple and then fleet serve with mdm configuration to turn on MDM features.`) } - if mdm.MacOSSettings.EnableDiskEncryption { - invalid.Append("macos_settings.enable_disk_encryption", - `Couldn't update macos_settings because MDM features aren't turned on in Fleet. Use fleetctl generate mdm-apple and then fleet serve with mdm configuration to turn on MDM features.`) - } - if oldMdm.MacOSSetup.MacOSSetupAssistant.Value != mdm.MacOSSetup.MacOSSetupAssistant.Value { invalid.Append("macos_setup.macos_setup_assistant", `Couldn't update macos_setup because MDM features aren't turned on in Fleet. Use fleetctl generate mdm-apple and then fleet serve with mdm configuration to turn on MDM features.`) @@ -683,6 +715,14 @@ func (svc *Service) validateMDM( return } } + + // if either macOS or Windows MDM is enabled, this setting can be set. + if !mdm.AtLeastOnePlatformEnabledAndConfigured() { + if mdm.EnableDiskEncryption.Valid && mdm.EnableDiskEncryption.Value != oldMdm.EnableDiskEncryption.Value { + invalid.Append("mdm.enable_disk_encryption", + `Couldn't edit enable_disk_encryption. Neither macOS MDM nor Windows is turned on. Visit https://fleetdm.com/docs/using-fleet to learn how to turn on MDM.`) + } + } } func validateSSOProviderSettings(incoming, existing fleet.SSOProviderSettings, invalid *fleet.InvalidArgumentError) { diff --git a/server/service/appconfig_test.go b/server/service/appconfig_test.go index 38598287e8..6efc8a602d 100644 --- a/server/service/appconfig_test.go +++ b/server/service/appconfig_test.go @@ -810,8 +810,9 @@ func TestMDMAppleConfig(t *testing.T) { name: "nochange", licenseTier: "free", expectedMDM: fleet.MDM{ - MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}}, - MacOSUpdates: fleet.MacOSUpdates{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, + MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}}, + MacOSUpdates: fleet.MacOSUpdates{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, + EnableDiskEncryption: optjson.Bool{Set: true, Valid: false}, }, }, { name: "newDefaultTeamNoLicense", @@ -835,9 +836,10 @@ func TestMDMAppleConfig(t *testing.T) { findTeam: true, newMDM: fleet.MDM{AppleBMDefaultTeam: "foobar"}, expectedMDM: fleet.MDM{ - AppleBMDefaultTeam: "foobar", - MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}}, - MacOSUpdates: fleet.MacOSUpdates{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, + AppleBMDefaultTeam: "foobar", + MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}}, + MacOSUpdates: fleet.MacOSUpdates{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, + EnableDiskEncryption: optjson.Bool{Set: true, Valid: false}, }, }, { name: "foundEdit", @@ -846,9 +848,10 @@ func TestMDMAppleConfig(t *testing.T) { oldMDM: fleet.MDM{AppleBMDefaultTeam: "bar"}, newMDM: fleet.MDM{AppleBMDefaultTeam: "foobar"}, expectedMDM: fleet.MDM{ - AppleBMDefaultTeam: "foobar", - MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}}, - MacOSUpdates: fleet.MacOSUpdates{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, + AppleBMDefaultTeam: "foobar", + MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}}, + MacOSUpdates: fleet.MacOSUpdates{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, + EnableDiskEncryption: optjson.Bool{Set: true, Valid: false}, }, }, { name: "ssoFree", @@ -866,6 +869,7 @@ func TestMDMAppleConfig(t *testing.T) { EndUserAuthentication: fleet.MDMEndUserAuthentication{SSOProviderSettings: fleet.SSOProviderSettings{EntityID: "foo"}}, MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}}, MacOSUpdates: fleet.MacOSUpdates{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, + EnableDiskEncryption: optjson.Bool{Set: true, Valid: false}, }, }, { name: "ssoAllFields", @@ -884,8 +888,9 @@ func TestMDMAppleConfig(t *testing.T) { MetadataURL: "http://isser.metadata.com", IDPName: "onelogin", }}, - MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}}, - MacOSUpdates: fleet.MacOSUpdates{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, + MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}}, + MacOSUpdates: fleet.MacOSUpdates{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, + EnableDiskEncryption: optjson.Bool{Set: true, Valid: false}, }, }, { name: "ssoShortEntityID", diff --git a/server/service/apple_mdm.go b/server/service/apple_mdm.go index 5417b8f094..53270d2950 100644 --- a/server/service/apple_mdm.go +++ b/server/service/apple_mdm.go @@ -18,6 +18,7 @@ import ( "github.com/VividCortex/mysqlerr" "github.com/docker/go-units" "github.com/fleetdm/fleet/v4/pkg/file" + "github.com/fleetdm/fleet/v4/pkg/optjson" "github.com/fleetdm/fleet/v4/server" "github.com/fleetdm/fleet/v4/server/authz" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" @@ -609,7 +610,6 @@ func getMdmAppleFileVaultSummaryEndpoint(ctx context.Context, request interface{ }, nil } -// QUESTION: workflow for developing new APIs? whats your setup quickly test code working? func (svc *Service) GetMDMAppleFileVaultSummary(ctx context.Context, teamID *uint) (*fleet.MDMAppleFileVaultSummary, error) { if err := svc.authz.Authorize(ctx, fleet.MDMAppleConfigProfile{TeamID: teamID}, fleet.ActionRead); err != nil { return nil, ctxerr.Wrap(ctx, err) @@ -1716,8 +1716,8 @@ func (svc *Service) updateAppConfigMDMAppleSettings(ctx context.Context, payload var didUpdate, didUpdateMacOSDiskEncryption bool if payload.EnableDiskEncryption != nil { - if ac.MDM.MacOSSettings.EnableDiskEncryption != *payload.EnableDiskEncryption { - ac.MDM.MacOSSettings.EnableDiskEncryption = *payload.EnableDiskEncryption + if ac.MDM.EnableDiskEncryption.Value != *payload.EnableDiskEncryption { + ac.MDM.EnableDiskEncryption = optjson.SetBool(*payload.EnableDiskEncryption) didUpdate = true didUpdateMacOSDiskEncryption = true } @@ -1729,7 +1729,7 @@ func (svc *Service) updateAppConfigMDMAppleSettings(ctx context.Context, payload } if didUpdateMacOSDiskEncryption { var act fleet.ActivityDetails - if ac.MDM.MacOSSettings.EnableDiskEncryption { + if ac.MDM.EnableDiskEncryption.Value { act = fleet.ActivityTypeEnabledMacosDiskEncryption{} if err := svc.EnterpriseOverrides.MDMAppleEnableFileVaultAndEscrow(ctx, nil); err != nil { return ctxerr.Wrap(ctx, err, "enable no-team filevault and escrow") diff --git a/server/service/apple_mdm_test.go b/server/service/apple_mdm_test.go index 49f941ce3c..8815b63a28 100644 --- a/server/service/apple_mdm_test.go +++ b/server/service/apple_mdm_test.go @@ -697,15 +697,15 @@ func TestHostDetailsMDMProfiles(t *testing.T) { } ds.HostFunc = func(ctx context.Context, hostID uint) (*fleet.Host, error) { if hostID == uint(42) { - return &fleet.Host{ID: uint(42), UUID: "H057-UU1D-1337"}, nil + return &fleet.Host{ID: uint(42), UUID: "H057-UU1D-1337", Platform: "darwin"}, nil } - return &fleet.Host{ID: hostID, UUID: "WR0N6-UU1D"}, nil + return &fleet.Host{ID: hostID, UUID: "WR0N6-UU1D", Platform: "darwin"}, nil } ds.HostByIdentifierFunc = func(ctx context.Context, identifier string) (*fleet.Host, error) { if identifier == "h0571d3n71f13r" { - return &fleet.Host{ID: uint(42), UUID: "H057-UU1D-1337"}, nil + return &fleet.Host{ID: uint(42), UUID: "H057-UU1D-1337", Platform: "darwin"}, nil } - return &fleet.Host{ID: uint(21), UUID: "WR0N6-UU1D"}, nil + return &fleet.Host{ID: uint(21), UUID: "WR0N6-UU1D", Platform: "darwin"}, nil } ds.LoadHostSoftwareFunc = func(ctx context.Context, host *fleet.Host, includeCVEScores bool) error { return nil diff --git a/server/service/handler.go b/server/service/handler.go index aa76a30afa..4b027d44f6 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -448,7 +448,7 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC // Only Fleet MDM specific endpoints should be within the root /mdm/ path. // NOTE: remember to update - // `service.mdmAppleConfigurationRequiredEndpoints` when you add an + // `service.mdmConfigurationRequiredEndpoints` when you add an // endpoint that's behind the mdmConfiguredMiddleware, this applies // both to this set of endpoints and to any public/token-authenticated // endpoints using `neMDM` below in this file. @@ -484,7 +484,7 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC // host-specific mdm routes mdmAppleMW.PATCH("/api/_version_/fleet/mdm/hosts/{id:[0-9]+}/unenroll", mdmAppleCommandRemoveEnrollmentProfileEndpoint, mdmAppleCommandRemoveEnrollmentProfileRequest{}) - mdmAppleMW.GET("/api/_version_/fleet/mdm/hosts/{id:[0-9]+}/encryption_key", getHostEncryptionKey, getHostEncryptionKeyRequest{}) + mdmAppleMW.POST("/api/_version_/fleet/mdm/hosts/{id:[0-9]+}/lock", deviceLockEndpoint, deviceLockRequest{}) mdmAppleMW.POST("/api/_version_/fleet/mdm/hosts/{id:[0-9]+}/wipe", deviceWipeEndpoint, deviceWipeRequest{}) mdmAppleMW.GET("/api/_version_/fleet/mdm/hosts/{id:[0-9]+}/profiles", getHostProfilesEndpoint, getHostProfilesRequest{}) @@ -500,6 +500,10 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC mdmAppleMW.POST("/api/_version_/fleet/mdm/apple/profiles/preassign", preassignMDMAppleProfileEndpoint, preassignMDMAppleProfileRequest{}) mdmAppleMW.POST("/api/_version_/fleet/mdm/apple/profiles/match", matchMDMApplePreassignmentEndpoint, matchMDMApplePreassignmentRequest{}) + mdmAppleOrWinMW := ue.WithCustomMiddleware(mdmConfiguredMiddleware.VerifyAppleOrWindowsMDM()) + mdmAppleOrWinMW.GET("/api/_version_/fleet/mdm/disk_encryption/summary", getMDMDiskEncryptionSummaryEndpoint, getMDMDiskEncryptionSummaryRequest{}) + mdmAppleOrWinMW.GET("/api/_version_/fleet/mdm/hosts/{id:[0-9]+}/encryption_key", getHostEncryptionKey, getHostEncryptionKeyRequest{}) + // the following set of mdm endpoints must always be accessible (even // if MDM is not configured) as it bootstraps the setup of MDM // (generates CSR request for APNs, plus the SCEP and ABM keypairs). @@ -587,6 +591,9 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC oe.POST("/api/fleet/orbit/scripts/request", getOrbitScriptEndpoint, orbitGetScriptRequest{}) oe.POST("/api/fleet/orbit/scripts/result", postOrbitScriptResultEndpoint, orbitPostScriptResultRequest{}) + oeWindowsMDM := oe.WithCustomMiddleware(mdmConfiguredMiddleware.VerifyWindowsMDM()) + oeWindowsMDM.POST("/api/fleet/orbit/disk_encryption_key", postOrbitDiskEncryptionKeyEndpoint, orbitPostDiskEncryptionKeyRequest{}) + // unauthenticated endpoints - most of those are either login-related, // invite-related or host-enrolling. So they typically do some kind of // one-time authentication by verifying that a valid secret token is provided @@ -597,7 +604,7 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC // These endpoint are token authenticated. // NOTE: remember to update - // `service.mdmAppleConfigurationRequiredEndpoints` when you add an + // `service.mdmConfigurationRequiredEndpoints` when you add an // endpoint that's behind the mdmConfiguredMiddleware, this applies // both to this set of endpoints and to any user authenticated // endpoints using `mdmAppleMW.*` above in this file. diff --git a/server/service/hosts.go b/server/service/hosts.go index 840807bde9..db568e87b6 100644 --- a/server/service/hosts.go +++ b/server/service/hosts.go @@ -3,6 +3,7 @@ package service import ( "bytes" "context" + "crypto/tls" "encoding/csv" "encoding/json" "errors" @@ -19,7 +20,7 @@ import ( "github.com/fleetdm/fleet/v4/server/contexts/logging" "github.com/fleetdm/fleet/v4/server/contexts/viewer" "github.com/fleetdm/fleet/v4/server/fleet" - apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple" + "github.com/fleetdm/fleet/v4/server/mdm" "github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig" "github.com/fleetdm/fleet/v4/server/worker" "github.com/gocarina/gocsv" @@ -918,21 +919,34 @@ func (svc *Service) getHostDetails(ctx context.Context, host *fleet.Host, opts f var profiles []fleet.HostMDMAppleProfile if ac.MDM.EnabledAndConfigured { - profs, err := svc.ds.GetHostMDMProfiles(ctx, host.UUID) - if err != nil { - return nil, ctxerr.Wrap(ctx, err, "get host mdm profiles") - } - - // determine disk encryption and action required here based on profiles and - // raw decryptable key status. - host.MDM.DetermineDiskEncryptionStatus(profs, mobileconfig.FleetFileVaultPayloadIdentifier) - - for _, p := range profs { - if p.Identifier == mobileconfig.FleetFileVaultPayloadIdentifier { - p.Status = host.MDM.ProfileStatusFromDiskEncryptionState(p.Status) + host.MDM.OSSettings = &fleet.HostMDMOSSettings{} + switch host.Platform { + case "windows": + if license.IsPremium(ctx) { + bls, err := svc.ds.GetMDMWindowsBitLockerStatus(ctx, host) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "get host mdm bitlocker status") + } + host.MDM.OSSettings.DiskEncryption.Status = bls + } + case "darwin": + profs, err := svc.ds.GetHostMDMProfiles(ctx, host.UUID) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "get host mdm profiles") + } + + // determine disk encryption and action required here based on profiles and + // raw decryptable key status. + host.MDM.DetermineMacOSDiskEncryptionStatus(profs, mobileconfig.FleetFileVaultPayloadIdentifier) + host.MDM.OSSettings.DiskEncryption.Status = host.MDM.MacOSSettings.DiskEncryption + + for _, p := range profs { + if p.Identifier == mobileconfig.FleetFileVaultPayloadIdentifier { + p.Status = host.MDM.ProfileStatusFromDiskEncryptionState(p.Status) + } + p.Detail = fleet.HostMDMProfileDetail(p.Detail).Message() + profiles = append(profiles, p) } - p.Detail = fleet.HostMDMProfileDetail(p.Detail).Message() - profiles = append(profiles, p) } } host.MDM.Profiles = &profiles @@ -1563,25 +1577,48 @@ func (svc *Service) HostEncryptionKey(ctx context.Context, id uint) (*fleet.Host return nil, err } + // The middleware checks that either Apple or Windows MDM are configured and + // enabled, but here we must check if the specific one is enabled for that + // particular host's platform. + var decryptCert *tls.Certificate + switch host.FleetPlatform() { + case "windows": + if err := svc.VerifyMDMWindowsConfigured(ctx); err != nil { + return nil, err + } + + // use Microsoft's WSTEP certificate for decrypting + cert, _, _, err := svc.config.MDM.MicrosoftWSTEP() + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "getting Microsoft WSTEP certificate to decrypt key") + } + decryptCert = cert + + default: + if err := svc.VerifyMDMAppleConfigured(ctx); err != nil { + return nil, err + } + + // use Apple's SCEP certificate for decrypting + cert, _, _, err := svc.config.MDM.AppleSCEP() + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "getting Apple SCEP certificate to decrypt key") + } + decryptCert = cert + } + key, err := svc.ds.GetHostDiskEncryptionKey(ctx, id) if err != nil { return nil, ctxerr.Wrap(ctx, err, "getting host encryption key") } - if key.Decryptable == nil || !*key.Decryptable { - return nil, ctxerr.Wrap(ctx, newNotFoundError(), "getting host encryption key") + return nil, ctxerr.Wrap(ctx, newNotFoundError(), "host encryption key is not decryptable") } - cert, _, _, err := svc.config.MDM.AppleSCEP() + decryptedKey, err := mdm.DecryptBase64CMS(key.Base64Encrypted, decryptCert.Leaf, decryptCert.PrivateKey) if err != nil { - return nil, ctxerr.Wrap(ctx, err, "getting host encryption key") + return nil, ctxerr.Wrap(ctx, err, "decrypt host encryption key") } - - decryptedKey, err := apple_mdm.DecryptBase64CMS(key.Base64Encrypted, cert.Leaf, cert.PrivateKey) - if err != nil { - return nil, ctxerr.Wrap(ctx, err, "getting host encryption key") - } - key.DecryptedValue = string(decryptedKey) err = svc.ds.NewActivity( diff --git a/server/service/hosts_test.go b/server/service/hosts_test.go index e62fe936c6..1b77fa3f70 100644 --- a/server/service/hosts_test.go +++ b/server/service/hosts_test.go @@ -14,6 +14,7 @@ import ( "github.com/WatchBeam/clock" "github.com/fleetdm/fleet/v4/server/authz" "github.com/fleetdm/fleet/v4/server/config" + "github.com/fleetdm/fleet/v4/server/contexts/license" "github.com/fleetdm/fleet/v4/server/contexts/viewer" "github.com/fleetdm/fleet/v4/server/datastore/mysql" "github.com/fleetdm/fleet/v4/server/fleet" @@ -83,7 +84,7 @@ func TestHostDetails(t *testing.T) { require.Nil(t, hostDetail.MDM.MacOSSettings) } -func TestHostDetailsMDMDiskEncryption(t *testing.T) { +func TestHostDetailsMDMAppleDiskEncryption(t *testing.T) { ds := new(mock.Store) svc := &Service{ds: ds} @@ -308,7 +309,7 @@ func TestHostDetailsMDMDiskEncryption(t *testing.T) { } require.NoError(t, mdmData.Scan([]byte(fmt.Sprintf(`{"raw_decryptable": %s}`, rawDecrypt)))) - host := &fleet.Host{ID: 3, MDM: mdmData, UUID: "abc"} + host := &fleet.Host{ID: 3, MDM: mdmData, UUID: "abc", Platform: "darwin"} opts := fleet.HostDetailOptions{ IncludeCVEScores: false, IncludePolicies: false, @@ -322,12 +323,16 @@ func TestHostDetailsMDMDiskEncryption(t *testing.T) { } hostDetail, err := svc.getHostDetails(test.UserContext(context.Background(), test.UserAdmin), host, opts) require.NoError(t, err) + require.NotNil(t, hostDetail.MDM.MacOSSettings) if c.wantState == "" { require.Nil(t, hostDetail.MDM.MacOSSettings.DiskEncryption) + require.Nil(t, hostDetail.MDM.OSSettings.DiskEncryption.Status) } else { require.NotNil(t, hostDetail.MDM.MacOSSettings.DiskEncryption) require.Equal(t, c.wantState, *hostDetail.MDM.MacOSSettings.DiskEncryption) + require.NotNil(t, hostDetail.MDM.OSSettings.DiskEncryption.Status) + require.Equal(t, c.wantState, *hostDetail.MDM.OSSettings.DiskEncryption.Status) } if c.wantAction == "" { require.Nil(t, hostDetail.MDM.MacOSSettings.ActionRequired) @@ -346,6 +351,100 @@ func TestHostDetailsMDMDiskEncryption(t *testing.T) { } } +func TestHostDetailsOSSettings(t *testing.T) { + ds := new(mock.Store) + svc := &Service{ds: ds} + + ctx := context.Background() + + ds.ListLabelsForHostFunc = func(ctx context.Context, hid uint) ([]*fleet.Label, error) { + return nil, nil + } + ds.ListPacksForHostFunc = func(ctx context.Context, hid uint) ([]*fleet.Pack, error) { + return nil, nil + } + ds.LoadHostSoftwareFunc = func(ctx context.Context, host *fleet.Host, includeCVEScores bool) error { + return nil + } + ds.ListPoliciesForHostFunc = func(ctx context.Context, host *fleet.Host) ([]*fleet.HostPolicy, error) { + return nil, nil + } + ds.ListHostBatteriesFunc = func(ctx context.Context, hostID uint) ([]*fleet.HostBattery, error) { + return nil, nil + } + ds.GetHostMDMMacOSSetupFunc = func(ctx context.Context, hid uint) (*fleet.HostMDMMacOSSetup, error) { + return nil, nil + } + + type testCase struct { + name string + host *fleet.Host + licenseTier string + wantStatus fleet.DiskEncryptionStatus + } + cases := []testCase{ + {"windows", &fleet.Host{ID: 42, Platform: "windows"}, fleet.TierPremium, fleet.DiskEncryptionEnforcing}, + {"darwin", &fleet.Host{ID: 42, Platform: "darwin"}, fleet.TierPremium, ""}, + {"ubuntu", &fleet.Host{ID: 42, Platform: "ubuntu"}, fleet.TierPremium, ""}, + {"not premium", &fleet.Host{ID: 42, Platform: "windows"}, fleet.TierFree, ""}, + } + + setupDS := func(c testCase) { + ds.AppConfigFuncInvoked = false + ds.GetMDMWindowsBitLockerStatusFuncInvoked = false + ds.GetHostMDMProfilesFuncInvoked = false + + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return &fleet.AppConfig{MDM: fleet.MDM{EnabledAndConfigured: true}}, nil + } + ds.GetMDMWindowsBitLockerStatusFunc = func(ctx context.Context, host *fleet.Host) (*fleet.DiskEncryptionStatus, error) { + if c.wantStatus == "" { + return nil, nil + } + return &c.wantStatus, nil + } + ds.GetHostMDMProfilesFunc = func(ctx context.Context, uuid string) ([]fleet.HostMDMAppleProfile, error) { + return nil, nil + } + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + setupDS(c) + + ctx = license.NewContext(ctx, &fleet.LicenseInfo{Tier: c.licenseTier}) + + hostDetail, err := svc.getHostDetails(test.UserContext(ctx, test.UserAdmin), c.host, fleet.HostDetailOptions{ + IncludeCVEScores: false, + IncludePolicies: false, + }) + require.NoError(t, err) + require.NotNil(t, hostDetail) + require.True(t, ds.AppConfigFuncInvoked) + + switch c.host.Platform { + case "windows": + require.False(t, ds.GetHostMDMProfilesFuncInvoked) + if c.wantStatus != "" { + require.True(t, ds.GetMDMWindowsBitLockerStatusFuncInvoked) + require.NotNil(t, hostDetail.MDM.OSSettings.DiskEncryption.Status) + require.Equal(t, c.wantStatus, *hostDetail.MDM.OSSettings.DiskEncryption.Status) + } else { + require.False(t, ds.GetMDMWindowsBitLockerStatusFuncInvoked) + require.Nil(t, hostDetail.MDM.OSSettings.DiskEncryption.Status) + } + case "darwin": + require.True(t, ds.GetHostMDMProfilesFuncInvoked) + require.False(t, ds.GetMDMWindowsBitLockerStatusFuncInvoked) + require.Nil(t, hostDetail.MDM.OSSettings.DiskEncryption.Status) + default: + require.False(t, ds.GetHostMDMProfilesFuncInvoked) + require.False(t, ds.GetMDMWindowsBitLockerStatusFuncInvoked) + } + }) + } +} + func TestHostAuth(t *testing.T) { ds := new(mock.Store) svc, ctx := newTestService(t, ds, nil, nil) @@ -902,9 +1001,18 @@ func TestHostEncryptionKey(t *testing.T) { require.NoError(t, err) base64EncryptedKey := base64.StdEncoding.EncodeToString(encryptedKey) + wstep, _, _, err := fleetCfg.MDM.MicrosoftWSTEP() + require.NoError(t, err) + winEncryptedKey, err := pkcs7.Encrypt([]byte(recoveryKey), []*x509.Certificate{wstep.Leaf}) + require.NoError(t, err) + winBase64EncryptedKey := base64.StdEncoding.EncodeToString(winEncryptedKey) + for _, tt := range cases { t.Run(tt.name, func(t *testing.T) { ds := new(mock.Store) + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return &fleet.AppConfig{MDM: fleet.MDM{EnabledAndConfigured: true}}, nil + } svc, ctx := newTestServiceWithConfig(t, ds, fleetCfg, nil, nil) ds.HostLiteFunc = func(ctx context.Context, id uint) (*fleet.Host, error) { @@ -951,7 +1059,10 @@ func TestHostEncryptionKey(t *testing.T) { t.Run("test error cases", func(t *testing.T) { ds := new(mock.Store) - svc, ctx := newTestService(t, ds, nil, nil) + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return &fleet.AppConfig{MDM: fleet.MDM{EnabledAndConfigured: true}}, nil + } + svc, ctx := newTestServiceWithConfig(t, ds, fleetCfg, nil, nil) ctx = test.UserContext(ctx, test.UserAdmin) hostErr := errors.New("host error") @@ -981,6 +1092,56 @@ func TestHostEncryptionKey(t *testing.T) { _, err = svc.HostEncryptionKey(ctx, 1) require.Error(t, err) }) + + t.Run("host platform mdm enabled", func(t *testing.T) { + cases := []struct { + hostPlatform string + macMDMEnabled bool + winMDMEnabled bool + shouldFail bool + }{ + {"windows", true, false, true}, + {"windows", false, true, false}, + {"windows", true, true, false}, + {"darwin", true, false, false}, + {"darwin", false, true, true}, + {"darwin", true, true, false}, + } + for _, c := range cases { + t.Run(fmt.Sprintf("%s: mac mdm: %t; win mdm: %t", c.hostPlatform, c.macMDMEnabled, c.winMDMEnabled), func(t *testing.T) { + ds := new(mock.Store) + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return &fleet.AppConfig{MDM: fleet.MDM{EnabledAndConfigured: c.macMDMEnabled, WindowsEnabledAndConfigured: c.winMDMEnabled}}, nil + } + ds.HostLiteFunc = func(ctx context.Context, id uint) (*fleet.Host, error) { + return &fleet.Host{Platform: c.hostPlatform}, nil + } + ds.GetHostDiskEncryptionKeyFunc = func(ctx context.Context, id uint) (*fleet.HostDiskEncryptionKey, error) { + key := base64EncryptedKey + if c.hostPlatform == "windows" { + key = winBase64EncryptedKey + } + return &fleet.HostDiskEncryptionKey{ + Base64Encrypted: key, + Decryptable: ptr.Bool(true), + }, nil + } + ds.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error { + return nil + } + + svc, ctx := newTestServiceWithConfig(t, ds, fleetCfg, nil, nil) + ctx = test.UserContext(ctx, test.UserAdmin) + _, err := svc.HostEncryptionKey(ctx, 1) + if c.shouldFail { + require.Error(t, err) + require.ErrorContains(t, err, fleet.ErrMDMNotConfigured.Error()) + } else { + require.NoError(t, err) + } + }) + } + }) } func TestHostMDMProfileDetail(t *testing.T) { @@ -1005,7 +1166,8 @@ func TestHostMDMProfileDetail(t *testing.T) { ds.HostFunc = func(ctx context.Context, id uint) (*fleet.Host, error) { return &fleet.Host{ - ID: 1, + ID: 1, + Platform: "darwin", }, nil } ds.LoadHostSoftwareFunc = func(ctx context.Context, host *fleet.Host, includeCVEScores bool) error { diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index d3ab73919f..9bc27ec2fd 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -4970,11 +4970,18 @@ func (s *integrationTestSuite) TestAppConfig() { // set the macos disk encryption field, fails due to license res := s.Do("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ - "mdm": { "macos_settings": { "enable_disk_encryption": true } } + "mdm": { "enable_disk_encryption": true } }`), http.StatusUnprocessableEntity) errMsg := extractServerErrorText(res.Body) assert.Contains(t, errMsg, "missing or invalid license") + // legacy config + res = s.Do("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ + "mdm": { "macos_settings": { "enable_disk_encryption": true } } + }`), http.StatusUnprocessableEntity) + errMsg = extractServerErrorText(res.Body) + assert.Contains(t, errMsg, "missing or invalid license") + // try to set the apple bm default team, which is premium only s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ "mdm": { "apple_bm_default_team": "xyz" } @@ -6425,6 +6432,16 @@ func (s *integrationTestSuite) TestGetHostDiskEncryption() { s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", hostLin.ID), nil, http.StatusOK, &getHostResp) require.Equal(t, hostLin.ID, getHostResp.Host.ID) require.Nil(t, getHostResp.Host.DiskEncryptionEnabled) + + // the orbit endpoint to set the disk encryption key always fails in this + // suite because MDM is not configured. + orbitHost := createOrbitEnrolledHost(t, "windows", "diskenc", s.ds) + res := s.Do("POST", "/api/fleet/orbit/disk_encryption_key", orbitPostDiskEncryptionKeyRequest{ + OrbitNodeKey: *orbitHost.OrbitNodeKey, + EncryptionKey: []byte("testkey"), + }, http.StatusBadRequest) + errMsg := extractServerErrorText(res.Body) + require.Contains(t, errMsg, fleet.ErrMDMNotConfigured.Error()) } func (s *integrationTestSuite) TestOSVersions() { @@ -6477,14 +6494,14 @@ func (s *integrationTestSuite) TestPingEndpoints() { s.DoRawNoAuth("HEAD", "/api/fleet/device/ping", nil, http.StatusOK) } -func (s *integrationTestSuite) TestAppleMDMNotConfigured() { +func (s *integrationTestSuite) TestMDMNotConfiguredEndpoints() { t := s.T() // create a host with device token to test device authenticated routes tkn := "D3V1C370K3N" createHostAndDeviceToken(t, s.ds, tkn) - for _, route := range mdmAppleConfigurationRequiredEndpoints() { + for _, route := range mdmConfigurationRequiredEndpoints() { which := fmt.Sprintf("%s %s", route.method, route.path) var expectedErr fleet.ErrWithStatusCode = fleet.ErrMDMNotConfigured if route.premiumOnly && route.deviceAuthenticated { diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index 83f4976de8..60d9093449 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -239,7 +239,7 @@ func (s *integrationEnterpriseTestSuite) TestTeamSpecs() { require.NoError(t, err) require.Contains(t, string(*team.Config.AgentOptions), `"foo": "bar"`) // unchanged require.Empty(t, team.Config.MDM.MacOSSettings.CustomSettings) // unchanged - require.False(t, team.Config.MDM.MacOSSettings.EnableDiskEncryption) // unchanged + require.False(t, team.Config.MDM.EnableDiskEncryption) // unchanged // apply without agent options specified teamSpecs = map[string]any{ @@ -764,7 +764,7 @@ func (s *integrationEnterpriseTestSuite) TestTeamEndpoints() { // modify team's disk encryption, impossible without mdm enabled res := s.Do("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", tm1ID), fleet.TeamPayload{ MDM: &fleet.TeamPayloadMDM{ - MacOSSettings: &fleet.MacOSSettings{EnableDiskEncryption: true}, + EnableDiskEncryption: optjson.SetBool(true), }, }, http.StatusUnprocessableEntity) errMsg := extractServerErrorText(res.Body) @@ -2532,14 +2532,14 @@ func (s *integrationEnterpriseTestSuite) TestListHosts() { require.Nil(t, summaryResp.LowDiskSpaceCount) } -func (s *integrationEnterpriseTestSuite) TestAppleMDMNotConfigured() { +func (s *integrationEnterpriseTestSuite) TestMDMNotConfiguredEndpoints() { t := s.T() // create a host with device token to test device authenticated routes tkn := "D3V1C370K3N" createHostAndDeviceToken(t, s.ds, tkn) - for _, route := range mdmAppleConfigurationRequiredEndpoints() { + for _, route := range mdmConfigurationRequiredEndpoints() { var expectedErr fleet.ErrWithStatusCode = fleet.ErrMDMNotConfigured path := route.path if route.deviceAuthenticated { diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go index ddf83f81a2..618d6c2dcd 100644 --- a/server/service/integration_mdm_test.go +++ b/server/service/integration_mdm_test.go @@ -32,6 +32,7 @@ import ( "github.com/fleetdm/fleet/v4/server/datastore/mysql" "github.com/fleetdm/fleet/v4/server/datastore/redis/redistest" "github.com/fleetdm/fleet/v4/server/fleet" + servermdm "github.com/fleetdm/fleet/v4/server/mdm" apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple" "github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig" microsoft_mdm "github.com/fleetdm/fleet/v4/server/mdm/microsoft" @@ -238,7 +239,7 @@ func (s *integrationMDMTestSuite) TearDownTest() { // ensure windows mdm is always enabled for the next test appCfg.MDM.WindowsEnabledAndConfigured = true // ensure global disk encryption is disabled on exit - appCfg.MDM.MacOSSettings.EnableDiskEncryption = false + appCfg.MDM.EnableDiskEncryption = optjson.SetBool(false) err := s.ds.SaveAppConfig(ctx, &appCfg.AppConfig) require.NoError(t, err) @@ -1059,7 +1060,7 @@ func (s *integrationMDMTestSuite) TestPuppetMatchPreassignProfiles() { require.Equal(t, prof1, []byte(profs[0].Mobileconfig)) require.Equal(t, prof2, []byte(profs[1].Mobileconfig)) // filevault is enabled by default - require.True(t, tm1.Config.MDM.MacOSSettings.EnableDiskEncryption) + require.True(t, tm1.Config.MDM.EnableDiskEncryption) // setup assistant settings are copyied from "no team" teamAsst, err := s.ds.GetMDMAppleSetupAssistant(ctx, &tm1.ID) require.NoError(t, err) @@ -1146,7 +1147,7 @@ func (s *integrationMDMTestSuite) TestPuppetMatchPreassignProfiles() { // simulate having its profiles installed mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { - _, err := q.ExecContext(ctx, `UPDATE host_mdm_apple_profiles SET status = ? WHERE host_uuid = ?`, fleet.MacOSSettingsVerifying, mdmHost2.UUID) + _, err := q.ExecContext(ctx, `UPDATE host_mdm_apple_profiles SET status = ? WHERE host_uuid = ?`, fleet.OSSettingsVerifying, mdmHost2.UUID) return err }) @@ -1297,7 +1298,7 @@ func (s *integrationMDMTestSuite) TestPuppetRun() { require.Equal(t, prof1, []byte(profs[0].Mobileconfig)) require.Equal(t, prof2, []byte(profs[1].Mobileconfig)) require.Equal(t, prof3, []byte(profs[2].Mobileconfig)) - require.True(t, tm1.Config.MDM.MacOSSettings.EnableDiskEncryption) + require.True(t, tm1.Config.MDM.EnableDiskEncryption) // host2 checks in puppetRun(host2) @@ -1318,7 +1319,7 @@ func (s *integrationMDMTestSuite) TestPuppetRun() { require.Equal(t, prof2, []byte(profs[1].Mobileconfig)) require.Equal(t, prof3, []byte(profs[2].Mobileconfig)) require.Equal(t, prof4, []byte(profs[3].Mobileconfig)) - require.True(t, tm2.Config.MDM.MacOSSettings.EnableDiskEncryption) + require.True(t, tm2.Config.MDM.EnableDiskEncryption) // host3 checks in puppetRun(host3) @@ -1345,7 +1346,7 @@ func (s *integrationMDMTestSuite) TestPuppetRun() { require.Equal(t, prof2, []byte(profs[1].Mobileconfig)) require.Equal(t, prof3, []byte(profs[2].Mobileconfig)) require.NotEqual(t, oldProf2, []byte(profs[1].Mobileconfig)) - require.True(t, tm1.Config.MDM.MacOSSettings.EnableDiskEncryption) + require.True(t, tm1.Config.MDM.EnableDiskEncryption) // host2 checks in, still belongs to the same team puppetRun(host2) @@ -1362,7 +1363,7 @@ func (s *integrationMDMTestSuite) TestPuppetRun() { require.Equal(t, prof3, []byte(profs[2].Mobileconfig)) require.Equal(t, prof4, []byte(profs[3].Mobileconfig)) require.NotEqual(t, oldProf2, []byte(profs[1].Mobileconfig)) - require.True(t, tm1.Config.MDM.MacOSSettings.EnableDiskEncryption) + require.True(t, tm1.Config.MDM.EnableDiskEncryption) // the puppet manifest is changed, and prof3 is removed // node default { @@ -1423,7 +1424,7 @@ func (s *integrationMDMTestSuite) TestPuppetRun() { require.Len(t, profs, 2) require.Equal(t, prof1, []byte(profs[0].Mobileconfig)) require.Equal(t, prof2, []byte(profs[1].Mobileconfig)) - require.True(t, tm1.Config.MDM.MacOSSettings.EnableDiskEncryption) + require.True(t, tm1.Config.MDM.EnableDiskEncryption) // same for host2 puppetRun(host2) @@ -1436,7 +1437,7 @@ func (s *integrationMDMTestSuite) TestPuppetRun() { require.Equal(t, prof1, []byte(profs[0].Mobileconfig)) require.Equal(t, prof2, []byte(profs[1].Mobileconfig)) require.Equal(t, prof4, []byte(profs[2].Mobileconfig)) - require.True(t, tm1.Config.MDM.MacOSSettings.EnableDiskEncryption) + require.True(t, tm1.Config.MDM.EnableDiskEncryption) // The puppet manifest is drastically updated, this time to use exclusions on host3: // @@ -1515,7 +1516,7 @@ func (s *integrationMDMTestSuite) TestPuppetRun() { require.Equal(t, prof1, []byte(profs[0].Mobileconfig)) require.Equal(t, prof2, []byte(profs[1].Mobileconfig)) require.Equal(t, prof3, []byte(profs[2].Mobileconfig)) - require.True(t, tm1.Config.MDM.MacOSSettings.EnableDiskEncryption) + require.True(t, tm1.Config.MDM.EnableDiskEncryption) // host2 checks in puppetRun(host2) @@ -1544,7 +1545,7 @@ func (s *integrationMDMTestSuite) TestPuppetRun() { require.Len(t, profs, 2) require.Equal(t, prof1, []byte(profs[0].Mobileconfig)) require.Equal(t, prof2, []byte(profs[1].Mobileconfig)) - require.True(t, tm3.Config.MDM.MacOSSettings.EnableDiskEncryption) + require.True(t, tm3.Config.MDM.EnableDiskEncryption) } func createHostThenEnrollMDM(ds fleet.Datastore, fleetServerURL string, t *testing.T) (*fleet.Host, *mdmtest.TestMDMClient) { @@ -2174,6 +2175,261 @@ func (s *integrationMDMTestSuite) TestMDMAppleUnenroll() { require.Equal(t, "", hostResp.Host.MDM.Name) } +func (s *integrationMDMTestSuite) TestMDMDiskEncryptionSettingBackwardsCompat() { + t := s.T() + + acResp := appConfigResponse{} + s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ + "mdm": { "enable_disk_encryption": false } + }`), http.StatusOK, &acResp) + assert.False(t, acResp.MDM.EnableDiskEncryption.Value) + + // new config takes precedence over old config + acResp = appConfigResponse{} + s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ + "mdm": { "enable_disk_encryption": false, "macos_settings": {"enable_disk_encryption": true} } + }`), http.StatusOK, &acResp) + assert.False(t, acResp.MDM.EnableDiskEncryption.Value) + + s.assertConfigProfilesByIdentifier(nil, mobileconfig.FleetFileVaultPayloadIdentifier, false) + + // if new config is not present, old config is applied + acResp = appConfigResponse{} + s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ + "mdm": { "macos_settings": {"enable_disk_encryption": true} } + }`), http.StatusOK, &acResp) + assert.True(t, acResp.MDM.EnableDiskEncryption.Value) + s.assertConfigProfilesByIdentifier(nil, mobileconfig.FleetFileVaultPayloadIdentifier, true) + + // new config takes precedence over old config again + acResp = appConfigResponse{} + s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ + "mdm": { "enable_disk_encryption": false, "macos_settings": {"enable_disk_encryption": true} } + }`), http.StatusOK, &acResp) + assert.False(t, acResp.MDM.EnableDiskEncryption.Value) + s.assertConfigProfilesByIdentifier(nil, mobileconfig.FleetFileVaultPayloadIdentifier, false) + + // unrelated change doesn't affect the disk encryption setting + acResp = appConfigResponse{} + s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ + "mdm": { "macos_settings": {"custom_settings": ["test.mobileconfig"]} } + }`), http.StatusOK, &acResp) + assert.False(t, acResp.MDM.EnableDiskEncryption.Value) + + // Same tests, but for teams + team, err := s.ds.NewTeam(context.Background(), &fleet.Team{ + Name: "team1_" + t.Name(), + Description: "desc team1_" + t.Name(), + }) + require.NoError(t, err) + + checkTeamDiskEncryption := func(wantSetting bool) { + var teamResp getTeamResponse + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp) + require.Equal(t, wantSetting, teamResp.Team.Config.MDM.EnableDiskEncryption) + } + + // after creation, disk encryption is off + checkTeamDiskEncryption(false) + + // new config takes precedence over old config + teamSpecs := applyTeamSpecsRequest{Specs: []*fleet.TeamSpec{{ + Name: team.Name, + MDM: fleet.TeamSpecMDM{ + EnableDiskEncryption: optjson.SetBool(false), + MacOSSettings: map[string]interface{}{"enable_disk_encryption": true}, + }, + }}} + s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK) + checkTeamDiskEncryption(false) + s.assertConfigProfilesByIdentifier(ptr.Uint(team.ID), mobileconfig.FleetFileVaultPayloadIdentifier, false) + + // if new config is not present, old config is applied + teamSpecs = applyTeamSpecsRequest{Specs: []*fleet.TeamSpec{{ + Name: team.Name, + MDM: fleet.TeamSpecMDM{ + MacOSSettings: map[string]interface{}{"enable_disk_encryption": true}, + }, + }}} + s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK) + checkTeamDiskEncryption(true) + s.assertConfigProfilesByIdentifier(ptr.Uint(team.ID), mobileconfig.FleetFileVaultPayloadIdentifier, true) + + // new config takes precedence over old config again + teamSpecs = applyTeamSpecsRequest{Specs: []*fleet.TeamSpec{{ + Name: team.Name, + MDM: fleet.TeamSpecMDM{ + EnableDiskEncryption: optjson.SetBool(false), + MacOSSettings: map[string]interface{}{"enable_disk_encryption": true}, + }, + }}} + s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK) + checkTeamDiskEncryption(false) + s.assertConfigProfilesByIdentifier(ptr.Uint(team.ID), mobileconfig.FleetFileVaultPayloadIdentifier, false) + + // unrelated change doesn't affect the disk encryption setting + teamSpecs = applyTeamSpecsRequest{Specs: []*fleet.TeamSpec{{ + Name: team.Name, + MDM: fleet.TeamSpecMDM{ + EnableDiskEncryption: optjson.SetBool(false), + MacOSSettings: map[string]interface{}{"custom_settings": []interface{}{"A", "B"}}, + }, + }}} + s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK) + checkTeamDiskEncryption(false) + s.assertConfigProfilesByIdentifier(ptr.Uint(team.ID), mobileconfig.FleetFileVaultPayloadIdentifier, false) +} + +func (s *integrationMDMTestSuite) TestDiskEncryptionSharedSetting() { + t := s.T() + + // create a team + teamName := t.Name() + team := &fleet.Team{ + Name: teamName, + Description: "desc " + teamName, + } + var createTeamResp teamResponse + s.DoJSON("POST", "/api/latest/fleet/teams", team, http.StatusOK, &createTeamResp) + require.NotZero(t, createTeamResp.Team.ID) + + setMDMEnabled := func(macMDM, windowsMDM bool) { + appConf, err := s.ds.AppConfig(context.Background()) + require.NoError(s.T(), err) + appConf.MDM.WindowsEnabledAndConfigured = windowsMDM + appConf.MDM.EnabledAndConfigured = macMDM + err = s.ds.SaveAppConfig(context.Background(), appConf) + require.NoError(s.T(), err) + } + + // before doing any modifications, grab the current values and make + // sure they're set to the same ones on cleanup to not interfere with + // other tests. + origAppConf, err := s.ds.AppConfig(context.Background()) + require.NoError(s.T(), err) + t.Cleanup(func() { + err := s.ds.SaveAppConfig(context.Background(), origAppConf) + require.NoError(s.T(), err) + }) + + checkConfigSetErrors := func() { + // try to set app config + res := s.Do("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ + "mdm": { "enable_disk_encryption": true } + }`), http.StatusUnprocessableEntity) + errMsg := extractServerErrorText(res.Body) + require.Contains(t, errMsg, "Couldn't edit enable_disk_encryption. Neither macOS MDM nor Windows is turned on. Visit https://fleetdm.com/docs/using-fleet to learn how to turn on MDM.") + + // try to create a new team using specs + teamSpecs := map[string]any{ + "specs": []any{ + map[string]any{ + "name": teamName + uuid.NewString(), + "mdm": map[string]any{ + "enable_disk_encryption": true, + }, + }, + }, + } + res = s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusUnprocessableEntity) + errMsg = extractServerErrorText(res.Body) + require.Contains(t, errMsg, "Couldn't edit enable_disk_encryption. Neither macOS MDM nor Windows is turned on. Visit https://fleetdm.com/docs/using-fleet to learn how to turn on MDM.") + + // try to edit the existing team using specs + teamSpecs = map[string]any{ + "specs": []any{ + map[string]any{ + "name": teamName, + "mdm": map[string]any{ + "enable_disk_encryption": true, + }, + }, + }, + } + res = s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusUnprocessableEntity) + errMsg = extractServerErrorText(res.Body) + require.Contains(t, errMsg, "Couldn't edit enable_disk_encryption. Neither macOS MDM nor Windows is turned on. Visit https://fleetdm.com/docs/using-fleet to learn how to turn on MDM.") + } + + checkConfigSetSucceeds := func() { + res := s.Do("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ + "mdm": { "enable_disk_encryption": true } + }`), http.StatusOK) + errMsg := extractServerErrorText(res.Body) + require.Empty(t, errMsg) + + // try to create a new team using specs + teamSpecs := map[string]any{ + "specs": []any{ + map[string]any{ + "name": teamName + uuid.NewString(), + "mdm": map[string]any{ + "enable_disk_encryption": true, + }, + }, + }, + } + res = s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK) + errMsg = extractServerErrorText(res.Body) + require.Empty(t, errMsg) + + // edit the existing team using specs + teamSpecs = map[string]any{ + "specs": []any{ + map[string]any{ + "name": teamName, + "mdm": map[string]any{ + "enable_disk_encryption": true, + }, + }, + }, + } + res = s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK) + errMsg = extractServerErrorText(res.Body) + require.Empty(t, errMsg) + + // always try to set the value to `false` so we start fresh + s.Do("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ + "mdm": { "enable_disk_encryption": false } + }`), http.StatusOK) + teamSpecs = map[string]any{ + "specs": []any{ + map[string]any{ + "name": teamName, + "mdm": map[string]any{ + "enable_disk_encryption": false, + }, + }, + }, + } + s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK) + } + + // 1. disable both windows and mac mdm + // 2. turn off windows feature flag + // we should get an error + setMDMEnabled(false, false) + t.Setenv("FLEET_DEV_MDM_ENABLED", "0") + checkConfigSetErrors() + + // turn on windows feature flag + // we should get an error + t.Setenv("FLEET_DEV_MDM_ENABLED", "1") + checkConfigSetErrors() + + // enable windows mdm, no errors + setMDMEnabled(false, true) + checkConfigSetSucceeds() + + // enable mac mdm, no errors + setMDMEnabled(true, true) + checkConfigSetSucceeds() + + // only macos mdm enabled, no errors + setMDMEnabled(true, false) + checkConfigSetSucceeds() +} + func (s *integrationMDMTestSuite) TestMDMAppleGetEncryptionKey() { t := s.T() ctx := context.Background() @@ -2196,9 +2452,9 @@ func (s *integrationMDMTestSuite) TestMDMAppleGetEncryptionKey() { acResp := appConfigResponse{} s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ - "mdm": { "macos_settings": { "enable_disk_encryption": true } } + "mdm": { "enable_disk_encryption": true } }`), http.StatusOK, &acResp) - assert.True(t, acResp.MDM.MacOSSettings.EnableDiskEncryption) + assert.True(t, acResp.MDM.EnableDiskEncryption.Value) fileVaultProf := s.assertConfigProfilesByIdentifier(nil, mobileconfig.FleetFileVaultPayloadIdentifier, true) hostCmdUUID := uuid.New().String() err = s.ds.BulkUpsertMDMAppleHostProfiles(ctx, []*fleet.MDMAppleBulkUpsertHostProfilePayload{ @@ -2246,7 +2502,7 @@ func (s *integrationMDMTestSuite) TestMDMAppleGetEncryptionKey() { require.NoError(t, err) base64EncryptedKey := base64.StdEncoding.EncodeToString(encryptedKey) - err = s.ds.SetOrUpdateHostDiskEncryptionKey(ctx, host.ID, base64EncryptedKey) + err = s.ds.SetOrUpdateHostDiskEncryptionKey(ctx, host.ID, base64EncryptedKey, "", nil) require.NoError(t, err) // get that host - it has an encryption key with unknown decryptability, so @@ -2321,7 +2577,7 @@ func (s *integrationMDMTestSuite) TestMDMAppleGetEncryptionKey() { teamSpecs := applyTeamSpecsRequest{Specs: []*fleet.TeamSpec{{ Name: "team1_" + t.Name(), MDM: fleet.TeamSpecMDM{ - MacOSSettings: map[string]interface{}{"enable_disk_encryption": true}, + EnableDiskEncryption: optjson.SetBool(true), }, }}} s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK) @@ -2420,6 +2676,52 @@ func (s *integrationMDMTestSuite) TestMDMAppleGetEncryptionKey() { s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/mdm/hosts/%d/encryption_key", host.ID), nil, http.StatusForbidden, &resp) } +func (s *integrationMDMTestSuite) TestWindowsMDMGetEncryptionKey() { + t := s.T() + ctx := context.Background() + + // create a host and enroll it in Fleet + host := createOrbitEnrolledHost(t, "windows", "h1", s.ds) + err := s.ds.SetOrUpdateMDMData(ctx, host.ID, false, true, s.server.URL, false, fleet.WellKnownMDMFleet) + require.NoError(t, err) + + // request encryption key with no auth token + res := s.DoRawNoAuth("GET", fmt.Sprintf("/api/latest/fleet/mdm/hosts/%d/encryption_key", host.ID), nil, http.StatusUnauthorized) + res.Body.Close() + + // no encryption key + resp := getHostEncryptionKeyResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/mdm/hosts/%d/encryption_key", host.ID), nil, http.StatusNotFound, &resp) + + // invalid host id + resp = getHostEncryptionKeyResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/mdm/hosts/%d/encryption_key", host.ID+999), nil, http.StatusNotFound, &resp) + + // add an encryption key for the host + cert, _, _, err := s.fleetCfg.MDM.MicrosoftWSTEP() + require.NoError(t, err) + recoveryKey := "AAA-BBB-CCC" + encryptedKey, err := microsoft_mdm.Encrypt(recoveryKey, cert.Leaf) + require.NoError(t, err) + + err = s.ds.SetOrUpdateHostDiskEncryptionKey(ctx, host.ID, encryptedKey, "", ptr.Bool(true)) + require.NoError(t, err) + + resp = getHostEncryptionKeyResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/mdm/hosts/%d/encryption_key", host.ID), nil, http.StatusOK, &resp) + require.Equal(t, host.ID, resp.HostID) + require.Equal(t, recoveryKey, resp.EncryptionKey.DecryptedValue) + s.lastActivityOfTypeMatches(fleet.ActivityTypeReadHostDiskEncryptionKey{}.ActivityName(), + fmt.Sprintf(`{"host_display_name": "%s", "host_id": %d}`, host.DisplayName(), host.ID), 0) + + // update the key to blank with a client error + err = s.ds.SetOrUpdateHostDiskEncryptionKey(ctx, host.ID, "", "failed", nil) + require.NoError(t, err) + + resp = getHostEncryptionKeyResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/mdm/hosts/%d/encryption_key", host.ID), nil, http.StatusNotFound, &resp) +} + func (s *integrationMDMTestSuite) TestMDMAppleListConfigProfiles() { t := s.T() ctx := context.Background() @@ -2663,9 +2965,9 @@ func (s *integrationMDMTestSuite) TestMDMAppleConfigProfileCRUD() { // make fleet add a FileVault profile acResp := appConfigResponse{} s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ - "mdm": { "macos_settings": { "enable_disk_encryption": true } } + "mdm": { "enable_disk_encryption": true } }`), http.StatusOK, &acResp) - assert.True(t, acResp.MDM.MacOSSettings.EnableDiskEncryption) + assert.True(t, acResp.MDM.EnableDiskEncryption.Value) profile := s.assertConfigProfilesByIdentifier(nil, mobileconfig.FleetFileVaultPayloadIdentifier, true) // try to delete the profile @@ -2693,7 +2995,7 @@ func (s *integrationMDMTestSuite) TestAppConfigMDMAppleProfiles() { // field, should not remove them acResp = appConfigResponse{} s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ - "mdm": { "macos_settings": {"enable_disk_encryption": true} } + "mdm": { "enable_disk_encryption": true } }`), http.StatusOK, &acResp) assert.Equal(t, []string{"foo", "bar"}, acResp.MDM.MacOSSettings.CustomSettings) @@ -2719,9 +3021,9 @@ func (s *integrationMDMTestSuite) TestAppConfigMDMAppleDiskEncryption() { // set the macos disk encryption field acResp := appConfigResponse{} s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ - "mdm": { "macos_settings": { "enable_disk_encryption": true } } + "mdm": { "enable_disk_encryption": true } }`), http.StatusOK, &acResp) - assert.True(t, acResp.MDM.MacOSSettings.EnableDiskEncryption) + assert.True(t, acResp.MDM.EnableDiskEncryption.Value) enabledDiskActID := s.lastActivityMatches(fleet.ActivityTypeEnabledMacosDiskEncryption{}.ActivityName(), `{"team_id": null, "team_name": null}`, 0) @@ -2731,7 +3033,7 @@ func (s *integrationMDMTestSuite) TestAppConfigMDMAppleDiskEncryption() { // check that they are returned by a GET /config acResp = appConfigResponse{} s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &acResp) - assert.True(t, acResp.MDM.MacOSSettings.EnableDiskEncryption) + assert.True(t, acResp.MDM.EnableDiskEncryption.Value) // patch without specifying the macos disk encryption and an unrelated field, // should not alter it @@ -2739,7 +3041,7 @@ func (s *integrationMDMTestSuite) TestAppConfigMDMAppleDiskEncryption() { s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ "mdm": { "macos_settings": {"custom_settings": ["a"]} } }`), http.StatusOK, &acResp) - assert.True(t, acResp.MDM.MacOSSettings.EnableDiskEncryption) + assert.True(t, acResp.MDM.EnableDiskEncryption.Value) assert.Equal(t, []string{"a"}, acResp.MDM.MacOSSettings.CustomSettings) s.lastActivityMatches(fleet.ActivityTypeEnabledMacosDiskEncryption{}.ActivityName(), ``, enabledDiskActID) @@ -2747,9 +3049,9 @@ func (s *integrationMDMTestSuite) TestAppConfigMDMAppleDiskEncryption() { // patch with false, would reset it but this is a dry-run acResp = appConfigResponse{} s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ - "mdm": { "macos_settings": { "enable_disk_encryption": false } } + "mdm": { "enable_disk_encryption": false } }`), http.StatusOK, &acResp, "dry_run", "true") - assert.True(t, acResp.MDM.MacOSSettings.EnableDiskEncryption) + assert.True(t, acResp.MDM.EnableDiskEncryption.Value) assert.Equal(t, []string{"a"}, acResp.MDM.MacOSSettings.CustomSettings) s.lastActivityMatches(fleet.ActivityTypeEnabledMacosDiskEncryption{}.ActivityName(), ``, enabledDiskActID) @@ -2757,9 +3059,9 @@ func (s *integrationMDMTestSuite) TestAppConfigMDMAppleDiskEncryption() { // patch with false, resets it acResp = appConfigResponse{} s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ - "mdm": { "macos_settings": { "enable_disk_encryption": false, "custom_settings": ["b"] } } + "mdm": { "enable_disk_encryption": false, "macos_settings": { "custom_settings": ["b"] } } }`), http.StatusOK, &acResp) - assert.False(t, acResp.MDM.MacOSSettings.EnableDiskEncryption) + assert.False(t, acResp.MDM.EnableDiskEncryption.Value) assert.Equal(t, []string{"b"}, acResp.MDM.MacOSSettings.CustomSettings) s.lastActivityMatches(fleet.ActivityTypeDisabledMacosDiskEncryption{}.ActivityName(), `{"team_id": null, "team_name": null}`, 0) @@ -2778,7 +3080,7 @@ func (s *integrationMDMTestSuite) TestAppConfigMDMAppleDiskEncryption() { acResp = appConfigResponse{} s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &acResp) - assert.True(t, acResp.MDM.MacOSSettings.EnableDiskEncryption) + assert.True(t, acResp.MDM.EnableDiskEncryption.Value) assert.Equal(t, []string{"b"}, acResp.MDM.MacOSSettings.CustomSettings) // call update endpoint with no changes @@ -2792,7 +3094,7 @@ func (s *integrationMDMTestSuite) TestAppConfigMDMAppleDiskEncryption() { acResp = appConfigResponse{} s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &acResp) - assert.True(t, acResp.MDM.MacOSSettings.EnableDiskEncryption) + assert.True(t, acResp.MDM.EnableDiskEncryption.Value) assert.Equal(t, []string{"b"}, acResp.MDM.MacOSSettings.CustomSettings) } @@ -2856,7 +3158,7 @@ func (s *integrationMDMTestSuite) TestMDMAppleDiskEncryptionAggregate() { }) require.NoError(t, err) oneMinuteAfterThreshold := time.Now().Add(+1 * time.Minute) - err = s.ds.SetOrUpdateHostDiskEncryptionKey(ctx, host.ID, "test-key") + err = s.ds.SetOrUpdateHostDiskEncryptionKey(ctx, host.ID, "test-key", "", nil) require.NoError(t, err) err = s.ds.SetHostsDiskEncryptionKeyStatus(ctx, []uint{host.ID}, decryptable, oneMinuteAfterThreshold) require.NoError(t, err) @@ -3043,7 +3345,7 @@ func (s *integrationMDMTestSuite) TestApplyTeamsMDMAppleProfiles() { teamSpecs = applyTeamSpecsRequest{Specs: []*fleet.TeamSpec{{ Name: teamName, MDM: fleet.TeamSpecMDM{ - MacOSSettings: map[string]interface{}{"enable_disk_encryption": false}, + EnableDiskEncryption: optjson.SetBool(false), }, }}} s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK) @@ -3098,7 +3400,7 @@ func (s *integrationMDMTestSuite) TestTeamsMDMAppleDiskEncryption() { teamSpecs := applyTeamSpecsRequest{Specs: []*fleet.TeamSpec{{ Name: teamName, MDM: fleet.TeamSpecMDM{ - MacOSSettings: map[string]interface{}{"enable_disk_encryption": true}, + EnableDiskEncryption: optjson.SetBool(true), }, }}} s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK) @@ -3111,7 +3413,7 @@ func (s *integrationMDMTestSuite) TestTeamsMDMAppleDiskEncryption() { // retrieving the team returns the disk encryption setting var teamResp getTeamResponse s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp) - require.True(t, teamResp.Team.Config.MDM.MacOSSettings.EnableDiskEncryption) + require.True(t, teamResp.Team.Config.MDM.EnableDiskEncryption) // apply with invalid disk encryption value should fail teamSpecs = applyTeamSpecsRequest{Specs: []*fleet.TeamSpec{{ @@ -3142,7 +3444,7 @@ func (s *integrationMDMTestSuite) TestTeamsMDMAppleDiskEncryption() { s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK) teamResp = getTeamResponse{} s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp) - require.True(t, teamResp.Team.Config.MDM.MacOSSettings.EnableDiskEncryption) + require.True(t, teamResp.Team.Config.MDM.EnableDiskEncryption) require.Equal(t, []string{"a"}, teamResp.Team.Config.MDM.MacOSSettings.CustomSettings) s.lastActivityOfTypeMatches(fleet.ActivityTypeEnabledMacosDiskEncryption{}.ActivityName(), ``, lastDiskActID) @@ -3151,13 +3453,13 @@ func (s *integrationMDMTestSuite) TestTeamsMDMAppleDiskEncryption() { teamSpecs = applyTeamSpecsRequest{Specs: []*fleet.TeamSpec{{ Name: teamName, MDM: fleet.TeamSpecMDM{ - MacOSSettings: map[string]interface{}{"enable_disk_encryption": false}, + EnableDiskEncryption: optjson.SetBool(false), }, }}} s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK, "dry_run", "true") teamResp = getTeamResponse{} s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp) - require.True(t, teamResp.Team.Config.MDM.MacOSSettings.EnableDiskEncryption) + require.True(t, teamResp.Team.Config.MDM.EnableDiskEncryption) s.lastActivityOfTypeMatches(fleet.ActivityTypeEnabledMacosDiskEncryption{}.ActivityName(), ``, lastDiskActID) @@ -3171,7 +3473,7 @@ func (s *integrationMDMTestSuite) TestTeamsMDMAppleDiskEncryption() { s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK) teamResp = getTeamResponse{} s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp) - require.False(t, teamResp.Team.Config.MDM.MacOSSettings.EnableDiskEncryption) + require.False(t, teamResp.Team.Config.MDM.EnableDiskEncryption) s.lastActivityOfTypeMatches(fleet.ActivityTypeDisabledMacosDiskEncryption{}.ActivityName(), fmt.Sprintf(`{"team_id": %d, "team_name": %q}`, team.ID, teamName), 0) @@ -3182,10 +3484,11 @@ func (s *integrationMDMTestSuite) TestTeamsMDMAppleDiskEncryption() { var modResp teamResponse s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), fleet.TeamPayload{ MDM: &fleet.TeamPayloadMDM{ - MacOSSettings: &fleet.MacOSSettings{EnableDiskEncryption: true}, + EnableDiskEncryption: optjson.SetBool(true), + MacOSSettings: &fleet.MacOSSettings{}, }, }, http.StatusOK, &modResp) - require.True(t, modResp.Team.Config.MDM.MacOSSettings.EnableDiskEncryption) + require.True(t, modResp.Team.Config.MDM.EnableDiskEncryption) s.lastActivityOfTypeMatches(fleet.ActivityTypeEnabledMacosDiskEncryption{}.ActivityName(), fmt.Sprintf(`{"team_id": %d, "team_name": %q}`, team.ID, teamName), 0) @@ -3197,10 +3500,10 @@ func (s *integrationMDMTestSuite) TestTeamsMDMAppleDiskEncryption() { s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), fleet.TeamPayload{ Description: ptr.String("foobar"), MDM: &fleet.TeamPayloadMDM{ - MacOSSettings: &fleet.MacOSSettings{EnableDiskEncryption: false}, + EnableDiskEncryption: optjson.SetBool(false), }, }, http.StatusOK, &modResp) - require.False(t, modResp.Team.Config.MDM.MacOSSettings.EnableDiskEncryption) + require.False(t, modResp.Team.Config.MDM.EnableDiskEncryption) require.Equal(t, "foobar", modResp.Team.Description) s.lastActivityOfTypeMatches(fleet.ActivityTypeDisabledMacosDiskEncryption{}.ActivityName(), fmt.Sprintf(`{"team_id": %d, "team_name": %q}`, team.ID, teamName), 0) @@ -3219,7 +3522,7 @@ func (s *integrationMDMTestSuite) TestTeamsMDMAppleDiskEncryption() { teamResp = getTeamResponse{} s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp) - require.True(t, teamResp.Team.Config.MDM.MacOSSettings.EnableDiskEncryption) + require.True(t, teamResp.Team.Config.MDM.EnableDiskEncryption) // use the MDM settings endpoint with no changes s.Do("PATCH", "/api/latest/fleet/mdm/apple/settings", @@ -3232,7 +3535,7 @@ func (s *integrationMDMTestSuite) TestTeamsMDMAppleDiskEncryption() { teamResp = getTeamResponse{} s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp) - require.True(t, teamResp.Team.Config.MDM.MacOSSettings.EnableDiskEncryption) + require.True(t, teamResp.Team.Config.MDM.EnableDiskEncryption) // use the MDM settings endpoint with an unknown team id s.Do("PATCH", "/api/latest/fleet/mdm/apple/settings", @@ -3449,7 +3752,7 @@ func (s *integrationMDMTestSuite) TestHostMDMProfilesStatus() { // profile deployment is asynchronous, so we simulate it here by // updating any "pending" (not NULL) profiles to "verifying" mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { - _, err := q.ExecContext(ctx, `UPDATE host_mdm_apple_profiles SET status = ? WHERE status = ?`, fleet.MacOSSettingsVerifying, fleet.MacOSSettingsPending) + _, err := q.ExecContext(ctx, `UPDATE host_mdm_apple_profiles SET status = ? WHERE status = ?`, fleet.OSSettingsVerifying, fleet.OSSettingsPending) return err }) } @@ -5381,9 +5684,9 @@ func (s *integrationMDMTestSuite) TestGitOpsUserActions() { // Attempt to edit global MDM settings, should allow. acResp := appConfigResponse{} s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ - "mdm": { "macos_settings": { "enable_disk_encryption": true } } + "mdm": { "enable_disk_encryption": true } }`), http.StatusOK, &acResp) - assert.True(t, acResp.MDM.MacOSSettings.EnableDiskEncryption) + assert.True(t, acResp.MDM.EnableDiskEncryption.Value) // Attempt to setup Apple MDM, will fail but the important thing is that it // fails with 422 (cannot enable end user auth because no IdP is configured) @@ -5407,9 +5710,9 @@ func (s *integrationMDMTestSuite) TestGitOpsUserActions() { teamSpecs := applyTeamSpecsRequest{Specs: []*fleet.TeamSpec{{ Name: t1.Name, MDM: fleet.TeamSpecMDM{ + EnableDiskEncryption: optjson.SetBool(true), MacOSSettings: map[string]interface{}{ - "enable_disk_encryption": true, - "custom_settings": []interface{}{"foo", "bar"}, + "custom_settings": []interface{}{"foo", "bar"}, }, }, }}} @@ -5434,9 +5737,9 @@ func (s *integrationMDMTestSuite) TestGitOpsUserActions() { teamSpecs = applyTeamSpecsRequest{Specs: []*fleet.TeamSpec{{ Name: t1.Name, MDM: fleet.TeamSpecMDM{ + EnableDiskEncryption: optjson.SetBool(true), MacOSSettings: map[string]interface{}{ - "enable_disk_encryption": true, - "custom_settings": []interface{}{"foo", "bar"}, + "custom_settings": []interface{}{"foo", "bar"}, }, }, }}} @@ -5590,7 +5893,7 @@ func (s *integrationMDMTestSuite) TestSSO() { // field, should not remove them acResp = appConfigResponse{} s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ - "mdm": { "macos_settings": {"enable_disk_encryption": true} } + "mdm": { "enable_disk_encryption": true } }`), http.StatusOK, &acResp) assert.Equal(t, wantSettings, acResp.MDM.EndUserAuthentication.SSOProviderSettings) @@ -6703,8 +7006,28 @@ func (s *integrationMDMTestSuite) TestValidSyncMLRequestNoAuth() { // Target Endpoint URL for the management endpoint targetEndpointURL := microsoft_mdm.MDE2ManagementPath + // Target DeviceID to use + deviceID := "DB257C3A08778F4FB61E2749066C1F27" + + // Inserting new device + enrolledDevice := &fleet.MDMWindowsEnrolledDevice{ + MDMDeviceID: deviceID, + MDMHardwareID: uuid.New().String() + uuid.New().String(), + MDMDeviceState: uuid.New().String(), + MDMDeviceType: "CIMClient_Windows", + MDMDeviceName: "DESKTOP-1C3ARC1", + MDMEnrollType: "ProgrammaticEnrollment", + MDMEnrollUserID: "upn@domain.com", + MDMEnrollProtoVersion: "5.0", + MDMEnrollClientVersion: "10.0.19045.2965", + MDMNotInOOBE: false, + } + + err := s.ds.MDMWindowsInsertEnrolledDevice(context.Background(), enrolledDevice) + require.NoError(t, err) + // Preparing the SyncML request - requestBytes, err := s.newSyncMLSessionMsg(targetEndpointURL) + requestBytes, err := s.newSyncMLSessionMsg(deviceID, targetEndpointURL) require.NoError(t, err) resp := s.DoRaw("POST", targetEndpointURL, requestBytes, http.StatusOK) @@ -6727,6 +7050,179 @@ func (s *integrationMDMTestSuite) TestValidSyncMLRequestNoAuth() { require.True(t, s.isXMLTagContentPresent("Add", resSoapMsg)) } +func (s *integrationMDMTestSuite) TestBitLockerEnforcementNotifications() { + t := s.T() + ctx := context.Background() + windowsHost := createOrbitEnrolledHost(t, "windows", t.Name(), s.ds) + + checkNotification := func(want bool) { + resp := orbitGetConfigResponse{} + s.DoJSON("POST", "/api/fleet/orbit/config", json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *windowsHost.OrbitNodeKey)), http.StatusOK, &resp) + require.Equal(t, want, resp.Notifications.EnforceBitLockerEncryption) + } + + // notification is false by default + checkNotification(false) + + // enroll the host into Fleet MDM + encodedBinToken, err := GetEncodedBinarySecurityToken(fleet.WindowsMDMProgrammaticEnrollmentType, *windowsHost.OrbitNodeKey) + require.NoError(t, err) + requestBytes, err := s.newSecurityTokenMsg(encodedBinToken, true, false) + require.NoError(t, err) + s.DoRaw("POST", microsoft_mdm.MDE2EnrollPath, requestBytes, http.StatusOK) + + // simulate osquery checking in and updating this info + // TODO: should we automatically fill these fields on MDM enrollment? + require.NoError(t, s.ds.SetOrUpdateMDMData(context.Background(), windowsHost.ID, false, true, "https://example.com", true, fleet.WellKnownMDMFleet)) + + // notification is still false + checkNotification(false) + + // configure disk encryption for the global team + acResp := appConfigResponse{} + s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ "mdm": { "macos_settings": { "enable_disk_encryption": true } } }`), http.StatusOK, &acResp) + assert.True(t, acResp.MDM.EnableDiskEncryption.Value) + + // host still doesn't get the notification because we don't have disk + // encryption information yet. + checkNotification(false) + + // host has disk encryption off, gets the notification + require.NoError(t, s.ds.SetOrUpdateHostDisksEncryption(context.Background(), windowsHost.ID, false)) + checkNotification(true) + + // host has disk encryption on, we don't have disk encryption info. Gets the notification + require.NoError(t, s.ds.SetOrUpdateHostDisksEncryption(context.Background(), windowsHost.ID, true)) + checkNotification(true) + + // host has disk encryption on, we don't know if the key is decriptable. Gets the notification + err = s.ds.SetOrUpdateHostDiskEncryptionKey(ctx, windowsHost.ID, "test-key", "", nil) + require.NoError(t, err) + checkNotification(true) + + // host has disk encryption on, the key is not decryptable by fleet. Gets the notification + err = s.ds.SetHostsDiskEncryptionKeyStatus(ctx, []uint{windowsHost.ID}, false, time.Now()) + require.NoError(t, err) + checkNotification(true) + + // host has disk encryption on, the disk was encrypted by fleet. Doesn't get the notification + err = s.ds.SetHostsDiskEncryptionKeyStatus(ctx, []uint{windowsHost.ID}, true, time.Now()) + require.NoError(t, err) + checkNotification(false) + + // create a new team + tm, err := s.ds.NewTeam(context.Background(), &fleet.Team{ + Name: t.Name(), + Description: "desc", + }) + require.NoError(t, err) + // add the host to the team + err = s.ds.AddHostsToTeam(context.Background(), &tm.ID, []uint{windowsHost.ID}) + require.NoError(t, err) + + // notification is false now since the team doesn't have disk encryption enabled + checkNotification(false) + + // enable disk encryption on the team + teamSpecs := applyTeamSpecsRequest{Specs: []*fleet.TeamSpec{{ + Name: tm.Name, + MDM: fleet.TeamSpecMDM{ + EnableDiskEncryption: optjson.SetBool(true), + }, + }}} + s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK) + + // host gets the notification + checkNotification(true) + + // host has disk encryption off, gets the notification + require.NoError(t, s.ds.SetOrUpdateHostDisksEncryption(context.Background(), windowsHost.ID, false)) + checkNotification(true) + + // host has disk encryption on, we don't have disk encryption info. Gets the notification + require.NoError(t, s.ds.SetOrUpdateHostDisksEncryption(context.Background(), windowsHost.ID, true)) + checkNotification(true) + + // host has disk encryption on, we don't know if the key is decriptable. Gets the notification + err = s.ds.SetOrUpdateHostDiskEncryptionKey(ctx, windowsHost.ID, "test-key", "", nil) + require.NoError(t, err) + checkNotification(true) + + // host has disk encryption on, the key is not decryptable by fleet. Gets the notification + err = s.ds.SetHostsDiskEncryptionKeyStatus(ctx, []uint{windowsHost.ID}, false, time.Now()) + require.NoError(t, err) + checkNotification(true) + + // host has disk encryption on, the disk was encrypted by fleet. Doesn't get the notification + err = s.ds.SetHostsDiskEncryptionKeyStatus(ctx, []uint{windowsHost.ID}, true, time.Now()) + require.NoError(t, err) + checkNotification(false) +} + +func (s *integrationMDMTestSuite) TestHostDiskEncryptionKey() { + t := s.T() + ctx := context.Background() + + host := createOrbitEnrolledHost(t, "windows", "h1", s.ds) + + // try to call the endpoint while the host is not MDM-enrolled + res := s.Do("POST", "/api/fleet/orbit/disk_encryption_key", orbitPostDiskEncryptionKeyRequest{ + OrbitNodeKey: *host.OrbitNodeKey, + EncryptionKey: []byte("WILL-FAIL"), + }, http.StatusBadRequest) + msg := extractServerErrorText(res.Body) + require.Contains(t, msg, "host is not enrolled with fleet") + + // mark it as enrolled in Fleet + err := s.ds.SetOrUpdateMDMData(ctx, host.ID, false, true, s.server.URL, false, fleet.WellKnownMDMFleet) + require.NoError(t, err) + + // set its encryption key + s.Do("POST", "/api/fleet/orbit/disk_encryption_key", orbitPostDiskEncryptionKeyRequest{ + OrbitNodeKey: *host.OrbitNodeKey, + EncryptionKey: []byte("ABC"), + }, http.StatusNoContent) + + hdek, err := s.ds.GetHostDiskEncryptionKey(ctx, host.ID) + require.NoError(t, err) + require.NotNil(t, hdek.Decryptable) + require.True(t, *hdek.Decryptable) + + // the key is encrypted the same way as the macOS keys (except with the WSTEP + // certificate), so it can be decrypted using the same decryption function. + wstepCert, _, _, err := s.fleetCfg.MDM.MicrosoftWSTEP() + require.NoError(t, err) + decrypted, err := servermdm.DecryptBase64CMS(hdek.Base64Encrypted, wstepCert.Leaf, wstepCert.PrivateKey) + require.NoError(t, err) + require.Equal(t, "ABC", string(decrypted)) + + // set it with a client error + s.Do("POST", "/api/fleet/orbit/disk_encryption_key", orbitPostDiskEncryptionKeyRequest{ + OrbitNodeKey: *host.OrbitNodeKey, + ClientError: "fail", + }, http.StatusNoContent) + + hdek, err = s.ds.GetHostDiskEncryptionKey(ctx, host.ID) + require.NoError(t, err) + require.Nil(t, hdek.Decryptable) + require.Empty(t, hdek.Base64Encrypted) + + // set a different key + s.Do("POST", "/api/fleet/orbit/disk_encryption_key", orbitPostDiskEncryptionKeyRequest{ + OrbitNodeKey: *host.OrbitNodeKey, + EncryptionKey: []byte("DEF"), + }, http.StatusNoContent) + + hdek, err = s.ds.GetHostDiskEncryptionKey(ctx, host.ID) + require.NoError(t, err) + require.NotNil(t, hdek.Decryptable) + require.True(t, *hdek.Decryptable) + + decrypted, err = servermdm.DecryptBase64CMS(hdek.Base64Encrypted, wstepCert.Leaf, wstepCert.PrivateKey) + require.NoError(t, err) + require.Equal(t, "DEF", string(decrypted)) +} + // /////////////////////////////////////////////////////////////////////////// // Common helpers @@ -6923,7 +7419,7 @@ func (s *integrationMDMTestSuite) newSecurityTokenMsg(encodedBinToken string, de } // TODO: Add support to add custom DeviceID when DeviceAuth is in place -func (s *integrationMDMTestSuite) newSyncMLSessionMsg(managementUrl string) ([]byte, error) { +func (s *integrationMDMTestSuite) newSyncMLSessionMsg(deviceID string, managementUrl string) ([]byte, error) { if len(managementUrl) == 0 { return nil, errors.New("managementUrl is empty") } @@ -6939,7 +7435,7 @@ func (s *integrationMDMTestSuite) newSyncMLSessionMsg(managementUrl string) ([]b ` + managementUrl + ` - DB257C3A08778F4FB61E2749066C1F27 + ` + deviceID + ` @@ -6963,7 +7459,7 @@ func (s *integrationMDMTestSuite) newSyncMLSessionMsg(managementUrl string) ([]b ./DevInfo/DevId - DB257C3A08778F4FB61E2749066C1F27 + ` + deviceID + ` diff --git a/server/service/mdm.go b/server/service/mdm.go index f0312e4177..7632eb4cf1 100644 --- a/server/service/mdm.go +++ b/server/service/mdm.go @@ -404,3 +404,61 @@ func (svc *Service) VerifyMDMWindowsConfigured(ctx context.Context) error { return nil } + +//////////////////////////////////////////////////////////////////////////////// +// Apple or Windows MDM Middleware +//////////////////////////////////////////////////////////////////////////////// + +func (svc *Service) VerifyMDMAppleOrWindowsConfigured(ctx context.Context) error { + appCfg, err := svc.ds.AppConfig(ctx) + if err != nil { + // skipauth: Authorization is currently for user endpoints only. + svc.authz.SkipAuthorization(ctx) + return err + } + + // Apple or Windows MDM configuration setting + if !appCfg.MDM.EnabledAndConfigured && !appCfg.MDM.WindowsEnabledAndConfigured { + // skipauth: Authorization is currently for user endpoints only. + svc.authz.SkipAuthorization(ctx) + return fleet.ErrMDMNotConfigured + } + + return nil +} + +//////////////////////////////////////////////////////////////////////////////// +// GET /mdm/disk_encryption/summary +//////////////////////////////////////////////////////////////////////////////// + +type getMDMDiskEncryptionSummaryRequest struct { + TeamID *uint `query:"team_id,optional"` +} + +type getMDMDiskEncryptionSummaryResponse struct { + *fleet.MDMDiskEncryptionSummary + Err error `json:"error,omitempty"` +} + +func (r getMDMDiskEncryptionSummaryResponse) error() error { return r.Err } + +func getMDMDiskEncryptionSummaryEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { + req := request.(*getMDMDiskEncryptionSummaryRequest) + + des, err := svc.GetMDMDiskEncryptionSummary(ctx, req.TeamID) + if err != nil { + return getMDMDiskEncryptionSummaryResponse{Err: err}, nil + } + + return &getMDMDiskEncryptionSummaryResponse{ + MDMDiskEncryptionSummary: des, + }, nil +} + +func (svc *Service) GetMDMDiskEncryptionSummary(ctx context.Context, teamID *uint) (*fleet.MDMDiskEncryptionSummary, error) { + // skipauth: No authorization check needed due to implementation returning + // only license error. + svc.authz.SkipAuthorization(ctx) + + return nil, fleet.ErrMissingLicense +} diff --git a/server/service/mdm_test.go b/server/service/mdm_test.go index 13809587ec..f395d26a98 100644 --- a/server/service/mdm_test.go +++ b/server/service/mdm_test.go @@ -15,8 +15,10 @@ import ( "github.com/fleetdm/fleet/v4/server/authz" "github.com/fleetdm/fleet/v4/server/config" authz_ctx "github.com/fleetdm/fleet/v4/server/contexts/authz" + "github.com/fleetdm/fleet/v4/server/contexts/viewer" "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/mock" + "github.com/fleetdm/fleet/v4/server/ptr" "github.com/fleetdm/fleet/v4/server/test" "github.com/micromdm/scep/v2/cryptoutil/x509util" "github.com/stretchr/testify/require" @@ -111,6 +113,12 @@ func TestVerifyMDMAppleConfigured(t *testing.T) { ds.AppConfigFuncInvoked = false require.True(t, authzCtx.Checked()) + err = svc.VerifyMDMAppleOrWindowsConfigured(ctx) + require.ErrorIs(t, err, fleet.ErrMDMNotConfigured) + require.True(t, ds.AppConfigFuncInvoked) + ds.AppConfigFuncInvoked = false + require.True(t, authzCtx.Checked()) + // error retrieving app config authzCtx = &authz_ctx.AuthorizationContext{} ctx = authz_ctx.NewContext(baseCtx, authzCtx) @@ -124,6 +132,12 @@ func TestVerifyMDMAppleConfigured(t *testing.T) { ds.AppConfigFuncInvoked = false require.True(t, authzCtx.Checked()) + err = svc.VerifyMDMAppleOrWindowsConfigured(ctx) + require.ErrorIs(t, err, testErr) + require.True(t, ds.AppConfigFuncInvoked) + ds.AppConfigFuncInvoked = false + require.True(t, authzCtx.Checked()) + // mdm configured authzCtx = &authz_ctx.AuthorizationContext{} ctx = authz_ctx.NewContext(baseCtx, authzCtx) @@ -135,9 +149,14 @@ func TestVerifyMDMAppleConfigured(t *testing.T) { require.True(t, ds.AppConfigFuncInvoked) ds.AppConfigFuncInvoked = false require.False(t, authzCtx.Checked()) + + err = svc.VerifyMDMAppleOrWindowsConfigured(ctx) + require.NoError(t, err) + require.True(t, ds.AppConfigFuncInvoked) + ds.AppConfigFuncInvoked = false + require.False(t, authzCtx.Checked()) } -// TODO: update this test with the correct config option func TestVerifyMDMWindowsConfigured(t *testing.T) { ds := new(mock.Store) license := &fleet.LicenseInfo{Tier: fleet.TierPremium} @@ -148,7 +167,7 @@ func TestVerifyMDMWindowsConfigured(t *testing.T) { authzCtx := &authz_ctx.AuthorizationContext{} ctx := authz_ctx.NewContext(baseCtx, authzCtx) ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { - return &fleet.AppConfig{MDM: fleet.MDM{EnabledAndConfigured: false}}, nil + return &fleet.AppConfig{MDM: fleet.MDM{WindowsEnabledAndConfigured: false}}, nil } err := svc.VerifyMDMWindowsConfigured(ctx) @@ -157,6 +176,12 @@ func TestVerifyMDMWindowsConfigured(t *testing.T) { ds.AppConfigFuncInvoked = false require.True(t, authzCtx.Checked()) + err = svc.VerifyMDMAppleOrWindowsConfigured(ctx) + require.ErrorIs(t, err, fleet.ErrMDMNotConfigured) + require.True(t, ds.AppConfigFuncInvoked) + ds.AppConfigFuncInvoked = false + require.True(t, authzCtx.Checked()) + // error retrieving app config authzCtx = &authz_ctx.AuthorizationContext{} ctx = authz_ctx.NewContext(baseCtx, authzCtx) @@ -171,6 +196,12 @@ func TestVerifyMDMWindowsConfigured(t *testing.T) { ds.AppConfigFuncInvoked = false require.True(t, authzCtx.Checked()) + err = svc.VerifyMDMAppleOrWindowsConfigured(ctx) + require.ErrorIs(t, err, testErr) + require.True(t, ds.AppConfigFuncInvoked) + ds.AppConfigFuncInvoked = false + require.True(t, authzCtx.Checked()) + // mdm configured authzCtx = &authz_ctx.AuthorizationContext{} ctx = authz_ctx.NewContext(baseCtx, authzCtx) @@ -183,6 +214,12 @@ func TestVerifyMDMWindowsConfigured(t *testing.T) { require.True(t, ds.AppConfigFuncInvoked) ds.AppConfigFuncInvoked = false require.False(t, authzCtx.Checked()) + + err = svc.VerifyMDMAppleOrWindowsConfigured(ctx) + require.NoError(t, err) + require.True(t, ds.AppConfigFuncInvoked) + ds.AppConfigFuncInvoked = false + require.False(t, authzCtx.Checked()) } func TestMicrosoftWSTEPConfig(t *testing.T) { @@ -195,7 +232,7 @@ func TestMicrosoftWSTEPConfig(t *testing.T) { ds.WSTEPStoreCertificateFunc = func(ctx context.Context, name string, crt *x509.Certificate) error { require.Equal(t, "test-client", name) require.Equal(t, "test-client", crt.Subject.CommonName) - require.Equal(t, "FleetDM", crt.Subject.OrganizationalUnit[0]) + require.Equal(t, "Fleet", crt.Subject.OrganizationalUnit[0]) return nil } @@ -251,5 +288,175 @@ func TestMicrosoftWSTEPConfig(t *testing.T) { parsedCert, err := x509.ParseCertificate(rawDER) require.NoError(t, err) require.Equal(t, "test-client", parsedCert.Subject.CommonName) - require.Equal(t, "FleetDM", parsedCert.Subject.OrganizationalUnit[0]) + require.Equal(t, "Fleet", parsedCert.Subject.OrganizationalUnit[0]) +} + +func TestMDMCommonAuthorization(t *testing.T) { + ds := new(mock.Store) + license := &fleet.LicenseInfo{Tier: fleet.TierPremium} + svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: license, SkipCreateTestUsers: true}) + + ds.GetMDMAppleFileVaultSummaryFunc = func(ctx context.Context, teamID *uint) (*fleet.MDMAppleFileVaultSummary, error) { + return &fleet.MDMAppleFileVaultSummary{}, nil + } + ds.GetMDMWindowsBitLockerSummaryFunc = func(ctx context.Context, teamID *uint) (*fleet.MDMWindowsBitLockerSummary, error) { + return &fleet.MDMWindowsBitLockerSummary{}, nil + } + + mockTeamFuncWithUser := func(u *fleet.User) mock.TeamFunc { + return func(ctx context.Context, teamID uint) (*fleet.Team, error) { + if len(u.Teams) > 0 { + for _, t := range u.Teams { + if t.ID == teamID { + return &fleet.Team{ID: teamID, Users: []fleet.TeamUser{{User: *u, Role: t.Role}}}, nil + } + } + } + return &fleet.Team{}, nil + } + } + + testCases := []struct { + name string + user *fleet.User + shouldFailGlobal bool + shouldFailTeam bool + }{ + { + "global admin", + &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}, + false, + false, + }, + { + "global maintainer", + &fleet.User{GlobalRole: ptr.String(fleet.RoleMaintainer)}, + false, + false, + }, + { + "global observer", + &fleet.User{GlobalRole: ptr.String(fleet.RoleObserver)}, + true, + true, + }, + { + "team admin, belongs to team", + &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleAdmin}}}, + true, + false, + }, + { + "team admin, DOES NOT belong to team", + &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleAdmin}}}, + true, + true, + }, + { + "team maintainer, belongs to team", + &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleMaintainer}}}, + true, + false, + }, + { + "team maintainer, DOES NOT belong to team", + &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleMaintainer}}}, + true, + true, + }, + { + "team observer, belongs to team", + &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleObserver}}}, + true, + true, + }, + { + "team observer, DOES NOT belong to team", + &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleObserver}}}, + true, + true, + }, + { + "user no roles", + &fleet.User{ID: 1337}, + true, + true, + }, + } + + checkShouldFail := func(err error, shouldFail bool) { + if !shouldFail { + require.NoError(t, err) + } else { + require.Error(t, err) + require.Contains(t, err.Error(), authz.ForbiddenErrorMessage) + } + } + + for _, tt := range testCases { + ctx := viewer.NewContext(ctx, viewer.Viewer{User: tt.user}) + ds.TeamFunc = mockTeamFuncWithUser(tt.user) + + t.Run(tt.name, func(t *testing.T) { + // test authz get disk encryptions summary (no team) + _, err := svc.GetMDMDiskEncryptionSummary(ctx, nil) + checkShouldFail(err, tt.shouldFailGlobal) + + // test authz get disk encryptions summary (team 1) + _, err = svc.GetMDMDiskEncryptionSummary(ctx, ptr.Uint(1)) + checkShouldFail(err, tt.shouldFailTeam) + }) + } +} + +func TestGetMDMDiskEncryptionSummary(t *testing.T) { + ds := new(mock.Store) + license := &fleet.LicenseInfo{Tier: fleet.TierPremium} + svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: license}) + + ctx = test.UserContext(ctx, test.UserAdmin) + + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return &fleet.AppConfig{MDM: fleet.MDM{EnabledAndConfigured: true}}, nil + } + ds.GetMDMAppleFileVaultSummaryFunc = func(ctx context.Context, teamID *uint) (*fleet.MDMAppleFileVaultSummary, error) { + require.Nil(t, teamID) + return &fleet.MDMAppleFileVaultSummary{Verified: 1, Verifying: 2, ActionRequired: 3, Failed: 4, Enforcing: 5, RemovingEnforcement: 6}, nil + } + ds.GetMDMWindowsBitLockerSummaryFunc = func(ctx context.Context, teamID *uint) (*fleet.MDMWindowsBitLockerSummary, error) { + require.Nil(t, teamID) + // Use default zeros verifying, action_required, or removing_enforcement + return &fleet.MDMWindowsBitLockerSummary{Verified: 7, Failed: 8, Enforcing: 9}, nil + } + + // Test that the summary properly combines the results of the two methods + des, err := svc.GetMDMDiskEncryptionSummary(ctx, nil) + require.NoError(t, err) + require.NotNil(t, des) + require.Equal(t, *des, fleet.MDMDiskEncryptionSummary{ + Verified: fleet.MDMPlatformsCounts{ + MacOS: 1, + Windows: 7, + }, + Verifying: fleet.MDMPlatformsCounts{ + MacOS: 2, + Windows: 0, + }, + ActionRequired: fleet.MDMPlatformsCounts{ + MacOS: 3, + Windows: 0, + }, + Failed: fleet.MDMPlatformsCounts{ + MacOS: 4, + Windows: 8, + }, + Enforcing: fleet.MDMPlatformsCounts{ + MacOS: 5, + Windows: 9, + }, + RemovingEnforcement: fleet.MDMPlatformsCounts{ + MacOS: 6, + Windows: 0, + }, + }) } diff --git a/server/service/microsoft_mdm.go b/server/service/microsoft_mdm.go index 071c5ca3b5..2ebaf1bfc7 100644 --- a/server/service/microsoft_mdm.go +++ b/server/service/microsoft_mdm.go @@ -13,6 +13,7 @@ import ( "io" "net/http" "net/url" + "regexp" "strconv" "strings" "text/template" @@ -1184,6 +1185,29 @@ func (svc *Service) GetMDMWindowsTOSContent(ctx context.Context, redirectUri str return htmlBuf.String(), nil } +// isValidUPN checks if the provided user ID is a valid UPN +func isValidUPN(userID string) bool { + const upnRegex = `^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$` + re := regexp.MustCompile(upnRegex) + return re.MatchString(userID) +} + +// isDeviceProgrammaticallyEnrolled checks if the device was enrolled through programmatic flow +func (svc *Service) isDeviceProgrammaticallyEnrolled(ctx context.Context, deviceID string) (bool, error) { + enrolledDevice, err := svc.ds.MDMWindowsGetEnrolledDeviceWithDeviceID(ctx, deviceID) + + if err != nil || enrolledDevice == nil { + return false, errors.New("device not found") + } + + // If user identity is a MS-MDM UPN it means that the device was enrolled through user-driven flow + if isValidUPN(enrolledDevice.MDMEnrollUserID) { + return false, nil + } + + return true, nil +} + func (svc *Service) getManagementResponse(ctx context.Context, reqSyncML *fleet.SyncMLMessage) (*string, error) { if reqSyncML == nil { return nil, fleet.NewInvalidArgumentError("syncml req message", "message is not present") @@ -1212,9 +1236,15 @@ func (svc *Service) getManagementResponse(ctx context.Context, reqSyncML *fleet. return nil, err } + // Checking if the device was enrolled through programmatic flow + isProgrammaticEnrollment, err := svc.isDeviceProgrammaticallyEnrolled(ctx, deviceID) + if err != nil { + return nil, err + } + // Checking the SyncML message types var response string - if isSessionInitializationMessage(reqSyncML.Body) { + if isSessionInitializationMessage(reqSyncML.Body) && !isProgrammaticEnrollment { // Create response payload - MDM SyncML configuration profiles commands will be enforced here response = ` diff --git a/server/service/middleware/mdmconfigured/mdmconfigured.go b/server/service/middleware/mdmconfigured/mdmconfigured.go index be6b5ddc46..75343ad65c 100644 --- a/server/service/middleware/mdmconfigured/mdmconfigured.go +++ b/server/service/middleware/mdmconfigured/mdmconfigured.go @@ -17,6 +17,18 @@ func NewMDMConfigMiddleware(svc fleet.Service) *Middleware { return &Middleware{svc: svc} } +func (m *Middleware) VerifyAppleOrWindowsMDM() endpoint.Middleware { + return func(next endpoint.Endpoint) endpoint.Endpoint { + return func(ctx context.Context, req interface{}) (interface{}, error) { + if err := m.svc.VerifyMDMAppleOrWindowsConfigured(ctx); err != nil { + return nil, err + } + + return next(ctx, req) + } + } +} + func (m *Middleware) VerifyAppleMDM() endpoint.Middleware { return func(next endpoint.Endpoint) endpoint.Endpoint { return func(ctx context.Context, req interface{}) (interface{}, error) { diff --git a/server/service/middleware/mdmconfigured/mdmconfigured_test.go b/server/service/middleware/mdmconfigured/mdmconfigured_test.go index 7ac09b6bf8..2c6a01337f 100644 --- a/server/service/middleware/mdmconfigured/mdmconfigured_test.go +++ b/server/service/middleware/mdmconfigured/mdmconfigured_test.go @@ -2,6 +2,7 @@ package mdmconfigured import ( "context" + "fmt" "sync/atomic" "testing" @@ -32,6 +33,13 @@ func (m *mockService) VerifyMDMWindowsConfigured(ctx context.Context) error { return nil } +func (m *mockService) VerifyMDMAppleOrWindowsConfigured(ctx context.Context) error { + if !m.mdmConfigured.Load() && !m.msMdmConfigured.Load() { + return fleet.ErrMDMNotConfigured + } + return nil +} + func TestMDMConfigured(t *testing.T) { svc := mockService{} svc.mdmConfigured.Store(true) @@ -99,3 +107,49 @@ func TestWindowsMDMNotConfigured(t *testing.T) { require.ErrorIs(t, err, fleet.ErrMDMNotConfigured) require.False(t, nextCalled) } + +func TestAppleOrWindowsMDMConfigured(t *testing.T) { + svc := mockService{} + mw := NewMDMConfigMiddleware(&svc) + + cases := []struct { + apple bool + windows bool + }{ + {true, false}, + {false, true}, + {true, true}, + } + for _, c := range cases { + t.Run(fmt.Sprintf("apple:%t;windows:%t", c.apple, c.windows), func(t *testing.T) { + svc.mdmConfigured.Store(c.apple) + svc.msMdmConfigured.Store(c.windows) + nextCalled := false + next := func(ctx context.Context, req interface{}) (interface{}, error) { + nextCalled = true + return struct{}{}, nil + } + + f := mw.VerifyAppleOrWindowsMDM()(next) + _, err := f(context.Background(), struct{}{}) + require.NoError(t, err) + require.True(t, nextCalled) + }) + } +} + +func TestAppleOrWindowsMDMNotConfigured(t *testing.T) { + svc := mockService{} + mw := NewMDMConfigMiddleware(&svc) + + nextCalled := false + next := func(ctx context.Context, req interface{}) (interface{}, error) { + nextCalled = true + return struct{}{}, nil + } + + f := mw.VerifyAppleOrWindowsMDM()(next) + _, err := f(context.Background(), struct{}{}) + require.ErrorIs(t, err, fleet.ErrMDMNotConfigured) + require.False(t, nextCalled) +} diff --git a/server/service/orbit.go b/server/service/orbit.go index 28df989cd1..40a0bac9f9 100644 --- a/server/service/orbit.go +++ b/server/service/orbit.go @@ -14,6 +14,7 @@ import ( "github.com/fleetdm/fleet/v4/server/contexts/license" "github.com/fleetdm/fleet/v4/server/contexts/logging" "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/ptr" "github.com/go-kit/kit/log/level" microsoft_mdm "github.com/fleetdm/fleet/v4/server/mdm/microsoft" @@ -270,6 +271,12 @@ func (svc *Service) GetOrbitConfig(ctx context.Context) (fleet.OrbitConfig, erro } } + if config.IsMDMFeatureFlagEnabled() && + mdmConfig.EnableDiskEncryption && + host.IsEligibleForBitLockerEncryption() { + notifs.EnforceBitLockerEncryption = true + } + return fleet.OrbitConfig{ Flags: opts.CommandLineStartUpFlags, Extensions: extensionsFiltered, @@ -300,6 +307,13 @@ func (svc *Service) GetOrbitConfig(ctx context.Context) (fleet.OrbitConfig, erro } } + if appConfig.MDM.WindowsEnabledAndConfigured && + config.IsMDMFeatureFlagEnabled() && + appConfig.MDM.EnableDiskEncryption.Value && + host.IsEligibleForBitLockerEncryption() { + notifs.EnforceBitLockerEncryption = true + } + return fleet.OrbitConfig{ Flags: opts.CommandLineStartUpFlags, Extensions: extensionsFiltered, @@ -503,3 +517,82 @@ func (svc *Service) SaveHostScriptResult(ctx context.Context, result *fleet.Host return fleet.ErrMissingLicense } + +///////////////////////////////////////////////////////////////////////////////// +// Post Orbit disk encryption key +///////////////////////////////////////////////////////////////////////////////// + +type orbitPostDiskEncryptionKeyRequest struct { + OrbitNodeKey string `json:"orbit_node_key"` + EncryptionKey []byte `json:"encryption_key"` + ClientError string `json:"client_error"` +} + +// interface implementation required by the OrbitClient +func (r *orbitPostDiskEncryptionKeyRequest) setOrbitNodeKey(nodeKey string) { + r.OrbitNodeKey = nodeKey +} + +// interface implementation required by orbit authentication +func (r *orbitPostDiskEncryptionKeyRequest) orbitHostNodeKey() string { + return r.OrbitNodeKey +} + +type orbitPostDiskEncryptionKeyResponse struct { + Err error `json:"error,omitempty"` +} + +func (r orbitPostDiskEncryptionKeyResponse) error() error { return r.Err } +func (r orbitPostDiskEncryptionKeyResponse) Status() int { return http.StatusNoContent } + +func postOrbitDiskEncryptionKeyEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { + req := request.(*orbitPostDiskEncryptionKeyRequest) + if err := svc.SetOrUpdateDiskEncryptionKey(ctx, string(req.EncryptionKey), req.ClientError); err != nil { + return orbitPostDiskEncryptionKeyResponse{Err: err}, nil + } + return orbitPostDiskEncryptionKeyResponse{}, nil +} + +func (svc *Service) SetOrUpdateDiskEncryptionKey(ctx context.Context, encryptionKey, clientError string) error { + // this is not a user-authenticated endpoint + svc.authz.SkipAuthorization(ctx) + + host, ok := hostctx.FromContext(ctx) + if !ok { + return newOsqueryError("internal error: missing host from request context") + } + if !host.MDMInfo.IsFleetEnrolled() { + return badRequest("host is not enrolled with fleet") + } + + var ( + encryptedEncryptionKey string + decryptable *bool + ) + + // only set the encryption key if there was no client error + if clientError == "" && encryptionKey != "" { + wstepCert, _, _, err := svc.config.MDM.MicrosoftWSTEP() + if err != nil { + // should never return an error because the WSTEP is first parsed and + // cached at the start of the fleet serve process. + return ctxerr.Wrap(ctx, err, "get WSTEP certificate") + } + enc, err := microsoft_mdm.Encrypt(encryptionKey, wstepCert.Leaf) + if err != nil { + return ctxerr.Wrap(ctx, err, "encrypt the key with WSTEP certificate") + } + encryptedEncryptionKey = enc + decryptable = ptr.Bool(true) + } + + if err := svc.ds.SetOrUpdateHostDiskEncryptionKey(ctx, host.ID, encryptedEncryptionKey, clientError, decryptable); err != nil { + return ctxerr.Wrap(ctx, err, "set or update disk encryption key") + } + if encryptedEncryptionKey != "" { + if err := svc.ds.SetOrUpdateHostDisksEncryption(ctx, host.ID, true); err != nil { + return ctxerr.Wrap(ctx, err, "set or update host disks encryption") + } + } + return nil +} diff --git a/server/service/orbit_client.go b/server/service/orbit_client.go index b4a8ca3e6b..b7374a58f2 100644 --- a/server/service/orbit_client.go +++ b/server/service/orbit_client.go @@ -338,3 +338,18 @@ func OrbitRetryInterval() time.Duration { } return constant.OrbitEnrollRetrySleep } + +// SetOrUpdateDiskEncryptionKey sends a request to the server to set or update the disk +// encryption keys and result of the encryption process +func (oc *OrbitClient) SetOrUpdateDiskEncryptionKey(diskEncryptionStatus fleet.OrbitHostDiskEncryptionKeyPayload) error { + verb, path := "POST", "/api/fleet/orbit/disk_encryption_key" + + var resp orbitPostDiskEncryptionKeyResponse + if err := oc.authenticatedRequest(verb, path, &orbitPostDiskEncryptionKeyRequest{ + EncryptionKey: diskEncryptionStatus.EncryptionKey, + ClientError: diskEncryptionStatus.ClientError, + }, &resp); err != nil { + return err + } + return nil +} diff --git a/server/service/osquery_utils/queries.go b/server/service/osquery_utils/queries.go index 9887a1243d..165b7e2cc7 100644 --- a/server/service/osquery_utils/queries.go +++ b/server/service/osquery_utils/queries.go @@ -446,7 +446,7 @@ var extraDetailQueries = map[string]DetailQuery{ ) UNION ALL SELECT * FROM ( - SELECT "is_federated" AS "key", data as "value" FROM registry + SELECT "is_federated" AS "key", data as "value" FROM registry WHERE path LIKE 'HKEY_LOCAL_MACHINE\Software\Microsoft\Enrollments\%\IsFederated' LIMIT 1 ) @@ -609,7 +609,7 @@ var mdmQueries = map[string]DetailQuery{ // [1]: https://developer.apple.com/documentation/devicemanagement/fderecoverykeyescrow "mdm_disk_encryption_key_file_lines_darwin": { Query: fmt.Sprintf(` - WITH + WITH de AS (SELECT IFNULL((%s), 0) as encrypted), fl AS (SELECT line FROM file_lines WHERE path = '/var/db/FileVaultPRK.dat') SELECT encrypted, hex(line) as hex_line FROM de LEFT JOIN fl;`, usesMacOSDiskEncryptionQuery), @@ -1460,7 +1460,7 @@ func directIngestDiskEncryptionKeyFileDarwin( // it's okay if the key comes empty, this can happen and if the disk is // encrypted it means we need to reset the encryption key - return ds.SetOrUpdateHostDiskEncryptionKey(ctx, host.ID, rows[0]["filevault_key"]) + return ds.SetOrUpdateHostDiskEncryptionKey(ctx, host.ID, rows[0]["filevault_key"], "", nil) } // directIngestDiskEncryptionKeyFileLinesDarwin ingests the FileVault key from the `file_lines` @@ -1511,7 +1511,7 @@ func directIngestDiskEncryptionKeyFileLinesDarwin( // it's okay if the key comes empty, this can happen and if the disk is // encrypted it means we need to reset the encryption key - return ds.SetOrUpdateHostDiskEncryptionKey(ctx, host.ID, base64.StdEncoding.EncodeToString(b)) + return ds.SetOrUpdateHostDiskEncryptionKey(ctx, host.ID, base64.StdEncoding.EncodeToString(b), "", nil) } func directIngestMacOSProfiles( diff --git a/server/service/osquery_utils/queries_test.go b/server/service/osquery_utils/queries_test.go index 2c9e3d60ee..6e42246259 100644 --- a/server/service/osquery_utils/queries_test.go +++ b/server/service/osquery_utils/queries_test.go @@ -1130,7 +1130,7 @@ func TestDirectIngestDiskEncryptionKeyDarwin(t *testing.T) { } } - ds.SetOrUpdateHostDiskEncryptionKeyFunc = func(ctx context.Context, hostID uint, encryptedBase64Key string) error { + ds.SetOrUpdateHostDiskEncryptionKeyFunc = func(ctx context.Context, hostID uint, encryptedBase64Key, clientError string, decryptable *bool) error { if base64.StdEncoding.EncodeToString([]byte(wantKey)) != encryptedBase64Key { return errors.New("key mismatch") } diff --git a/server/service/testing_utils.go b/server/service/testing_utils.go index cfd3365aac..7b90c43c0f 100644 --- a/server/service/testing_utils.go +++ b/server/service/testing_utils.go @@ -586,7 +586,7 @@ func mockSuccessfulPush(pushes []*mdm.Push) (map[string]*push.Response, error) { return res, nil } -func mdmAppleConfigurationRequiredEndpoints() []struct { +func mdmConfigurationRequiredEndpoints() []struct { method, path string deviceAuthenticated bool premiumOnly bool @@ -630,6 +630,8 @@ func mdmAppleConfigurationRequiredEndpoints() []struct { {"POST", "/api/latest/fleet/device/%s/migrate_mdm", true, true}, {"POST", "/api/latest/fleet/mdm/apple/profiles/preassign", false, true}, {"POST", "/api/latest/fleet/mdm/apple/profiles/match", false, true}, + {"POST", "/api/fleet/orbit/disk_encryption_key", false, false}, + {"GET", "/api/latest/fleet/mdm/disk_encryption/summary", false, true}, } } diff --git a/server/service/transport.go b/server/service/transport.go index c9caee032b..4170695e11 100644 --- a/server/service/transport.go +++ b/server/service/transport.go @@ -317,9 +317,9 @@ func hostListOptionsFromRequest(r *http.Request) (fleet.HostListOptions, error) } macOSSettingsStatus := r.URL.Query().Get("macos_settings") - switch fleet.MacOSSettingsStatus(macOSSettingsStatus) { - case fleet.MacOSSettingsFailed, fleet.MacOSSettingsPending, fleet.MacOSSettingsVerifying, fleet.MacOSSettingsVerified: - hopt.MacOSSettingsFilter = fleet.MacOSSettingsStatus(macOSSettingsStatus) + switch fleet.OSSettingsStatus(macOSSettingsStatus) { + case fleet.OSSettingsFailed, fleet.OSSettingsPending, fleet.OSSettingsVerifying, fleet.OSSettingsVerified: + hopt.MacOSSettingsFilter = fleet.OSSettingsStatus(macOSSettingsStatus) case "": // No error when unset default: @@ -342,6 +342,32 @@ func hostListOptionsFromRequest(r *http.Request) (fleet.HostListOptions, error) return hopt, ctxerr.Errorf(r.Context(), "invalid macos_settings_disk_encryption status %s", macOSSettingsDiskEncryptionStatus) } + osSettingsStatus := r.URL.Query().Get("os_settings") + switch fleet.OSSettingsStatus(osSettingsStatus) { + case fleet.OSSettingsFailed, fleet.OSSettingsPending, fleet.OSSettingsVerifying, fleet.OSSettingsVerified: + hopt.OSSettingsFilter = fleet.OSSettingsStatus(osSettingsStatus) + case "": + // No error when unset + default: + return hopt, ctxerr.Errorf(r.Context(), "invalid os_settings status %s", osSettingsStatus) + } + + osSettingsDiskEncryptionStatus := r.URL.Query().Get("os_settings_disk_encryption") + switch fleet.DiskEncryptionStatus(osSettingsDiskEncryptionStatus) { + case + fleet.DiskEncryptionVerifying, + fleet.DiskEncryptionVerified, + fleet.DiskEncryptionActionRequired, + fleet.DiskEncryptionEnforcing, + fleet.DiskEncryptionFailed, + fleet.DiskEncryptionRemovingEnforcement: + hopt.OSSettingsDiskEncryptionFilter = fleet.DiskEncryptionStatus(osSettingsDiskEncryptionStatus) + case "": + // No error when unset + default: + return hopt, ctxerr.Errorf(r.Context(), "invalid os_settings_disk_encryption status %s", macOSSettingsDiskEncryptionStatus) + } + mdmBootstrapPackageStatus := r.URL.Query().Get("bootstrap_package") switch fleet.MDMBootstrapPackageStatus(mdmBootstrapPackageStatus) { case fleet.MDMBootstrapPackageFailed, fleet.MDMBootstrapPackagePending, fleet.MDMBootstrapPackageInstalled: diff --git a/terraform/byo-vpc/byo-db/byo-ecs/variables.tf b/terraform/byo-vpc/byo-db/byo-ecs/variables.tf index 8e0e7049a2..e8a73e36fb 100644 --- a/terraform/byo-vpc/byo-db/byo-ecs/variables.tf +++ b/terraform/byo-vpc/byo-db/byo-ecs/variables.tf @@ -13,7 +13,7 @@ variable "fleet_config" { type = object({ mem = optional(number, 4096) cpu = optional(number, 512) - image = optional(string, "fleetdm/fleet:v4.38.0") + image = optional(string, "fleetdm/fleet:v4.38.1") family = optional(string, "fleet") sidecars = optional(list(any), []) depends_on = optional(list(any), []) diff --git a/terraform/byo-vpc/byo-db/variables.tf b/terraform/byo-vpc/byo-db/variables.tf index 3753cfe1ad..e4700c9238 100644 --- a/terraform/byo-vpc/byo-db/variables.tf +++ b/terraform/byo-vpc/byo-db/variables.tf @@ -74,7 +74,7 @@ variable "fleet_config" { type = object({ mem = optional(number, 4096) cpu = optional(number, 512) - image = optional(string, "fleetdm/fleet:v4.38.0") + image = optional(string, "fleetdm/fleet:v4.38.1") family = optional(string, "fleet") sidecars = optional(list(any), []) depends_on = optional(list(any), []) diff --git a/terraform/byo-vpc/example/main.tf b/terraform/byo-vpc/example/main.tf index 03077d2911..49c62767a8 100644 --- a/terraform/byo-vpc/example/main.tf +++ b/terraform/byo-vpc/example/main.tf @@ -17,7 +17,7 @@ provider "aws" { } locals { - fleet_image = "fleetdm/fleet:v4.38.0" + fleet_image = "fleetdm/fleet:v4.38.1" } resource "random_pet" "main" {} diff --git a/terraform/byo-vpc/variables.tf b/terraform/byo-vpc/variables.tf index f6839f192b..eef43bfdfc 100644 --- a/terraform/byo-vpc/variables.tf +++ b/terraform/byo-vpc/variables.tf @@ -163,7 +163,7 @@ variable "fleet_config" { type = object({ mem = optional(number, 4096) cpu = optional(number, 512) - image = optional(string, "fleetdm/fleet:v4.38.0") + image = optional(string, "fleetdm/fleet:v4.38.1") family = optional(string, "fleet") sidecars = optional(list(any), []) depends_on = optional(list(any), []) diff --git a/terraform/variables.tf b/terraform/variables.tf index b4afdb3d21..8373dd0569 100644 --- a/terraform/variables.tf +++ b/terraform/variables.tf @@ -215,7 +215,7 @@ variable "fleet_config" { type = object({ mem = optional(number, 4096) cpu = optional(number, 512) - image = optional(string, "fleetdm/fleet:v4.38.0") + image = optional(string, "fleetdm/fleet:v4.38.1") family = optional(string, "fleet") sidecars = optional(list(any), []) depends_on = optional(list(any), []) diff --git a/tools/fleetctl-npm/package.json b/tools/fleetctl-npm/package.json index da38159b8f..1d364cadb2 100644 --- a/tools/fleetctl-npm/package.json +++ b/tools/fleetctl-npm/package.json @@ -1,6 +1,6 @@ { "name": "fleetctl", - "version": "v4.38.0", + "version": "v4.38.1", "description": "Installer for the fleetctl CLI tool", "bin": { "fleetctl": "./run.js" diff --git a/website/api/controllers/view-integrations.js b/website/api/controllers/view-integrations.js new file mode 100644 index 0000000000..e996de3e67 --- /dev/null +++ b/website/api/controllers/view-integrations.js @@ -0,0 +1,27 @@ +module.exports = { + + + friendlyName: 'View integrations', + + + description: 'Display "integrations" page.', + + + exits: { + + success: { + viewTemplatePath: 'pages/integrations' + } + + }, + + + fn: async function () { + + // Respond with view. + return {}; + + } + + +}; diff --git a/website/api/controllers/webhooks/receive-from-stripe.js b/website/api/controllers/webhooks/receive-from-stripe.js index 4b8a9e1138..0af2abd168 100644 --- a/website/api/controllers/webhooks/receive-from-stripe.js +++ b/website/api/controllers/webhooks/receive-from-stripe.js @@ -69,10 +69,14 @@ module.exports = { let subscriptionForThisEvent = await Subscription.findOne({stripeSubscriptionId: subscriptionIdToFind}).populate('user'); let STRIPE_EVENTS_SENT_BEFORE_A_SUBSCRIPTION_RECORD_EXISTS = [ - 'invoice.created', - 'invoice.finalized', - 'invoice.paid', - 'invoice.payment_succeeded', + 'invoice.created',// Sent when a user submits the billing form on /customers/new-license, before the user's biliing card is charged. + 'invoice.finalized',// Sent when a user submits the billing form on /customers/new-license, before the user's biliing card is charged. + 'invoice.paid',//Sent when a user submits the billing form on /customers/new-license, when the user's biliing card is charged. + 'invoice.payment_succeeded',// Sent when payment for a users subscription is successful. The save-billing-info-and-subscribe action will check for this event before creating a license key. + 'invoice.payment_failed',// Sent when a users subscritpion payment fails. This can happen before we create a license key and save the subscription in the database. + 'invoice.payment_action_required',// Sent when a user's billing card requires additional verification from stripe. + 'invoice.updated',// Sent before an incomplete invoice is voided. (~24 hours after a payment fails) + 'invoice.voided',// Sent when an incomplete invoice is marked as voided. (~24 hours after a payment fails) ]; // If this event is for a subscription that was just created, we won't have a matching Subscription record in the database. This is because we wait until the subscription's invoice is paid to create the record in our database. diff --git a/website/assets/images/device-management-transparency-438x373@2x.png b/website/assets/images/device-management-transparency-438x373@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..b8caf62521310159d24cc49f174d40fa1e8f3128 GIT binary patch literal 154237 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 SNPv6&s48kI)XIGf`~Lt2?%PKI literal 0 HcmV?d00001 diff --git a/website/assets/images/icon-checkmark-circle-green-16x16@2x.png b/website/assets/images/icon-checkmark-circle-green-16x16@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..94f8b26a57190ca4d3e6b417aa3de303d6680fd6 GIT binary patch literal 797 zcmV+&1LFLNP)t>>d4iN%1xPVcih8Sff{;Vc$pxr-fdmr+ege`pe(5)B+oz`0hn&T;J9m; z15DCMNQ1+F=Ri;*Kr7ZD!HBK)6&^H`h{09F{^9tW5JMUuvl+Y5V|t03n_X3zvNp+i3 z+T_Buz8O!$$5GqK8aTd(tpYvY3{HN(YmDUWZMSB#n589_y`W5(e1NUuIk{lE;TE9+ zuoVZAW7r|N!0yzvgCrGX6#`jN#$wknA^FkIO4bLs(tbktT17@T3d(oI#;3viFD2ik zH$ng@$c>5W#l#a!MVXur9%5Js^deG|hlms)J9qwFFJd$DPyp`%SJ%Mne|%N9;6Xif&pLQ)#Y8A`t!qJB z7cmM}0ySJ~1R*T3)lx*;dA&F5F01M8%)Gbj3VvXi-Pw8L$2V_&zu(Lof;3R*?=FME zDqWPcjd-hdD26~GLY6Vs493b5Hv8O^wyKnume&0KT$eFd&j_ttCA<1h^?{*d?E0-f zNa9G^6vcvv!Kj0QYkX6#jpjOE0)*0zkheMj2UwOq>3`;7#$wrWk9uHFbUk#g5Cqsu zO5MRx@6DiQ06b zelYk6uO%Xh)bJ3(udZCREG!U1r~N`Hs*W9p434E6ii#bVpH0Q*rN+7=X;C1_6hP7T zr{NSw(xO0;rR2b2I7LY7ut}bRyAqiKit^`XVc0~R-i?A8Y>+Za2C<3YAy4N$ZCHA| znniKPXBwekExIyF+JqCstid>=^i%d!b~O{ z{XGQ*J)I9h=v`!pRKn4HEWk5n3(uIg(!2x0#4x{90_07qBrSjwfKOmJft3>oFOagcbONIjm@HDJveEbi zVUY@dqF-LmV8s#rni*AWGcaEj1=amt)AM!r>we!fK#CM8Qlv<+DZr?P#Wz&QP43D7 zR0Pc*h2NR-;qiV0HVCEOxDLwui4aCg>kkXa_zLHP;c?!N(@ticLFsZ_B=nl+U@N~Z$r5WX_EM45yP^)*SsdZ0E zQbAM!r1QgHXRo4|i^P;J=M{iOH}Ql#0P!G4AxAk{IEO8TA%{am#bsk9CY&u$6nuOH zdg0OhDWE=p4Z|eA-!gsUF!&rmzn1KXydzgT_TZ~^McA)=Cga)rm=XRTCdG#AEZY{UislF+`B{lUM5}}61*4EC z3>r8>LTkTs6NpEQgvDZS1PKHmV`BF^7BRzP^kGL3iw#Z)om|Rl+&MfPKvZphxQ^)48@Jg;S`EuKSUDhg@%|)xrInrC9PA>5a20xl@b6YbeG;J|1uOvn>6 zdJ-w2F-wLL5Ne_us;a}UBGxI-(I(1mf=Oxe-Qh|0AS6iEYJ$J97Ao?I)F?NZtlgbR z$ixj;(s@+UNua)kM*u-TiDsGcNqK*|EbKEz!;uk=Ibhuyebl8}F#CO}ib)&De@r)G zlceQ9uVp_bR1Uz5?|G?}E~dBqfNzb!@3K`U;J6HoKAIiR-%8||>1hJ3rOIcaLJWr4 zX|{`CCC(q`vt7wAH`_l0OPnyvpO?MpXEq3)1#du>YS>@4KoMBI!jsReNKUBaXtAacjk;ujPf}ZrdbZcn5o9`Glx*_PaDAtl*V;_ zp}o2lnlo~3W0K1aKJ{Nwc3C4mvuUKJfgo@t5N4O-8D)EA5@^^qxppPSkfJlNqX-EF zlTsGSuO$t8g_zKgtj382hVR)|ix*k zv50Y=fVT{>h;hBS+cxaPQZjlI#Dtv~K_f+)I$@F78iq5N}sPBC7+R6r0J5 zGZ{(0@m)EY^?nSe1y`&Y*z%=fppo`w9d8c?zyG{1$_if-M!yEQ0rYmDn!a9cVgg6f9oRPT z5}M{^UFjp7+x1deKUgOmCjus>AU4b^opnN(!P=YQOTD7byHSh9@iG#YHOC&r#|hS- z9f1bzh1b?KA3K!x`By(_cL!6XNRc8%iWF<{5BRSu*(6xC(EtDd07*qoM6N<$f@rvY AR{#J2 literal 0 HcmV?d00001 diff --git a/website/assets/images/icon-webhooks-30x28@2x.png b/website/assets/images/icon-webhooks-30x28@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..284735a1d21677a9700ccc121e568bae87b60d42 GIT binary patch literal 2404 zcmV-q37htbP)D4wK~#7F&00Nd z9M=(^*}a4mVoN8bv84chG^JWp=Z?!FmXb!N&Ou`ESf(;r22sL0M}X7u*tru8CjnBC z29iKb!&R3~R4<8Z#F5nL3_CC>@^(D)?#KInmbZ7eC;I~^-R{19Z)fMdnQy+g2(J+I z-bV_4bQ}Q$S0RO}J{drp|NR_@_VF)&h+)b=m@=r|s6wYwXM=9?VRUL^#DxEiAq<{JybKnH1G1ei^k_P{` z?zz-tqZ=>*N;BB&U5~L!)vCwgVU36u^?d*jDaD=V$-Ttv@6IZ}ybWuZHZOs>Ng{W7 zYnXx$+>@!1PcN!>YcKJegEXbPL{OJMws-@P4&HqGx?voCdbRw+VU-%jpDGFTQ=5 zx}n(EjbYu0$1ly_sP~U~>L%cNInwNHOq4aHU4xMZc{A)%N(BYL(|y_M$4yth7hDi! z8LXT$mCnH6G@wx)Fp|JFI0{3TaA2TdOq5PM`p8C7BpiDR0vwmhrKe_acAZgXdKW;F zBs--VnMGL!C(fD7I-5z4wxA#onSPr6D5nvn-+LbRc`)k#NRqhC6lW8LBS}+e8fOSS z86ZetOq64Ar4xJ38bsvTlik$PS)JTkw!5~`p5}&^b>Bi!!xO2QCsseoEf2f( z>;tg*all6V zY}!2SK3=a|;3);m_Hy|i?o`%2y4F@!49LT)@N%Ij{j63d6 zyB+6ZxxQ_U(1tKS7u$Gq?z-gi$m(OQ_iK@#LBV$sJ(2zc#Lqe%h>Jw^%4ru_XZipM z8l4UmEAK?r;AmrjU~?qJlD*7cjjTTM)Dk5sU`kX&)GsRzLu`KW=w6)DUc?9&BFVgd zeqP>mo&RR->+Ip=s~3+xGoDEVc+fK-+fV}4Ef@h`w;v_1pT8VIL50MElIVW+!e#Z7 zb5|a6Uk-hB*6=0u^MwFO$U|Y3qATJ-!@0l`EsTLK-yR=gG|!Sfah!e4Fj$Nw4EA!B zMZHx5n_R+~I+Fw{rzr=jF=?QzMFe!TEGalpm!eWL=N2G+D4)@Tpd}Zi0eGhp#sLLu z|4aVmlx0N)_)%d}PKJx%_-lQ)Qe z4;XpV>-NN(UgLrdZ}@U|&&#n+OR2v>lR2(oY;Q;Zrv5%dzRe(V8C4}5DJqYG9v@g> zFI^>B&q(*5J~kb~Mm0E{Rykc6ic9Y$#3*YZ#o&R}cfwO(u7A**x*G*HxhLM;^Feg# zqu`r$*^cpCHr8mjeN;Wd8L>%a8%jHlMPbeEfdY%-orK#%jU7@POaeCcZjP}63APWi z(Kt^u3<8KM{cX!EW;?TkY0{0tL%zQ0x8<3T72x#_!tCK7#T|EoDpER3Po3iBi*wia$s|xph=(%oqc81_pg2ZpTRy_nVu$$9YM~rz$voLD-iw0p9Q?o$ z*z}C&`FW#7_v^S~KvTkD67UwhLU4(g!0F?+E7UbO8%VAiO-S$07AwLlm96X{zsv=* zSj77rfEq9YdigUd(A3Asl-;I+@(^H9LzP|pyp7W09K1aA^FgavxH$iAjZ#~#rJd&o zyD^LaPB-Ax$3BpmhBQg(n9t11b)PFick61lj&B_SoZFg`d*Rt$o@X;9J;=tST5>i$){(9ROOq3FS<1K4A zgT3WJK7QfIa`WkvU85!zF^^RZeu-IcbBRdlrp@Rm?Pv*ne!G7E%fW51E8 z9G=2|pBvsPy5*t0gLv1|&#B(c;q%()nefp-ypz0gGU3C<>Awp`uM8-^iRFGv+{qU?7Ugf1A z>?VRd5Ukk&B@e^K=ckp2zCX(N{i)nPc8Dsk=WQ5H)(gQNtO$k}J@NRCO#AhV$A8b6 zUWK8>Hlkm#XFneCR-WFEcgh%_jr}bf>&z^#F3syLK3koC$N}gb$W<_N24ez}5{j|^ zfiWTDv(cY$Uou_QDEG?vKc+Lk_#*j-cl@EXm;NX;z-1;V(JAsRs}5Sa-70$%Tc??T zR(bqI%GJuXs(GJNRcly8FI;R2bdCj7DFlPP9Uf$Vnjey7p})0;|Ems!gYidw!QTQR WLrCdchXz&v0000NO zf}R);rK+5BPSztc>!~LK=NDn4D%C^v$dl)lCk5Ph&SeLJfB68>CJ6j2#4ixZO#p%G zU^>Q$>remM1R448a{Yp} z@muUP=<~9F+c-Gp&+Ty6_m-v<2gEcPAiKIOE76GLNt zq5;$KXn#HjXEl7|@A$VyTdbwh`F4fVP92T~(?;3pgQFQzzQaD|7|yRcY(9q(3Dryd zx6j{yx*{!}e8JC@E8vDcqX3oPlq+OBKp}UC$9Rg`KMCEB5RA`p-B)}L9XP!{hP5@+ zB!3KX-+$tMCn$t{JYS#BN1CRL2DtuDH0598{s$;*$@PN1YJuycAqV-WQ)DeQpx?G! z?bzY?3ArB$`Ky7C!m5WttPlV3_Hp?GYN8ZS>qDyrEL?wRTazG@C(6(B? z!I*+@8v{5UeE^99_2tGJ{{Hjm2!h)~6sBDiim1gt>ZV%j>GeJe+W`j84tV*G>mBxs zzC;uL>>35SI7v0v)9d%}k9a$W!?AEYa5SSOHq;07MYhCmu{r(~9)mKSe^{faB6OE{ zC?xX?Ig^2)0G`Jht%A0vb-RBj7XoUl!FLpxN&}X13$@d4>9zq1aA!?894mnnq)j2y zdyJ-eJL=+R3M#!qZM2t0$Xk{m6{tOuZ!}Nlvy9M052MZO92Dq_Ax)Oj$0P>+lm7k z2>6l- zXTJx+ZQO64g}1}uSU3V3`*(l^`PzioX2IY_HaQ%Q2aNNPIvftij5yPNo^TSphVxIZ zF3GbZU$hPRrroo1On?)l^Focq|8A`Nee@ zTHvuH{9LF3D!;MDuaCg{NpOO69#HX;AoA4$nS)};LX=2`lP(+5kcD{LczY@j7Zi+(!XC886z$F$kTUNb_nB0ff<6qmx$DI7pjoVsRK{b^ z+vqb49;o+S)g)C2$h5i7qs!E{rp){4{G^E!Kb^UN8L}TXl3zizF48n5F8UlQ-<*d* zCjFLEi9Q>>#Gk#5e%##t7^G=x`g8d|s~AfWT%ek^rQn!#oiqfN%;md=gz4we$JF;s z)&@NvJ2guAR=3jSqHETl71d5iu20ojcnvbzQU2=iSJ-<}oQ9UZ8+@)#dMtWA?e@os zb76Vn%9|Zjub$((wP_qv1>-JZZLMdPc1)kGW_97dx%aK+oylE`@Yw?3>yekxZcsOZ zEWg`$xHXa7cU54W?&&@sm+A`BcltBnpJ=`FrRSk3n6xaNDC&|RrDDaeV4l!TCqLoK z_ywS{aHsqN{sPN{rr%gJQ5;kvsKF0(|?*SLXu}J3V zO5Y9Re);&{79NLO4Z|2(RDJRBbVfd6YsczCg(5G z#B3-xRZpuD;v}~X4FaIZc)AT|J@h#xi6>l4g}0Bw=L>2P+jlBVx%$^lU!_Me&tV!o z?2IC9Sq>v{YRB(=1SBr)-+5o)Mj;hcS(LpA({F-oQt$gQ`DZ(r|;;4$ttKpm&NY z3H(_5ZnokrBs8g-RnmZqG_gEd#sXePQmraMUF&}QJz6p106Sc>q0+1ZZSOozaPRPB zgXdSj-f0jgioW4FP7Cq(Hz zr|M_cd3ja|GS@}yLc{wWwCG;t)6eKW@QAb+mrMhT?OG#yWWplXN}LHP0zIl zW!TD7tQ@qpVS&TB77C`wbz)W&^*qa9(Zln~%=+~FmU&MvMO7+{x=%;h3hbTBdSA7! z3(JJ6_NOLmP4CTuJ{>6qN~6`kx2{!?*)sgTwo~`w5H(TAgej+=)mua&-JS$=w{V*lT9J`vIjR6+4@!@zDNmYmKvDbscx~+9 zozp)uD-u*;2p^UW^~NN;7J+h(`{}Z7)+gA}d#@&XpUh3q;6o}CqIZRebZs%|y^A4j zS;xLVsv#wGD;ovf0}|p1o|w*@UKF#xV~N@Pu2hIp#}jFSEDWoCpXAHcO>;X{%RU|j zV3t)Ef`f7Txlt>u%e7sLj$rbPi4I>^RiM&-x9ZXrlNp26wyE;u(x0Y9$Sgo-oAn9m zA!&0mRR{n|qmsJs1sm0>A;#C+f)$B(-l=c zicZn7ogkf-IJ+@`ryL>M#0mCDMHg(Hkd5W{U)x(ss0-TXc=@O-*)Kn%<*h z3y$CpESvP+j0ZO+Oj6Apne#c*{V)7JWE%9h^patsiK!a(ij`va8X9PG$MQC1yS01nD#w z4rgW`8$!cY|14y;&WQz1TU7V@muM(MLsHuP#byI4NX<^M-1RDu(i2)=2e!# ziRRie4JrtL&? zDL&5RL$}ZEv72N7%fP;t$^FfK`bo<9S_2$(N=G`GgH8%)!9f^foUwEn%Rj%B9|#iLqa8h!-LJ9woG zWZOrxS}(!CT(0po&J%SlgLHY4VdJVm5NP`j3bq=+wAewX#OhLmTN1OrUlbgD_=0ra zd8DRU{}q}-1x7ObTwb4N1Wr1YCEBK8-oi@dDJl~4o&-&r!X*c#T2s5#GnT9JQCA5y zs3ao{-@&rb1CX=UxV44Zvh#AK))vY#KZ+QKTgLLV)<6{$bjsM_-)5xp6&E~r8tEk1 zd1TNaM=e1YJcOOs*XCmlweROUXVQ;3r|eK;)O&9-?~Q1E-j9^+`*(Ao!m5{}qoI^% z*XGDPO7vc8!Db`vY-{F@R>~9Oc)yPhVHSk$v#oJW=_!HKp7e@MWzaK_!mPBmi2L7S z;hYU*piW*-6)O?mPLNK9Id;5B{$qkIc)&96SxeAfZW>ek$MrExi)RH8lpk!q=%zbq zNxE5Ir>zP3PCL#PkGo$*NMHpw|1<_mkPupC0y~lGXytKk`tM0FM?R^Ps#%1y&}R^+ z(QxywuGh8KGX6^+9t*i4$MeEY{0CNO5+$V6mE#0yQ*xtmrb%Kp%KGKacOC|(y^v}K z%I~EwIIS<6p|WN5oC4m@)bNU+d7hHSNG4h$Y?^2bG#*@NIha(ag*uMYzsd!E)%8wJ zv4&2X5B&xb?4*UNAfXT0?>yn@N0tpWa1Zpl5vqPg)%{>i8a2%#)0sPVsSBeHUn@O> zqW7fgSVU;#gLY~=M(MpVwlBLDejeAy#*lNe1}2#vdr}Oyl|Fbtln%Ucd8AFT$Bs{v z#H^CpGn3t}phPeCr>kvfd0({LNMskUZth%M?oBTd#trW~oQ?+Z z@;BJq*nwH#YCEHoxR^xNz^X=i>5QXY<660+im3A#a6pP@_~c?9es;YqxVtbrJ9r)m zMzJ12)sQ;zHCWJufUvyr=tcZv{Q^FZ-f6W?eV__oBxqE1qaj-&Y8F(g!|C(D2UX6o zn>(Ie+=QTK5C!-QM*Y-W*N^od*1hug!1M6@nu~@@-d!K6dn@Q1yN2-F^6g^^{c~2x$TIfD3MTZ|OKU|@` zT%z1rKMoJToU{$nqB3Gm$;`P9r6R~=Q+}-~Xr(NG9rJqe?|cJ@o%Eg*YN@i%8W2qg zc$Y#nK?#xkb5)IghwqtsM||zu=Mx`~?s@jU`RQuU7+RV^?LkW0-0r0JwJ6ho-V>T^ z(LA?rUWZn9m8w%M%Nj6)PEhAca2jcIhFq497(?@6Z8`9P$S8|i7FEtyX7N6nM_n#@ z?7=UFiNSNSqqZjDD+{u+Tv<8}X}F$ugxf#Q8U&SVD{xKd4MDW}&;)8gXQMZ1Zf)IA z$D-d@d9D1z)ExB5loNaF1DFbVnWo#ALwq&(Jb&{2Z8E(@5u}|(QS~;IPkB4V{U%5N zeY)!LSaTjl#>Jd1LB+!9GA?J?t34iRtYlgj9*>TN_eEf|y@=vns}vUZ>VP{ zN(K<9-}bEd-1>93>hYE#(7&1Hd5h-v)W*ma=SplBRhx_0yjcuMF<~_}{R`X$G%c>1 z4)^|%`sscQ-AtSfV5{V@YW&j(e}{=)s5+VBsRE~wI#v@W3?{=)+ILJ3%^?;g!+=p6 zZBUBOGa{!Bhr{tR!=u-@h_r$NrwuIHHb+jWv>W-31gAh0_*)zfhvR{xCKlA@#A;sf zx*bbfb7L;swdR9|>TQUEwI!a$DHTr0IvkFNhcf4nK+oKujFN^F`$(0)r62z@qg5ni zY1ovI9Tu`pG}%ci-8dW$$Ffj?@N*QRw(L5XBuG7Zut^EoUc6K+WGS^oFW9OoThq2X z;BYt`s{&1T-D>HnPHI~4m+Kdk97e!0F$Asjl{U*l*I$^+z@$Jb=gO#+)HvdASjMa*!kgy>qZiB)1^_gtU-<0`Jl_N` zHH6tj(CwDaht75I3^M0PZTp9Q^M*v`)r*Aztz|plz-GB5r&!X(e!`oci|X}aX21zs zu7zMX(}3HjHJsVAf*D8?uBjUyLq*^072T4vi~^MaX96IAz+>0{V+DNlpUNk^h4bUe z_h@JxJM8+ym(+-zn>sqgCE)c=D@e7Bp^8eTqJ6qhI&CF|M1lF8!C5_x~k7O7FJ*t)atvo|*e6-qxZmCb+ z>>{kQ92aRqN=D$dKLv>~=*7OJLdJ*`&W^~B?DBgdGXNozK01JC2#E0?psZ68kR zJ;ouwy@8MvTbe19Ur&KwU4KP1N!qbu+Lm-!e&w&|ZLg0xUmzyczasiLZfCTaA21F! zh5Rb^{|T^NoR(`BWXeP#McD%r?o!S)9KB@ACjtXYwN=6F8?Ly%HA>cN$w*nFiXXlz z$Le5qMM=X^7Dv2P=HP^*JMTBlwYSW+xErH9HKutmY4Gl%M^i+0U`bw!825Z~pU4;>QZE{Ns^S201Z< zHxy;Bv@$+;JDFFC21F3lM!cKuIK27LcbSzcvb#QSJ_F@XDB(G>0DFdh$-TKuQTNH_ zUHe=E&Ak%Em*;*5D;@uslhL~;Xnt*MA#8B{XQrwk6X)!q_Gc;aj)}mrCa&oZ}eh_9K z!{ah(VqnnhRqi?Wp#Id!LuZU)FcXy&`99c&*32Bu32bU|!&6ZfbLxo z5@;2e-FP%HX1yr31F^DVFo;uNn}qO(S=_@E>bIHJ$%=v(Y90CQCAfv5dg}Y1luo~= zCdM&02^0;Cwap?+S`6l8dmiC8?4ZRO8Pz+W;*5m%bQiRwqcD)FSJjndSwyOpliWUV z-yfy(B5QG1W-RkjCBENZI&Iyg1VQKj#t-N>el|WPV)H!mva%8j_9-t9l5ee!KgdEQ zl>N(b%bf-|P%#7vp_)5LVzXDOR=EoKCGX6UTd#`;GQo9?>HJf>A_rm7@7%c1f1=8i zln$4h%c8G7D^vXBMy@3QtMYJ&oh%*q7E{Jr@mil6DLxl}soNg1^%e6UhV~-f zJ-YsIv?_}0pt>20N_YQ)N;aZgvcsOFr4B@9EX`C=GiQutPTuvbs&LQFb$fT**HW+= z3w+-Xb7Eya!(gD&MEBZ^sl|@F{M|XWOd#z)+Ny0f+|m1*oe&%2@k?X9+&C3xf*;oi zm0+!l^i}tY=E;+P2Mg`auet4YR4}jtlO-3kc&ZC!v)p&ot8_?l3nWxX@1%vK0Xk4iFsZVG~oetI~3dHaCbHzVP63M%9nn*4kN_jOc(3%)z zUpmw{+VNEqYc5!V_2?hrP}W~yDdn{TeSC`8V-=b5<&rK97_K@z=^634TP7l?gJ?|g zf=wcH)Xa)Hp5(~r7wGmC9u87Dw@D)Dp7EZ#buU=%yN#q4&8oU%H@-JFpiw+Qh*J2T zA2+knNT6MugUFV3#mB!*IzRl)5gPHmTc*EMek;XCPG0B~$_54V%V!0j#L72cD^+;1 zxH3W6%D+)6o!L#7B1U>2Iv*}&jF~yCL6gq>`N>5CPZmZgIs_4Bf)`XJH8H|iR1VqG z4l$0jE;smPn>FIWkTe-kvRwQP>rADzj<5YCiYFWdS5y8U5lT~uE!rzFt~ zIEF`@A?XmRpyt9)r@&X+VolWFpPfuak*mnT7Dj(yoSFD;7mn83yxp&}q2 zYfnKDYGJT1WTJ?68F@50d4OT4B>F1m|VhJancJ4K-^$P(A>r*=Zr z6ekz*?455-Gu#+~xHn7^KcT;4+F7k&seoN3{p*vi zdaH`4M*dExOb$(YMUHv<;<&hbk@Mo+X5so_bu$T9`6mUd=OE1E4r;Pxeg~VZ9ymSW z70cI<+t2b3oO0cg!)GmG@w8zQuxLKWseL!;)w@U5Je^J^5EvSD7R6`SW-CXP6N(## ziO?TmV)o||GY&}jW%1MF6Pu|T$rGg1lR?%b*MrjPv%u+uQIEzs>QVx>Na#oB6+8 zy>T`WAML4zdzDKemJKI+K1yQ0>Z*lNp3FHw!@Y;&rz=J+oR6VEF3o!zv-Besr+PAI zu+U?DBiFA~mBTJ{f#qtD3QmIe?~Mw>4+v+Su)9YNogGirsyx_A7Blx~mX72?1q^@S z4L81ulC<}XhbW|{B3r!J-HD)2+@$pi>@}@<-b!1EcduQHlk#TW-H25oY=sj+bslNU zgP2*dsm1XYyg$1!(deba?z)t`BlBUQcX#Q1T9rwy&13SYg}w#-i!t9O?!s7a{+IeVVR3n>&5+1O2plS_MSYk%Tubg-_Je44hr1&b zo2P5@w9~%OT%$1me6m|x9bWQnak?s?EA#x>$BcBuQN8fgJ)-H=Q$BoZ=ysXTa~^XO zdLm001F2<2IA1pR6JcP?g5D5kE{dYx1ppnkl(p31GB-_!zSZ!XP;cyJ$m&x@nqCRK zS9-4R(vJ37=Mzt?EM3&kS+1S(#pwDrzh^bQW#oyXVwtAk2=%t?J9Uq>vr3!!I_b1ucDb&8-nlTi>8s*j4|-1qxbK-z8ypbs z4PsrG8Th>mUi!=fEUiy9Sg5&vsTI9ICsCbV@~A@pUo(0372RiRFV?@o!nbG^AbZ;K zVvUcZvOdgWM&+ohpATBkE$79r__xx*wkCVofdg@LMQXERP))yv2TFG!q4&x(T_pV) z4J@i4KCkgYir^0LQmczvUyws)uD8+XZ+>trW)WV@njP9$OF3yNm0EeD`W+hfecwUF zMRFcsq1KvbuMNo3^Clir-qxV%9#PY|@iv4P>nSrc90+=?m1?c)+PMyEI&$hSRn?dD zJW#f@bfiTSnqTJzwn0EMMhYqNOSShR*P=ycx=y9Ar3(YM+p4~x=L?Rsz=`ndYv0?s z4Q5XIg5Y{lArKlNo2n{u%X3}!z|)>AD^w%-D^B_Df)lunkb4sDwZ!$!;Y1`t^-%J{ zrp3%j|6r3)38nO@Tc%5NJ1}}&tW0s%23a!IG48I9YpGoMgDwnYcB@Wb8?R;tm^tZf z!f|#T{X$vSwGLc8Kh8dNY_}=+;Xt5+r+)wHm2|JTF7YhW>HyL71(RRqgEv8)%V@yq z01ZpYU);^RB+B@&`YeE+a~=e4S(9dsRl$o%>cU$^=VD6{m`X!4{#E&AbEyG}t; z<_2F;uz!d2h7`FHOC`vMTgVY>goXH6QRk@5LR*=8F*>UC-YlZi^RqWf?qhi$BWDFl zN(Jlh;%GbzvKSz}DBO~uz5C-fmX`lV1-fSAlmEza;Kc$z!BvG^`}rTS>xjMv3WiZBJ&K;e;=liI z{EcV4o~38`b15nd9)B0V*{RYiUKsK3kXk~DPDlQF9_$oJ_ck1a*%FR|BsLwkB4dcST4z~Z;1V9g_4Nx_L(blRr{nnH?BN4kF3@miqgo^PKATGsO|=DI#~ z{d@8gT+?DS09d#mdnt(w-9=}8VOXT|s7O<$%%7DojsGe}r@5{Voy`gO<&oG3Tvn$b z8!X4apLHIE!R^8F*WH1)o}&PZ^t-KhNJNA*1^nnV&!ad=tf8alBc$$Nn0~yyQ*RgQtg-=bk#!89V$eqT5`hhG%C}8!J$$q* zo|=#rZ9n&-^efEuZKt%$k=Dar|G68UbM%)Y_(<&zy{$MNIB4{Km47Vno!Zx_5I)`UT4yg*|?2_0IB;s8V>MTIvdLNPK(#60@(G5D)vy5rsrY` z=$fESt2It=;n_IPGrl;iPV6NeayMBrcnc|yWU7MFL=dAa5`(ynlL(- zw@ALKYEN;oRZ0D3)$65Y^cZw|B=Fr=TOBrf4+#PB>!mN?gHyYk@8dVmMg@p6*NfM= zz4Q2+!@7o2rKDUo@5VQB^~5tZRG;7);T@uGPIt6;cc&wDc#UAGDi)|aRu|qtvlRs! z&@I=4F-b4>dw-1S+H8lv53`qCVX$__^ztG`gB^h?}4q)YYnOd_%0 z1oXc---7&pOB50bOPc|rXrjXP*0(u9sDxNDkj7s z@L%bW=I{W*--2w8OfM;ujfCu64R2W7!#$(T^iXJK*JwJ3AAtB`R&Y+f0Hfg_sM}PE z^}ozlXzcSZiZ;g!ss0B>W`SOlJvwvpZwV03$N9o>0W{t6URbk`lzyF0p&|_VNGKnv zO`7K!1ejR>xcqpe-=RN-x0Au~WD=12xJW!mpX~5}JE+ELsh^(f zJQE*^Hz;$=>=gze`mGIsvFaPzf>f|(+kQFA$Jf~rJToBd=}hok{f+i3gCiq1f%%H| z236yT_!|+wV)-cqLQgB)MMLVij=PYibe|2hQAAp6^+o)OW`MC&q7_4OwO^iq(9MyB zkL$lI`1856WWP3jfaX?jKMW5-fLU-&@uxlrCSP(zYbLDM_-t~>Dush_7n0#Fpc!A2 zFCwt3n0tY(b1%7vvzI?qTO^e2=%3<&Vz{XBFC|BY;Gs!@ghb{mGkt1vtT1yZHp>jj ziG32t&izT%DS<}&Ab)%6aCm~tJQiW z{zu)!F!`KCjCl4R;Dq#PgWWzp>ln4MY6)z3Ri4_Sx)fy|QjC7i;L$|t@70GMX)4yNFJr6VXBvy+)00 z^%nK>em}ln-*e8LIdgx^ojY^SnP=vK>wr}$$Qa3RaBwIXL1``(!zu=fM93^!A0Sq>^lLPar7AENJC;#MtsDn{Igy4uk!0*BOT zajebnoS#xHfrW~i)sO$#`=+N3Q%sxS6*k8YJs{SVc}@=X1Yd+&27$$Nhs6G#rlIcQ zxxSa7;rYy!N;+(P0$At{|bFrk1xM1mL4mhd|yH73G$*&^g$zcR@^z7SX2vsY@HyTtrC<4pAOwki@sBO!B#qr6=>HIO*;u~oOl^BQY-9^|1z$=S$XB&QB^P1qYkuLU|Kel&jd-=@d&JRlj)eB2ok zSaO$4$jhg0@RrTTTdhs*sIHG~N?x?6*uL_;K- z{#BAzx`?n+JD%KRMQ5-rwRg)a8RuW`kZSpC)~Hq`y=?chj7D@DLHI2T&u^)mS`X=& zZg<`Lz}ZSG9$Yl)MFtPk8u=sSAL(b7-8!^c673Q5EjY-x1evo0o8?Hlbk3-*vo0-J z>00EUP5Q~mHpsK>NLLJR-t=DbNlVG%Z5A#6 z7^QEeEMAto52om2KR6&Fb(Cv?T^vae23p34D2U!mO?Iq9AFxB}T#05P z+^eWAXuu<+Xb=3l)D-;PqCp8WAV{x+R%~sd$ZxH~qhxl?)T#*n^_06&Z$k@Ys~_B$ z6T3ORacA4`54kAa{NP(?>V;+IAip6Q@tiILt!AO2#}C4a%?>~IcoUkX#oR})P8a1- z#H43dKEJyt*T2sn{VBse{}?64eu8zv$Q?J)s5xC^MX0a7D|`v?PqyUw9WEeVLMyru zn9~DBx2f`gNR>&}qU4gRc_@G$e87!Vbs(yh7g*$#VnEzi+&*bgD6dXcPUYtIvO|!< zkJ@bX^)2lrb$r$8TQXW_nKmEbZC(BA~g^_(|wW7n#@kP;akY+K-w59 zp>8Z$*|qI+XA-pC6#TmS38*GmKnu-%)yqGlA5kMlsuny2dEUtv$3!;Z_T*BT+e1OD z;E7f6O{}b)@b3w-o}(8`9`dRrnS-f9-yA)?ujA@0Ih!bE^-OXQ#!eFGz8loD$6nK= zgN|mOB&oT`NxFcIuA6e#AUFt28vknf3F)|v=+>l>B;ADsb#6eFbFnk0?AL~rS(Rco z^`d6I&D1cCyJzzSfn*=qy`80RD(AEqNTEDWcq)wrK2LtKAQ2%9U)ufRZLY+wJ_1+>n4k(uXLJ@5Y<}+n(2f7hx#e%WLp+-KxU+WKC@@b z2;nC2yPOm-`v)DdPb|4I?-Ax*Y;FgS;NgDp?uXssMtlGKcN^at7U4^UzFw}4Ns$HltnaBhxT2^@BH{zVPivqVZ#M@xeSox zaTZ$vS(*xiJCS??g}uEPZG(l#Z}HY)GFn_K&zxS$dZ3>s^l5o4$v�>dU)b$F7G2 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 literal 0 HcmV?d00001 diff --git a/website/assets/images/logo-chef-169x28@2x.png b/website/assets/images/logo-chef-169x28@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..ade25d93859b3f554d4b9643b20c7850864e2563 GIT binary patch literal 6354 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 RTfP7Q002ovPDHLkV1mD_I2Zr` literal 0 HcmV?d00001 diff --git a/website/assets/images/logo-github-89x28@2x.png b/website/assets/images/logo-github-89x28@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..7784f46ee7fa69755c2064889072e7fd40f4e905 GIT binary patch literal 2011 zcmV<12PF83P)(+{lul4OLCFMlH!yjB$M>YWBS65m z>>2~Me&0vBrffIzrgU8^qs9lwt zcAv-IH`H~f*z$>~?=jR!p&;8qP2r;#;C&D$4~v(QhW2Ix;1`WUGKT{ao`-idCbxwR z1AJNbqJ{nbcY4ANirUe*ReGZClkmK6id&Hadq{>*BSGHA@GKlcYJTleh()j|V=pfE zB{ls1lLqV;^=|}+eH-!eVH<_{d9{J$ziKkD89MUna zK|*T2(&Td2U?ps;AGpUKbv#-!IOyF*b{>(nQ-i7}8t1cyCvV6nA)A z&A>-^V>-X74_a+qqaN*BnD^?xn=BUyY=ibYVmM}y zfrWlzdMh`jAQ<_|sTUV@3vfTXc{Iwjg0Wde#K#@vzuy<@E$)G;|wlmihi zqtarHd_t5;U{Vf<%bNV*=U?u+u|FBjtysZg`ilNe+9$|1qwZOrb1F4^@Lk5F_Ta0w zTSi{e*^JBuidWcmno;4A09hm7v`&!yEOxg3KR*#IMvKQZgrO+iVwSa)=VNruZpaP! z9g5-DN)&*i93fVrpg49B^{lr*pu$<|HC?g-h*L0WNxR)XF54ayDaLR#S&-27$bu|K z2et|b?~=tsTxOTB-;X4fhFTBLS0pAOLdb#~gCr>OXmc_$Cp@IBC#1FGog0n$tZqJ~ z0o+a`aH-SBmvjCqK%G8HVptH>VW2XTE1M`V8jq2HgA zX)^Omwv)~6*2ejVjvK)#5E!mAH-S^d2vbYC&oh zUrU1q8-97Jx6A_K(@JcvJso8%i@{qV_oX!9|M@Ww>7M- z9N;@>8RcAzCyc+S9U@+)MQr6GbMg<}5g(v!$X$%5%xcL{7ceV1-I-XQJS5}R3A3xA z-0F+$R>xph>ho=skH}mhVud-t*M6CP17y@dt_gdQ1DXT09ll}lXiIz5r$|^f*FWa9 zb<0ig%4+9*4Gz0cxLCJ+3hWnGgt2{dfOXt_pe_YuCbf>2RV4OF|0e>S>?-bFxMq(B z#tIn^4tJ!ENt5;c(Fq0kcd|(Gs^7;T;e~u)Z1uC`pmqbAGkH1dQQv>!rgI%|I@4X)L=f};u2D`>R?|NmlA9(-ug{{u%`WE_r tftMYweKAdR&8VU%ilQirq9{tY_#e?)AX1Le^)LVc002ovPDHLkV1k8!!>|AV literal 0 HcmV?d00001 diff --git a/website/assets/images/logo-gitlab-124x28@2x.png b/website/assets/images/logo-gitlab-124x28@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..8efa1f81ed1007566461e2083921c3bf114857c4 GIT binary patch literal 4117 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 T$KMLd00000NkvXXu0mjf)yeD6 literal 0 HcmV?d00001 diff --git a/website/assets/images/logo-google-chronicle-128x28@2x.png b/website/assets/images/logo-google-chronicle-128x28@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..a5bd8cf760fcf9dc0e1b60e1ceaac53c38b97836 GIT binary patch literal 5801 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 literal 0 HcmV?d00001 diff --git a/website/assets/images/logo-google-cloud-174x28@2x.png b/website/assets/images/logo-google-cloud-174x28@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..135f685cdfd06d0cd4f0ba7a590124eb139e61bb GIT binary patch literal 23926 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 JPDHLkV1oCJz>EL@ literal 0 HcmV?d00001 diff --git a/website/assets/images/logo-okta-85x28@2x.png b/website/assets/images/logo-okta-85x28@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..eec4d5ee1fa0dabece19b44c8f1f1f3c38b784ce GIT binary patch literal 3936 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 Hu0mjfP{H#s literal 0 HcmV?d00001 diff --git a/website/assets/images/logo-snowflake-color-117x28@2x.png b/website/assets/images/logo-snowflake-color-117x28@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..b8c98e3e34b77db5efd227ad5ec8ab97a4ef73e9 GIT binary patch literal 4934 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 Ef+2{sO#lD@ literal 0 HcmV?d00001 diff --git a/website/assets/images/logo-splunk-95x28@2x.png b/website/assets/images/logo-splunk-95x28@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..5586e071cc7b8d440ba62256221f0eb15a4c349d GIT binary patch literal 2707 zcmV;E3T*X>P) z+qf2n4`l_{MH|5PVVVN>VO-o~aNn-Gf^k;RWCcxD(A*WoSwWK(Bwaz$6~wcGrf(NW zi+UagXgUugAT8n|vN`8xB-gPeij<|)vG@UP97{4~>L+>5;UObv!C8L%?+qgFdD`*I z&*$6ZxJ4D}%%=rMdZI;AZfQ<&!k?fDb>@-7Q9+#W9hJlh-%&}N@Ew(8;hkH@{iM-s zHU|Br4H^!EnzwWoeENTn3dHsJj=3bfs&ypE9=hgh8E&8{2SlZ{8 zZ5nzTOW(pH)%zES6TV~a7)sfodOr{(no`Q2gP<3v_X@<>_>Q>)J$xwrmx6{T+?clJHN`S|dKgtEp?J;V{+Z=MN|2Z+#D8(XS|%%V@;qMrED} zA>P!e)-AdR7~MmI@1dUVNZFRHtpn!r)A`ZVHUHweA0*qfvL=GBf1ICF#ic=qcRIyF2 z=r+!Mw6fY|W$u0X^u9ZOo~X$U9;$YeK0^qv$3I5Gf4H*tGQ5%~$yAM1Dx!G~A= zZg@pIag}jBOq(6y0Syj@gWZ}} z+bgTVcvA(+!70DA4IQ<)zE5#IS%Pa=#HWHJ!dEt)`_ABM>201e7lB7lR@?ag{Bp8U~DkakzWtb$dIxcz)1KQS~4o~ zgvGthm9{RV+~iw2fZdG)_{p~K9$Au5K>>f5GMn&^%X=M{p`jr{d0LsCpQ9YH2{8+7 z8toQ68wyyY$7wpkvt;vhJ&$=>bOTT}J{-DGXciva*zcW*cVVCJV3rMzYH)GhBMYW0 zM;`bO2A!lzK^Z(mzoK+gsDTRSpU-IV8LtDbEBRVT>LQ;7Z?ONCyN@u7JPW6*6lB@S+=Q(hmZ@fv#c2eEswMjN7KxoWk%;6~IJDIL2Yb&5Vy~7h&!oA_u=A z>6oqPW6;@MUYtLLd95>d^O`^=Gq(*BFFY?czCqNR>xRlw?{#kA5z<6R(K%i1N&Wgv z!bg2a5xK-$$Rql3%4z^g_$vKVhos{&A}4twLHy7Uw8y0^G>dN@!6cgLxtL_s9fwaq zIZ*}e&h6#r^DPtc<4dE(-|V=Rn;zvP(DGxww<%Jd9w|r|hIBTYU0+4?7JpSjvh6gFNDwl)GXwC*11&(nb@<%?hdEkT5xxSkEeX@X9}=I|hP>|A`FL ztRRPW0t?P34;$+yrjHW8#pDr-H@EOJzEn_nj{YnZyhwUpFqlc-xGPJ*Q1D2A+^f~E zRrJzm&8;$hR7PfoT`tX225^IUhC?iH(4HXZhd5X1h@WXeiDuHfsV5);_~e%tR1!9V z_aB5$q2styG+u13;q#*ia%^+4kUf43JCXW{BsttK z(%D64iIL>d%IaTv1RrCy%R)56+tr-Xu=3| zVs1J_O^EZx**&Hl6fO9ek?>6&Qz7(2x25pZVrkQpL(L-a2|euyR$cWT|K z#d_HL&XQYCHyGM?o{P7Wc0v1v#T>T@;H_|CKVE$(@r}c<@I-| z0I-dT_Bc<{=(1JSLhu>ZFW*<9{Qq>}89KXaMPNySAKfedi&pBWe1^f*Qk%WyseFrg zD+}GjWbx@!hEp%(3VFQNOj=13mrI)7Gw+&T&HWs(`gOg!%2s6u>x50e-*^-}7}=dd}CjrDjRnqJn4laAkE5$&6{|z}e+au(DPBs3J$g`P^d8%Ww>Ps&KuxAx z{#i`kH^9ijVX~eH+3F)7HZ1~;Xlh0O~Ey2Fh)5+(*<`?ZW*En==Ifh zDmhTw9dd)b>n;1{1*9}&ggCJ~#Jh~p_e?f?79MUA9=Xb969)PC6E{|SaQ$qS5aLMu z=4E65%x$Egmc1V%go}F7<#|sqs*|!BhmmF7^5K`$1WPkC`${ zZ}u&`Zb@tw8|5y4KUr^ea@^Dy6x~aoq!r$+f!)qe%btM1* N002ovPDHLkV1g%=EI|MO literal 0 HcmV?d00001 diff --git a/website/assets/images/logo-tines-90x28@2x.png b/website/assets/images/logo-tines-90x28@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..40d5365a095778ccc43046416c38a8898071e091 GIT binary patch literal 2119 zcmV-N2)Or&P)gm=O^d5`5hcJiZ>`%dcNdnU69ypyVvrmd-nj!B@CMsXBy)x@^Kqlkh$}=7L&3ABHO7eYM#PNZzhdgb3?c@`(znxhC zDYVIIK6G-6b=(3{>=Jg1eMBZR6YB0wN=1sk!}>7TmHP`a8I39TgP|Xw>HN`&z_tjO z{psJ(hyE@FR}b?$vP-o(q|mx`6gVFO=8)ed;Ti-pWLAYEO+S{DST7gH<&c&7jF1AS zSm_MsYi!ISA!CcI)Mtbu8>v}oi5`uivTiYGHT4pqb(58PW!_kh?{TUme<2dc*-DNWyXX?9F38$cSnhsQ4%xzmdX;dTzO);pvF=040sYE7{W!xkd8vDmRUb`i*>1xl{$G$KJ^E~F$Oc_*V5vmf3XhavtL{CH+9ui_8VkgHM&yU z6iBB=KUH318)y)Cz;uLz4QcMN92SsvH<7;8(wZ9NBc4x@+tTUxlhAW>?V9^RTEyWR zW*v!{55u^VDoP$?<)*s)b6Ucw@{tI{A*QROQd-hfS~~8yA5+qO#`M6QHgy!32ZMib zpc(RE2#3ROCspvXe4i+A3b9}+gR6oo#36O1W(eO-7qY5xhxx&Q2L6h-!@O;Q4LlkT zo483zlN%iFvc?FFJw+6i63dmE-bFLBEJsYk5NFHYVIrR$mIWamFrk-)<6?pI5U`gw zhKuwRQB+DSSL(ZkzDz3FM z$BFB1(HlTM$PK35hrJ;m=15-+_uUV92)G;C5dj%ANPCfu%c?AOLAXI|U2<(nFGZz9 zT&0!@AR`kT?rm^V1WqX&=}DPgbxPOT-i4_V*#`Z0mizA<&t+_+g(WdV`f!-bULZad z0xX4v67iH;IPzg|Terq=O|$U%t^>+iTGDaH{ar@v|K32xsXth5QsFXq#A!?Yh`!=p zDpOnPTJ$^aZSRnwuQM4N=P((;Xquup)S$kq2cBM_ ztg*(rLK>F3!SXVbsF8zPEPpnFIr3vly3&$n@n&jTPn}YzE+$j6e3#7M0cl<9;MGR| zE0*U)w6I%Su6(x5`pSW4ftkAb7Us3dFM$?urpr_0Yd~7xIEMP z0FM~g);p)mrB6+*`LlMeMOwRETztf7Mb^j=j~;2*%yXgqqYV$e}_J#~#!r6w5qfx;)Gg{v3&hD-=8?7;##Fx#RGLitX9G()f8a)Y-G zDtCdQ*M6g%AnRtc#&p2}=|E*E9Otv84#%drdz=R<54}g~Dab|n8SY6SE3sPv|IA3FXSBc^BA&bln$9-o4RSMW=Crk8J*wk2R*vCSxQVx@WeP_AO zYiG$3=Xr_L#rQ0_EU+G~-|6;^^OsmYqsZY8^SsZ_k?sazG?x3wLH!nMzcs?J9=C8eb{|TlsnmdbO5InnUAtwZzU%_Zq*Gv`{`8HvG8D$) zh7C6_FuEb`~41W)Jxlr%i!oId< ziWa?(UQeVU|M`gUSw!{v)+aH?X^SY34h5E3uUIhj4SdSlsFb)2ORZjC!r!7&I)U(| z%tb&R53W3}9GU2`*BX7RNC8p+u6%FN&p4eE>alDuvi3-)N0D~0hfHk?&`iX-55Wg{ zhtYb|_0*%bcpl-H%QeQWW$%?dNdCdtbI(E`4Qr^waayh;G8qpT%w0=pw-S>b%v}^i zCgVwow5;M?V&%zPAPnZohOPdEv&6RUqd~UPnHdzYk8%sF4~KnJTp*K~iGVwVLorQ` zIM#cIaUDU0c|rY5#tQ+z7~&QpkjX@1kMpkoApf2tlZgz&_N(@sGf0_CEEv{b+(h>l x$jY1v;0ke&Q?Wb5Q9zmsZIRy3GGs1*{{SqU#KQxgW&;2K002ovPDHLkV1h^(0$Kn7 literal 0 HcmV?d00001 diff --git a/website/assets/js/components/scrollable-tweets.component.js b/website/assets/js/components/scrollable-tweets.component.js index ed7030fdff..f40a2e9f57 100644 --- a/website/assets/js/components/scrollable-tweets.component.js +++ b/website/assets/js/components/scrollable-tweets.component.js @@ -20,7 +20,7 @@ parasails.registerComponent('scrollableTweets', { data: function () { return { currentTweetPage: 0, - numberOfTweetCards: 6, + numberOfTweetCards: 7, numberOfTweetPages: 0, numberOfTweetsPerPage: 0, tweetCardWidth: 0, @@ -110,6 +110,19 @@ parasails.registerComponent('scrollableTweets', {

+ +
+
+ Deloitte logo +
+

One of the best teams out there to go work for and help shape security platforms.

+
+
+

Dhruv Majumdar

+

Director Of Cyber Risk & Advisory @Deloitte

+
+
+