15605 Merge Inherited Queries and Policies (#18813)

This commit is contained in:
RachelElysia 2024-05-07 16:09:26 -04:00 committed by GitHub
commit 15ab9a4ed8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
56 changed files with 1688 additions and 803 deletions

View file

@ -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

View file

@ -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>): IPolicyStats => {
return { ...DEFAULT_POLICY_MOCK, ...overrides };
};
export const createMockPoliciesResponse = (
overrides?: Partial<IPolicyStats>
) => {
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;

View file

@ -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;
}

View file

@ -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 (
<div className={baseClass}>
<span
className={`${baseClass}__element-text`}
data-tooltip-id={tooltipId}
>
Inherited
</span>
<ReactTooltip5
className={`${baseClass}__tooltip-text`}
disableStyleInjection
place={tooltipPosition}
opacity={1}
id={tooltipId}
offset={8}
positionStrategy="fixed"
>
{tooltipContent}
</ReactTooltip5>
</div>
);
};
export default InheritedBadge;

View file

@ -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;
}
}

View file

@ -0,0 +1 @@
export { default } from "./InheritedBadge";

View file

@ -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,

View file

@ -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<T = any> {
setExportRows?: (rows: Row[]) => void;
resetPageIndex?: boolean;
disableTableHeader?: boolean;
show0Count?: boolean;
}
const baseClass = "table-container";
@ -156,6 +157,7 @@ const TableContainer = <T,>({
setExportRows,
resetPageIndex,
disableTableHeader,
show0Count,
}: ITableContainerProps<T>) => {
const [searchQuery, setSearchQuery] = useState(defaultSearchQuery);
const [sortHeader, setSortHeader] = useState(defaultSortHeader || "");
@ -321,7 +323,7 @@ const TableContainer = <T,>({
)}
{!renderCount &&
!disableCount &&
(isMultiColumnFilter || displayCount()) ? (
(isMultiColumnFilter || displayCount() || show0Count) ? (
<div
className={`${baseClass}__results-count ${
stackControls ? "stack-table-controls" : ""
@ -330,7 +332,8 @@ const TableContainer = <T,>({
>
{TableContainerUtils.generateResultsCountText(
resultsTitle,
displayCount()
displayCount(),
show0Count
)}
{resultsHtml}
</div>

View file

@ -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

View file

@ -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<HeaderProps<any>>;
checkIfRowIsSelectable: (row: Row<any>) => boolean;
}
export const getConditionalSelectHeaderCheckboxProps = ({
headerProps,
checkIfRowIsSelectable,
}: GetConditionalSelectHeaderCheckboxProps) => {
// Define if the checkbox should show as checked or indeterminate
const checkIfAllSelectableRowsSelected = (rows: Row<any>[]) =>
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 };

View file

@ -56,7 +56,6 @@ const DropdownOptionTooltipWrapper = ({
clickable={clickable}
offset={offset}
positionStrategy="fixed"
classNameArrow="tooltip-arrow"
>
{tipContent}
</ReactTooltip5>

View file

@ -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;
}

File diff suppressed because one or more lines are too long

View file

@ -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,

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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 {

View file

@ -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;
}

View file

@ -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

View file

@ -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 {

View file

@ -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;
}

View file

@ -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";

View file

@ -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<IPolicyStats[]>();
const [inheritedPolicies, setInheritedPolicies] = useState<IPolicyStats[]>();
const [
policiesAvailableToAutomate,
setPoliciesAvailableToAutomate,
] = useState<IPolicyStats[]>([]);
// 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<ITableQueryData>();
const [
inheritedTableQueryData,
setInheritedTableQueryData,
] = useState<ITableQueryData>();
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<IWebhookSettings, "failing_policies_webhook">;
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 = ({
</div>
{showCtaButtons && (
<div className={`${baseClass} button-wrap`}>
{canManageAutomations && automationsConfig && (
<div className={`${baseClass}__manage-automations-wrapper`}>
<Dropdown
className={`${baseClass}__manage-automations-dropdown`}
onChange={onSelectAutomationOption}
placeholder="Manage automations"
searchable={false}
options={getAutomationsDropdownOptions()}
/>
</div>
)}
{canManageAutomations &&
automationsConfig &&
hasPoliciesToAutomateOrDelete && (
<div className={`${baseClass}__manage-automations-wrapper`}>
<Dropdown
className={`${baseClass}__manage-automations-dropdown`}
onChange={onSelectAutomationOption}
placeholder="Manage automations"
searchable={false}
options={getAutomationsDropdownOptions()}
/>
</div>
)}
{canAddOrDeletePolicy && (
<div className={`${baseClass}__action-button-container`}>
<Button
@ -852,7 +788,7 @@ const ManagePolicyPage = ({
className={`${baseClass}__select-policy-button`}
onClick={onAddPolicyClick}
>
Add a policy
Add policy
</Button>
</div>
)}
@ -867,57 +803,11 @@ const ManagePolicyPage = ({
</p>
</div>
{renderMainTable()}
{showInheritedPoliciesButton && globalPoliciesCount && (
<RevealButton
isShowing={showInheritedTable}
className={baseClass}
hideText={inheritedPoliciesButtonText(
showInheritedTable,
globalPoliciesCount
)}
showText={inheritedPoliciesButtonText(
showInheritedTable,
globalPoliciesCount
)}
caretPosition="before"
tooltipContent={
<>
&quot;All teams&quot; policies are checked
<br />
for this team&apos;s hosts.
</>
}
onClick={toggleShowInheritedPolicies}
/>
)}
{showInheritedPoliciesButton && showInheritedTable && (
<div className={`${baseClass}__inherited-policies-table`}>
{globalPoliciesError && <TableDataError />}
{!globalPoliciesError && (
<PoliciesTable
isLoading={isFetchingTeamPolicies}
policiesList={inheritedPolicies || []}
onDeletePolicyClick={noop}
canAddOrDeletePolicy={canAddOrDeletePolicy}
tableType="inheritedPolicies"
currentTeam={currentTeamSummary}
searchQuery=""
renderPoliciesCount={() =>
renderPoliciesCount(teamPoliciesCount)
}
sortHeader={inheritedSortHeader}
sortDirection={inheritedSortDirection}
page={inheritedPage}
onQueryChange={onQueryChange}
/>
)}
</div>
)}
{config && automationsConfig && showOtherWorkflowsModal && (
<OtherWorkflowsModal
automationsConfig={automationsConfig}
availableIntegrations={config.integrations}
availablePolicies={availablePoliciesForAutomation}
availablePolicies={policiesAvailableToAutomate}
isUpdatingAutomations={isUpdatingAutomations}
onExit={toggleOtherWorkflowsModal}
handleSubmit={handleUpdateOtherWorkflows}
@ -950,7 +840,7 @@ const ManagePolicyPage = ({
?.enable_calendar_events ?? false
}
url={teamConfig?.integrations.google_calendar?.webhook_url || ""}
policies={teamPolicies || []}
policies={policiesAvailableToAutomate}
isUpdating={updatingPolicyEnabledCalendarEvents}
/>
)}

View file

@ -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;
}
}
}
}

View file

@ -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(
<PoliciesTable
policiesList={[testCriticalPolicy]}
policiesList={[]}
isLoading={false}
// eslint-disable-next-line @typescript-eslint/no-empty-function
onDeletePolicyClick={() => {}}
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(
<PoliciesTable
policiesList={[]}
isLoading={false}
// eslint-disable-next-line @typescript-eslint/no-empty-function
onDeletePolicyClick={() => {}}
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(
<PoliciesTable
policiesList={[testCriticalPolicy]}
isLoading={false}
@ -44,21 +86,148 @@ describe("Policies table", () => {
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(
<PoliciesTable
policiesList={[testInheritedPolicy]}
isLoading={false}
// eslint-disable-next-line @typescript-eslint/no-empty-function
onDeletePolicyClick={() => {}}
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(
<PoliciesTable
policiesList={[testGlobalPolicy]}
isLoading={false}
// eslint-disable-next-line @typescript-eslint/no-empty-function
onDeletePolicyClick={() => {}}
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(
<PoliciesTable
policiesList={[...testInheritedPolicies, ...testTeamPolicies]}
isLoading={false}
// eslint-disable-next-line @typescript-eslint/no-empty-function
onDeletePolicyClick={() => {}}
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);
});
});
});

View file

@ -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&apos;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 (
<div
className={`${baseClass} ${
canAddOrDeletePolicy ? "" : "hide-selection-column"
}`}
>
<div className={baseClass}>
<TableContainer
resultsTitle="policies"
columnConfigs={generateTableHeaders(
{
selectedTeamId: currentTeam?.id,
canAddOrDeletePolicy,
tableType,
hasPermissionAndPoliciesToDelete,
},
policiesList,
isPremiumTier,
isSandboxMode
isPremiumTier
)}
data={generateDataSet(
policiesList,
@ -152,7 +132,6 @@ const PoliciesTable = ({
primaryButton: emptyState().primaryButton,
})
}
disableCount={tableType === "inheritedPolicies"}
renderCount={renderPoliciesCount}
onQueryChange={onTableQueryChange}
inputPlaceHolder="Search by name"

View file

@ -4,23 +4,22 @@
import React from "react";
import {
formatDistanceToNowStrict,
isAfter,
millisecondsToHours,
millisecondsToMinutes,
} from "date-fns";
import ReactTooltip from "react-tooltip";
import { Tooltip as ReactTooltip5 } from "react-tooltip-5";
// @ts-ignore
import Checkbox from "components/forms/fields/Checkbox";
import HeaderCell from "components/TableContainer/DataTable/HeaderCell";
import LinkCell from "components/TableContainer/DataTable/LinkCell/LinkCell";
import StatusIndicator from "components/StatusIndicator";
import Icon from "components/Icon";
import { IPolicyStats } from "interfaces/policy";
import PATHS from "router/paths";
import sortUtils from "utilities/sort";
import { PolicyResponse } from "utilities/constants";
import { buildQueryStringFromParams } from "utilities/url";
import { COLORS } from "styles/var/colors";
import InheritedBadge from "components/InheritedBadge";
import { getConditionalSelectHeaderCheckboxProps } from "components/TableContainer/utilities/config_utils";
import PassingColumnHeader from "../PassingColumnHeader";
interface IGetToggleAllRowsSelectedProps {
@ -88,11 +87,11 @@ const getPolicyRefreshTime = (ms: number): string => {
const getTooltip = (osqueryPolicyMs: number): JSX.Element => {
return (
<span className={`tooltip__tooltip-text`}>
<>
Fleet is collecting policy results. Try again
<br />
in about {getPolicyRefreshTime(osqueryPolicyMs)} as the system catches up.
</span>
</>
);
};
@ -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 = (
<>
<div className="policy-name-text">{cellProps.cell.value}</div>
{isPremiumTier && cellProps.row.original.critical && (
<>
<div className="critical-badge">
<span
className="tooltip-base"
data-tip
data-for={`critical-tooltip-${cellProps.row.original.id}`}
className="critical-badge-icon"
data-tooltip-id={`critical-tooltip-${cellProps.row.original.id}`}
>
<Icon
className="critical-policy-icon"
@ -157,23 +154,21 @@ const generateTableHeaders = (
color="core-fleet-blue"
/>
</span>
<ReactTooltip
<ReactTooltip5
className="critical-tooltip"
disableStyleInjection
place="top"
type="dark"
effect="solid"
opacity={1}
id={`critical-tooltip-${cellProps.row.original.id}`}
backgroundColor={COLORS["tooltip-bg"]}
offset={8}
positionStrategy="fixed"
>
This policy has been marked as critical.
{isSandboxMode && (
<>
<br />
This is a premium feature.
</>
)}
</ReactTooltip>
</>
</ReactTooltip5>
</div>
)}
{viewingTeamPolicies && !cellProps.row.original.team_id && (
<InheritedBadge tooltipContent="This policy runs on all hosts." />
)}
</>
}
@ -208,24 +203,24 @@ const generateTableHeaders = (
);
}
return (
<>
<div className="policy-has-not-run">
<span
className="text-cell text-muted has-not-run tooltip"
data-tip
data-for={`passing_${cellProps.row.original.id.toString()}`}
data-tooltip-id={`passing_${cellProps.row.original.id.toString()}`}
>
---
</span>
<ReactTooltip
place="bottom"
effect="solid"
backgroundColor={COLORS["tooltip-bg"]}
<ReactTooltip5
className="policy-has-not-run-tooltip"
disableStyleInjection
place="top"
opacity={1}
id={`passing_${cellProps.row.original.id.toString()}`}
data-html
offset={8}
positionStrategy="fixed"
>
{getTooltip(cellProps.row.original.next_update_ms)}
</ReactTooltip>
</>
</ReactTooltip5>
</div>
);
},
},
@ -259,52 +254,73 @@ const generateTableHeaders = (
);
}
return (
<>
<div className="policy-has-not-run">
<span
className="text-cell text-muted has-not-run tooltip"
data-tip
data-for={`failing_${cellProps.row.original.id.toString()}`}
data-tooltip-id={`passing_${cellProps.row.original.id.toString()}`}
>
---
</span>
<ReactTooltip
place="bottom"
effect="solid"
backgroundColor={COLORS["tooltip-bg"]}
id={`failing_${cellProps.row.original.id.toString()}`}
data-html
<ReactTooltip5
className="policy-has-not-run-tooltip"
disableStyleInjection
place="top"
opacity={1}
id={`passing_${cellProps.row.original.id.toString()}`}
offset={8}
positionStrategy="fixed"
>
{getTooltip(cellProps.row.original.next_update_ms)}
</ReactTooltip>
</>
</ReactTooltip5>
</div>
);
},
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 <Checkbox {...checkboxProps} />;
},
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 <Checkbox {...checkboxProps} />;
},
disableHidden: true,

View file

@ -26,10 +26,6 @@
}
}
.has-not-run {
width: 20px;
}
.no-team-policy {
border: 1px solid #e2e4ea;
box-sizing: border-box;

View file

@ -26,11 +26,7 @@ const PolicyErrorsTable = ({
canAddOrDeletePolicy,
}: IPolicyErrorsTableProps): JSX.Element => {
return (
<div
className={`${baseClass} ${
canAddOrDeletePolicy ? "" : "hide-selection-column"
}`}
>
<div className={baseClass}>
<TableContainer
resultsTitle={resultsTitle || "policies"}
columnConfigs={generateTableHeaders()}

View file

@ -26,11 +26,7 @@ const PolicyResultsTable = ({
canAddOrDeletePolicy,
}: IPolicyResultsTableProps): JSX.Element => {
return (
<div
className={`${baseClass} ${
canAddOrDeletePolicy ? "" : "hide-selection-column"
}`}
>
<div className={baseClass}>
<TableContainer
resultsTitle={resultsTitle || "policies"}
columnConfigs={generateTableHeaders()}

View file

@ -15,13 +15,13 @@ import { TableContext } from "context/table";
import { NotificationContext } from "context/notification";
import { getPerformanceImpactDescription } from "utilities/helpers";
import { SupportedPlatform } from "interfaces/platform";
import { API_ALL_TEAMS_ID } from "interfaces/team";
import {
IEnhancedQuery,
IQueryKeyQueriesLoadAll,
ISchedulableQuery,
} from "interfaces/schedulable_query";
import { DEFAULT_TARGETS_BY_TYPE } from "interfaces/target";
import { API_ALL_TEAMS_ID } from "interfaces/team";
import queriesAPI from "services/entities/queries";
import PATHS from "router/paths";
import { DEFAULT_QUERY } from "utilities/constants";
@ -32,7 +32,6 @@ import TableDataError from "components/DataError";
import MainContent from "components/MainContent";
import TeamsDropdown from "components/TeamsDropdown";
import useTeamIdParam from "hooks/useTeamIdParam";
import RevealButton from "components/buttons/RevealButton";
import QueriesTable from "./components/QueriesTable";
import DeleteQueryModal from "./components/DeleteQueryModal";
import ManageQueryAutomationsModal from "./components/ManageQueryAutomationsModal/ManageQueryAutomationsModal";
@ -50,9 +49,6 @@ interface IManageQueriesPageProps {
order_key?: string;
order_direction?: "asc" | "desc";
team_id?: string;
inherited_order_key?: string;
inherited_order_direction?: "asc" | "desc";
inherited_page?: string;
};
search: string;
};
@ -64,7 +60,7 @@ const getPlatforms = (queryString: string): SupportedPlatform[] => {
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 <h1>Queries</h1>;
};
const renderCurrentScopeQueriesTable = () => {
if (isFetchingCurTeamQueries) {
const renderQueriesTable = () => {
if (isFetchingQueries) {
return <Spinner />;
}
if (curTeamQueriesError) {
if (queriesError) {
return <TableDataError />;
}
return (
<QueriesTable
queriesList={curTeamEnhancedQueries || []}
isLoading={isFetchingCurTeamQueries}
queriesList={enhancedQueries || []}
onlyInheritedQueries={onlyInheritedQueries}
isLoading={isFetchingQueries}
onCreateQueryClick={onCreateQueryClick}
onDeleteQueryClick={onDeleteQueryClick}
isOnlyObserver={isOnlyObserver}
@ -289,70 +278,11 @@ const ManageQueriesPage = ({
isAnyTeamObserverPlus={isAnyTeamObserverPlus || false}
router={router}
queryParams={queryParams}
currentTeamId={teamIdForApi}
/>
);
};
const renderShowInheritedQueriesTableButton = () => {
const inheritedQueryCount = globalEnhancedQueries?.length;
return (
<RevealButton
isShowing={showInheritedQueries}
className={baseClass}
hideText={`Hide ${inheritedQueryCount} inherited quer${
inheritedQueryCount === 1 ? "y" : "ies"
}`}
showText={`Show ${inheritedQueryCount} inherited quer${
inheritedQueryCount === 1 ? "y" : "ies"
}`}
caretPosition="before"
tooltipContent={
<>
Queries from the &quot;All teams&quot;
<br />
schedule run on this team&apos;s hosts.
</>
}
onClick={() => {
setShowInheritedQueries(!showInheritedQueries);
}}
/>
);
};
const renderInheritedQueriesTable = () => {
if (isFetchingGlobalQueries) {
return <Spinner />;
}
if (globalQueriesError) {
return <TableDataError />;
}
return (
<QueriesTable
queriesList={globalEnhancedQueries || []}
isLoading={isFetchingGlobalQueries}
onCreateQueryClick={onCreateQueryClick}
onDeleteQueryClick={onDeleteQueryClick}
isOnlyObserver={isOnlyObserver}
isObserverPlus={isObserverPlus}
isAnyTeamObserverPlus={isAnyTeamObserverPlus || false}
router={router}
queryParams={queryParams}
isInherited
currentTeamId={currentTeamId}
/>
);
};
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 = ({
<div className={`${baseClass}__title`}>{renderHeader()}</div>
</div>
</div>
<div className={`${baseClass}__action-button-container`}>
{(isGlobalAdmin || isTeamAdmin) && (
<Button
onClick={onManageAutomationsClick}
className={`${baseClass}__manage-automations button`}
variant="inverse"
>
Manage automations
</Button>
)}
{(!isOnlyObserver || isObserverPlus || isAnyTeamObserverPlus) &&
!!curTeamEnhancedQueries?.length && (
<>
<Button
variant="brand"
className={`${baseClass}__create-button`}
onClick={onCreateQueryClick}
>
{isObserverPlus ? "Live query" : "Add query"}
</Button>
</>
{!!enhancedQueries?.length && (
<div className={`${baseClass}__action-button-container`}>
{(isGlobalAdmin || isTeamAdmin) && !onlyInheritedQueries && (
<Button
onClick={onManageAutomationsClick}
className={`${baseClass}__manage-automations button`}
variant="inverse"
>
Manage automations
</Button>
)}
</div>
{(!isOnlyObserver || isObserverPlus || isAnyTeamObserverPlus) && (
<Button
variant="brand"
className={`${baseClass}__create-button`}
onClick={onCreateQueryClick}
>
{isObserverPlus ? "Live query" : "Add query"}
</Button>
)}
</div>
)}
</div>
<div className={`${baseClass}__description`}>
<p>
@ -466,11 +395,7 @@ const ManageQueriesPage = ({
: "Gather data about all hosts."}
</p>
</div>
{renderCurrentScopeQueriesTable()}
{isAnyTeamSelected &&
globalEnhancedQueries &&
globalEnhancedQueries?.length > 0 &&
renderInheritedQueriesSection()}
{renderQueriesTable()}
{renderModals()}
</div>
</MainContent>

View file

@ -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 {

View file

@ -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(<QueriesTable {...tableProps} />);
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(
<QueriesTable {...tableProps} />
);
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(
<QueriesTable
queriesList={testQueries}
onlyInheritedQueries={false}
isLoading={false}
onDeleteQueryClick={jest.fn()}
onCreateQueryClick={jest.fn()}
isOnlyObserver={false}
isObserverPlus={false}
isAnyTeamObserverPlus={false}
currentTeamId={1}
/>
);
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(
<QueriesTable
queriesList={testQueries}
onlyInheritedQueries={false}
isLoading={false}
onDeleteQueryClick={jest.fn()}
onCreateQueryClick={jest.fn()}
isOnlyObserver={false}
isObserverPlus={false}
isAnyTeamObserverPlus={false}
currentTeamId={1}
/>
);
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(
<QueriesTable
queriesList={testQueries}
onlyInheritedQueries={false}
isLoading={false}
onDeleteQueryClick={jest.fn()}
onCreateQueryClick={jest.fn()}
isOnlyObserver={false}
isObserverPlus={false}
isAnyTeamObserverPlus={false}
currentTeamId={undefined}
/>
);
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(
<QueriesTable
queriesList={[...testTeamQueries, ...testGlobalQueries]}
onlyInheritedQueries={false}
isLoading={false}
onDeleteQueryClick={jest.fn()}
onCreateQueryClick={jest.fn()}
isOnlyObserver={false}
isObserverPlus={false}
isAnyTeamObserverPlus={false}
currentTeamId={1}
/>
);
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);
});
});
});

View file

@ -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{" "}
<CustomLink
url="https://fleetdm.com/docs/using-fleet/standard-query-library"
text="import Fleets standard query library"
text="import Fleet's standard query library"
newTab
/>
</>
@ -243,7 +195,7 @@ const QueriesTable = ({
className={`${baseClass}__create-button`}
onClick={onCreateQueryClick}
>
Create new query
Add query
</Button>
);
}
@ -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 ? (
<div className={`${baseClass}`}>
<TableContainer
disableCount={isInherited}
resultsTitle="queries"
columnConfigs={columnConfigs}
data={queriesList}
filters={{ name: isInherited ? "" : trimmedSearchQuery }}
filters={{ name: trimmedSearchQuery }}
isLoading={isLoading}
defaultSortHeader={sortHeader || DEFAULT_SORT_HEADER}
defaultSortDirection={sortDirection || DEFAULT_SORT_DIRECTION}
defaultSearchQuery={isInherited ? "" : trimmedSearchQuery}
defaultSearchQuery={trimmedSearchQuery}
defaultPageIndex={page}
pageSize={DEFAULT_PAGE_SIZE}
inputPlaceHolder="Search by name"
@ -317,9 +271,7 @@ const QueriesTable = ({
isAllPagesSelected={false}
searchable={searchable}
searchQueryColumn="name"
customControl={
searchable && !isInherited ? renderPlatformDropdown : undefined
}
customControl={searchable ? renderPlatformDropdown : undefined}
isClientSidePagination
onClientSidePaginationChange={onClientSidePaginationChange}
isClientSideFilter
@ -330,7 +282,8 @@ const QueriesTable = ({
variant: "text-icon",
onActionButtonClick: onDeleteQueryClick,
}}
selectedDropdownFilter={!isInherited ? platform : undefined}
selectedDropdownFilter={platform}
show0Count
/>
</div>
) : (

View file

@ -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 = ({
<>
<div className="query-name-text">{cellProps.cell.value}</div>
{!isOnlyObserver && cellProps.row.original.observer_can_run && (
<>
<div className="observer-can-run-badge">
<span
className="tooltip-base"
data-tip
data-for={`observer-can-run-tooltip-${cellProps.row.original.id}`}
className="observer-can-run-icon"
data-tooltip-id={`observer-can-run-tooltip-${cellProps.row.original.id}`}
>
<Icon className="query-icon" name="query" size="small" />
<Icon
className="observer-can-run-query-icon"
name="query"
size="small"
color="core-fleet-blue"
/>
</span>
<ReactTooltip
<ReactTooltip5
className="observer-can-run-tooltip"
disableStyleInjection
place="top"
type="dark"
effect="solid"
opacity={1}
id={`observer-can-run-tooltip-${cellProps.row.original.id}`}
backgroundColor={COLORS["tooltip-bg"]}
offset={8}
positionStrategy="fixed"
>
Observers can run this query.
</ReactTooltip>
</>
</ReactTooltip5>
</div>
// <>
// <span
// className="tooltip-base"
// data-tip
// data-for={`observer-can-run-tooltip-${cellProps.row.original.id}`}
// >
// <Icon className="query-icon" name="query" size="small" />
// </span>
// <ReactTooltip
// className="observer-can-run-tooltip"
// place="top"
// type="dark"
// effect="solid"
// id={`observer-can-run-tooltip-${cellProps.row.original.id}`}
// backgroundColor={COLORS["tooltip-bg"]}
// >
// Observers can run this query.
// </ReactTooltip>
// </>
)}
{viewingTeamScope &&
// inherited
cellProps.row.original.team_id !== currentTeamId && (
<InheritedBadge tooltipContent="This query runs on all hosts." />
)}
</>
}
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 <Checkbox {...checkboxProps} />;
},
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 = {

View file

@ -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(

View file

@ -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<IPoliciesApiParams, "query" | "teamId"> {
scope: "teamPoliciesCount";
extends Pick<IPoliciesApiParams, "query" | "teamId" | "mergeInherited"> {
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<ILoadTeamPoliciesResponse> => {
@ -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<ILoadTeamPoliciesResponse> => {
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<IPoliciesCountResponse> => {
const { TEAM_POLICIES } = endpoints;
const path = `${TEAM_POLICIES(teamId)}/count`;
const queryParams = {
query,
mergeInherited,
};
const snakeCaseParams = convertParamsToSnakeCase(queryParams);
const queryString = buildQueryStringFromParams(snakeCaseParams);

View file

@ -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,
};

View file

@ -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);
}
}

View file

@ -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 ||

View file

@ -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, '<deleted>') 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, '<deleted>') 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)
}

View file

@ -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) {

View file

@ -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"
}

View file

@ -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) {

View file

@ -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 {

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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
}

View file

@ -263,7 +263,7 @@ func (s *integrationTestSuite) TestQueryCreationLogsActivity() {
}
var createQueryResp createQueryResponse
s.DoJSON("POST", "/api/latest/fleet/queries", &params, 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)
}

View file

@ -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", &params, 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", &params, 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)
}

View file

@ -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")
}

View file

@ -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 := ""

View file

@ -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)
}

View file

@ -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)

View file

@ -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
}