diff --git a/changes/15605-merge-inherited-and-team-queries-policies b/changes/15605-merge-inherited-and-team-queries-policies new file mode 100644 index 0000000000..0841ea667f --- /dev/null +++ b/changes/15605-merge-inherited-and-team-queries-policies @@ -0,0 +1,2 @@ +- UI Change: Team queries page renders team level and inherited queries in a single table set by a new merge_inherited API parameter +- UI Change: Team policies page renders team level and inherited policies in a single table set by a new merge_inherited API parameter diff --git a/frontend/__mocks__/policyMock.ts b/frontend/__mocks__/policyMock.ts index c66c58a0bc..b14095463e 100644 --- a/frontend/__mocks__/policyMock.ts +++ b/frontend/__mocks__/policyMock.ts @@ -11,7 +11,7 @@ const DEFAULT_POLICY_MOCK: IPolicyStats = { author_id: 1, author_name: "Test User", author_email: "test@user.com", - team_id: undefined, + team_id: null, resolution: "Ensure ClamAV and Freshclam are installed and running.", platform: "linux" as const, created_at: "2023-03-24T22:13:59Z", @@ -29,4 +29,87 @@ const createMockPolicy = (overrides?: Partial): IPolicyStats => { return { ...DEFAULT_POLICY_MOCK, ...overrides }; }; +export const createMockPoliciesResponse = ( + overrides?: Partial +) => { + const MOCK_POLICIES_RESPONSE: { policies: IPolicyStats[] } = { + policies: [ + { + id: 5, + name: "Gatekeeper enabled", + query: "SELECT 1 FROM gatekeeper WHERE assessments_enabled = 1;", + description: "Checks if gatekeeper is enabled on macOS devices", + critical: true, + author_id: 42, + author_name: "John", + author_email: "john@example.com", + team_id: 2, + resolution: "Resolution steps", + platform: "darwin", + created_at: "2021-12-16T14:37:37Z", + updated_at: "2021-12-16T16:39:00Z", + passing_host_count: 2000, + failing_host_count: 300, + host_count_updated_at: "2023-12-20T15:23:57Z", + webhook: "Off", + has_run: true, + next_update_ms: 3600000, + calendar_events_enabled: false, + }, + { + id: 29090, + name: "Windows machines with encrypted hard disks", + query: "SELECT 1 FROM bitlocker_info WHERE protection_status = 1;", + description: "Checks if the hard disk is encrypted on Windows devices", + critical: false, + author_id: 43, + author_name: "Alice", + author_email: "alice@example.com", + team_id: 2, + resolution: "Resolution steps", + platform: "windows", + created_at: "2021-12-16T14:37:37Z", + updated_at: "2021-12-16T16:39:00Z", + passing_host_count: 2300, + failing_host_count: 0, + host_count_updated_at: "2023-12-20T15:23:57Z", + webhook: "Off", + has_run: true, + next_update_ms: 3600000, + calendar_events_enabled: false, + }, + { + id: 136, + name: "Arbitrary Test Policy (all platforms) (all teams)", + query: "SELECT 1 FROM osquery_info WHERE 1=1;", + description: + "If you're seeing this, mostly likely this is because someone is testing out failing policies in dogfood. You can ignore this.", + critical: true, + author_id: 77, + author_name: "Test Admin", + author_email: "test@admin.com", + team_id: null, + resolution: + 'To make it pass, change "1=0" to "1=1". To make it fail, change "1=1" to "1=0".', + platform: "darwin,windows,linux", + created_at: "2022-08-04T19:30:18Z", + updated_at: "2022-08-30T15:08:26Z", + passing_host_count: 10, + failing_host_count: 9, + host_count_updated_at: "2023-12-20T15:23:57Z", + webhook: "Off", + has_run: true, + next_update_ms: 3600000, + calendar_events_enabled: false, + }, + ], + }; + + if (overrides) { + MOCK_POLICIES_RESPONSE.policies.push(createMockPolicy(overrides)); + } + + return MOCK_POLICIES_RESPONSE; +}; + export default createMockPolicy; diff --git a/frontend/components/EmptyTable/_styles.scss b/frontend/components/EmptyTable/_styles.scss index 940afb6945..2aa30b7d68 100644 --- a/frontend/components/EmptyTable/_styles.scss +++ b/frontend/components/EmptyTable/_styles.scss @@ -39,10 +39,9 @@ &__info, &__additional-info { + @include help-text; line-height: 1.5; text-align: center; - color: $core-fleet-blue; - font-size: $x-small; margin: 0; } diff --git a/frontend/components/InheritedBadge/InheritedBadge.tsx b/frontend/components/InheritedBadge/InheritedBadge.tsx new file mode 100644 index 0000000000..0a098317c1 --- /dev/null +++ b/frontend/components/InheritedBadge/InheritedBadge.tsx @@ -0,0 +1,40 @@ +import { uniqueId } from "lodash"; +import React from "react"; +import { PlacesType, Tooltip as ReactTooltip5 } from "react-tooltip-5"; + +const baseClass = "inherited-badge"; + +interface IInheritedBadgeProps { + tooltipPosition?: PlacesType; + tooltipContent: React.ReactNode; +} + +const InheritedBadge = ({ + tooltipPosition = "top", + tooltipContent, +}: IInheritedBadgeProps) => { + const tooltipId = uniqueId(); + return ( +
+ + Inherited + + + {tooltipContent} + +
+ ); +}; + +export default InheritedBadge; diff --git a/frontend/components/InheritedBadge/_styles.scss b/frontend/components/InheritedBadge/_styles.scss new file mode 100644 index 0000000000..5c6bdba177 --- /dev/null +++ b/frontend/components/InheritedBadge/_styles.scss @@ -0,0 +1,19 @@ +.inherited-badge { + &__element-text { + font-weight: $bold; + font-size: $xxx-small; + color: $core-fleet-black; + line-height: 15px; + border-radius: 4px; + background: $ui-vibrant-blue-10; + padding: 4px; + } + + @include tooltip5-arrow-styles; + + .react-tooltip { + @include tooltip-text; + font-style: normal; + text-align: center; + } +} diff --git a/frontend/components/InheritedBadge/index.ts b/frontend/components/InheritedBadge/index.ts new file mode 100644 index 0000000000..1f70caec54 --- /dev/null +++ b/frontend/components/InheritedBadge/index.ts @@ -0,0 +1 @@ +export { default } from "./InheritedBadge"; diff --git a/frontend/components/TableContainer/DataTable/DataTable.tsx b/frontend/components/TableContainer/DataTable/DataTable.tsx index 9d299b9867..89f8709f40 100644 --- a/frontend/components/TableContainer/DataTable/DataTable.tsx +++ b/frontend/components/TableContainer/DataTable/DataTable.tsx @@ -69,8 +69,9 @@ interface IHeaderGroup extends HeaderGroup { const CLIENT_SIDE_DEFAULT_PAGE_SIZE = 20; -// This data table uses react-table for implementation. The relevant documentation of the library -// can be found here https://react-table.tanstack.com/docs/api/useTable +// This data table uses react-table for implementation. The relevant v7 documentation of the library +// can be found here https://react-table-v7-docs.netlify.app/docs/api/usetable + const DataTable = ({ columns: tableColumns, data: tableData, diff --git a/frontend/components/TableContainer/TableContainer.tsx b/frontend/components/TableContainer/TableContainer.tsx index a78c023c1f..68fddb04cf 100644 --- a/frontend/components/TableContainer/TableContainer.tsx +++ b/frontend/components/TableContainer/TableContainer.tsx @@ -12,7 +12,7 @@ import Icon from "components/Icon/Icon"; import { COLORS } from "styles/var/colors"; import DataTable from "./DataTable/DataTable"; -import TableContainerUtils from "./TableContainerUtils"; +import TableContainerUtils from "./utilities/TableContainerUtils"; import { IActionButtonProps } from "./DataTable/ActionButton/ActionButton"; export interface ITableQueryData { @@ -100,6 +100,7 @@ interface ITableContainerProps { setExportRows?: (rows: Row[]) => void; resetPageIndex?: boolean; disableTableHeader?: boolean; + show0Count?: boolean; } const baseClass = "table-container"; @@ -156,6 +157,7 @@ const TableContainer = ({ setExportRows, resetPageIndex, disableTableHeader, + show0Count, }: ITableContainerProps) => { const [searchQuery, setSearchQuery] = useState(defaultSearchQuery); const [sortHeader, setSortHeader] = useState(defaultSortHeader || ""); @@ -321,7 +323,7 @@ const TableContainer = ({ )} {!renderCount && !disableCount && - (isMultiColumnFilter || displayCount()) ? ( + (isMultiColumnFilter || displayCount() || show0Count) ? (
({ > {TableContainerUtils.generateResultsCountText( resultsTitle, - displayCount() + displayCount(), + show0Count )} {resultsHtml}
diff --git a/frontend/components/TableContainer/TableContainerUtils.ts b/frontend/components/TableContainer/utilities/TableContainerUtils.ts similarity index 90% rename from frontend/components/TableContainer/TableContainerUtils.ts rename to frontend/components/TableContainer/utilities/TableContainerUtils.ts index 4f883b678b..e825324eda 100644 --- a/frontend/components/TableContainer/TableContainerUtils.ts +++ b/frontend/components/TableContainer/utilities/TableContainerUtils.ts @@ -2,9 +2,10 @@ const DEFAULT_RESULTS_NAME = "results"; const generateResultsCountText = ( name: string = DEFAULT_RESULTS_NAME, - resultsCount: number + resultsCount: number, + show0Count = false ): string => { - if (resultsCount === 0) return `No ${name}`; + if (resultsCount === 0 && !show0Count) return `No ${name}`; // If there is 1 result and the last 3 letters in the result // name are "ies," we remove the "ies" and add "y" // to make the name singular diff --git a/frontend/components/TableContainer/utilities/config_utils.ts b/frontend/components/TableContainer/utilities/config_utils.ts new file mode 100644 index 0000000000..7acf97737d --- /dev/null +++ b/frontend/components/TableContainer/utilities/config_utils.ts @@ -0,0 +1,50 @@ +// from https://stackoverflow.com/a/68213902/15458245 + +import { HeaderProps, Row } from "react-table"; + +interface GetConditionalSelectHeaderCheckboxProps { + /** react-table header props */ + headerProps: React.PropsWithChildren>; + checkIfRowIsSelectable: (row: Row) => boolean; +} + +export const getConditionalSelectHeaderCheckboxProps = ({ + headerProps, + checkIfRowIsSelectable, +}: GetConditionalSelectHeaderCheckboxProps) => { + // Define if the checkbox should show as checked or indeterminate + const checkIfAllSelectableRowsSelected = (rows: Row[]) => + rows.filter(checkIfRowIsSelectable).every((row) => row.isSelected); + const allSelectableRowsSelected = checkIfAllSelectableRowsSelected( + headerProps.rows + ); + const indeterminate = + !allSelectableRowsSelected && + headerProps.rows.some((row) => row.isSelected); + + const onChange = () => { + if (checkIfAllSelectableRowsSelected(headerProps.rows)) { + headerProps.rows.forEach((row) => { + headerProps.toggleRowSelected(row.id, false); + }); + } else { + // Otherwise select every selectable row on the page + headerProps.page.forEach((row) => { + const rowChecked = checkIfRowIsSelectable(row); + headerProps.toggleRowSelected(row.id, rowChecked); + }); + } + }; + + // Usual checkbox props + const checkboxProps = headerProps.getToggleAllRowsSelectedProps(); + + return { + ...checkboxProps, + value: allSelectableRowsSelected, + indeterminate, + onChange, + }; +}; + +export default { getConditionalSelectHeaderCheckboxProps }; diff --git a/frontend/components/forms/fields/Dropdown/DropdownOptionTooltipWrapper/DropdownOptionTooltipWrapper.tsx b/frontend/components/forms/fields/Dropdown/DropdownOptionTooltipWrapper/DropdownOptionTooltipWrapper.tsx index 8fc4da9dc5..5acdd89f26 100644 --- a/frontend/components/forms/fields/Dropdown/DropdownOptionTooltipWrapper/DropdownOptionTooltipWrapper.tsx +++ b/frontend/components/forms/fields/Dropdown/DropdownOptionTooltipWrapper/DropdownOptionTooltipWrapper.tsx @@ -56,7 +56,6 @@ const DropdownOptionTooltipWrapper = ({ clickable={clickable} offset={offset} positionStrategy="fixed" - classNameArrow="tooltip-arrow" > {tipContent} diff --git a/frontend/components/forms/fields/Dropdown/DropdownOptionTooltipWrapper/_styles.scss b/frontend/components/forms/fields/Dropdown/DropdownOptionTooltipWrapper/_styles.scss index c6f03b9cb7..c617631615 100644 --- a/frontend/components/forms/fields/Dropdown/DropdownOptionTooltipWrapper/_styles.scss +++ b/frontend/components/forms/fields/Dropdown/DropdownOptionTooltipWrapper/_styles.scss @@ -17,21 +17,5 @@ text-align: center; } - // arrow styles directly from react-tooltip-5 css - .tooltip-arrow { - width: 8px; - height: 8px; - } - [class*="react-tooltip__place-top"] > .styles-module_arrow__K0L3T { - transform: rotate(45deg); - } - [class*="react-tooltip__place-right"] > .styles-module_arrow__K0L3T { - transform: rotate(135deg); - } - [class*="react-tooltip__place-bottom"] > .styles-module_arrow__K0L3T { - transform: rotate(225deg); - } - [class*="react-tooltip__place-left"] > .styles-module_arrow__K0L3T { - transform: rotate(315deg); - } + @include tooltip5-arrow-styles; } diff --git a/frontend/components/graphics/EmptyQueries.tsx b/frontend/components/graphics/EmptyQueries.tsx index 26f6027b59..5d97e1a0ba 100644 --- a/frontend/components/graphics/EmptyQueries.tsx +++ b/frontend/components/graphics/EmptyQueries.tsx @@ -3,107 +3,135 @@ import React from "react"; const EmptyQueries = () => { return ( - - - - - - - + + + - + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - + - - + - - + + diff --git a/frontend/context/query.tsx b/frontend/context/query.tsx index e117a33bd5..f7d05f3b91 100644 --- a/frontend/context/query.tsx +++ b/frontend/context/query.tsx @@ -29,7 +29,7 @@ type InitialStateType = { lastEditedQueryMinOsqueryVersion: string; lastEditedQueryLoggingType: QueryLoggingOption; lastEditedQueryDiscardData: boolean; - editingExistingQuery: boolean; + editingExistingQuery?: boolean; selectedQueryTargets: ITarget[]; // Mimicks old selectedQueryTargets still used for policies for SelectTargets.tsx and running a live query selectedQueryTargetsByType: ISelectedTargetsByType; // New format by type for cleaner app wide state setLastEditedQueryId: (value: number | null) => void; @@ -63,7 +63,7 @@ const initialState = { lastEditedQueryMinOsqueryVersion: DEFAULT_QUERY.min_osquery_version, lastEditedQueryLoggingType: DEFAULT_QUERY.logging, lastEditedQueryDiscardData: DEFAULT_QUERY.discard_data, - editingExistingQuery: DEFAULT_QUERY.editingExistingQuery, + editingExistingQuery: DEFAULT_QUERY.editingExistingQuery ?? false, selectedQueryTargets: DEFAULT_TARGETS, selectedQueryTargetsByType: DEFAULT_TARGETS_BY_TYPE, setLastEditedQueryId: () => null, diff --git a/frontend/interfaces/activity.ts b/frontend/interfaces/activity.ts index 841c326ab2..51f8fe1229 100644 --- a/frontend/interfaces/activity.ts +++ b/frontend/interfaces/activity.ts @@ -1,6 +1,6 @@ import { IPolicy } from "./policy"; import { IQuery } from "./query"; -import { IScheduledQueryStats } from "./scheduled_query_stats"; +import { ISchedulableQueryStats } from "./schedulable_query"; import { ITeamSummary } from "./team"; import { UserRole } from "./user"; @@ -131,6 +131,6 @@ export interface IActivityDetails { script_name?: string; deadline_days?: number; grace_period_days?: number; - stats?: IScheduledQueryStats; + stats?: ISchedulableQueryStats; host_id?: number; } diff --git a/frontend/interfaces/policy.ts b/frontend/interfaces/policy.ts index 056ab70406..41586ea22d 100644 --- a/frontend/interfaces/policy.ts +++ b/frontend/interfaces/policy.ts @@ -36,7 +36,7 @@ export interface IPolicy { author_email: string; resolution: string; platform: SelectedPlatformString; - team_id?: number; + team_id: number | null; created_at: string; updated_at: string; critical: boolean; @@ -74,14 +74,16 @@ export interface IHostPolicy extends IPolicy { response: PolicyStatusResponse; } +// Policies API can return {} export interface ILoadAllPoliciesResponse { - policies: IPolicyStats[]; + policies?: IPolicyStats[]; } +// Team policies API can return {} export interface ILoadTeamPoliciesResponse { - policies: IPolicyStats[]; - inherited_policies: IPolicyStats[]; + policies?: IPolicyStats[]; } + export interface IPolicyFormData { description?: string | number | boolean | undefined; resolution?: string | number | boolean | undefined; @@ -89,7 +91,7 @@ export interface IPolicyFormData { platform?: SelectedPlatformString; name?: string | number | boolean | undefined; query?: string | number | boolean | undefined; - team_id?: number; + team_id?: number | null; id?: number; calendar_events_enabled?: boolean; } diff --git a/frontend/interfaces/query.ts b/frontend/interfaces/query.ts index cad78f3745..9485f2da9a 100644 --- a/frontend/interfaces/query.ts +++ b/frontend/interfaces/query.ts @@ -1,7 +1,6 @@ import { IFormField } from "./form_field"; import { IPack } from "./pack"; -import { ISchedulableQuery } from "./schedulable_query"; -import { IScheduledQueryStats } from "./scheduled_query_stats"; +import { ISchedulableQuery, ISchedulableQueryStats } from "./schedulable_query"; export interface IEditQueryFormData { description?: string | number | boolean | undefined; @@ -32,7 +31,7 @@ export interface IQuery { author_email: string; observer_can_run: boolean; packs: IPack[]; - stats?: IScheduledQueryStats; + stats?: ISchedulableQueryStats; } export interface IEditQueryFormFields { diff --git a/frontend/interfaces/query_stats.ts b/frontend/interfaces/query_stats.ts index edc319fa61..eaa7f1e159 100644 --- a/frontend/interfaces/query_stats.ts +++ b/frontend/interfaces/query_stats.ts @@ -1,8 +1,8 @@ import PropTypes, { number } from "prop-types"; -import scheduledQueryStatsInterface, { - IScheduledQueryStats, -} from "./scheduled_query_stats"; +import ILegacySchedulableQueryStats, { + ISchedulableQueryStats, +} from "./schedulable_query"; export default PropTypes.shape({ scheduled_query_name: PropTypes.string, @@ -20,7 +20,7 @@ export default PropTypes.shape({ system_time: PropTypes.number, user_time: PropTypes.number, wall_time: PropTypes.number, - stats: scheduledQueryStatsInterface, + stats: ILegacySchedulableQueryStats, }); export interface IQueryStats { @@ -42,5 +42,5 @@ export interface IQueryStats { system_time: number; user_time: number; wall_time?: number; - stats?: IScheduledQueryStats; + stats?: ISchedulableQueryStats; } diff --git a/frontend/interfaces/schedulable_query.ts b/frontend/interfaces/schedulable_query.ts index 8e167eeec0..89d56dc726 100644 --- a/frontend/interfaces/schedulable_query.ts +++ b/frontend/interfaces/schedulable_query.ts @@ -1,3 +1,6 @@ +// for legacy legacy query stats interface +import PropTypes from "prop-types"; + import { IFormField } from "./form_field"; import { IPack } from "./pack"; import { SelectedPlatformString, SupportedPlatform } from "./platform"; @@ -24,7 +27,7 @@ export interface ISchedulableQuery { discard_data: boolean; packs: IPack[]; stats: ISchedulableQueryStats; - editingExistingQuery: boolean; + editingExistingQuery?: boolean; } export interface IEnhancedQuery extends ISchedulableQuery { @@ -32,13 +35,22 @@ export interface IEnhancedQuery extends ISchedulableQuery { platforms: SupportedPlatform[]; } export interface ISchedulableQueryStats { - user_time_p50?: number; - user_time_p95?: number; - system_time_p50?: number; - system_time_p95?: number; + user_time_p50?: number | null; + user_time_p95?: number | null; + system_time_p50?: number | null; + system_time_p95?: number | null; total_executions?: number; } +// legacy +export default PropTypes.shape({ + user_time_p50: PropTypes.number, + user_time_p95: PropTypes.number, + system_time_p50: PropTypes.number, + system_time_p95: PropTypes.number, + total_executions: PropTypes.number, +}); + // API shapes // Get a query by id diff --git a/frontend/interfaces/scheduled_query.ts b/frontend/interfaces/scheduled_query.ts index 779042a956..48239007cc 100644 --- a/frontend/interfaces/scheduled_query.ts +++ b/frontend/interfaces/scheduled_query.ts @@ -1,7 +1,8 @@ +// legacy interfaces to maintain packs support import PropTypes from "prop-types"; -import scheduledQueryStatsInterface, { - IScheduledQueryStats, -} from "./scheduled_query_stats"; +import ILegacySchedulableQueryStats, { + ISchedulableQueryStats, +} from "interfaces/schedulable_query"; export default PropTypes.shape({ created_at: PropTypes.string, @@ -19,7 +20,7 @@ export default PropTypes.shape({ version: PropTypes.string, shard: PropTypes.number, denylist: PropTypes.bool, - stats: scheduledQueryStatsInterface, + stats: ILegacySchedulableQueryStats, }); export interface IPackQueryFormData { @@ -52,7 +53,7 @@ export interface IScheduledQuery { shard?: number | undefined; denylist?: boolean; logging_type?: string; - stats: IScheduledQueryStats; + stats: ISchedulableQueryStats; team_id?: number; } export interface IEditScheduledQuery extends IScheduledQuery { diff --git a/frontend/interfaces/scheduled_query_stats.ts b/frontend/interfaces/scheduled_query_stats.ts deleted file mode 100644 index f5e8fb5e75..0000000000 --- a/frontend/interfaces/scheduled_query_stats.ts +++ /dev/null @@ -1,17 +0,0 @@ -import PropTypes from "prop-types"; - -export default PropTypes.shape({ - user_time_p50: PropTypes.number, - user_time_p95: PropTypes.number, - system_time_p50: PropTypes.number, - system_time_p95: PropTypes.number, - total_executions: PropTypes.number, -}); - -export interface IScheduledQueryStats { - user_time_p50?: number; - user_time_p95?: number; - system_time_p50?: number; - system_time_p95?: number; - total_executions?: number; -} diff --git a/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx b/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx index ef86fd4c19..8c6dd2a85f 100644 --- a/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx +++ b/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx @@ -112,7 +112,7 @@ interface IManageHostsProps { router: InjectedRouter; params: Params; // eslint-disable-next-line @typescript-eslint/no-explicit-any - location: any; // no type in react-router v3 + location: any; // no type in react-router v3 TODO: Improve this type } const CSV_HOSTS_TITLE = "Hosts"; diff --git a/frontend/pages/policies/ManagePoliciesPage/ManagePoliciesPage.tsx b/frontend/pages/policies/ManagePoliciesPage/ManagePoliciesPage.tsx index 7223973e29..a17b43fb2d 100644 --- a/frontend/pages/policies/ManagePoliciesPage/ManagePoliciesPage.tsx +++ b/frontend/pages/policies/ManagePoliciesPage/ManagePoliciesPage.tsx @@ -2,7 +2,7 @@ import React, { useCallback, useContext, useEffect, useState } from "react"; import { useQuery } from "react-query"; import { InjectedRouter } from "react-router/lib/Router"; import PATHS from "router/paths"; -import { noop, isEqual } from "lodash"; +import { isEqual } from "lodash"; import { getNextLocationPath } from "utilities/helpers"; @@ -18,6 +18,7 @@ import { ILoadAllPoliciesResponse, ILoadTeamPoliciesResponse, IPoliciesCountResponse, + IPolicy, } from "interfaces/policy"; import { ITeamConfig } from "interfaces/team"; @@ -36,7 +37,6 @@ import { ITableQueryData } from "components/TableContainer/TableContainer"; import Button from "components/buttons/Button"; // @ts-ignore import Dropdown from "components/forms/fields/Dropdown"; -import RevealButton from "components/buttons/RevealButton"; import Spinner from "components/Spinner"; import TeamsDropdown from "components/TeamsDropdown"; import TableDataError from "components/DataError"; @@ -62,10 +62,6 @@ interface IManagePoliciesPageProps { order_key?: string; order_direction?: "asc" | "desc"; page?: string; - inherited_table?: "true"; - inherited_order_key?: string; - inherited_order_direction?: "asc" | "desc"; - inherited_page?: string; }; search: string; }; @@ -138,9 +134,10 @@ const ManagePolicyPage = ({ const [showAddPolicyModal, setShowAddPolicyModal] = useState(false); const [showDeletePolicyModal, setShowDeletePolicyModal] = useState(false); const [showCalendarEventsModal, setShowCalendarEventsModal] = useState(false); - - const [teamPolicies, setTeamPolicies] = useState(); - const [inheritedPolicies, setInheritedPolicies] = useState(); + const [ + policiesAvailableToAutomate, + setPoliciesAvailableToAutomate, + ] = useState([]); // Functions to avoid race conditions const initialSearchQuery = (() => queryParams.query ?? "")(); @@ -152,40 +149,15 @@ const ManagePolicyPage = ({ DEFAULT_SORT_DIRECTION)(); const initialPage = (() => queryParams && queryParams.page ? parseInt(queryParams?.page, 10) : 0)(); - const initialShowInheritedTable = (() => - queryParams && queryParams.inherited_table === "true")(); - const initialInheritedSortHeader = (() => - (queryParams?.inherited_order_key as "name" | "failing_host_count") ?? - DEFAULT_SORT_COLUMN)(); - const initialInheritedSortDirection = (() => - (queryParams?.inherited_order_direction as "asc" | "desc") ?? - DEFAULT_SORT_DIRECTION)(); - const initialInheritedPage = (() => - queryParams && queryParams.inherited_page - ? parseInt(queryParams?.inherited_page, 10) - : 0)(); - - const showInheritedTable = initialShowInheritedTable; // Needs update on location change or table state might not match URL const [searchQuery, setSearchQuery] = useState(initialSearchQuery); const [page, setPage] = useState(initialPage); - const [inheritedPage, setInheritedPage] = useState(initialInheritedPage); const [tableQueryData, setTableQueryData] = useState(); - const [ - inheritedTableQueryData, - setInheritedTableQueryData, - ] = useState(); const [sortHeader, setSortHeader] = useState(initialSortHeader); const [sortDirection, setSortDirection] = useState< "asc" | "desc" | undefined >(initialSortDirection); - const [inheritedSortDirection, setInheritedSortDirection] = useState( - initialInheritedSortDirection - ); - const [inheritedSortHeader, setInheritedSortHeader] = useState( - initialInheritedSortHeader - ); useEffect(() => { setLastEditedQueryPlatform(null); @@ -199,9 +171,6 @@ const ManagePolicyPage = ({ setSearchQuery(initialSearchQuery); setSortHeader(initialSortHeader); setSortDirection(initialSortDirection); - setInheritedPage(initialInheritedPage); - setInheritedSortHeader(initialInheritedSortHeader); - setInheritedSortDirection(initialInheritedSortDirection); }, [location, isRouteOk]); useEffect(() => { @@ -246,8 +215,11 @@ const ManagePolicyPage = ({ }, { enabled: isRouteOk && !isAnyTeamSelected, - select: (data) => data.policies, + select: (data) => data.policies || [], staleTime: 5000, + onSuccess: (data) => { + setPoliciesAvailableToAutomate(data || []); + }, } ); @@ -260,7 +232,7 @@ const ManagePolicyPage = ({ [ { scope: "policiesCount", - query: isAnyTeamSelected ? "" : searchQuery, // Search query not used for inherited count + query: isAnyTeamSelected ? "" : searchQuery, }, ], ({ queryKey }) => globalPoliciesAPI.getCount(queryKey[0]), @@ -274,13 +246,14 @@ const ManagePolicyPage = ({ ); const { + data: teamPolicies, error: teamPoliciesError, isFetching: isFetchingTeamPolicies, refetch: refetchTeamPolicies, } = useQuery< ILoadTeamPoliciesResponse, Error, - ILoadTeamPoliciesResponse, + IPolicyStats[], ITeamPoliciesQueryKey[] >( [ @@ -291,11 +264,8 @@ const ManagePolicyPage = ({ query: searchQuery, orderDirection: sortDirection, orderKey: sortHeader, - inheritedPage: inheritedTableQueryData?.pageIndex, - inheritedPerPage: DEFAULT_PAGE_SIZE, - inheritedOrderDirection: inheritedSortDirection, - inheritedOrderKey: inheritedSortHeader, teamId: teamIdForApi || 0, + mergeInherited: !!teamIdForApi, }, ], ({ queryKey }) => { @@ -303,13 +273,44 @@ const ManagePolicyPage = ({ }, { enabled: isRouteOk && isPremiumTier && !!teamIdForApi, + select: (data: ILoadTeamPoliciesResponse) => data.policies || [], onSuccess: (data) => { - setTeamPolicies(data.policies); - setInheritedPolicies(data.inherited_policies); + const allPoliciesAvailableToAutomate = data.filter( + (policy: IPolicy) => policy.team_id === currentTeamId + ); + setPoliciesAvailableToAutomate(allPoliciesAvailableToAutomate || []); }, } ); + const { + data: teamPoliciesCountMergeInherited, + isFetching: isFetchingTeamCountMergeInherited, + refetch: refetchTeamPoliciesCountMergeInherited, + } = useQuery< + IPoliciesCountResponse, + Error, + number, + ITeamPoliciesCountQueryKey[] + >( + [ + { + scope: "teamPoliciesCountMergeInherited", + query: searchQuery, + teamId: teamIdForApi || 0, // TODO: Fix number/undefined type + mergeInherited: !!teamIdForApi, + }, + ], + ({ queryKey }) => teamPoliciesAPI.getCount(queryKey[0]), + { + enabled: isRouteOk && !!teamIdForApi, + keepPreviousData: true, + refetchOnWindowFocus: false, + retry: 1, + select: (data) => data.count, + } + ); + const { data: teamPoliciesCount, isFetching: isFetchingTeamCount, @@ -325,6 +326,7 @@ const ManagePolicyPage = ({ scope: "teamPoliciesCount", query: searchQuery, teamId: teamIdForApi || 0, // TODO: Fix number/undefined type + mergeInherited: false, }, ], ({ queryKey }) => teamPoliciesAPI.getCount(queryKey[0]), @@ -337,9 +339,10 @@ const ManagePolicyPage = ({ } ); - const canAddOrDeletePolicy: boolean = + const canAddOrDeletePolicy = isGlobalAdmin || isGlobalMaintainer || isTeamMaintainer || isTeamAdmin; - const canManageAutomations: boolean = isGlobalAdmin || isTeamAdmin; + const canManageAutomations = isGlobalAdmin || isTeamAdmin; + const hasPoliciesToAutomateOrDelete = policiesAvailableToAutomate.length > 0; const { data: config, @@ -375,6 +378,7 @@ const ManagePolicyPage = ({ const refetchPolicies = (teamId?: number) => { if (teamId) { refetchTeamPolicies(); + refetchTeamPoliciesCountMergeInherited(); refetchTeamPoliciesCount(); } else { refetchGlobalPolicies(); // Only call on global policies as this is expensive @@ -391,72 +395,36 @@ const ManagePolicyPage = ({ ); // TODO: Look into useDebounceCallback with dependencies - // Inherited table uses the same onQueryChange function but routes to different URL params const onQueryChange = useCallback( async (newTableQuery: ITableQueryData) => { if (!isRouteOk || isEqual(newTableQuery, tableQueryData)) { return; } - newTableQuery.editingInheritedTable - ? setInheritedTableQueryData({ ...newTableQuery }) - : setTableQueryData({ ...newTableQuery }); + setTableQueryData({ ...newTableQuery }); const { pageIndex: newPageIndex, searchQuery: newSearchQuery, sortDirection: newSortDirection, sortHeader: newSortHeader, - editingInheritedTable, } = newTableQuery; // Rebuild queryParams to dispatch new browser location to react-router const newQueryParams: { [key: string]: string | number | undefined } = {}; newQueryParams.query = newSearchQuery; - // Updates main policy table URL params - // No change to inherited policy table URL params - if (!editingInheritedTable) { - newQueryParams.order_key = newSortHeader; - newQueryParams.order_direction = newSortDirection; - newQueryParams.page = newPageIndex.toString(); - if (showInheritedTable) { - newQueryParams.inherited_order_key = inheritedSortHeader; - newQueryParams.inherited_order_direction = inheritedSortDirection; - newQueryParams.inherited_page = inheritedPage.toString(); - } - // Reset page number to 0 for new filters - if ( - newSortDirection !== sortDirection || - newSortHeader !== sortHeader || - newSearchQuery !== searchQuery - ) { - newQueryParams.page = "0"; - } - } + newQueryParams.order_key = newSortHeader; + newQueryParams.order_direction = newSortDirection; + newQueryParams.page = newPageIndex.toString(); - if (showInheritedTable) { - newQueryParams.inherited_table = - showInheritedTable && showInheritedTable.toString(); - } - - // Updates inherited policy table URL params - // No change to main policy table URL params - if (showInheritedTable && editingInheritedTable) { - newQueryParams.inherited_order_key = newSortHeader; - newQueryParams.inherited_order_direction = newSortDirection; - newQueryParams.inherited_page = newPageIndex.toString(); - newQueryParams.order_key = sortHeader; - newQueryParams.order_direction = sortDirection; - newQueryParams.page = page.toString(); - newQueryParams.query = searchQuery; - // Reset page number to 0 for new filters - if ( - newSortDirection !== inheritedSortDirection || - newSortHeader !== inheritedSortHeader - ) { - newQueryParams.inherited_page = "0"; - } + // Reset page number to 0 for new filters + if ( + newSortDirection !== sortDirection || + newSortHeader !== sortHeader || + newSearchQuery !== searchQuery + ) { + newQueryParams.page = "0"; } if (isRouteOk && teamIdForApi !== undefined) { @@ -470,14 +438,7 @@ const ManagePolicyPage = ({ router?.replace(locationPath); }, - [ - isRouteOk, - teamIdForApi, - searchQuery, - showInheritedTable, - inheritedSortDirection, - sortDirection, - ] // Other dependencies can cause infinite re-renders as URL is source of truth + [isRouteOk, teamIdForApi, searchQuery, sortDirection] // Other dependencies can cause infinite re-renders as URL is source of truth ); const toggleOtherWorkflowsModal = () => @@ -504,19 +465,6 @@ const ManagePolicyPage = ({ } }; - const toggleShowInheritedPolicies = () => { - // URL source of truth - const locationPath = getNextLocationPath({ - pathPrefix: PATHS.MANAGE_POLICIES, - queryParams: { - ...queryParams, - inherited_table: showInheritedTable ? undefined : "true", - inherited_page: showInheritedTable ? undefined : "0", - }, - }); - router?.replace(locationPath); - }; - const handleUpdateOtherWorkflows = async (requestBody: { webhook_settings: Pick; integrations: IZendeskJiraIntegrations; @@ -574,7 +522,7 @@ const ManagePolicyPage = ({ // update changed policies calendar events enabled const changedPolicies = formData.policies.filter((formPolicy) => { - const prevPolicyState = teamPolicies?.find( + const prevPolicyState = policiesAvailableToAutomate.find( (policy) => policy.id === formPolicy.id ); return ( @@ -649,24 +597,6 @@ const ManagePolicyPage = ({ } }; - const inheritedPoliciesButtonText = ( - showPolicies: boolean, - count: number - ) => { - return `${showPolicies ? "Hide" : "Show"} ${count} inherited ${ - count > 1 ? "policies" : "policy" - }`; - }; - - const showInheritedPoliciesButton = - isAnyTeamSelected && - !isFetchingTeamPolicies && - !teamPoliciesError && - !!inheritedPolicies?.length; // Returned with team policies - - const availablePoliciesForAutomation = - (isAnyTeamSelected ? teamPolicies : globalPolicies) || []; - const policiesErrors = isAnyTeamSelected ? teamPoliciesError : globalPoliciesError; @@ -731,13 +661,15 @@ const ManagePolicyPage = ({ onAddPolicyClick={onAddPolicyClick} onDeletePolicyClick={onDeletePolicyClick} canAddOrDeletePolicy={canAddOrDeletePolicy} + hasPoliciesToDelete={hasPoliciesToAutomateOrDelete} currentTeam={currentTeamSummary} currentAutomatedPolicies={currentAutomatedPolicies} renderPoliciesCount={() => - !isFetchingTeamCount && renderPoliciesCount(teamPoliciesCount) + (!isFetchingTeamCountMergeInherited && + renderPoliciesCount(teamPoliciesCountMergeInherited)) || + null } isPremiumTier={isPremiumTier} - isSandboxMode={isSandboxMode} searchQuery={searchQuery} sortHeader={sortHeader} sortDirection={sortDirection} @@ -753,12 +685,14 @@ const ManagePolicyPage = ({ onAddPolicyClick={onAddPolicyClick} onDeletePolicyClick={onDeletePolicyClick} canAddOrDeletePolicy={canAddOrDeletePolicy} + hasPoliciesToDelete={hasPoliciesToAutomateOrDelete} currentTeam={currentTeamSummary} currentAutomatedPolicies={currentAutomatedPolicies} isPremiumTier={isPremiumTier} - isSandboxMode={isSandboxMode} renderPoliciesCount={() => - !isFetchingGlobalCount && renderPoliciesCount(globalPoliciesCount) + (!isFetchingGlobalCount && + renderPoliciesCount(globalPoliciesCount)) || + null } searchQuery={searchQuery} sortHeader={sortHeader} @@ -834,17 +768,19 @@ const ManagePolicyPage = ({ {showCtaButtons && (
- {canManageAutomations && automationsConfig && ( -
- -
- )} + {canManageAutomations && + automationsConfig && + hasPoliciesToAutomateOrDelete && ( +
+ +
+ )} {canAddOrDeletePolicy && (
)} @@ -867,57 +803,11 @@ const ManagePolicyPage = ({

{renderMainTable()} - {showInheritedPoliciesButton && globalPoliciesCount && ( - - "All teams" policies are checked -
- for this team's hosts. - - } - onClick={toggleShowInheritedPolicies} - /> - )} - {showInheritedPoliciesButton && showInheritedTable && ( -
- {globalPoliciesError && } - {!globalPoliciesError && ( - - renderPoliciesCount(teamPoliciesCount) - } - sortHeader={inheritedSortHeader} - sortDirection={inheritedSortDirection} - page={inheritedPage} - onQueryChange={onQueryChange} - /> - )} -
- )} {config && automationsConfig && showOtherWorkflowsModal && ( )} diff --git a/frontend/pages/policies/ManagePoliciesPage/_styles.scss b/frontend/pages/policies/ManagePoliciesPage/_styles.scss index 0b7a6b064e..ca0978671d 100644 --- a/frontend/pages/policies/ManagePoliciesPage/_styles.scss +++ b/frontend/pages/policies/ManagePoliciesPage/_styles.scss @@ -165,8 +165,8 @@ } } - .critical-tooltip { - text-align: left; + .critical-tooltip, + .inherited-tooltip { font-weight: $regular; } @@ -184,8 +184,13 @@ display: flex; // required for inline icon gap: $pad-xsmall; - .tooltip-base { - display: inline-flex; + // Underlines only the name text + &:hover { + text-decoration: none; + + .policy-name-text { + text-decoration: underline; + } } .policy-name-text { @@ -194,6 +199,25 @@ } } } + + .critical-badge, + .policy-has-not-run { + .critical-badge-icon { + display: inline-flex; + } + + @include tooltip5-arrow-styles; + + .react-tooltip { + @include tooltip-text; + font-style: normal; + text-align: center; + } + } + + .inherited-badge { + overflow: initial; + } } } } diff --git a/frontend/pages/policies/ManagePoliciesPage/components/PoliciesTable/PoliciesTable.tests.tsx b/frontend/pages/policies/ManagePoliciesPage/components/PoliciesTable/PoliciesTable.tests.tsx index 426de14942..f100e4761b 100644 --- a/frontend/pages/policies/ManagePoliciesPage/components/PoliciesTable/PoliciesTable.tests.tsx +++ b/frontend/pages/policies/ManagePoliciesPage/components/PoliciesTable/PoliciesTable.tests.tsx @@ -1,42 +1,84 @@ import React from "react"; -import { render, screen } from "@testing-library/react"; +import { screen, waitFor } from "@testing-library/react"; import { noop } from "lodash"; +import { createCustomRenderer } from "test/test-utils"; +import createMockUser from "__mocks__/userMock"; import createMockPolicy from "__mocks__/policyMock"; import PoliciesTable from "./PoliciesTable"; describe("Policies table", () => { - const testCriticalPolicy = createMockPolicy({ critical: true }); + it("Renders the page-wide empty state when no policies are present", async () => { + const render = createCustomRenderer({ + context: { + app: { + isGlobalAdmin: true, + currentUser: createMockUser(), + }, + }, + }); - it("Renders a tooltip including 'Premium feature' copy for a critical policy in Sandbox mode", () => { render( {}} currentTeam={{ id: -1, name: "All teams" }} isPremiumTier - isSandboxMode searchQuery="" page={0} onQueryChange={noop} - renderPoliciesCount={noop} + renderPoliciesCount={() => null} /> ); - expect( - screen.getByText("This policy has been marked as critical.", { - exact: false, - }) - ).toBeInTheDocument(); - expect( - screen.getByText("This is a premium feature.", { exact: false }) - ).toBeInTheDocument(); + expect(screen.getByText("You don't have any policies")).toBeInTheDocument(); + expect(screen.queryByText("Name")).toBeNull(); }); - it("Renders a tooltip excluding 'Premium feature' copy for a critical policy not in Sandbox mode", () => { + it("Renders the empty search state when search query exists for server side search with no results", async () => { + const render = createCustomRenderer({ + context: { + app: { + isGlobalAdmin: true, + currentUser: createMockUser(), + }, + }, + }); + render( + {}} + currentTeam={{ id: -1, name: "All teams" }} + isPremiumTier + searchQuery="shouldn't match anything" + page={0} + onQueryChange={noop} + renderPoliciesCount={() => null} + /> + ); + + expect(screen.getByText("No matching policies")).toBeInTheDocument(); + expect(screen.queryByText("Name")).toBeNull(); + }); + + it("Renders a critical badge and tooltip for a critical policy", async () => { + const render = createCustomRenderer({ + context: { + app: { + isGlobalAdmin: true, + currentUser: createMockUser(), + }, + }, + }); + + const testCriticalPolicy = createMockPolicy({ critical: true }); + + const { user } = render( { onDeletePolicyClick={() => {}} currentTeam={{ id: -1, name: "All teams" }} isPremiumTier - isSandboxMode={false} searchQuery="" page={0} onQueryChange={noop} - renderPoliciesCount={noop} + renderPoliciesCount={() => null} /> ); - expect( - screen.getByText("This policy has been marked as critical.", { - exact: false, - }) - ).toBeInTheDocument(); - expect( - screen.queryByText("This is a premium feature.", { exact: false }) - ).toBeNull(); + await waitFor(() => { + waitFor(() => { + user.hover(screen.getByTestId("policy-icon")); + }); + + expect( + screen.getByText("This policy has been marked as critical.") + ).toBeInTheDocument(); + }); + }); + + it("Renders an inherited badge and tooltip for inherited policy on a team's policies page", async () => { + const render = createCustomRenderer({ + context: { + app: { + isGlobalAdmin: true, + currentUser: createMockUser(), + }, + }, + }); + + const testInheritedPolicy = createMockPolicy({ team_id: null }); + + const { user } = render( + {}} + currentTeam={{ id: 2, name: "Team 2" }} + isPremiumTier + searchQuery="" + page={0} + onQueryChange={noop} + renderPoliciesCount={() => null} + /> + ); + + await waitFor(() => { + waitFor(() => { + user.hover(screen.getByText("Inherited")); + }); + + expect( + screen.getByText("This policy runs on all hosts.") + ).toBeInTheDocument(); + }); + }); + + it("Does not render an inherited badge and tooltip for global policy on the All teams's policies page", () => { + const render = createCustomRenderer({ + context: { + app: { + isGlobalAdmin: true, + currentUser: createMockUser(), + }, + }, + }); + + const testGlobalPolicy = createMockPolicy({ team_id: null }); + + render( + {}} + currentTeam={{ id: -1, name: "All teams" }} + isPremiumTier + searchQuery="" + page={0} + onQueryChange={noop} + renderPoliciesCount={() => null} + /> + ); + + expect(screen.queryByText("Inherited")).not.toBeInTheDocument(); + }); + + it("Renders the correct number of checkboxes for team policies and not inherited policies on a team's policies page and can check select all box", async () => { + const render = createCustomRenderer({ + context: { + app: { + isGlobalAdmin: true, + currentUser: createMockUser(), + }, + }, + }); + + const testInheritedPolicies = [ + createMockPolicy({ team_id: null, name: "Inherited policy 1" }), + createMockPolicy({ id: 2, team_id: null, name: "Inherited policy 2" }), + createMockPolicy({ id: 3, team_id: null, name: "Inherited policy 3" }), + ]; + + const testTeamPolicies = [ + createMockPolicy({ id: 4, team_id: 2, name: "Team policy 1" }), + createMockPolicy({ id: 5, team_id: 2, name: "Team policy 2" }), + ]; + + const { container, user } = render( + {}} + currentTeam={{ id: 2, name: "Team 2" }} + isPremiumTier + searchQuery="" + page={0} + onQueryChange={noop} + renderPoliciesCount={() => null} + canAddOrDeletePolicy + hasPoliciesToDelete + /> + ); + + const numberOfCheckboxes = container.querySelectorAll( + "input[type='checkbox']" + ).length; + + expect(numberOfCheckboxes).toBe( + testTeamPolicies.length + 1 // +1 for Select all checkbox + ); + + const checkbox = container.querySelectorAll( + "input[type='checkbox']" + )[0] as HTMLInputElement; + + await waitFor(() => { + waitFor(() => { + user.click(checkbox); + }); + + expect(checkbox.checked).toBe(true); + }); }); }); diff --git a/frontend/pages/policies/ManagePoliciesPage/components/PoliciesTable/PoliciesTable.tsx b/frontend/pages/policies/ManagePoliciesPage/components/PoliciesTable/PoliciesTable.tsx index 9494e49805..9047242c8b 100644 --- a/frontend/pages/policies/ManagePoliciesPage/components/PoliciesTable/PoliciesTable.tsx +++ b/frontend/pages/policies/ManagePoliciesPage/components/PoliciesTable/PoliciesTable.tsx @@ -1,6 +1,5 @@ import React, { useContext } from "react"; import { AppContext } from "context/app"; -import PATHS from "router/paths"; import { IPolicyStats } from "interfaces/policy"; import { ITeamSummary } from "interfaces/team"; @@ -14,12 +13,6 @@ import { generateTableHeaders, generateDataSet } from "./PoliciesTableConfig"; const baseClass = "policies-table"; -const TAGGED_TEMPLATES = { - hostsByTeamRoute: (teamId: number | undefined | null) => { - return `${teamId ? `/?team_id=${teamId}` : ""}`; - }, -}; - const DEFAULT_SORT_DIRECTION = "asc"; const DEFAULT_SORT_HEADER = "name"; @@ -29,13 +22,11 @@ interface IPoliciesTableProps { onAddPolicyClick?: () => void; onDeletePolicyClick: (selectedTableIds: number[]) => void; canAddOrDeletePolicy?: boolean; - tableType?: "inheritedPolicies"; + hasPoliciesToDelete?: boolean; currentTeam: ITeamSummary | undefined; currentAutomatedPolicies?: number[]; isPremiumTier?: boolean; - isSandboxMode?: boolean; - // onClientSidePaginationChange?: (pageIndex: number) => void; - renderPoliciesCount: any; // TODO: typing + renderPoliciesCount: () => JSX.Element | null; onQueryChange: (newTableQuery: ITableQueryData) => void; searchQuery: string; sortHeader?: "name" | "failing_host_count"; @@ -49,13 +40,11 @@ const PoliciesTable = ({ onAddPolicyClick, onDeletePolicyClick, canAddOrDeletePolicy, - tableType, + hasPoliciesToDelete, currentTeam, currentAutomatedPolicies, isPremiumTier, - isSandboxMode, onQueryChange, - // onClientSidePaginationChange, renderPoliciesCount, searchQuery, sortHeader, @@ -64,23 +53,18 @@ const PoliciesTable = ({ }: IPoliciesTableProps): JSX.Element => { const { config } = useContext(AppContext); - // Inherited table uses the same onQueryChange but require different URL params const onTableQueryChange = (newTableQuery: ITableQueryData) => { onQueryChange({ ...newTableQuery, - editingInheritedTable: tableType === "inheritedPolicies", }); }; const emptyState = () => { const emptyPolicies: IEmptyTableProps = { graphicName: "empty-policies", - header: <>You don't have any policies, - info: ( - <> - Add policies to detect device health issues and trigger automations. - - ), + header: "You don't have any policies", + info: + "Add policies to detect device health issues and trigger automations.", }; if (canAddOrDeletePolicy) { emptyPolicies.primaryButton = ( @@ -96,9 +80,8 @@ const PoliciesTable = ({ if (searchQuery) { delete emptyPolicies.graphicName; delete emptyPolicies.primaryButton; - emptyPolicies.header = "No policies match the current search criteria."; - emptyPolicies.info = - "Expecting to see policies? Try again in a few seconds as the system catches up."; + emptyPolicies.header = "No matching policies"; + emptyPolicies.info = "No policies match the current filters."; } return emptyPolicies; @@ -106,23 +89,20 @@ const PoliciesTable = ({ const searchable = !(policiesList?.length === 0 && searchQuery === ""); + const hasPermissionAndPoliciesToDelete = + canAddOrDeletePolicy && hasPoliciesToDelete; + return ( -
+
{ const getTooltip = (osqueryPolicyMs: number): JSX.Element => { return ( - + <> Fleet is collecting policy results. Try again
in about {getPolicyRefreshTime(osqueryPolicyMs)} as the system catches up. -
+ ); }; @@ -101,15 +100,14 @@ const getTooltip = (osqueryPolicyMs: number): JSX.Element => { const generateTableHeaders = ( options: { selectedTeamId?: number | null; - canAddOrDeletePolicy?: boolean; + hasPermissionAndPoliciesToDelete?: boolean; tableType?: string; }, policiesList: IPolicyStats[] = [], - isPremiumTier?: boolean, - isSandboxMode?: boolean + isPremiumTier?: boolean ): IDataColumn[] => { - const { selectedTeamId, tableType, canAddOrDeletePolicy } = options; - + const { selectedTeamId, hasPermissionAndPoliciesToDelete } = options; + const viewingTeamPolicies = selectedTeamId !== -1; // Figure the time since the host counts were updated. // First, find first policy item with host_count_updated_at. const updatedAt = @@ -144,11 +142,10 @@ const generateTableHeaders = ( <>
{cellProps.cell.value}
{isPremiumTier && cellProps.row.original.critical && ( - <> +
- This policy has been marked as critical. - {isSandboxMode && ( - <> -
- This is a premium feature. - - )} -
- + +
+ )} + {viewingTeamPolicies && !cellProps.row.original.team_id && ( + )} } @@ -208,24 +203,24 @@ const generateTableHeaders = ( ); } return ( - <> +
--- - {getTooltip(cellProps.row.original.next_update_ms)} - - + +
); }, }, @@ -259,52 +254,73 @@ const generateTableHeaders = ( ); } return ( - <> +
--- - {getTooltip(cellProps.row.original.next_update_ms)} - - + +
); }, sortType: "caseInsensitive", }, ]; - if (tableType !== "inheritedPolicies") { - if (!canAddOrDeletePolicy) { - return tableHeaders; - } - + if (hasPermissionAndPoliciesToDelete) { tableHeaders.unshift({ id: "selection", - Header: (cellProps: IHeaderProps) => { - const props = cellProps.getToggleAllRowsSelectedProps(); - const checkboxProps = { - value: props.checked, - indeterminate: props.indeterminate, - onChange: () => cellProps.toggleAllRowsSelected(), + Header: (headerProps: any) => { + // When viewing team policies select all checkbox accounts for not selecting inherited policies + const teamCheckboxProps = getConditionalSelectHeaderCheckboxProps({ + headerProps, + checkIfRowIsSelectable: (row) => row.original.team_id !== null, + }); + + // Regular table selection logic + const { + getToggleAllRowsSelectedProps, + toggleAllRowsSelected, + } = headerProps; + const { checked, indeterminate } = getToggleAllRowsSelectedProps(); + + const regularCheckboxProps = { + value: checked, + indeterminate, + onChange: () => { + toggleAllRowsSelected(); + }, }; + + const checkboxProps = viewingTeamPolicies + ? teamCheckboxProps + : regularCheckboxProps; return ; }, Cell: (cellProps: ICellProps): JSX.Element => { + const inheritedPolicy = !cellProps.row.original.team_id; const props = cellProps.row.getToggleRowSelectedProps(); const checkboxProps = { value: props.checked, onChange: () => cellProps.row.toggleRowSelected(), }; + + // When viewing team policies and a row is an inherited policy, do not render checkbox + if (viewingTeamPolicies && inheritedPolicy) { + return <>; + } + return ; }, disableHidden: true, diff --git a/frontend/pages/policies/ManagePoliciesPage/components/PoliciesTable/_styles.scss b/frontend/pages/policies/ManagePoliciesPage/components/PoliciesTable/_styles.scss index 9913e4d229..1d8354c466 100644 --- a/frontend/pages/policies/ManagePoliciesPage/components/PoliciesTable/_styles.scss +++ b/frontend/pages/policies/ManagePoliciesPage/components/PoliciesTable/_styles.scss @@ -26,10 +26,6 @@ } } -.has-not-run { - width: 20px; -} - .no-team-policy { border: 1px solid #e2e4ea; box-sizing: border-box; diff --git a/frontend/pages/policies/PolicyPage/components/PolicyErrorsTable/PolicyErrorsTable.tsx b/frontend/pages/policies/PolicyPage/components/PolicyErrorsTable/PolicyErrorsTable.tsx index 7948d73c66..0b38f9a628 100644 --- a/frontend/pages/policies/PolicyPage/components/PolicyErrorsTable/PolicyErrorsTable.tsx +++ b/frontend/pages/policies/PolicyPage/components/PolicyErrorsTable/PolicyErrorsTable.tsx @@ -26,11 +26,7 @@ const PolicyErrorsTable = ({ canAddOrDeletePolicy, }: IPolicyErrorsTableProps): JSX.Element => { return ( -
+
{ return ( -
+
{ return platforms ?? []; }; -const enhanceQuery = (q: ISchedulableQuery): IEnhancedQuery => { +export const enhanceQuery = (q: ISchedulableQuery): IEnhancedQuery => { return { ...q, performance: getPerformanceImpactDescription( @@ -120,14 +116,16 @@ const ManageQueriesPage = ({ ); const [showPreviewDataModal, setShowPreviewDataModal] = useState(false); const [isUpdatingQueries, setIsUpdatingQueries] = useState(false); - const [showInheritedQueries, setShowInheritedQueries] = useState(false); const [isUpdatingAutomations, setIsUpdatingAutomations] = useState(false); + const [queriesAvailableToAutomate, setQueriesAvailableToAutomate] = useState< + IEnhancedQuery[] | [] + >([]); const { - data: curTeamEnhancedQueries, - error: curTeamQueriesError, - isFetching: isFetchingCurTeamQueries, - refetch: refetchCurTeamQueries, + data: enhancedQueries, + error: queriesError, + isFetching: isFetchingQueries, + refetch: refetchQueries, } = useQuery< IEnhancedQuery[], Error, @@ -137,46 +135,41 @@ const ManageQueriesPage = ({ [{ scope: "queries", teamId: teamIdForApi }], ({ queryKey: [{ teamId }] }) => queriesAPI - .loadAll(teamId) + .loadAll(teamId, teamId !== API_ALL_TEAMS_ID) .then(({ queries }) => queries.map(enhanceQuery)), { refetchOnWindowFocus: false, enabled: isRouteOk, staleTime: 5000, + onSuccess: (data) => { + if (data) { + const enhancedAllQueries = data.map(enhanceQuery); + + const allQueriesAvailableToAutomate = teamIdForApi + ? enhancedAllQueries.filter( + (query: IEnhancedQuery) => query.team_id === currentTeamId + ) + : enhancedAllQueries; + + setQueriesAvailableToAutomate(allQueriesAvailableToAutomate); + } + }, } ); - // If a team is selected, inherit global queries - const { - data: globalEnhancedQueries, - error: globalQueriesError, - isFetching: isFetchingGlobalQueries, - refetch: refetchGlobalQueries, - } = useQuery< - IEnhancedQuery[], - Error, - IEnhancedQuery[], - IQueryKeyQueriesLoadAll[] - >( - [{ scope: "queries", teamId: API_ALL_TEAMS_ID }], - ({ queryKey: [{ teamId }] }) => - queriesAPI - .loadAll(teamId) - .then(({ queries }) => queries.map(enhanceQuery)), - { - refetchOnWindowFocus: false, - enabled: isRouteOk && isAnyTeamSelected, - staleTime: 5000, + const onlyInheritedQueries = useMemo(() => { + if (teamIdForApi === API_ALL_TEAMS_ID) { + // global scope + return false; } - ); + return !enhancedQueries?.some((query) => query.team_id === teamIdForApi); + }, [teamIdForApi, enhancedQueries]); const automatedQueryIds = useMemo(() => { - return curTeamEnhancedQueries - ? curTeamEnhancedQueries - .filter((query) => query.automations_enabled) - .map((query) => query.id) - : []; - }, [curTeamEnhancedQueries]); + return queriesAvailableToAutomate + .filter((query) => query.automations_enabled) + .map((query) => query.id); + }, [queriesAvailableToAutomate]); useEffect(() => { const path = location.pathname + location.search; @@ -204,11 +197,6 @@ const ManageQueriesPage = ({ setSelectedQueryIds(selectedTableQueryIds); }; - const refetchAllQueries = useCallback(() => { - refetchCurTeamQueries(); - refetchGlobalQueries(); - }, [refetchCurTeamQueries, refetchGlobalQueries]); - const toggleManageAutomationsModal = useCallback(() => { setShowManageAutomationsModal(!showManageAutomationsModal); }, [showManageAutomationsModal, setShowManageAutomationsModal]); @@ -237,7 +225,7 @@ const ManageQueriesPage = ({ `Successfully deleted ${bulk ? "queries" : "query"}.` ); setResetSelectedRows(true); - refetchAllQueries(); + refetchQueries(); } catch (errorResponse) { renderFlash( "error", @@ -249,7 +237,7 @@ const ManageQueriesPage = ({ toggleDeleteQueryModal(); setIsUpdatingQueries(false); } - }, [refetchAllQueries, selectedQueryIds, toggleDeleteQueryModal]); + }, [refetchQueries, selectedQueryIds, toggleDeleteQueryModal]); const renderHeader = () => { if (isPremiumTier) { @@ -271,17 +259,18 @@ const ManageQueriesPage = ({ return

Queries

; }; - const renderCurrentScopeQueriesTable = () => { - if (isFetchingCurTeamQueries) { + const renderQueriesTable = () => { + if (isFetchingQueries) { return ; } - if (curTeamQueriesError) { + if (queriesError) { return ; } return ( ); }; - const renderShowInheritedQueriesTableButton = () => { - const inheritedQueryCount = globalEnhancedQueries?.length; - return ( - - Queries from the "All teams" -
- schedule run on this team's hosts. - - } - onClick={() => { - setShowInheritedQueries(!showInheritedQueries); - }} - /> - ); - }; - - const renderInheritedQueriesTable = () => { - if (isFetchingGlobalQueries) { - return ; - } - if (globalQueriesError) { - return ; - } - return ( - - ); - }; - - const renderInheritedQueriesSection = () => { - return ( - <> - {renderShowInheritedQueriesTableButton()} - {showInheritedQueries && renderInheritedQueriesTable()} - - ); - }; - const onSaveQueryAutomations = useCallback( async (newAutomatedQueryIds: any) => { setIsUpdatingAutomations(true); @@ -382,7 +312,7 @@ const ManageQueriesPage = ({ try { await Promise.all(updateAutomatedQueries).then(() => { renderFlash("success", `Successfully updated query automations.`); - refetchAllQueries(); + refetchQueries(); }); } catch (errorResponse) { renderFlash( @@ -394,7 +324,7 @@ const ManageQueriesPage = ({ setIsUpdatingAutomations(false); } }, - [refetchAllQueries, automatedQueryIds, toggleManageAutomationsModal] + [refetchQueries, automatedQueryIds, toggleManageAutomationsModal] ); const renderModals = () => { @@ -414,7 +344,7 @@ const ManageQueriesPage = ({ onCancel={toggleManageAutomationsModal} isShowingPreviewDataModal={showPreviewDataModal} togglePreviewDataModal={togglePreviewDataModal} - availableQueries={curTeamEnhancedQueries} + availableQueries={queriesAvailableToAutomate} automatedQueryIds={automatedQueryIds} logDestination={config?.logging.result.plugin || ""} /> @@ -435,29 +365,28 @@ const ManageQueriesPage = ({
{renderHeader()}
-
- {(isGlobalAdmin || isTeamAdmin) && ( - - )} - {(!isOnlyObserver || isObserverPlus || isAnyTeamObserverPlus) && - !!curTeamEnhancedQueries?.length && ( - <> - - + {!!enhancedQueries?.length && ( +
+ {(isGlobalAdmin || isTeamAdmin) && !onlyInheritedQueries && ( + )} -
+ {(!isOnlyObserver || isObserverPlus || isAnyTeamObserverPlus) && ( + + )} +
+ )}

@@ -466,11 +395,7 @@ const ManageQueriesPage = ({ : "Gather data about all hosts."}

- {renderCurrentScopeQueriesTable()} - {isAnyTeamSelected && - globalEnhancedQueries && - globalEnhancedQueries?.length > 0 && - renderInheritedQueriesSection()} + {renderQueriesTable()} {renderModals()}
diff --git a/frontend/pages/queries/ManageQueriesPage/_styles.scss b/frontend/pages/queries/ManageQueriesPage/_styles.scss index 8db063b32d..3f7cd25150 100644 --- a/frontend/pages/queries/ManageQueriesPage/_styles.scss +++ b/frontend/pages/queries/ManageQueriesPage/_styles.scss @@ -96,6 +96,8 @@ } &__table { thead { + // maintain height when select header is removed + height: 52.3833px; .name__header { width: auto; } @@ -131,15 +133,33 @@ .query-name-cell { display: flex; // required for inline icon gap: $pad-xsmall; - - .children-wrapper { + text-decoration: none; + align-items: center; + &:hover { .query-name-text { - text-overflow: ellipsis; - overflow: hidden; + text-decoration: underline; + } + } + + .query-name-text { + text-overflow: ellipsis; + overflow: hidden; + } + + .inherited-badge { + overflow: initial; + } + .observer-can-run-badge { + @include tooltip5-arrow-styles; + + .react-tooltip { + @include tooltip-text; + font-style: normal; + text-align: center; } } } - .query-icon { + .observer-can-run-query-icon { display: block; } @@ -147,9 +167,6 @@ display: flex; gap: $pad-xsmall; } - .observer-can-run-tooltip { - font-weight: $regular; - } } @media (max-width: $break-md) { @@ -184,6 +201,13 @@ } } } + .empty-table { + &__additional-info { + * { + font-size: $xx-small; + } + } + } } .reveal-button { .component__tooltip-wrapper__underline { diff --git a/frontend/pages/queries/ManageQueriesPage/components/QueriesTable/QueriesTable.tests.tsx b/frontend/pages/queries/ManageQueriesPage/components/QueriesTable/QueriesTable.tests.tsx new file mode 100644 index 0000000000..3269ebcc1e --- /dev/null +++ b/frontend/pages/queries/ManageQueriesPage/components/QueriesTable/QueriesTable.tests.tsx @@ -0,0 +1,367 @@ +import React from "react"; + +import { screen, waitFor } from "@testing-library/react"; + +import { createCustomRenderer } from "test/test-utils"; +import createMockUser from "__mocks__/userMock"; +import createMockQuery from "__mocks__/queryMock"; + +import { ISchedulableQuery } from "interfaces/schedulable_query"; +import QueriesTable, { IQueriesTableProps } from "./QueriesTable"; +import { enhanceQuery } from "../../ManageQueriesPage"; + +const testRawGlobalQueries: ISchedulableQuery[] = [ + { + created_at: "2024-03-22T19:01:20Z", + updated_at: "2024-03-22T19:01:20Z", + id: 1, + team_id: null, + interval: 0, + platform: "linux", + min_osquery_version: "", + automations_enabled: false, + logging: "snapshot", + name: "Global query 1", + description: "Retrieves the OpenSSL version.", + query: + "SELECT name AS name, version AS version, 'deb_packages' AS source FROM deb_packages WHERE name LIKE 'openssl%' UNION SELECT name AS name, version AS version, 'apt_sources' AS source FROM apt_sources WHERE name LIKE 'openssl%' UNION SELECT name AS name, version AS version, 'rpm_packages' AS source FROM rpm_packages WHERE name LIKE 'openssl%';", + saved: true, + observer_can_run: true, + author_id: 1, + author_name: "Tess Tuser", + author_email: "tess@fake.com", + packs: [], + stats: { + system_time_p50: null, + system_time_p95: null, + user_time_p50: null, + user_time_p95: null, + total_executions: 0, + }, + discard_data: false, + }, + { + created_at: "2024-03-22T19:01:20Z", + updated_at: "2024-03-22T19:01:20Z", + id: 2, + team_id: null, + interval: 0, + platform: "linux", + min_osquery_version: "", + automations_enabled: false, + logging: "snapshot", + name: "Global query 2", + description: "Retrieves the OpenSSL version.", + query: + "SELECT name AS name, version AS version, 'deb_packages' AS source FROM deb_packages WHERE name LIKE 'openssl%' UNION SELECT name AS name, version AS version, 'apt_sources' AS source FROM apt_sources WHERE name LIKE 'openssl%' UNION SELECT name AS name, version AS version, 'rpm_packages' AS source FROM rpm_packages WHERE name LIKE 'openssl%';", + saved: true, + observer_can_run: true, + author_id: 1, + author_name: "Tess Tuser", + author_email: "tess@fake.com", + packs: [], + stats: { + system_time_p50: null, + system_time_p95: null, + user_time_p50: null, + user_time_p95: null, + total_executions: 0, + }, + discard_data: false, + }, +]; + +const testRawTeamQueries: ISchedulableQuery[] = [ + { + created_at: "2024-04-25T04:16:09Z", + updated_at: "2024-04-25T04:16:09Z", + id: 3, + team_id: 1, + interval: 3600, + platform: "", + min_osquery_version: "", + automations_enabled: false, + logging: "snapshot", + name: "Team query 1", + description: "", + query: "SELECT * FROM osquery_info;", + saved: true, + observer_can_run: false, + author_id: 1, + author_name: "Tess Tuser", + author_email: "tess@fake.com", + packs: [], + stats: { + system_time_p50: null, + system_time_p95: null, + user_time_p50: null, + user_time_p95: null, + total_executions: 0, + }, + discard_data: false, + }, + { + created_at: "2024-04-25T04:16:09Z", + updated_at: "2024-04-25T04:16:09Z", + id: 4, + team_id: 1, + interval: 3600, + platform: "", + min_osquery_version: "", + automations_enabled: false, + logging: "snapshot", + name: "Team query 2", + description: "", + query: "SELECT * FROM osquery_info;", + saved: true, + observer_can_run: true, + author_id: 1, + author_name: "Tess Tuser", + author_email: "tess@fake.com", + packs: [], + stats: { + system_time_p50: null, + system_time_p95: null, + user_time_p50: null, + user_time_p95: null, + total_executions: 0, + }, + discard_data: false, + }, +]; + +const testGlobalQueries = testRawGlobalQueries.map(enhanceQuery); +const testTeamQueries = testRawTeamQueries.map(enhanceQuery); + +const renderAsPremiumGlobalAdmin = createCustomRenderer({ + context: { + app: { + isPremiumTier: true, + isGlobalAdmin: true, + currentUser: createMockUser(), + }, + }, +}); +describe("QueriesTable", () => { + it("Renders the page-wide empty state when no queries are present", () => { + const testData: IQueriesTableProps[] = [ + { + queriesList: [], + onlyInheritedQueries: false, + isLoading: false, + onDeleteQueryClick: jest.fn(), + onCreateQueryClick: jest.fn(), + isOnlyObserver: false, + isObserverPlus: false, + isAnyTeamObserverPlus: false, + currentTeamId: undefined, + }, + ]; + + testData.forEach((tableProps) => { + renderAsPremiumGlobalAdmin(); + expect( + screen.getByText("You don't have any queries") + ).toBeInTheDocument(); + expect(screen.queryByText("Frequency")).toBeNull(); + }); + }); + it("Renders inherited global queries and team queries when viewing a team, then renders the 'no-matching' empty state when a search string is entered that matches no queries", async () => { + const testData: IQueriesTableProps[] = [ + { + queriesList: [...testGlobalQueries, ...testTeamQueries], + onlyInheritedQueries: false, + isLoading: false, + onDeleteQueryClick: jest.fn(), + onCreateQueryClick: jest.fn(), + isOnlyObserver: false, + isObserverPlus: false, + isAnyTeamObserverPlus: false, + currentTeamId: 1, + }, + ]; + const dataStrings = [ + "Global query 1", + "Global query 2", + "Inherited", + "Frequency", + "Team query 1", + "Team query 2", + ]; + + testData.forEach(async (tableProps) => { + // will have no context to get current user from + const { user } = renderAsPremiumGlobalAdmin( + + ); + dataStrings.forEach((val) => { + expect(screen.getAllByText(val)[0]).toBeInTheDocument(); + }); + + await user.type( + screen.getByPlaceholderText("Search by name"), + "shouldn't match anything" + ); + expect(screen.getByText("No matching queries")).toBeInTheDocument(); + dataStrings.forEach((val) => { + expect(screen.getAllByText(val)).toHaveLength(0); + }); + }); + }); + + it("Renders an observer can run badge and tooltip for a observer can run query", async () => { + const render = createCustomRenderer({ + context: { + app: { + isGlobalAdmin: true, + currentUser: createMockUser(), + }, + }, + }); + + const testObserverCanRunQuery = [ + createMockQuery({ + observer_can_run: true, + }), + ]; + const testQueries = testObserverCanRunQuery.map(enhanceQuery); + + const { user } = render( + + ); + + await waitFor(() => { + waitFor(() => { + user.hover(screen.getByTestId("query-icon")); + }); + + expect( + screen.getByText("Observers can run this query.") + ).toBeInTheDocument(); + }); + }); + + it("Renders an inherited badge and tooltip for inherited query on a team's queries page", async () => { + const render = createCustomRenderer({ + context: { + app: { + isGlobalAdmin: true, + currentUser: createMockUser(), + }, + }, + }); + + const testInheritedQuery = [createMockQuery()]; + + const testQueries = testInheritedQuery.map(enhanceQuery); + + const { user } = render( + + ); + + await waitFor(() => { + waitFor(() => { + user.hover(screen.getByText("Inherited")); + }); + + expect( + screen.getByText("This query runs on all hosts.") + ).toBeInTheDocument(); + }); + }); + + it("Does not render an inherited badge and tooltip for global query on the All team's queries page", () => { + const render = createCustomRenderer({ + context: { + app: { + isGlobalAdmin: true, + currentUser: createMockUser(), + }, + }, + }); + + const testGlobalQuery = [createMockQuery()]; + const testQueries = testGlobalQuery.map(enhanceQuery); + + render( + + ); + + expect(screen.queryByText("Inherited")).not.toBeInTheDocument(); + }); + + it("Renders the correct number of checkboxes for team queries and not inherited queries on a team's queries page and can check select all box", async () => { + const render = createCustomRenderer({ + context: { + app: { + isGlobalAdmin: true, + currentUser: createMockUser(), + }, + }, + }); + + const { container, user } = render( + + ); + + const numberOfCheckboxes = container.querySelectorAll( + "input[type='checkbox']" + ).length; + + expect(numberOfCheckboxes).toBe( + testTeamQueries.length + 1 // +1 for Select all checkbox + ); + + const checkbox = container.querySelectorAll( + "input[type='checkbox']" + )[0] as HTMLInputElement; + + await waitFor(() => { + waitFor(() => { + user.click(checkbox); + }); + + expect(checkbox.checked).toBe(true); + }); + }); +}); diff --git a/frontend/pages/queries/ManageQueriesPage/components/QueriesTable/QueriesTable.tsx b/frontend/pages/queries/ManageQueriesPage/components/QueriesTable/QueriesTable.tsx index d58bd75bed..ccc864cc1c 100644 --- a/frontend/pages/queries/ManageQueriesPage/components/QueriesTable/QueriesTable.tsx +++ b/frontend/pages/queries/ManageQueriesPage/components/QueriesTable/QueriesTable.tsx @@ -3,8 +3,8 @@ import React, { useContext, useCallback, useMemo } from "react"; import { InjectedRouter } from "react-router"; import { AppContext } from "context/app"; -import { IQuery } from "interfaces/query"; import { IEmptyTableProps } from "interfaces/empty_table"; +import { IEnhancedQuery } from "interfaces/schedulable_query"; import { ITableQueryData } from "components/TableContainer/TableContainer"; import PATHS from "router/paths"; import { getNextLocationPath } from "utilities/helpers"; @@ -17,13 +17,9 @@ import Dropdown from "components/forms/fields/Dropdown"; import generateColumnConfigs from "./QueriesTableConfig"; const baseClass = "queries-table"; - -interface IQueryTableData extends IQuery { - performance: string; - platforms: string[]; -} -interface IQueriesTableProps { - queriesList: IQueryTableData[] | null; +export interface IQueriesTableProps { + queriesList: IEnhancedQuery[] | null; + onlyInheritedQueries: boolean; isLoading: boolean; onDeleteQueryClick: (selectedTableQueryIds: number[]) => void; onCreateQueryClick: () => void; @@ -38,11 +34,7 @@ interface IQueriesTableProps { order_key?: string; order_direction?: "asc" | "desc"; team_id?: string; - inherited_order_key?: string; - inherited_order_direction?: "asc" | "desc"; - inherited_page?: string; }; - isInherited?: boolean; currentTeamId?: number; } @@ -86,6 +78,7 @@ const PLATFORM_FILTER_OPTIONS = [ const QueriesTable = ({ queriesList, + onlyInheritedQueries, isLoading, onDeleteQueryClick, onCreateQueryClick, @@ -94,7 +87,6 @@ const QueriesTable = ({ isAnyTeamObserverPlus, router, queryParams, - isInherited = false, currentTeamId, }: IQueriesTableProps): JSX.Element | null => { const { currentUser } = useContext(AppContext); @@ -112,28 +104,14 @@ const QueriesTable = ({ DEFAULT_PLATFORM)(); const initialPage = (() => queryParams && queryParams.page ? parseInt(queryParams?.page, 10) : 0)(); - const initialInheritedSortHeader = (() => - (queryParams?.inherited_order_key as "name" | "failing_host_count") ?? - DEFAULT_SORT_HEADER)(); - const initialInheritedSortDirection = (() => - (queryParams?.inherited_order_direction as "asc" | "desc") ?? - DEFAULT_SORT_DIRECTION)(); - const initialInheritedPage = (() => - queryParams && queryParams.inherited_page - ? parseInt(queryParams?.inherited_page, 10) - : 0)(); // Source of truth is state held within TableContainer. That state is initialized using URL // params, then subsquent updates to that state are pushed to the URL. const searchQuery = initialSearchQuery; const platform = initialPlatform; - const page = isInherited ? initialInheritedPage : initialPage; - const sortDirection = isInherited - ? initialInheritedSortDirection - : initialSortDirection; - const sortHeader = isInherited - ? initialInheritedSortHeader - : initialSortHeader; + const page = initialPage; + const sortDirection = initialSortDirection; + const sortHeader = initialSortHeader; // TODO: Look into useDebounceCallback with dependencies const onQueryChange = useCallback( @@ -148,37 +126,19 @@ const QueriesTable = ({ // Rebuild queryParams to dispatch new browser location to react-router const newQueryParams: { [key: string]: string | number | undefined } = {}; - // Updates main query table URL params - // No change to inherited query table URL params - if (!isInherited) { - newQueryParams.order_key = newSortHeader; - newQueryParams.order_direction = newSortDirection; - newQueryParams.platform = platform; // must set from URL - newQueryParams.page = newPageIndex; - newQueryParams.query = newSearchQuery; - // Reset page number to 0 for new filters - if ( - newSortDirection !== sortDirection || - newSortHeader !== sortHeader || - newSearchQuery !== searchQuery - ) { - newQueryParams.page = "0"; - } - } - - // Updates inherited query table URL params - // No change to main query table URL params - if (isInherited) { - newQueryParams.inherited_order_key = newSortHeader; - newQueryParams.inherited_order_direction = newSortDirection; - newQueryParams.inherited_page = newPageIndex; - // Reset page number to 0 for new filters - if ( - newSortDirection !== initialInheritedSortDirection || - newSortHeader !== initialInheritedSortHeader - ) { - newQueryParams.inherited_page = "0"; - } + // Updates URL params + newQueryParams.order_key = newSortHeader; + newQueryParams.order_direction = newSortDirection; + newQueryParams.platform = platform; // must set from URL + newQueryParams.page = newPageIndex; + newQueryParams.query = newSearchQuery; + // Reset page number to 0 for new filters + if ( + newSortDirection !== sortDirection || + newSortHeader !== sortHeader || + newSearchQuery !== searchQuery + ) { + newQueryParams.page = "0"; } newQueryParams.team_id = queryParams?.team_id; @@ -194,17 +154,11 @@ const QueriesTable = ({ const onClientSidePaginationChange = useCallback( (pageIndex: number) => { - const newQueryParams = isInherited - ? { - ...queryParams, - inherited_page: pageIndex, // update inherited page index - query: searchQuery, - } - : { - ...queryParams, - page: pageIndex, // update main table index - query: searchQuery, - }; + const newQueryParams = { + ...queryParams, + page: pageIndex, // update main table index + query: searchQuery, + }; const locationPath = getNextLocationPath({ pathPrefix: PATHS.MANAGE_QUERIES, @@ -219,20 +173,18 @@ const QueriesTable = ({ const emptyQueries: IEmptyTableProps = { graphicName: "empty-queries", header: "You don't have any queries", - info: "A query is a specific question you can ask about your devices.", }; if (searchQuery) { delete emptyQueries.graphicName; - emptyQueries.header = "No queries match the current search criteria"; - emptyQueries.info = - "Expecting to see queries? Try again in a few seconds as the system catches up."; + emptyQueries.header = "No matching queries"; + emptyQueries.info = "No queries match the current filters."; } else if (!isOnlyObserver || isObserverPlus || isAnyTeamObserverPlus) { emptyQueries.additionalInfo = ( <> Create a new query, or{" "} @@ -243,7 +195,7 @@ const QueriesTable = ({ className={`${baseClass}__create-button`} onClick={onCreateQueryClick} > - Create new query + Add query ); } @@ -280,26 +232,28 @@ const QueriesTable = ({ const columnConfigs = useMemo( () => currentUser && - generateColumnConfigs({ currentUser, isInherited, currentTeamId }), - [currentUser, isInherited, currentTeamId] + generateColumnConfigs({ + currentUser, + currentTeamId, + omitSelectionColumn: onlyInheritedQueries, + }), + [currentUser, currentTeamId, onlyInheritedQueries] ); - const searchable = - !(queriesList?.length === 0 && searchQuery === "") && !isInherited; + const searchable = !(queriesList?.length === 0 && searchQuery === ""); const trimmedSearchQuery = searchQuery.trim(); return columnConfigs && !isLoading ? (
) : ( diff --git a/frontend/pages/queries/ManageQueriesPage/components/QueriesTable/QueriesTableConfig.tsx b/frontend/pages/queries/ManageQueriesPage/components/QueriesTable/QueriesTableConfig.tsx index a7467bdf3b..32b712ef82 100644 --- a/frontend/pages/queries/ManageQueriesPage/components/QueriesTable/QueriesTableConfig.tsx +++ b/frontend/pages/queries/ManageQueriesPage/components/QueriesTable/QueriesTableConfig.tsx @@ -14,16 +14,19 @@ import { ISchedulableQuery, } from "interfaces/schedulable_query"; import { SupportedPlatform } from "interfaces/platform"; +import { API_ALL_TEAMS_ID } from "interfaces/team"; import Icon from "components/Icon"; import Checkbox from "components/forms/fields/Checkbox"; +import { getConditionalSelectHeaderCheckboxProps } from "components/TableContainer/utilities/config_utils"; import LinkCell from "components/TableContainer/DataTable/LinkCell/LinkCell"; import HeaderCell from "components/TableContainer/DataTable/HeaderCell/HeaderCell"; import PlatformCell from "components/TableContainer/DataTable/PlatformCell"; import TextCell from "components/TableContainer/DataTable/TextCell"; import PerformanceImpactCell from "components/TableContainer/DataTable/PerformanceImpactCell"; import TooltipWrapper from "components/TooltipWrapper"; -import { COLORS } from "styles/var/colors"; +import InheritedBadge from "components/InheritedBadge"; +import { Tooltip as ReactTooltip5 } from "react-tooltip-5"; import QueryAutomationsStatusIndicator from "../QueryAutomationsStatusIndicator"; interface IQueryRow { @@ -101,18 +104,19 @@ interface IDataColumn { interface IGenerateTableHeaders { currentUser: IUser; - isInherited?: boolean; currentTeamId?: number; + omitSelectionColumn?: boolean; } // NOTE: cellProps come from react-table // more info here https://react-table.tanstack.com/docs/api/useTable#cell-properties const generateTableHeaders = ({ currentUser, - isInherited = false, currentTeamId, + omitSelectionColumn = false, }: IGenerateTableHeaders): IDataColumn[] => { const isOnlyObserver = permissionsUtils.isOnlyObserver(currentUser); + const viewingTeamScope = currentTeamId !== API_ALL_TEAMS_ID; const tableHeaders: IDataColumn[] = [ { @@ -132,26 +136,56 @@ const generateTableHeaders = ({ <>
{cellProps.cell.value}
{!isOnlyObserver && cellProps.row.original.observer_can_run && ( - <> +
- + - Observers can run this query. - - + +
+ + // <> + // + // + // + // + // Observers can run this query. + // + // )} + {viewingTeamScope && + // inherited + cellProps.row.original.team_id !== currentTeamId && ( + + )} } path={PATHS.QUERY_DETAILS( @@ -246,26 +280,27 @@ const generateTableHeaders = ({ ), }, ]; - if (!isOnlyObserver && !isInherited) { - tableHeaders.splice(0, 0, { + if (!isOnlyObserver && !omitSelectionColumn) { + tableHeaders.unshift({ id: "selection", - Header: (cellProps: IHeaderProps): JSX.Element => { - const { - getToggleAllRowsSelectedProps, - toggleAllRowsSelected, - } = cellProps; - const { checked, indeterminate } = getToggleAllRowsSelectedProps(); + // TODO - improve typing of IHeaderProps instead of using any + // Header: (headerProps: IHeaderProps): JSX.Element => { + Header: (headerProps: any): JSX.Element => { + const checkboxProps = getConditionalSelectHeaderCheckboxProps({ + headerProps, + checkIfRowIsSelectable: (row) => + (row.original.team_id ?? undefined) === currentTeamId, + }); - const checkboxProps = { - value: checked, - indeterminate, - onChange: () => { - toggleAllRowsSelected(); - }, - }; return ; }, Cell: (cellProps: ICellProps): JSX.Element => { + const isInheritedQuery = + (cellProps.row.original.team_id ?? undefined) !== currentTeamId; + if (viewingTeamScope && isInheritedQuery) { + // disallow selecting inherited queries + return <>; + } const { row } = cellProps; const { checked } = row.getToggleRowSelectedProps(); const checkboxProps = { diff --git a/frontend/services/entities/queries.ts b/frontend/services/entities/queries.ts index bf30178745..a5c35dc765 100644 --- a/frontend/services/entities/queries.ts +++ b/frontend/services/entities/queries.ts @@ -35,9 +35,12 @@ export default { return sendRequest("GET", path); }, - loadAll: (teamId?: number) => { + loadAll: (teamId?: number, mergeInherited = false) => { const { QUERIES } = endpoints; - const queryString = buildQueryStringFromParams({ team_id: teamId }); + const queryString = buildQueryStringFromParams({ + team_id: teamId, + merge_inherited: mergeInherited || null, + }); const path = `${QUERIES}`; return sendRequest( diff --git a/frontend/services/entities/team_policies.ts b/frontend/services/entities/team_policies.ts index 2858938dc7..0380f2106c 100644 --- a/frontend/services/entities/team_policies.ts +++ b/frontend/services/entities/team_policies.ts @@ -17,14 +17,11 @@ interface IPoliciesApiQueryParams { orderKey?: string; orderDirection?: "asc" | "desc"; query?: string; - inheritedPage?: number; - inheritedPerPage?: number; - inheritedOrderKey?: string; - inheritedOrderDirection?: "asc" | "desc"; } export interface IPoliciesApiParams extends IPoliciesApiQueryParams { teamId: number; + mergeInherited?: boolean; } export interface ITeamPoliciesQueryKey extends IPoliciesApiParams { @@ -32,13 +29,14 @@ export interface ITeamPoliciesQueryKey extends IPoliciesApiParams { } export interface ITeamPoliciesCountQueryKey - extends Pick { - scope: "teamPoliciesCount"; + extends Pick { + scope: "teamPoliciesCountMergeInherited" | "teamPoliciesCount"; } interface IPoliciesCountApiParams { teamId: number; query?: string; + mergeInherited?: boolean; } const ORDER_KEY = "name"; @@ -118,7 +116,6 @@ export default { load: (team_id: number, id: number) => { const { TEAMS } = endpoints; const path = `${TEAMS}/${team_id}/policies/${id}`; - return sendRequest("GET", path); }, loadAll: (team_id?: number): Promise => { @@ -137,10 +134,7 @@ export default { orderKey = ORDER_KEY, orderDirection: orderDir = ORDER_DIRECTION, query, - inheritedPage, - inheritedPerPage, - inheritedOrderKey = ORDER_KEY, - inheritedOrderDirection: inheritedOrderDir = ORDER_DIRECTION, + mergeInherited, }: IPoliciesApiParams): Promise => { const { TEAMS } = endpoints; @@ -150,10 +144,7 @@ export default { orderKey, orderDirection: orderDir, query, - inheritedPage, - inheritedPerPage, - inheritedOrderKey, - inheritedOrderDirection: inheritedOrderDir, + mergeInherited, }; const snakeCaseParams = convertParamsToSnakeCase(queryParams); @@ -168,14 +159,16 @@ export default { getCount: async ({ query, teamId, + mergeInherited = false, }: Pick< IPoliciesCountApiParams, - "query" | "teamId" + "query" | "teamId" | "mergeInherited" >): Promise => { const { TEAM_POLICIES } = endpoints; const path = `${TEAM_POLICIES(teamId)}/count`; const queryParams = { query, + mergeInherited, }; const snakeCaseParams = convertParamsToSnakeCase(queryParams); const queryString = buildQueryStringFromParams(snakeCaseParams); diff --git a/frontend/services/mock_service/mocks/responses.ts b/frontend/services/mock_service/mocks/responses.ts index 57977ef922..709abc3f8b 100644 --- a/frontend/services/mock_service/mocks/responses.ts +++ b/frontend/services/mock_service/mocks/responses.ts @@ -4,6 +4,8 @@ * Also please check the README for how to use the mock service :) */ +import { createMockPoliciesResponse } from "__mocks__/policyMock"; + const count = { targets_count: 1, targets_online: 0, @@ -10590,6 +10592,7 @@ const globalQuery5 = { query: globalQueries.queries[5] }; const globalQuery6 = { query: globalQueries.queries[6] }; const teamQuery1 = { query: teamQueries.queries[0] }; const teamQuery2 = { query: teamQueries.queries[1] }; +const teamPolicy1 = createMockPoliciesResponse(); const aiAutofillPolicy = { description: @@ -10614,4 +10617,5 @@ export default { teamQuery1, teamQuery2, aiAutofillPolicy, + teamPolicy1, }; diff --git a/frontend/styles/var/mixins.scss b/frontend/styles/var/mixins.scss index 8f13df5d32..16fa29b45d 100644 --- a/frontend/styles/var/mixins.scss +++ b/frontend/styles/var/mixins.scss @@ -341,3 +341,23 @@ $max-width: 2560px; } } } + +@mixin tooltip5-arrow-styles { + // arrow styles directly from react-tooltip-5 css + .react-tooltip-arrow { + width: 8px; + height: 8px; + } + [class*="react-tooltip__place-top"] > .styles-module_arrow__K0L3T { + transform: rotate(45deg); + } + [class*="react-tooltip__place-right"] > .styles-module_arrow__K0L3T { + transform: rotate(135deg); + } + [class*="react-tooltip__place-bottom"] > .styles-module_arrow__K0L3T { + transform: rotate(225deg); + } + [class*="react-tooltip__place-left"] > .styles-module_arrow__K0L3T { + transform: rotate(315deg); + } +} diff --git a/frontend/utilities/helpers.tsx b/frontend/utilities/helpers.tsx index e18ee526ec..906f249ab0 100644 --- a/frontend/utilities/helpers.tsx +++ b/frontend/utilities/helpers.tsx @@ -53,7 +53,7 @@ import { PLATFORM_LABEL_DISPLAY_TYPES, isPlatformLabelNameFromAPI, } from "utilities/constants"; -import { IScheduledQueryStats } from "interfaces/scheduled_query_stats"; +import { ISchedulableQueryStats } from "interfaces/schedulable_query"; import { IDropdownOption } from "interfaces/dropdownOption"; const ORG_INFO_ATTRS = ["org_name", "org_logo_url"]; @@ -686,7 +686,7 @@ export const readableDate = (date: string) => { }; export const getPerformanceImpactDescription = ( - scheduledQueryStats: IScheduledQueryStats + scheduledQueryStats: ISchedulableQueryStats ) => { if ( !scheduledQueryStats.total_executions || diff --git a/server/datastore/mysql/policies.go b/server/datastore/mysql/policies.go index 3701c84b48..bff19b5b0d 100644 --- a/server/datastore/mysql/policies.go +++ b/server/datastore/mysql/policies.go @@ -451,6 +451,25 @@ func (ds *Datastore) CountPolicies(ctx context.Context, teamID *uint, matchQuery return count, nil } +func (ds *Datastore) CountMergedTeamPolicies(ctx context.Context, teamID uint, matchQuery string) (int, error) { + var args []interface{} + + query := `SELECT count(*) FROM policies p WHERE p.team_id = ? OR p.team_id IS NULL` + args = append(args, teamID) + + // We must normalize the name for full Unicode support (Unicode equivalence). + match := norm.NFC.String(matchQuery) + query, args = searchLike(query, args, match, policySearchColumns...) + + var count int + err := sqlx.GetContext(ctx, ds.reader(ctx), &count, query, args...) + if err != nil { + return 0, ctxerr.Wrap(ctx, err, "counting merged team policies") + } + + return count, nil +} + func (ds *Datastore) PoliciesByID(ctx context.Context, ids []uint) (map[uint]*fleet.Policy, error) { sql := `SELECT ` + policyCols + `, COALESCE(u.name, '') AS author_name, @@ -608,6 +627,40 @@ func (ds *Datastore) ListTeamPolicies(ctx context.Context, teamID uint, opts fle return teamPolicies, inheritedPolicies, err } +func (ds *Datastore) ListMergedTeamPolicies(ctx context.Context, teamID uint, opts fleet.ListOptions) ([]*fleet.Policy, error) { + var args []interface{} + + query := ` + SELECT + ` + policyCols + `, + COALESCE(u.name, '') AS author_name, + COALESCE(u.email, '') AS author_email, + ps.updated_at as host_count_updated_at, + COALESCE(ps.passing_host_count, 0) as passing_host_count, + COALESCE(ps.failing_host_count, 0) as failing_host_count + FROM policies p + LEFT JOIN users u ON p.author_id = u.id + LEFT JOIN policy_stats ps ON p.id = ps.policy_id + AND ps.inherited_team_id = COALESCE(p.team_id, 0) + WHERE (p.team_id = ? OR p.team_id IS NULL) + ` + + args = append(args, teamID) + + // We must normalize the name for full Unicode support (Unicode equivalence). + match := norm.NFC.String(opts.MatchQuery) + query, args = searchLike(query, args, match, policySearchColumns...) + query, _ = appendListOptionsToSQL(query, &opts) + + var policies []*fleet.Policy + err := sqlx.SelectContext(ctx, ds.reader(ctx), &policies, query, args...) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "listing merged team policies") + } + + return policies, nil +} + func (ds *Datastore) DeleteTeamPolicies(ctx context.Context, teamID uint, ids []uint) ([]uint, error) { return deletePolicyDB(ctx, ds.writer(ctx), ids, &teamID) } diff --git a/server/datastore/mysql/policies_test.go b/server/datastore/mysql/policies_test.go index 1e6980736e..b5be1af776 100644 --- a/server/datastore/mysql/policies_test.go +++ b/server/datastore/mysql/policies_test.go @@ -35,6 +35,7 @@ func TestPolicies(t *testing.T) { {"MembershipViewNotDeferred", func(t *testing.T, ds *Datastore) { testPoliciesMembershipView(false, t, ds) }}, {"TeamPolicyLegacy", testTeamPolicyLegacy}, {"TeamPolicyProprietary", testTeamPolicyProprietary}, + {"ListMergedTeamPolicies", testListMergedTeamPolicies}, {"PolicyQueriesForHost", testPolicyQueriesForHost}, {"PolicyQueriesForHostPlatforms", testPolicyQueriesForHostPlatforms}, {"PoliciesByID", testPoliciesByID}, @@ -709,6 +710,75 @@ func testTeamPolicyProprietary(t *testing.T, ds *Datastore) { require.Equal(t, user1.ID, *team2Policies[0].AuthorID) } +func testListMergedTeamPolicies(t *testing.T, ds *Datastore) { + ctx := context.Background() + gpol, err := ds.NewGlobalPolicy(ctx, nil, fleet.PolicyPayload{ + Name: "query1 global", + Query: "select 1;", + Description: "query1 desc", + Resolution: "query1 resolution", + }) + require.NoError(t, err) + + team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1"}) + require.NoError(t, err) + + p, err := ds.NewTeamPolicy(ctx, team1.ID, nil, fleet.PolicyPayload{ + Name: "query2 team1", + Query: "select 1;", + Description: "query1 desc", + Resolution: "query1 resolution", + }) + require.NoError(t, err) + + team2, err := ds.NewTeam(ctx, &fleet.Team{Name: "team2"}) + require.NoError(t, err) + + _, err = ds.NewTeamPolicy(ctx, team2.ID, nil, fleet.PolicyPayload{ + Name: "query3 team2", + Query: "select 2;", + Description: "query2 desc", + Resolution: "query2 resolution", + }) + require.NoError(t, err) + + merged, err := ds.ListMergedTeamPolicies(ctx, team1.ID, fleet.ListOptions{ + OrderKey: "name", + OrderDirection: fleet.OrderAscending, + }) + require.NoError(t, err) + + require.Len(t, merged, 2) + assert.Equal(t, gpol.ID, merged[0].ID) + assert.Equal(t, p.ID, merged[1].ID) + + // Test list options affect both global and team policies + merged, err = ds.ListMergedTeamPolicies(ctx, team1.ID, fleet.ListOptions{ + OrderKey: "name", + OrderDirection: fleet.OrderDescending, + }) + require.NoError(t, err) + + require.Len(t, merged, 2) + assert.Equal(t, p.ID, merged[0].ID) + assert.Equal(t, gpol.ID, merged[1].ID) + + // Test filter + merged, err = ds.ListMergedTeamPolicies(ctx, team1.ID, fleet.ListOptions{ + MatchQuery: "query1", + }) + require.NoError(t, err) + require.Len(t, merged, 1) + assert.Equal(t, gpol.ID, merged[0].ID) + + merged, err = ds.ListMergedTeamPolicies(ctx, team1.ID, fleet.ListOptions{ + MatchQuery: "query2", + }) + require.NoError(t, err) + require.Len(t, merged, 1) + assert.Equal(t, p.ID, merged[0].ID) +} + func newTestHostWithPlatform(t *testing.T, ds *Datastore, hostname, platform string, teamID *uint) *fleet.Host { nodeKey, err := server.GenerateRandomText(32) require.NoError(t, err) @@ -2899,6 +2969,10 @@ func testCountPolicies(t *testing.T, ds *Datastore) { require.NoError(t, err) assert.Equal(t, 0, teamCount) + mergedCount, err := ds.CountMergedTeamPolicies(ctx, tm.ID, "") + require.NoError(t, err) + assert.Equal(t, 0, mergedCount) + // 10 global policies for i := 0; i < 10; i++ { _, err := ds.NewGlobalPolicy(ctx, nil, fleet.PolicyPayload{Name: fmt.Sprintf("global policy %d", i)}) @@ -2913,6 +2987,10 @@ func testCountPolicies(t *testing.T, ds *Datastore) { require.NoError(t, err) assert.Equal(t, 0, teamCount) + mergedCount, err = ds.CountMergedTeamPolicies(ctx, tm.ID, "") + require.NoError(t, err) + assert.Equal(t, 10, mergedCount) + // add 5 team policies for i := 0; i < 5; i++ { _, err := ds.NewTeamPolicy(ctx, tm.ID, nil, fleet.PolicyPayload{Name: fmt.Sprintf("team policy %d", i)}) @@ -2926,6 +3004,10 @@ func testCountPolicies(t *testing.T, ds *Datastore) { globalCount, err = ds.CountPolicies(ctx, nil, "") require.NoError(t, err) assert.Equal(t, 10, globalCount) + + mergedCount, err = ds.CountMergedTeamPolicies(ctx, tm.ID, "") + require.NoError(t, err) + assert.Equal(t, 15, mergedCount) } func testUpdatePolicyHostCounts(t *testing.T, ds *Datastore) { diff --git a/server/datastore/mysql/queries.go b/server/datastore/mysql/queries.go index 2a26884985..2ad343922c 100644 --- a/server/datastore/mysql/queries.go +++ b/server/datastore/mysql/queries.go @@ -4,11 +4,12 @@ import ( "context" "database/sql" "fmt" + "strings" + "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" "github.com/fleetdm/fleet/v4/server/fleet" "github.com/go-kit/log/level" "github.com/jmoiron/sqlx" - "strings" ) const ( @@ -414,7 +415,6 @@ func (ds *Datastore) deleteQueryStats(ctx context.Context, queryIDs []uint) { level.Error(ds.logger).Log("msg", "error deleting aggregated stats", "err", err) } } - } // Query returns a single Query identified by id, if such exists. @@ -504,10 +504,14 @@ func (ds *Datastore) ListQueries(ctx context.Context, opt fleet.ListQueryOptions args := []interface{}{false, fleet.AggregatedStatsTypeScheduledQuery} whereClauses := "WHERE saved = true" - if opt.TeamID != nil { + switch { + case opt.TeamID != nil && opt.MergeInherited: + args = append(args, *opt.TeamID) + whereClauses += " AND (team_id = ? OR team_id IS NULL)" + case opt.TeamID != nil: args = append(args, *opt.TeamID) whereClauses += " AND team_id = ?" - } else { + default: whereClauses += " AND team_id IS NULL" } diff --git a/server/datastore/mysql/queries_test.go b/server/datastore/mysql/queries_test.go index 2096683477..f43c07fe7e 100644 --- a/server/datastore/mysql/queries_test.go +++ b/server/datastore/mysql/queries_test.go @@ -219,7 +219,6 @@ func testQueriesDelete(t *testing.T, ds *Datastore) { case <-time.After(10 * time.Second): t.Error("Timeout: stats not deleted for testQueriesDelete") } - } func testQueriesGetByName(t *testing.T, ds *Datastore) { @@ -765,6 +764,27 @@ func testListQueriesFiltersByTeamID(t *testing.T, ds *Datastore) { ) require.NoError(t, err) test.QueryElementsMatch(t, queries, []*fleet.Query{teamQ1, teamQ2, teamQ3}) + + // test merge inherited + queries, err = ds.ListQueries( + context.Background(), + fleet.ListQueryOptions{ + TeamID: &team.ID, + MergeInherited: true, + }, + ) + require.NoError(t, err) + test.QueryElementsMatch(t, queries, []*fleet.Query{globalQ1, globalQ2, globalQ3, teamQ1, teamQ2, teamQ3}) + + // merge inherited ignored for global queries + queries, err = ds.ListQueries( + context.Background(), + fleet.ListQueryOptions{ + MergeInherited: true, + }, + ) + require.NoError(t, err) + test.QueryElementsMatch(t, queries, []*fleet.Query{globalQ1, globalQ2, globalQ3}) } func testListQueriesFiltersByIsScheduled(t *testing.T, ds *Datastore) { diff --git a/server/fleet/app.go b/server/fleet/app.go index 6f99244500..ef5b06e5b2 100644 --- a/server/fleet/app.go +++ b/server/fleet/app.go @@ -1016,6 +1016,9 @@ type ListQueryOptions struct { TeamID *uint // IsScheduled filters queries that are meant to run at a set interval. IsScheduled *bool + // MergeInherited merges inherited global queries into the team list. Is only valid when TeamID + // is set. + MergeInherited bool } type ListActivitiesOptions struct { diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index 2070ad98ad..5d1bf64534 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -612,6 +612,7 @@ type Datastore interface { PoliciesByID(ctx context.Context, ids []uint) (map[uint]*Policy, error) DeleteGlobalPolicies(ctx context.Context, ids []uint) ([]uint, error) CountPolicies(ctx context.Context, teamID *uint, matchQuery string) (int, error) + CountMergedTeamPolicies(ctx context.Context, teamID uint, matchQuery string) (int, error) UpdateHostPolicyCounts(ctx context.Context) error PolicyQueriesForHost(ctx context.Context, host *Host) (map[string]string, error) @@ -661,6 +662,8 @@ type Datastore interface { NewTeamPolicy(ctx context.Context, teamID uint, authorID *uint, args PolicyPayload) (*Policy, error) ListTeamPolicies(ctx context.Context, teamID uint, opts ListOptions, iopts ListOptions) (teamPolicies, inheritedPolicies []*Policy, err error) + ListMergedTeamPolicies(ctx context.Context, teamID uint, opts ListOptions) ([]*Policy, error) + DeleteTeamPolicies(ctx context.Context, teamID uint, ids []uint) ([]uint, error) TeamPolicy(ctx context.Context, teamID uint, policyID uint) (*Policy, error) diff --git a/server/fleet/service.go b/server/fleet/service.go index 47d0e5fc5e..f5616443ea 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -270,7 +270,9 @@ type Service interface { // for distributed queries but not saved should not be returned). // When is set to scheduled != nil, then only scheduled queries will be returned if `*scheduled == true` // and only non-scheduled queries will be returned if `*scheduled == false`. - ListQueries(ctx context.Context, opt ListOptions, teamID *uint, scheduled *bool) ([]*Query, error) + // If mergeInherited is true and a teamID is provided, then queries from the global team will be + // included in the results. + ListQueries(ctx context.Context, opt ListOptions, teamID *uint, scheduled *bool, mergeInherited bool) ([]*Query, error) GetQuery(ctx context.Context, id uint) (*Query, error) // GetQueryReportResults returns all the stored results of a query for hosts the requestor has access to GetQueryReportResults(ctx context.Context, id uint) ([]HostQueryResultRow, error) @@ -640,11 +642,11 @@ type Service interface { // Team Policies NewTeamPolicy(ctx context.Context, teamID uint, p PolicyPayload) (*Policy, error) - ListTeamPolicies(ctx context.Context, teamID uint, opts ListOptions, iopts ListOptions) (teamPolicies, inheritedPolicies []*Policy, err error) + ListTeamPolicies(ctx context.Context, teamID uint, opts ListOptions, iopts ListOptions, mergeInherited bool) (teamPolicies, inheritedPolicies []*Policy, err error) DeleteTeamPolicies(ctx context.Context, teamID uint, ids []uint) ([]uint, error) ModifyTeamPolicy(ctx context.Context, teamID uint, id uint, p ModifyPolicyPayload) (*Policy, error) GetTeamPolicyByIDQueries(ctx context.Context, teamID uint, policyID uint) (*Policy, error) - CountTeamPolicies(ctx context.Context, teamID uint, matchQuery string) (int, error) + CountTeamPolicies(ctx context.Context, teamID uint, matchQuery string, mergeInherited bool) (int, error) // ///////////////////////////////////////////////////////////////////////////// // Geolocation diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index 157695382b..834bb4499c 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -447,6 +447,8 @@ type DeleteGlobalPoliciesFunc func(ctx context.Context, ids []uint) ([]uint, err type CountPoliciesFunc func(ctx context.Context, teamID *uint, matchQuery string) (int, error) +type CountMergedTeamPoliciesFunc func(ctx context.Context, teamID uint, matchQuery string) (int, error) + type UpdateHostPolicyCountsFunc func(ctx context.Context) error type PolicyQueriesForHostFunc func(ctx context.Context, host *fleet.Host) (map[string]string, error) @@ -495,6 +497,8 @@ type NewTeamPolicyFunc func(ctx context.Context, teamID uint, authorID *uint, ar type ListTeamPoliciesFunc func(ctx context.Context, teamID uint, opts fleet.ListOptions, iopts fleet.ListOptions) (teamPolicies []*fleet.Policy, inheritedPolicies []*fleet.Policy, err error) +type ListMergedTeamPoliciesFunc func(ctx context.Context, teamID uint, opts fleet.ListOptions) ([]*fleet.Policy, error) + type DeleteTeamPoliciesFunc func(ctx context.Context, teamID uint, ids []uint) ([]uint, error) type TeamPolicyFunc func(ctx context.Context, teamID uint, policyID uint) (*fleet.Policy, error) @@ -1564,6 +1568,9 @@ type DataStore struct { CountPoliciesFunc CountPoliciesFunc CountPoliciesFuncInvoked bool + CountMergedTeamPoliciesFunc CountMergedTeamPoliciesFunc + CountMergedTeamPoliciesFuncInvoked bool + UpdateHostPolicyCountsFunc UpdateHostPolicyCountsFunc UpdateHostPolicyCountsFuncInvoked bool @@ -1636,6 +1643,9 @@ type DataStore struct { ListTeamPoliciesFunc ListTeamPoliciesFunc ListTeamPoliciesFuncInvoked bool + ListMergedTeamPoliciesFunc ListMergedTeamPoliciesFunc + ListMergedTeamPoliciesFuncInvoked bool + DeleteTeamPoliciesFunc DeleteTeamPoliciesFunc DeleteTeamPoliciesFuncInvoked bool @@ -3776,6 +3786,13 @@ func (s *DataStore) CountPolicies(ctx context.Context, teamID *uint, matchQuery return s.CountPoliciesFunc(ctx, teamID, matchQuery) } +func (s *DataStore) CountMergedTeamPolicies(ctx context.Context, teamID uint, matchQuery string) (int, error) { + s.mu.Lock() + s.CountMergedTeamPoliciesFuncInvoked = true + s.mu.Unlock() + return s.CountMergedTeamPoliciesFunc(ctx, teamID, matchQuery) +} + func (s *DataStore) UpdateHostPolicyCounts(ctx context.Context) error { s.mu.Lock() s.UpdateHostPolicyCountsFuncInvoked = true @@ -3944,6 +3961,13 @@ func (s *DataStore) ListTeamPolicies(ctx context.Context, teamID uint, opts flee return s.ListTeamPoliciesFunc(ctx, teamID, opts, iopts) } +func (s *DataStore) ListMergedTeamPolicies(ctx context.Context, teamID uint, opts fleet.ListOptions) ([]*fleet.Policy, error) { + s.mu.Lock() + s.ListMergedTeamPoliciesFuncInvoked = true + s.mu.Unlock() + return s.ListMergedTeamPoliciesFunc(ctx, teamID, opts) +} + func (s *DataStore) DeleteTeamPolicies(ctx context.Context, teamID uint, ids []uint) ([]uint, error) { s.mu.Lock() s.DeleteTeamPoliciesFuncInvoked = true diff --git a/server/service/global_schedule.go b/server/service/global_schedule.go index a8efa4c87d..c75d860486 100644 --- a/server/service/global_schedule.go +++ b/server/service/global_schedule.go @@ -37,7 +37,7 @@ func getGlobalScheduleEndpoint(ctx context.Context, request interface{}, svc fle } func (svc *Service) GetGlobalScheduledQueries(ctx context.Context, opts fleet.ListOptions) ([]*fleet.ScheduledQuery, error) { - queries, err := svc.ListQueries(ctx, opts, nil, ptr.Bool(true)) // teamID == nil means global + queries, err := svc.ListQueries(ctx, opts, nil, ptr.Bool(true), false) // teamID == nil means global if err != nil { return nil, err } diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index a9e4c6ffc7..4a739e189e 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -263,7 +263,7 @@ func (s *integrationTestSuite) TestQueryCreationLogsActivity() { } var createQueryResp createQueryResponse s.DoJSON("POST", "/api/latest/fleet/queries", ¶ms, http.StatusOK, &createQueryResp) - defer cleanupQuery(s, createQueryResp.Query.ID) + defer s.cleanupQuery(createQueryResp.Query.ID) activities := listActivitiesResponse{} s.DoJSON("GET", "/api/latest/fleet/activities", nil, http.StatusOK, &activities) @@ -1579,7 +1579,7 @@ func (s *integrationTestSuite) TestListHosts() { user1 := test.NewUser(t, s.ds, "Alice", "alice@example.com", true) q := test.NewQuery(t, s.ds, nil, "query1", "select 1", 0, true) - defer cleanupQuery(s, q.ID) + defer s.cleanupQuery(q.ID) globalPolicy0, err := s.ds.NewGlobalPolicy( context.Background(), &user1.ID, fleet.PolicyPayload{ QueryID: &q.ID, @@ -5791,7 +5791,7 @@ func (s *integrationTestSuite) TestQueriesBadRequests() { s.DoJSON("POST", "/api/latest/fleet/queries", reqQuery, http.StatusOK, &createQueryResp) require.NotNil(t, createQueryResp.Query) existingQueryID := createQueryResp.Query.ID - defer cleanupQuery(s, existingQueryID) + defer s.cleanupQuery(existingQueryID) for _, tc := range []struct { tname string @@ -9011,7 +9011,7 @@ func createSession(t *testing.T, uid uint, ds fleet.Datastore) *fleet.Session { return ssn } -func cleanupQuery(s *integrationTestSuite, queryID uint) { +func (s *integrationTestSuite) cleanupQuery(queryID uint) { var delResp deleteQueryByIDResponse s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/queries/id/%d", queryID), nil, http.StatusOK, &delResp) } diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index b50bd25c7b..d7905364f5 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -844,6 +844,35 @@ func (s *integrationEnterpriseTestSuite) TestTeamPolicies() { assert.Equal(t, gpol.Name, ts.InheritedPolicies[0].Name) assert.Equal(t, gpol.ID, ts.InheritedPolicies[0].ID) + tc := countTeamPoliciesResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/count", team1.ID), nil, http.StatusOK, &tc) + require.Nil(t, tc.Err) + require.Equal(t, 1, tc.Count) + + gc := countGlobalPoliciesResponse{} + s.DoJSON("GET", "/api/latest/fleet/policies/count", nil, http.StatusOK, &gc) + require.Nil(t, gc.Err) + require.Equal(t, 1, gc.Count) + + // Test merge inherited + ts = listTeamPoliciesResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d/policies", team1.ID), nil, http.StatusOK, &ts, "merge_inherited", "true", "order_key", "team_id", "order_direction", "desc") + require.Len(t, ts.Policies, 2) + require.Nil(t, ts.InheritedPolicies) + assert.Equal(t, "TestQuery2", ts.Policies[0].Name) + assert.Equal(t, "select * from osquery;", ts.Policies[0].Query) + assert.Equal(t, "Some description", ts.Policies[0].Description) + require.NotNil(t, ts.Policies[0].Resolution) + assert.Equal(t, "some team resolution", *ts.Policies[0].Resolution) + assert.Equal(t, gpol.Name, ts.Policies[1].Name) + assert.Equal(t, gpol.ID, ts.Policies[1].ID) + + countResp := countTeamPoliciesResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/count", team1.ID), nil, http.StatusOK, &countResp, "merge_inherited", "true") + require.Nil(t, countResp.Err) + require.Equal(t, 2, countResp.Count) + + // Test delete deletePolicyParams := deleteTeamPoliciesRequest{IDs: []uint{ts.Policies[0].ID}} deletePolicyResp := deleteTeamPoliciesResponse{} s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/delete", team1.ID), deletePolicyParams, http.StatusOK, &deletePolicyResp) @@ -853,6 +882,53 @@ func (s *integrationEnterpriseTestSuite) TestTeamPolicies() { require.Len(t, ts.Policies, 0) } +func (s *integrationEnterpriseTestSuite) TestTeamQueries() { + t := s.T() + + team1, err := s.ds.NewTeam(context.Background(), &fleet.Team{ + ID: 42, + Name: "team1" + t.Name(), + Description: "desc team1", + }) + require.NoError(t, err) + + oldToken := s.token + t.Cleanup(func() { + s.token = oldToken + }) + + // create global query + params := fleet.QueryPayload{ + Name: ptr.String("global1"), + Query: ptr.String("select * from time;"), + } + var createQueryResp createQueryResponse + s.DoJSON("POST", "/api/latest/fleet/queries", ¶ms, http.StatusOK, &createQueryResp) + defer s.cleanupQuery(createQueryResp.Query.ID) + + // create team query + params = fleet.QueryPayload{ + Name: ptr.String("team1"), + Query: ptr.String("select * from time;"), + TeamID: ptr.Uint(team1.ID), + } + createQueryResp = createQueryResponse{} + s.DoJSON("POST", "/api/latest/fleet/queries", ¶ms, http.StatusOK, &createQueryResp) + defer s.cleanupQuery(createQueryResp.Query.ID) + + // list team queries + var listQueriesResp listQueriesResponse + s.DoJSON("GET", "/api/latest/fleet/queries", nil, http.StatusOK, &listQueriesResp, "team_id", fmt.Sprint(team1.ID)) + require.Len(t, listQueriesResp.Queries, 1) + assert.Equal(t, "team1", listQueriesResp.Queries[0].Name) + + // list merged team queries + s.DoJSON("GET", "/api/latest/fleet/queries", nil, http.StatusOK, &listQueriesResp, "team_id", fmt.Sprint(team1.ID), "merge_inherited", "true", "order_key", "team_id", "order_direction", "desc") + require.Len(t, listQueriesResp.Queries, 2) + assert.Equal(t, "team1", listQueriesResp.Queries[0].Name) + assert.Equal(t, "global1", listQueriesResp.Queries[1].Name) +} + func (s *integrationEnterpriseTestSuite) TestModifyTeamEnrollSecrets() { t := s.T() @@ -2840,7 +2916,8 @@ func (s *integrationEnterpriseTestSuite) TestMDMMacOSUpdates() { // edited macos min version activity got created s.lastActivityMatches(fleet.ActivityTypeEditedMacOSMinVersion{}.ActivityName(), `{"deadline":"2022-01-01", "minimum_version":"12.3.1", "team_id": null, "team_name": null}`, 0) s.assertMacOSUpdatesDeclaration(nil, &fleet.MacOSUpdates{ - MinimumVersion: optjson.SetString("12.3.1"), Deadline: optjson.SetString("2022-01-01")}) + MinimumVersion: optjson.SetString("12.3.1"), Deadline: optjson.SetString("2022-01-01"), + }) // get the appconfig acResp = appConfigResponse{} @@ -2864,7 +2941,8 @@ func (s *integrationEnterpriseTestSuite) TestMDMMacOSUpdates() { // another edited macos min version activity got created lastActivity = s.lastActivityMatches(fleet.ActivityTypeEditedMacOSMinVersion{}.ActivityName(), `{"deadline":"2024-01-01", "minimum_version":"12.3.1", "team_id": null, "team_name": null}`, 0) s.assertMacOSUpdatesDeclaration(nil, &fleet.MacOSUpdates{ - MinimumVersion: optjson.SetString("12.3.1"), Deadline: optjson.SetString("2024-01-01")}) + MinimumVersion: optjson.SetString("12.3.1"), Deadline: optjson.SetString("2024-01-01"), + }) // update something unrelated - the transparency url acResp = appConfigResponse{} @@ -2875,7 +2953,8 @@ func (s *integrationEnterpriseTestSuite) TestMDMMacOSUpdates() { // no activity got created s.lastActivityMatches("", ``, lastActivity) s.assertMacOSUpdatesDeclaration(nil, &fleet.MacOSUpdates{ - MinimumVersion: optjson.SetString("12.3.1"), Deadline: optjson.SetString("2024-01-01")}) + MinimumVersion: optjson.SetString("12.3.1"), Deadline: optjson.SetString("2024-01-01"), + }) // clear the macos requirement acResp = appConfigResponse{} @@ -8653,3 +8732,8 @@ func triggerAndWait(ctx context.Context, t *testing.T, ds fleet.Datastore, s *sc } } } + +func (s *integrationEnterpriseTestSuite) cleanupQuery(queryID uint) { + var delResp deleteQueryByIDResponse + s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/queries/id/%d", queryID), nil, http.StatusOK, &delResp) +} diff --git a/server/service/queries.go b/server/service/queries.go index a4d39fef83..988f3d232b 100644 --- a/server/service/queries.go +++ b/server/service/queries.go @@ -58,7 +58,8 @@ func (svc *Service) GetQuery(ctx context.Context, id uint) (*fleet.Query, error) type listQueriesRequest struct { ListOptions fleet.ListOptions `url:"list_options"` // TeamID url argument set to 0 means global. - TeamID uint `query:"team_id,optional"` + TeamID uint `query:"team_id,optional"` + MergeInherited bool `query:"merge_inherited,optional"` } type listQueriesResponse struct { @@ -76,7 +77,7 @@ func listQueriesEndpoint(ctx context.Context, request interface{}, svc fleet.Ser teamID = &req.TeamID } - queries, err := svc.ListQueries(ctx, req.ListOptions, teamID, nil) + queries, err := svc.ListQueries(ctx, req.ListOptions, teamID, nil, req.MergeInherited) if err != nil { return listQueriesResponse{Err: err}, nil } @@ -90,7 +91,7 @@ func listQueriesEndpoint(ctx context.Context, request interface{}, svc fleet.Ser }, nil } -func (svc *Service) ListQueries(ctx context.Context, opt fleet.ListOptions, teamID *uint, scheduled *bool) ([]*fleet.Query, error) { +func (svc *Service) ListQueries(ctx context.Context, opt fleet.ListOptions, teamID *uint, scheduled *bool, mergeInherited bool) ([]*fleet.Query, error) { // Check the user is allowed to list queries on the given team. if err := svc.authz.Authorize(ctx, &fleet.Query{ TeamID: teamID, @@ -99,9 +100,10 @@ func (svc *Service) ListQueries(ctx context.Context, opt fleet.ListOptions, team } queries, err := svc.ds.ListQueries(ctx, fleet.ListQueryOptions{ - ListOptions: opt, - TeamID: teamID, - IsScheduled: scheduled, + ListOptions: opt, + TeamID: teamID, + IsScheduled: scheduled, + MergeInherited: mergeInherited, }) if err != nil { return nil, err @@ -733,7 +735,7 @@ func getQuerySpecsEndpoint(ctx context.Context, request interface{}, svc fleet.S } func (svc *Service) GetQuerySpecs(ctx context.Context, teamID *uint) ([]*fleet.QuerySpec, error) { - queries, err := svc.ListQueries(ctx, fleet.ListOptions{}, teamID, nil) + queries, err := svc.ListQueries(ctx, fleet.ListOptions{}, teamID, nil, false) if err != nil { return nil, ctxerr.Wrap(ctx, err, "getting queries") } diff --git a/server/service/queries_test.go b/server/service/queries_test.go index 0fc1a44aec..9b9cdfb1c9 100644 --- a/server/service/queries_test.go +++ b/server/service/queries_test.go @@ -632,7 +632,7 @@ func TestQueryAuth(t *testing.T) { _, err = svc.QueryReportIsClipped(ctx, tt.qid) checkAuthErr(t, tt.shouldFailRead, err) - _, err = svc.ListQueries(ctx, fleet.ListOptions{}, query.TeamID, nil) + _, err = svc.ListQueries(ctx, fleet.ListOptions{}, query.TeamID, nil, false) checkAuthErr(t, tt.shouldFailRead, err) teamName := "" diff --git a/server/service/team_policies.go b/server/service/team_policies.go index 75cbe3ae96..a2698c145e 100644 --- a/server/service/team_policies.go +++ b/server/service/team_policies.go @@ -106,6 +106,7 @@ type listTeamPoliciesRequest struct { InheritedPerPage uint `query:"inherited_per_page,optional"` InheritedOrderDirection fleet.OrderDirection `query:"inherited_order_direction,optional"` InheritedOrderKey string `query:"inherited_order_key,optional"` + MergeInherited bool `query:"merge_inherited,optional"` } type listTeamPoliciesResponse struct { @@ -126,14 +127,14 @@ func listTeamPoliciesEndpoint(ctx context.Context, request interface{}, svc flee OrderKey: req.InheritedOrderKey, } - tmPols, inheritedPols, err := svc.ListTeamPolicies(ctx, req.TeamID, req.Opts, inheritedListOptions) + tmPols, inheritedPols, err := svc.ListTeamPolicies(ctx, req.TeamID, req.Opts, inheritedListOptions, req.MergeInherited) if err != nil { return listTeamPoliciesResponse{Err: err}, nil } return listTeamPoliciesResponse{Policies: tmPols, InheritedPolicies: inheritedPols}, nil } -func (svc *Service) ListTeamPolicies(ctx context.Context, teamID uint, opts fleet.ListOptions, iopts fleet.ListOptions) (teamPolicies, inheritedPolicies []*fleet.Policy, err error) { +func (svc *Service) ListTeamPolicies(ctx context.Context, teamID uint, opts fleet.ListOptions, iopts fleet.ListOptions, mergeInherited bool) (teamPolicies, inheritedPolicies []*fleet.Policy, err error) { if err := svc.authz.Authorize(ctx, &fleet.Policy{ PolicyData: fleet.PolicyData{ TeamID: ptr.Uint(teamID), @@ -146,6 +147,11 @@ func (svc *Service) ListTeamPolicies(ctx context.Context, teamID uint, opts flee return nil, nil, ctxerr.Wrapf(ctx, err, "loading team %d", teamID) } + if mergeInherited { + p, err := svc.ds.ListMergedTeamPolicies(ctx, teamID, opts) + return p, nil, err + } + return svc.ds.ListTeamPolicies(ctx, teamID, opts, iopts) } @@ -154,8 +160,9 @@ func (svc *Service) ListTeamPolicies(ctx context.Context, teamID uint, opts flee ///////////////////////////////////////////////////////////////////////////////// type countTeamPoliciesRequest struct { - ListOptions fleet.ListOptions `url:"list_options"` - TeamID uint `url:"team_id"` + ListOptions fleet.ListOptions `url:"list_options"` + TeamID uint `url:"team_id"` + MergeInherited bool `query:"merge_inherited,optional"` } type countTeamPoliciesResponse struct { @@ -167,14 +174,14 @@ func (r countTeamPoliciesResponse) error() error { return r.Err } func countTeamPoliciesEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { req := request.(*countTeamPoliciesRequest) - resp, err := svc.CountTeamPolicies(ctx, req.TeamID, req.ListOptions.MatchQuery) + resp, err := svc.CountTeamPolicies(ctx, req.TeamID, req.ListOptions.MatchQuery, req.MergeInherited) if err != nil { return countTeamPoliciesResponse{Err: err}, nil } return countTeamPoliciesResponse{Count: resp}, nil } -func (svc *Service) CountTeamPolicies(ctx context.Context, teamID uint, matchQuery string) (int, error) { +func (svc *Service) CountTeamPolicies(ctx context.Context, teamID uint, matchQuery string, mergeInherited bool) (int, error) { if err := svc.authz.Authorize(ctx, &fleet.Policy{ PolicyData: fleet.PolicyData{ TeamID: ptr.Uint(teamID), @@ -187,6 +194,10 @@ func (svc *Service) CountTeamPolicies(ctx context.Context, teamID uint, matchQue return 0, ctxerr.Wrapf(ctx, err, "loading team %d", teamID) } + if mergeInherited { + return svc.ds.CountMergedTeamPolicies(ctx, teamID, matchQuery) + } + return svc.ds.CountPolicies(ctx, &teamID, matchQuery) } diff --git a/server/service/team_policies_test.go b/server/service/team_policies_test.go index 9e1a502f67..6a2d35d4ff 100644 --- a/server/service/team_policies_test.go +++ b/server/service/team_policies_test.go @@ -149,7 +149,7 @@ func TestTeamPoliciesAuth(t *testing.T) { }) checkAuthErr(t, tt.shouldFailWrite, err) - _, _, err = svc.ListTeamPolicies(ctx, 1, fleet.ListOptions{}, fleet.ListOptions{}) + _, _, err = svc.ListTeamPolicies(ctx, 1, fleet.ListOptions{}, fleet.ListOptions{}, false) checkAuthErr(t, tt.shouldFailRead, err) _, err = svc.GetTeamPolicyByIDQueries(ctx, 1, 1) diff --git a/server/service/team_schedule.go b/server/service/team_schedule.go index 24ad3cde3b..da31740a77 100644 --- a/server/service/team_schedule.go +++ b/server/service/team_schedule.go @@ -47,7 +47,7 @@ func (svc Service) GetTeamScheduledQueries(ctx context.Context, teamID uint, opt if teamID != 0 { teamID_ = &teamID } - queries, err := svc.ListQueries(ctx, opts, teamID_, ptr.Bool(true)) + queries, err := svc.ListQueries(ctx, opts, teamID_, ptr.Bool(true), false) if err != nil { return nil, err }