From 2fc4e520b86369f97a95dd4fda648d2f2cb37b14 Mon Sep 17 00:00:00 2001 From: Gabriel Hernandez Date: Tue, 16 Apr 2024 17:22:08 +0100 Subject: [PATCH] add ability to create manual labels (#18303) relates to #17031 Adds functionality to create manual labels in fleet. - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://fleetdm.com/docs/contributing/committing-changes#changes-files) for more information. - [x] Added/updated tests - [x] M0anual QA for all new/changed functionality --------- Co-authored-by: Martin Angers --- changes/17899-add-manual-labels-api | 1 + changes/issue-17898-new-manual-lables | 1 + cmd/fleetctl/apply_test.go | 62 ++++ cmd/fleetctl/get_test.go | 9 +- frontend/__mocks__/labelsMock.ts | 31 ++ frontend/components/FleetAce/_styles.scss | 4 +- .../components/LiveQuery/SelectTargets.tsx | 14 +- .../LiveQuery/TargetsInput/TargetsInput.tsx | 43 ++- .../TargetsInputHostsTableConfig.tsx | 4 +- .../LiveQuery/TargetsInput/_styles.scss | 10 + .../components/MainContent/MainContent.tsx | 4 +- .../components/forms/FormField/FormField.tsx | 8 +- frontend/interfaces/label.ts | 7 - .../pages/LabelPage/LabelForm/LabelForm.tsx | 285 ------------------ frontend/pages/LabelPage/LabelPage.tsx | 226 -------------- frontend/pages/LabelPage/_styles.scss | 5 - frontend/pages/LabelPage/index.ts | 1 - .../labels/EditLabelPage/EditLabelPage.tsx | 139 +++++++++ .../pages/labels/EditLabelPage/_styles.scss | 5 + frontend/pages/labels/EditLabelPage/index.ts | 1 + .../DynamicLabel/DynamicLabel.tsx | 57 ++++ .../labels/NewLabelPage/DynamicLabel/index.ts | 1 + .../NewLabelPage/ManualLabel/ManualLabel.tsx | 39 +++ .../labels/NewLabelPage/ManualLabel/index.ts | 1 + .../labels/NewLabelPage/NewLabelPage.tsx | 118 ++++++++ .../pages/labels/NewLabelPage/_styles.scss | 15 + frontend/pages/labels/NewLabelPage/index.ts | 1 + .../DynamicLabelForm/DynamicLabelForm.tsx | 153 ++++++++++ .../components/DynamicLabelForm/index.ts | 1 + .../labels/components/LabelForm/LabelForm.tsx | 91 ++++++ .../components}/LabelForm/_styles.scss | 1 + .../components}/LabelForm/index.ts | 0 .../LabelHostTargetTableConfig.tsx | 55 ++++ .../ManualLabelForm/ManualLabelForm.tsx | 153 ++++++++++ .../components/ManualLabelForm/index.ts | 1 + .../PlatformField/PlatformField.tsx | 66 ++++ .../labels/components/PlatformField/index.ts | 1 + frontend/router/index.tsx | 15 +- frontend/router/paths.ts | 6 + frontend/services/entities/hosts.ts | 12 + frontend/services/entities/labels.ts | 99 +++--- frontend/utilities/endpoints.ts | 5 + server/datastore/mysql/hosts.go | 34 ++- server/datastore/mysql/hosts_test.go | 65 ++++ server/datastore/mysql/labels.go | 21 +- server/datastore/mysql/labels_test.go | 26 +- server/datastore/mysql/targets_test.go | 14 +- server/datastore/mysql/unicode_test.go | 2 +- server/fleet/datastore.go | 17 +- server/fleet/labels.go | 24 +- server/fleet/service.go | 6 +- server/mock/datastore_mock.go | 20 +- server/service/hosts.go | 2 +- server/service/integration_core_test.go | 162 ++++++++-- server/service/integration_ddm_test.go | 4 +- server/service/integration_enterprise_test.go | 4 +- server/service/labels.go | 153 +++++++--- server/service/labels_test.go | 16 +- server/service/metrics_labels.go | 12 +- 59 files changed, 1607 insertions(+), 726 deletions(-) create mode 100644 changes/17899-add-manual-labels-api create mode 100644 changes/issue-17898-new-manual-lables create mode 100644 frontend/__mocks__/labelsMock.ts delete mode 100644 frontend/pages/LabelPage/LabelForm/LabelForm.tsx delete mode 100644 frontend/pages/LabelPage/LabelPage.tsx delete mode 100644 frontend/pages/LabelPage/_styles.scss delete mode 100644 frontend/pages/LabelPage/index.ts create mode 100644 frontend/pages/labels/EditLabelPage/EditLabelPage.tsx create mode 100644 frontend/pages/labels/EditLabelPage/_styles.scss create mode 100644 frontend/pages/labels/EditLabelPage/index.ts create mode 100644 frontend/pages/labels/NewLabelPage/DynamicLabel/DynamicLabel.tsx create mode 100644 frontend/pages/labels/NewLabelPage/DynamicLabel/index.ts create mode 100644 frontend/pages/labels/NewLabelPage/ManualLabel/ManualLabel.tsx create mode 100644 frontend/pages/labels/NewLabelPage/ManualLabel/index.ts create mode 100644 frontend/pages/labels/NewLabelPage/NewLabelPage.tsx create mode 100644 frontend/pages/labels/NewLabelPage/_styles.scss create mode 100644 frontend/pages/labels/NewLabelPage/index.ts create mode 100644 frontend/pages/labels/components/DynamicLabelForm/DynamicLabelForm.tsx create mode 100644 frontend/pages/labels/components/DynamicLabelForm/index.ts create mode 100644 frontend/pages/labels/components/LabelForm/LabelForm.tsx rename frontend/pages/{LabelPage => labels/components}/LabelForm/_styles.scss (95%) rename frontend/pages/{LabelPage => labels/components}/LabelForm/index.ts (100%) create mode 100644 frontend/pages/labels/components/ManualLabelForm/LabelHostTargetTableConfig.tsx create mode 100644 frontend/pages/labels/components/ManualLabelForm/ManualLabelForm.tsx create mode 100644 frontend/pages/labels/components/ManualLabelForm/index.ts create mode 100644 frontend/pages/labels/components/PlatformField/PlatformField.tsx create mode 100644 frontend/pages/labels/components/PlatformField/index.ts diff --git a/changes/17899-add-manual-labels-api b/changes/17899-add-manual-labels-api new file mode 100644 index 0000000000..75f2b4ba14 --- /dev/null +++ b/changes/17899-add-manual-labels-api @@ -0,0 +1 @@ +* Updated the `POST /api/v1/fleet/labels` and `PATCH /api/v1/fleet/labels/{id}` endpoints to support creation and update of manual labels. diff --git a/changes/issue-17898-new-manual-lables b/changes/issue-17898-new-manual-lables new file mode 100644 index 0000000000..99c2eaef87 --- /dev/null +++ b/changes/issue-17898-new-manual-lables @@ -0,0 +1 @@ +- implement manual labels in fleet UI diff --git a/cmd/fleetctl/apply_test.go b/cmd/fleetctl/apply_test.go index e2bbcb13b8..b46ac86e99 100644 --- a/cmd/fleetctl/apply_test.go +++ b/cmd/fleetctl/apply_test.go @@ -937,6 +937,37 @@ spec: query: select 1; platforms: - darwin +` + manualLabelSpec = `--- +apiVersion: v1 +kind: label +spec: + name: manual_label + label_membership_type: manual + hosts: + - host1 + platforms: + - darwin +` + emptyManualLabelSpec = `--- +apiVersion: v1 +kind: label +spec: + name: empty_manual_label + label_membership_type: manual + hosts: [] + platforms: + - darwin +` + nohostsManualLabelSpec = `--- +apiVersion: v1 +kind: label +spec: + name: invalid_nohost_manual_label + label_membership_type: manual + hosts: + platforms: + - darwin ` packsSpec = `--- apiVersion: v1 @@ -1509,6 +1540,37 @@ func TestApplyLabels(t *testing.T) { require.Len(t, appliedLabels, 1) assert.Equal(t, "pending_updates", appliedLabels[0].Name) assert.Equal(t, "select 1;", appliedLabels[0].Query) + + appliedLabels = nil + ds.ApplyLabelSpecsFuncInvoked = false + + name = writeTmpYml(t, manualLabelSpec) + + assert.Equal(t, "[+] applied 1 labels\n", runAppForTest(t, []string{"apply", "-f", name})) + assert.True(t, ds.ApplyLabelSpecsFuncInvoked) + require.Len(t, appliedLabels, 1) + assert.Equal(t, "manual_label", appliedLabels[0].Name) + assert.Empty(t, appliedLabels[0].Query) + + appliedLabels = nil + ds.ApplyLabelSpecsFuncInvoked = false + + name = writeTmpYml(t, emptyManualLabelSpec) + + assert.Equal(t, "[+] applied 1 labels\n", runAppForTest(t, []string{"apply", "-f", name})) + assert.True(t, ds.ApplyLabelSpecsFuncInvoked) + require.Len(t, appliedLabels, 1) + assert.Equal(t, "empty_manual_label", appliedLabels[0].Name) + assert.Empty(t, appliedLabels[0].Query) + + appliedLabels = nil + ds.ApplyLabelSpecsFuncInvoked = false + + name = writeTmpYml(t, nohostsManualLabelSpec) + + _, err := runAppNoChecks([]string{"apply", "-f", name}) + require.Error(t, err) + require.ErrorContains(t, err, "declared as manual but contains no `hosts key`") } func TestApplyPacks(t *testing.T) { diff --git a/cmd/fleetctl/get_test.go b/cmd/fleetctl/get_test.go index cd54fc3ed9..160b899857 100644 --- a/cmd/fleetctl/get_test.go +++ b/cmd/fleetctl/get_test.go @@ -944,6 +944,7 @@ apiVersion: v1 kind: label spec: description: some description + hosts: null id: 32 label_membership_type: dynamic name: label1 @@ -954,14 +955,15 @@ apiVersion: v1 kind: label spec: description: some other description + hosts: null id: 33 label_membership_type: dynamic name: label2 platform: linux query: select 42; ` - expectedJson := `{"kind":"label","apiVersion":"v1","spec":{"id":32,"name":"label1","description":"some description","query":"select 1;","platform":"windows","label_membership_type":"dynamic"}} -{"kind":"label","apiVersion":"v1","spec":{"id":33,"name":"label2","description":"some other description","query":"select 42;","platform":"linux","label_membership_type":"dynamic"}} + expectedJson := `{"kind":"label","apiVersion":"v1","spec":{"id":32,"name":"label1","description":"some description","query":"select 1;","platform":"windows","label_membership_type":"dynamic","hosts":null}} +{"kind":"label","apiVersion":"v1","spec":{"id":33,"name":"label2","description":"some other description","query":"select 42;","platform":"linux","label_membership_type":"dynamic","hosts":null}} ` assert.Equal(t, expected, runAppForTest(t, []string{"get", "labels"})) @@ -990,13 +992,14 @@ apiVersion: v1 kind: label spec: description: some description + hosts: null id: 32 label_membership_type: dynamic name: label1 platform: windows query: select 1; ` - expectedJson := `{"kind":"label","apiVersion":"v1","spec":{"id":32,"name":"label1","description":"some description","query":"select 1;","platform":"windows","label_membership_type":"dynamic"}} + expectedJson := `{"kind":"label","apiVersion":"v1","spec":{"id":32,"name":"label1","description":"some description","query":"select 1;","platform":"windows","label_membership_type":"dynamic","hosts":null}} ` assert.Equal(t, expectedYaml, runAppForTest(t, []string{"get", "label", "label1"})) diff --git a/frontend/__mocks__/labelsMock.ts b/frontend/__mocks__/labelsMock.ts new file mode 100644 index 0000000000..35b703a5ce --- /dev/null +++ b/frontend/__mocks__/labelsMock.ts @@ -0,0 +1,31 @@ +import { ILabel } from "interfaces/label"; +import { IGetLabelResonse } from "services/entities/labels"; + +const DEFAULT_LABEL_MOCK: ILabel = { + created_at: "2024-04-12T13:32:00Z", + updated_at: "2024-04-12T14:27:07Z", + id: 1, + name: "test label", + description: "test label description", + query: "SELECT 1;", + platform: "darwin", + label_type: "regular", + label_membership_type: "dynamic", + display_text: "test macsss", + count: 0, + host_ids: null, +}; + +export const createMockLabel = (overrides?: Partial): ILabel => { + return { ...DEFAULT_LABEL_MOCK, ...overrides }; +}; + +const DEFAULT_GET_LABEL_RESPONSE_MOCK: IGetLabelResonse = { + label: createMockLabel(), +}; + +export const createMockGetLabelResponse = ( + overrides?: Partial +): IGetLabelResonse => { + return { ...DEFAULT_GET_LABEL_RESPONSE_MOCK, ...overrides }; +}; diff --git a/frontend/components/FleetAce/_styles.scss b/frontend/components/FleetAce/_styles.scss index 9ab6a639b8..509b01e62a 100644 --- a/frontend/components/FleetAce/_styles.scss +++ b/frontend/components/FleetAce/_styles.scss @@ -4,7 +4,7 @@ font-weight: $bold; &--error { - color: $ui-error; + color: $core-vibrant-red; } &--with-action { justify-content: space-between; @@ -18,7 +18,7 @@ &__wrapper { &--error { .ace-fleet { - border: 1px solid $ui-error; + border: 1px solid $core-vibrant-red; } } } diff --git a/frontend/components/LiveQuery/SelectTargets.tsx b/frontend/components/LiveQuery/SelectTargets.tsx index 79a4f544d5..84039c32ac 100644 --- a/frontend/components/LiveQuery/SelectTargets.tsx +++ b/frontend/components/LiveQuery/SelectTargets.tsx @@ -30,6 +30,7 @@ import Button from "components/buttons/Button"; import Spinner from "components/Spinner"; import TooltipWrapper from "components/TooltipWrapper"; import Icon from "components/Icon"; +import { generateTableHeaders } from "./TargetsInput/TargetsInputHostsTableConfig"; interface ITargetPillSelectorProps { entity: ISelectLabel | ISelectTeam; @@ -305,9 +306,8 @@ const SelectTargets = ({ : setTargetedTeams(newTargets as ITeam[]); }; - const handleRowSelect = (row: Row) => { - const selectedHost = row.original as IHost; - setTargetedHosts((prevHosts) => prevHosts.concat(selectedHost)); + const handleRowSelect = (row: Row) => { + setTargetedHosts((prevHosts) => prevHosts.concat(row.original)); setSearchText(""); // If "all hosts" is already selected when using host target picker, deselect "all hosts" @@ -434,6 +434,9 @@ const SelectTargets = ({ ); } + const resultsTableConfig = generateTableHeaders(); + const selectedHostsTableConfig = generateTableHeaders(handleRowRemove); + return (

Select targets

@@ -451,6 +454,9 @@ const SelectTargets = ({ renderTargetEntityList("Labels", labels.other)}
- ); - }; - - const isBuiltin = - selectedLabel && - (selectedLabel.label_type === "builtin" || selectedLabel.type === "status"); - const isManual = - selectedLabel && selectedLabel.label_membership_type === "manual"; - const headerText = isEdit ? "Edit label" : "New label"; - const saveBtnText = isEdit ? "Update label" : "Save label"; - const saveBtnClass = isEdit ? "update-label-loading" : "save-label-loading"; - const aceHelpText = isEdit - ? "Label queries are immutable. To change the query, delete this label and create a new one." - : ""; - - if (isBuiltin) { - return ( -
-

Built in labels cannot be edited

-
- ); - } - - return ( -
-

{headerText}

- {!isManual && ( - - )} - - {baseError &&
{baseError}
} - - - {!isManual && !isEdit && ( -
- -
- )} - {isEdit && platform && ( -
-

Platform

-

{platform ? PLATFORM_STRINGS[platform] : "All platforms"}

-

- Label platforms are immutable. To change the platform, delete this - label and create a new one. -

-
- )} -
- - -
- - ); -}; - -export default LabelForm; diff --git a/frontend/pages/LabelPage/LabelPage.tsx b/frontend/pages/LabelPage/LabelPage.tsx deleted file mode 100644 index 8257f32972..0000000000 --- a/frontend/pages/LabelPage/LabelPage.tsx +++ /dev/null @@ -1,226 +0,0 @@ -import React, { useState, useContext } from "react"; -import { useQuery } from "react-query"; -import { InjectedRouter, Params } from "react-router/lib/Router"; - -import PATHS from "router/paths"; -import MainContent from "components/MainContent"; -import SidePanelContent from "components/SidePanelContent"; -import QuerySidePanel from "components/side_panels/QuerySidePanel"; -import Spinner from "components/Spinner"; -import { QueryContext } from "context/query"; -import { NotificationContext } from "context/notification"; -import { IApiError } from "interfaces/errors"; -import { ILabel, ILabelFormData } from "interfaces/label"; -import labelsAPI, { ILabelsResponse } from "services/entities/labels"; -import deepDifference from "utilities/deep_difference"; -import useToggleSidePanel from "hooks/useToggleSidePanel"; - -import LabelForm from "./LabelForm"; - -const baseClass = "label-page"; - -interface ILabelPageProps { - router: InjectedRouter; - params: Params; - location: { - pathname: string; - }; -} - -const DEFAULT_CREATE_LABEL_ERRORS = { - name: "", -}; - -const LabelPage = ({ - router, - params, - location, -}: ILabelPageProps): JSX.Element | null => { - const isEditLabel = !location.pathname.includes("new"); - - const [selectedLabel, setSelectedLabel] = useState(); - const { isSidePanelOpen, setSidePanelOpen } = useToggleSidePanel(true); - const [showOpenSchemaActionText, setShowOpenSchemaActionText] = useState( - false - ); - const [labelValidator, setLabelValidator] = useState<{ - [key: string]: string; - }>(DEFAULT_CREATE_LABEL_ERRORS); - const [isUpdatingLabel, setIsUpdatingLabel] = useState(false); - const [isLoading, setIsLoading] = useState(true); - - const { selectedOsqueryTable, setSelectedOsqueryTable } = useContext( - QueryContext - ); - const { renderFlash } = useContext(NotificationContext); - - const { error: labelsError } = useQuery( - ["labels"], - () => labelsAPI.loadAll(), - { - select: (data: ILabelsResponse) => data.labels, - onSuccess: (responseLabels: ILabel[]) => { - if (params.label_id) { - const selectLabel = responseLabels.find( - (label) => label.id === parseInt(params.label_id, 10) - ); - setSelectedLabel(selectLabel); - setIsLoading(false); - } - }, - } - ); - - const onCloseSchemaSidebar = () => { - setSidePanelOpen(false); - setShowOpenSchemaActionText(true); - }; - - const onOpenSchemaSidebar = () => { - setSidePanelOpen(true); - setShowOpenSchemaActionText(false); - }; - - const onOsqueryTableSelect = (tableName: string) => { - setSelectedOsqueryTable(tableName); - }; - - const onEditLabel = (formData: ILabelFormData) => { - if (!selectedLabel) { - console.error("Label isn't available. This should not happen."); - return; - } - - setIsUpdatingLabel(true); - const updateAttrs = deepDifference(formData, selectedLabel); - - labelsAPI - .update(selectedLabel, updateAttrs) - .then(() => { - router.push(PATHS.MANAGE_HOSTS_LABEL(selectedLabel.id)); - renderFlash( - "success", - "Label updated. Try refreshing this page in just a moment to see the updated host count for your label." - ); - }) - .catch((updateError: { data: IApiError }) => { - if (updateError.data.errors[0].reason.includes("Duplicate")) { - setLabelValidator({ - name: "A label with this name already exists", - }); - } else if (updateError.data.errors[0].reason.includes("built-in")) { - setLabelValidator({ - name: "A built-in label with this name already exists", - }); - } else if ( - updateError.data.errors[0].reason.includes( - "Data too long for column 'name'" - ) - ) { - setLabelValidator({ - name: "Label name is too long", - }); - } else if ( - updateError.data.errors[0].reason.includes( - "Data too long for column 'description'" - ) - ) { - setLabelValidator({ - description: "Label description is too long", - }); - } else { - renderFlash("error", "Could not create label. Please try again."); - } - }) - .finally(() => { - setIsUpdatingLabel(false); - }); - }; - - const onAddLabel = (formData: ILabelFormData) => { - setIsUpdatingLabel(true); - - labelsAPI - .create(formData) - .then((label: ILabel) => { - router.push(PATHS.MANAGE_HOSTS_LABEL(label.id)); - renderFlash( - "success", - "Label created. Try refreshing this page in just a moment to see the updated host count for your label." - ); - }) - .catch((updateError: { data: IApiError }) => { - if (updateError.data.errors[0].reason.includes("Duplicate")) { - setLabelValidator({ - name: "A label with this name already exists", - }); - } else if (updateError.data.errors[0].reason.includes("built-in")) { - setLabelValidator({ - name: "A built-in label with this name already exists", - }); - } else if ( - updateError.data.errors[0].reason.includes( - "Data too long for column 'name'" - ) - ) { - setLabelValidator({ - name: "Label name is too long", - }); - } else if ( - updateError.data.errors[0].reason.includes( - "Data too long for column 'description'" - ) - ) { - setLabelValidator({ - description: "Label description is too long", - }); - } else { - renderFlash("error", "Could not create label. Please try again."); - } - }) - .finally(() => { - setIsUpdatingLabel(false); - }); - }; - - const onCancelLabel = () => { - router.goBack(); - }; - - return ( - <> - -
- {isLoading ? ( - - ) : ( - - )} -
-
- {isSidePanelOpen && !isEditLabel && ( - - - - )} - - ); -}; - -export default LabelPage; diff --git a/frontend/pages/LabelPage/_styles.scss b/frontend/pages/LabelPage/_styles.scss deleted file mode 100644 index 31361494f8..0000000000 --- a/frontend/pages/LabelPage/_styles.scss +++ /dev/null @@ -1,5 +0,0 @@ -.label-page { - &__sandboxMode { - margin-top: 70px; - } -} diff --git a/frontend/pages/LabelPage/index.ts b/frontend/pages/LabelPage/index.ts deleted file mode 100644 index 45649039fd..0000000000 --- a/frontend/pages/LabelPage/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./LabelPage"; diff --git a/frontend/pages/labels/EditLabelPage/EditLabelPage.tsx b/frontend/pages/labels/EditLabelPage/EditLabelPage.tsx new file mode 100644 index 0000000000..3d31896870 --- /dev/null +++ b/frontend/pages/labels/EditLabelPage/EditLabelPage.tsx @@ -0,0 +1,139 @@ +import React, { useContext } from "react"; +import { useQuery } from "react-query"; +import { RouteComponentProps } from "react-router"; +import { AxiosError } from "axios"; + +import PATHS from "router/paths"; +import labelsAPI, { IGetLabelResonse } from "services/entities/labels"; +import hostAPI from "services/entities/hosts"; +import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants"; +import { ILabel } from "interfaces/label"; +import { IHost } from "interfaces/host"; +import { NotificationContext } from "context/notification"; + +import MainContent from "components/MainContent"; +import Spinner from "components/Spinner"; +import DataError from "components/DataError"; + +import DynamicLabelForm from "../components/DynamicLabelForm"; +import ManualLabelForm from "../components/ManualLabelForm"; +import { IDynamicLabelFormData } from "../components/DynamicLabelForm/DynamicLabelForm"; +import { IManualLabelFormData } from "../components/ManualLabelForm/ManualLabelForm"; + +const baseClass = "edit-label-page"; + +interface IEditLabelPageRouteParams { + label_id: string; +} + +type IEditLabelPageProps = RouteComponentProps< + never, + IEditLabelPageRouteParams +>; + +const EditLabelPage = ({ routeParams, router }: IEditLabelPageProps) => { + const { renderFlash } = useContext(NotificationContext); + + const labelId = parseInt(routeParams.label_id, 10); + + const { + data: label, + isLoading: isLoadingLabel, + isError: isErrorLabel, + } = useQuery( + ["label", labelId], + () => labelsAPI.getLabel(labelId), + { + ...DEFAULT_USE_QUERY_OPTIONS, + select: (data) => data.label, + } + ); + + // TODO: clean this up when API allows getting hosts by + // host ids in a single request. We need to make another request when + // the label is manual to get the host data for the targeted hosts. + const { + data: targetedHosts, + isLoading: isLoadingHosts, + isError: isErrorHosts, + } = useQuery<{ host: IHost }[], AxiosError, IHost[]>( + ["hosts"], + () => { + return hostAPI.getHosts(label?.host_ids ?? []); + }, + { + ...DEFAULT_USE_QUERY_OPTIONS, + select: (res) => res.map((host) => host.host), + enabled: label?.label_membership_type === "manual", + } + ); + + const onCancelEdit = () => { + router.goBack(); + }; + + const onUpdateLabel = async ( + formData: IDynamicLabelFormData | IManualLabelFormData + ) => { + try { + const res = await labelsAPI.update(labelId, formData); + router.push(PATHS.MANAGE_HOSTS_LABEL(res.label.id)); + renderFlash("success", "Label updated successfully."); + } catch { + renderFlash("error", "Couldn't edit label. Please try again."); + } + }; + + const renderContent = () => { + if (isLoadingLabel || isLoadingHosts) { + return ; + } + + if (isErrorLabel || isErrorHosts) { + return ; + } + + if (!label) return null; + + if (label.label_type === "builtin") { + return ( + + ); + } + + return label.label_membership_type === "dynamic" ? ( + + ) : ( + + ); + }; + + return ( + <> + +

Edit label

+ {renderContent()} +
+ + ); +}; + +export default EditLabelPage; diff --git a/frontend/pages/labels/EditLabelPage/_styles.scss b/frontend/pages/labels/EditLabelPage/_styles.scss new file mode 100644 index 0000000000..7de738f81f --- /dev/null +++ b/frontend/pages/labels/EditLabelPage/_styles.scss @@ -0,0 +1,5 @@ +.edit-label-page { + h1 { + margin-bottom: $pad-xxlarge; + } +} diff --git a/frontend/pages/labels/EditLabelPage/index.ts b/frontend/pages/labels/EditLabelPage/index.ts new file mode 100644 index 0000000000..724c96f3d5 --- /dev/null +++ b/frontend/pages/labels/EditLabelPage/index.ts @@ -0,0 +1 @@ +export { default } from "./EditLabelPage"; diff --git a/frontend/pages/labels/NewLabelPage/DynamicLabel/DynamicLabel.tsx b/frontend/pages/labels/NewLabelPage/DynamicLabel/DynamicLabel.tsx new file mode 100644 index 0000000000..8f3237d278 --- /dev/null +++ b/frontend/pages/labels/NewLabelPage/DynamicLabel/DynamicLabel.tsx @@ -0,0 +1,57 @@ +import React, { useContext } from "react"; +import { RouteComponentProps } from "react-router"; + +import PATHS from "router/paths"; +import labelsAPI from "services/entities/labels"; +import { NotificationContext } from "context/notification"; + +import DynamicLabelForm from "pages/labels/components/DynamicLabelForm"; +import { IDynamicLabelFormData } from "pages/labels/components/DynamicLabelForm/DynamicLabelForm"; + +const baseClass = "dynamic-label"; + +const DEFAULT_QUERY = "SELECT 1 FROM os_version WHERE major >= 13;"; + +type IDynamicLabelProps = RouteComponentProps & { + showOpenSidebarButton: boolean; + onOpenSidebar: () => void; + onOsqueryTableSelect: (tableName: string) => void; +}; + +const DynamicLabel = ({ + showOpenSidebarButton, + router, + onOpenSidebar, + onOsqueryTableSelect, +}: IDynamicLabelProps) => { + const { renderFlash } = useContext(NotificationContext); + + const onSaveNewLabel = async (formData: IDynamicLabelFormData) => { + try { + const res = await labelsAPI.create(formData); + router.push(PATHS.MANAGE_HOSTS_LABEL(res.label.id)); + renderFlash("success", "Label added successfully."); + } catch { + renderFlash("error", "Couldn't add label. Please try again."); + } + }; + + const onCancelLabel = () => { + router.goBack(); + }; + + return ( +
+ +
+ ); +}; + +export default DynamicLabel; diff --git a/frontend/pages/labels/NewLabelPage/DynamicLabel/index.ts b/frontend/pages/labels/NewLabelPage/DynamicLabel/index.ts new file mode 100644 index 0000000000..4de9903772 --- /dev/null +++ b/frontend/pages/labels/NewLabelPage/DynamicLabel/index.ts @@ -0,0 +1 @@ +export { default } from "./DynamicLabel"; diff --git a/frontend/pages/labels/NewLabelPage/ManualLabel/ManualLabel.tsx b/frontend/pages/labels/NewLabelPage/ManualLabel/ManualLabel.tsx new file mode 100644 index 0000000000..1dd4553f7a --- /dev/null +++ b/frontend/pages/labels/NewLabelPage/ManualLabel/ManualLabel.tsx @@ -0,0 +1,39 @@ +import React, { useContext } from "react"; +import { RouteComponentProps } from "react-router"; + +import PATHS from "router/paths"; +import labelsAPI from "services/entities/labels"; +import { NotificationContext } from "context/notification"; + +import ManualLabelForm from "pages/labels/components/ManualLabelForm"; +import { IManualLabelFormData } from "pages/labels/components/ManualLabelForm/ManualLabelForm"; + +const baseClass = "manual-label"; + +type IManualLabelProps = RouteComponentProps; + +const ManualLabel = ({ router }: IManualLabelProps) => { + const { renderFlash } = useContext(NotificationContext); + + const onSaveNewLabel = async (formData: IManualLabelFormData) => { + try { + const res = await labelsAPI.create(formData); + router.push(PATHS.MANAGE_HOSTS_LABEL(res.label.id)); + renderFlash("success", "Label added successfully."); + } catch { + renderFlash("error", "Couldn't add label. Please try again."); + } + }; + + const onCancelLabel = () => { + router.goBack(); + }; + + return ( +
+ +
+ ); +}; + +export default ManualLabel; diff --git a/frontend/pages/labels/NewLabelPage/ManualLabel/index.ts b/frontend/pages/labels/NewLabelPage/ManualLabel/index.ts new file mode 100644 index 0000000000..6b1bcf6059 --- /dev/null +++ b/frontend/pages/labels/NewLabelPage/ManualLabel/index.ts @@ -0,0 +1 @@ +export { default } from "./ManualLabel"; diff --git a/frontend/pages/labels/NewLabelPage/NewLabelPage.tsx b/frontend/pages/labels/NewLabelPage/NewLabelPage.tsx new file mode 100644 index 0000000000..1f18639c42 --- /dev/null +++ b/frontend/pages/labels/NewLabelPage/NewLabelPage.tsx @@ -0,0 +1,118 @@ +import React, { useCallback, useContext, useState } from "react"; +import { Tab, TabList, Tabs } from "react-tabs"; + +import { QueryContext } from "context/query"; +import useToggleSidePanel from "hooks/useToggleSidePanel"; + +import MainContent from "components/MainContent"; +import SidePanelContent from "components/SidePanelContent"; +import TabsWrapper from "components/TabsWrapper"; +import QuerySidePanel from "components/side_panels/QuerySidePanel"; + +import PATHS from "router/paths"; +import { RouteComponentProps } from "react-router"; + +interface ILabelSubNavItem { + name: string; + pathname: string; +} + +const labelSubNav: ILabelSubNavItem[] = [ + { + name: "Dynamic", + pathname: PATHS.LABEL_NEW_DYNAMIC, + }, + { + name: "Manual", + pathname: PATHS.LABEL_NEW_MANUAL, + }, +]; + +const getTabIndex = (path: string): number => { + return labelSubNav.findIndex((navItem) => { + // tab stays highlighted for paths that start with same pathname + return path.startsWith(navItem.pathname); + }); +}; + +const baseClass = "new-label-page"; + +interface INewLabelPageProps extends RouteComponentProps { + children: JSX.Element; +} + +const NewLabelPage = ({ router, location, children }: INewLabelPageProps) => { + const { selectedOsqueryTable, setSelectedOsqueryTable } = useContext( + QueryContext + ); + const { isSidePanelOpen, setSidePanelOpen } = useToggleSidePanel(true); + const [showOpenSidebarButton, setShowOpenSidebarButton] = useState(false); + + const isDynamicLabel = location.pathname.includes("dynamic"); + + const navigateToNav = useCallback( + (i: number): void => { + router.replace(labelSubNav[i].pathname); + }, + [router] + ); + + const onCloseSidebar = () => { + setSidePanelOpen(false); + setShowOpenSidebarButton(true); + }; + + const onOpenSidebar = () => { + setSidePanelOpen(true); + setShowOpenSidebarButton(false); + }; + + const onOsqueryTableSelect = (tableName: string) => { + setSelectedOsqueryTable(tableName); + }; + + return ( + <> + +

Add label

+

+ Dynamic (smart) labels are assigned to hosts if the query returns + results. Manual labels are assigned to selected hosts. +

+ + + + {labelSubNav.map((navItem) => { + return ( + + {navItem.name} + + ); + })} + + + + {React.cloneElement(children, { + showOpenSidebarButton, + onOpenSidebar, + onOsqueryTableSelect, + })} +
+ {isDynamicLabel && isSidePanelOpen && ( + + + + )} + + ); +}; + +export default NewLabelPage; diff --git a/frontend/pages/labels/NewLabelPage/_styles.scss b/frontend/pages/labels/NewLabelPage/_styles.scss new file mode 100644 index 0000000000..637d670a31 --- /dev/null +++ b/frontend/pages/labels/NewLabelPage/_styles.scss @@ -0,0 +1,15 @@ +.new-label-page { + h1 { + margin-bottom: $pad-large; + } + + &__page-description { + margin: 0; + color: $ui-fleet-black-75; + font-size: $xx-small; + } + + &__new-label-tabs-wrapper { + margin-bottom: $pad-xxlarge; + } +} diff --git a/frontend/pages/labels/NewLabelPage/index.ts b/frontend/pages/labels/NewLabelPage/index.ts new file mode 100644 index 0000000000..1cb40e1b40 --- /dev/null +++ b/frontend/pages/labels/NewLabelPage/index.ts @@ -0,0 +1 @@ +export { default } from "./NewLabelPage"; diff --git a/frontend/pages/labels/components/DynamicLabelForm/DynamicLabelForm.tsx b/frontend/pages/labels/components/DynamicLabelForm/DynamicLabelForm.tsx new file mode 100644 index 0000000000..30982498d6 --- /dev/null +++ b/frontend/pages/labels/components/DynamicLabelForm/DynamicLabelForm.tsx @@ -0,0 +1,153 @@ +import React, { useState } from "react"; +import { useDebouncedCallback } from "use-debounce"; +import { IAceEditor } from "react-ace/lib/types"; + +// @ts-ignore +import validateQuery from "components/forms/validators/validate_query"; +import FleetAce from "components/FleetAce"; +import Button from "components/buttons/Button"; +import Icon from "components/Icon"; + +import LabelForm from "../LabelForm"; +import { ILabelFormData } from "../LabelForm/LabelForm"; +import PlatformField from "../PlatformField"; + +const baseClass = "dynamic-label-form"; + +const IMMUTABLE_QUERY_HELP_TEXT = + "Label queries are immutable. To change the query, delete this label and create a new one."; + +export interface IDynamicLabelFormData { + name: string; + description: string; + query: string; + platform: string; +} + +interface IDynamicLabelFormProps { + defaultName?: string; + defaultDescription?: string; + defaultQuery?: string; + defaultPlatform?: string; + showOpenSidebarButton?: boolean; + isEditing?: boolean; + onOpenSidebar?: () => void; + onOsqueryTableSelect?: (tableName: string) => void; + onSave: (formData: IDynamicLabelFormData) => void; + onCancel: () => void; +} + +const DynamicLabelForm = ({ + defaultName = "", + defaultDescription = "", + defaultQuery = "", + defaultPlatform = "", + isEditing = false, + showOpenSidebarButton = false, + onOpenSidebar, + onOsqueryTableSelect, + onSave, + onCancel, +}: IDynamicLabelFormProps) => { + const [query, setQuery] = useState(defaultQuery); + const [platform, setPlatform] = useState(defaultPlatform); + const [queryError, setQueryError] = useState(null); + + const debounceValidateSQL = useDebouncedCallback((queryString: string) => { + const { error } = validateQuery(queryString); + if (query === "" || error === "") { + setQueryError(null); + } else { + setQueryError(error); + } + }, 500); + + const onQueryChange = (newQuery: string) => { + setQuery(newQuery); + debounceValidateSQL(newQuery); + }; + + const onSaveForm = ( + labelFormData: ILabelFormData, + labelFormDataValid: boolean + ) => { + const { error } = validateQuery(query); + if (error) { + setQueryError(error); + } else if (labelFormDataValid) { + // values from LabelForm component must be valid too + onSave({ ...labelFormData, query, platform }); + } + }; + + const renderLabelComponent = (): JSX.Element | null => { + if (!showOpenSidebarButton) { + return null; + } + + return ( + + ); + }; + + const onLoad = (editor: IAceEditor) => { + editor.setOptions({ + enableLinking: true, + enableMultiselect: false, // Disables command + click creating multiple cursors + }); + + // @ts-expect-error + // the string "linkClick" is not officially in the lib but we need it + editor.on("linkClick", (data) => { + const { type, value } = data.token; + + if (type === "osquery-token" && onOsqueryTableSelect) { + return onOsqueryTableSelect(value); + } + + return false; + }); + }; + + const onChangePlatform = (value: string) => { + setPlatform(value); + }; + + return ( +
+ + + + + } + /> +
+ ); +}; + +export default DynamicLabelForm; diff --git a/frontend/pages/labels/components/DynamicLabelForm/index.ts b/frontend/pages/labels/components/DynamicLabelForm/index.ts new file mode 100644 index 0000000000..7a8745fbc8 --- /dev/null +++ b/frontend/pages/labels/components/DynamicLabelForm/index.ts @@ -0,0 +1 @@ +export { default } from "./DynamicLabelForm"; diff --git a/frontend/pages/labels/components/LabelForm/LabelForm.tsx b/frontend/pages/labels/components/LabelForm/LabelForm.tsx new file mode 100644 index 0000000000..4802c086de --- /dev/null +++ b/frontend/pages/labels/components/LabelForm/LabelForm.tsx @@ -0,0 +1,91 @@ +import React, { ReactNode, useState } from "react"; + +import validate_presence from "components/forms/validators/validate_presence"; + +// @ts-ignore +import InputField from "components/forms/fields/InputField"; +import Button from "components/buttons/Button"; + +export interface ILabelFormData { + name: string; + description: string; +} + +interface ILabelFormProps { + defaultName?: string; + defaultDescription?: string; + additionalFields?: ReactNode; + isUpdatingLabel?: boolean; + onCancel: () => void; + onSave: (formData: ILabelFormData, isValid: boolean) => void; +} + +const baseClass = "label-form"; + +const LabelForm = ({ + defaultName = "", + defaultDescription = "", + additionalFields, + isUpdatingLabel, + onCancel, + onSave, +}: ILabelFormProps) => { + const [name, setName] = useState(defaultName); + const [description, setDescription] = useState(defaultDescription); + const [nameError, setNameError] = useState(""); + + const onNameChange = (value: string) => { + setName(value); + setNameError(null); + }; + + const onDescriptionChange = (value: string) => { + setDescription(value); + }; + + const onSubmitForm = (evt: React.FormEvent) => { + evt.preventDefault(); + + let isFormValid = true; + if (!validate_presence(name)) { + setNameError("Label name must be present"); + isFormValid = false; + } + + onSave({ name, description }, isFormValid); + }; + + return ( +
+ + + {additionalFields} +
+ + +
+ + ); +}; + +export default LabelForm; diff --git a/frontend/pages/LabelPage/LabelForm/_styles.scss b/frontend/pages/labels/components/LabelForm/_styles.scss similarity index 95% rename from frontend/pages/LabelPage/LabelForm/_styles.scss rename to frontend/pages/labels/components/LabelForm/_styles.scss index 2d6412dbfd..465648eb8c 100644 --- a/frontend/pages/LabelPage/LabelForm/_styles.scss +++ b/frontend/pages/labels/components/LabelForm/_styles.scss @@ -2,6 +2,7 @@ &__label-title, &__label-description { width: 100%; + resize: vertical; } &__label-platform { diff --git a/frontend/pages/LabelPage/LabelForm/index.ts b/frontend/pages/labels/components/LabelForm/index.ts similarity index 100% rename from frontend/pages/LabelPage/LabelForm/index.ts rename to frontend/pages/labels/components/LabelForm/index.ts diff --git a/frontend/pages/labels/components/ManualLabelForm/LabelHostTargetTableConfig.tsx b/frontend/pages/labels/components/ManualLabelForm/LabelHostTargetTableConfig.tsx new file mode 100644 index 0000000000..e220c2f6db --- /dev/null +++ b/frontend/pages/labels/components/ManualLabelForm/LabelHostTargetTableConfig.tsx @@ -0,0 +1,55 @@ +/* eslint-disable react/prop-types */ + +import React from "react"; +import { Column, Row } from "react-table"; + +import { IStringCellProps } from "interfaces/datatable_config"; +import { IHost } from "interfaces/host"; + +import TextCell from "components/TableContainer/DataTable/TextCell"; +import Icon from "components/Icon/Icon"; + +export type ITargestInputHostTableConfig = Column; +type ITableStringCellProps = IStringCellProps; + +// NOTE: cellProps come from react-table +// more info here https://react-table.tanstack.com/docs/api/useTable#cell-properties +export const generateTableHeaders = ( + handleRowRemove?: (value: Row) => void +): ITargestInputHostTableConfig[] => { + const deleteHeader = handleRowRemove + ? [ + { + id: "delete", + Header: "", + Cell: (cellProps: ITableStringCellProps) => ( +
handleRowRemove(cellProps.row)}> + +
+ ), + disableHidden: true, + }, + ] + : []; + + return [ + { + Header: "Host", + accessor: "display_name", + Cell: (cellProps) => , + }, + { + Header: "Hostname", + accessor: "hostname", + Cell: (cellProps) => , + }, + { + Header: "Serial number", + accessor: "hardware_serial", + Cell: (cellProps) => , + }, + ...deleteHeader, + ]; +}; + +export default null; diff --git a/frontend/pages/labels/components/ManualLabelForm/ManualLabelForm.tsx b/frontend/pages/labels/components/ManualLabelForm/ManualLabelForm.tsx new file mode 100644 index 0000000000..77b243a337 --- /dev/null +++ b/frontend/pages/labels/components/ManualLabelForm/ManualLabelForm.tsx @@ -0,0 +1,153 @@ +import React, { useEffect, useState } from "react"; +import { useQuery } from "react-query"; +import { Row } from "react-table"; +import { useDebouncedCallback } from "use-debounce"; + +import { IHost } from "interfaces/host"; +import targetsAPI, { ITargetsSearchResponse } from "services/entities/targets"; + +import TargetsInput from "components/LiveQuery/TargetsInput"; + +import LabelForm from "../LabelForm"; +import { ILabelFormData } from "../LabelForm/LabelForm"; +import { generateTableHeaders } from "./LabelHostTargetTableConfig"; + +const baseClass = "ManualLabelForm"; + +const LABEL_TARGET_HOSTS_INPUT_LABEL = "Select hosts"; +const LABEL_TARGET_HOSTS_INPUT_PLACEHOLDER = + "Search name, hostname, or serial number"; +const DEBOUNCE_DELAY = 500; + +export interface IManualLabelFormData { + name: string; + description: string; + targetedHosts: IHost[]; +} + +interface ITargetsQueryKey { + scope: string; + query?: string | null; + excludedHostIds?: number[]; +} + +interface IManualLabelFormProps { + defaultName?: string; + defaultDescription?: string; + defaultTargetedHosts?: IHost[]; + onSave: (formData: IManualLabelFormData) => void; + onCancel: () => void; +} + +const ManualLabelForm = ({ + defaultName = "", + defaultDescription = "", + defaultTargetedHosts = [], + onSave, + onCancel, +}: IManualLabelFormProps) => { + const [searchQuery, setSearchQuery] = useState(""); + const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(""); + const [isDebouncing, setIsDebouncing] = useState(false); + const [targetedHosts, setTargetedHosts] = useState( + defaultTargetedHosts + ); + + const targetdHostsIds = targetedHosts.map((host) => host.id); + + const debounceSearch = useDebouncedCallback( + (search: string) => { + setDebouncedSearchQuery(search); + setIsDebouncing(false); + }, + DEBOUNCE_DELAY, + { trailing: true } + ); + + // TODO: find a better way to debounce search requests + useEffect(() => { + setIsDebouncing(true); + debounceSearch(searchQuery); + }, [debounceSearch, searchQuery]); + + const { + data: hostTargets, + isLoading: isLoadingSearchResults, + isError: isErrorSearchResults, + } = useQuery( + [ + { + scope: "labels-targets-search", + query: debouncedSearchQuery, + excludedHostIds: targetdHostsIds, + }, + ], + ({ queryKey }) => { + const { query, excludedHostIds } = queryKey[0]; + return targetsAPI.search({ + query: query ?? "", + excluded_host_ids: excludedHostIds ?? null, + }); + }, + { + select: (data) => data.hosts, + enabled: searchQuery !== "", + } + ); + + const onHostSelect = (row: Row) => { + setTargetedHosts((prevHosts) => prevHosts.concat(row.original)); + setSearchQuery(""); + }; + + const onHostRemove = (row: Row) => { + setTargetedHosts((prevHosts) => + prevHosts.filter((h) => h.id !== row.original.id) + ); + }; + + const onSaveNewLabel = ( + labelFormData: ILabelFormData, + labelFormDataValid: boolean + ) => { + if (labelFormDataValid) { + // values from LabelForm component must be valid too + onSave({ ...labelFormData, targetedHosts }); + } + }; + + const onChangeSearchQuery = (value: string) => { + setSearchQuery(value); + }; + + const resultsTableConfig = generateTableHeaders(); + const selectedHostsTableConfig = generateTableHeaders(onHostRemove); + + return ( +
+ + } + /> +
+ ); +}; + +export default ManualLabelForm; diff --git a/frontend/pages/labels/components/ManualLabelForm/index.ts b/frontend/pages/labels/components/ManualLabelForm/index.ts new file mode 100644 index 0000000000..7e9577641f --- /dev/null +++ b/frontend/pages/labels/components/ManualLabelForm/index.ts @@ -0,0 +1 @@ +export { default } from "./ManualLabelForm"; diff --git a/frontend/pages/labels/components/PlatformField/PlatformField.tsx b/frontend/pages/labels/components/PlatformField/PlatformField.tsx new file mode 100644 index 0000000000..288a446347 --- /dev/null +++ b/frontend/pages/labels/components/PlatformField/PlatformField.tsx @@ -0,0 +1,66 @@ +import React from "react"; +import { noop } from "lodash"; + +// @ts-ignore +import Dropdown from "components/forms/fields/Dropdown"; +import FormField from "components/forms/FormField"; + +const PLATFORM_STRINGS: { [key: string]: string } = { + darwin: "macOS", + windows: "MS Windows", + ubuntu: "Ubuntu Linux", + centos: "CentOS Linux", +}; + +const platformOptions = [ + { label: "All platforms", value: "" }, + { label: "macOS", value: "darwin" }, + { label: "Windows", value: "windows" }, + { label: "Ubuntu", value: "ubuntu" }, + { label: "Centos", value: "centos" }, +]; + +const baseClass = "platform-field"; + +interface IPlatformFieldProps { + platform: string; + isEditing?: boolean; + onChange?: (platform: string) => void; +} + +const PlatformField = ({ + platform, + isEditing = false, + onChange = noop, +}: IPlatformFieldProps) => { + return ( +
+ {!isEditing ? ( +
+ +
+ ) : ( + + <> +

{platform ? PLATFORM_STRINGS[platform] : "All platforms"}

+ +
+ )} +
+ ); +}; + +export default PlatformField; diff --git a/frontend/pages/labels/components/PlatformField/index.ts b/frontend/pages/labels/components/PlatformField/index.ts new file mode 100644 index 0000000000..eb47dfd447 --- /dev/null +++ b/frontend/pages/labels/components/PlatformField/index.ts @@ -0,0 +1 @@ +export { default } from "./PlatformField"; diff --git a/frontend/router/index.tsx b/frontend/router/index.tsx index ea74585b8c..5bf047eb04 100644 --- a/frontend/router/index.tsx +++ b/frontend/router/index.tsx @@ -25,7 +25,10 @@ import EmailTokenRedirect from "components/EmailTokenRedirect"; import ForgotPasswordPage from "pages/ForgotPasswordPage"; import GatedLayout from "layouts/GatedLayout"; import HostDetailsPage from "pages/hosts/details/HostDetailsPage"; -import LabelPage from "pages/LabelPage"; +import NewLabelPage from "pages/labels/NewLabelPage"; +import DynamicLabel from "pages/labels/NewLabelPage/DynamicLabel"; +import ManualLabel from "pages/labels/NewLabelPage/ManualLabel"; +import EditLabelPage from "pages/labels/EditLabelPage"; import LoginPage, { LoginPreviewPage } from "pages/LoginPage"; import LogoutPage from "pages/LogoutPage"; import ManageHostsPage from "pages/hosts/ManageHostsPage"; @@ -169,9 +172,13 @@ const routes = ( - - - + + + + + + + diff --git a/frontend/router/paths.ts b/frontend/router/paths.ts index 44add82148..b6a9cb3684 100644 --- a/frontend/router/paths.ts +++ b/frontend/router/paths.ts @@ -17,6 +17,7 @@ export default { CONTROLS_SETUP_ASSITANT: `${URL_PREFIX}/controls/setup-experience/setup-assistant`, CONTROLS_SCRIPTS: `${URL_PREFIX}/controls/scripts`, + // Dashboard pages DASHBOARD: `${URL_PREFIX}/dashboard`, DASHBOARD_LINUX: `${URL_PREFIX}/dashboard/linux`, DASHBOARD_MAC: `${URL_PREFIX}/dashboard/mac`, @@ -65,6 +66,11 @@ export default { return `${URL_PREFIX}/software/vulnerabilities/${cve}`; }, + // Label pages + LABEL_NEW_DYNAMIC: `${URL_PREFIX}/labels/new/dynamic`, + LABEL_NEW_MANUAL: `${URL_PREFIX}/labels/new/manual`, + LABEL_EDIT: (labelId: number) => `${URL_PREFIX}/labels/${labelId}`, + EDIT_PACK: (packId: number): string => { return `${URL_PREFIX}/packs/${packId}/edit`; }, diff --git a/frontend/services/entities/hosts.ts b/frontend/services/entities/hosts.ts index cd7be3beb2..557e4fe18e 100644 --- a/frontend/services/entities/hosts.ts +++ b/frontend/services/entities/hosts.ts @@ -315,6 +315,18 @@ export default { return sendRequest("GET", path); }, + // TODO: change/remove this when backend implments way for client to get + // a collection of hosts based on ho st ids + getHosts: (hostIds: number[]) => { + return Promise.all( + hostIds.map((hostId) => { + const { HOSTS } = endpoints; + const path = `${HOSTS}/${hostId}`; + return sendRequest("GET", path); + }) + ); + }, + loadHosts: ({ page = 0, perPage = 100, diff --git a/frontend/services/entities/labels.ts b/frontend/services/entities/labels.ts index 5e077199df..7a58cb9a04 100644 --- a/frontend/services/entities/labels.ts +++ b/frontend/services/entities/labels.ts @@ -2,7 +2,10 @@ import sendRequest from "services"; import endpoints from "utilities/endpoints"; import helpers from "utilities/helpers"; -import { ILabel, ILabelFormData, ILabelSummary } from "interfaces/label"; +import { ILabel, ILabelSummary } from "interfaces/label"; +import { IDynamicLabelFormData } from "pages/labels/components/DynamicLabelForm/DynamicLabelForm"; +import { IManualLabelFormData } from "pages/labels/components/ManualLabelForm/ManualLabelForm"; +import { IHost } from "interfaces/host"; export interface ILabelsResponse { labels: ILabel[]; @@ -12,27 +15,49 @@ export interface ILabelsSummaryResponse { labels: ILabelSummary[]; } +export interface ICreateLabelResponse { + label: ILabel; +} +export type IUpdateLabelResponse = ICreateLabelResponse; +export type IGetLabelResonse = ICreateLabelResponse; + +const isManualLabelFormData = ( + formData: IDynamicLabelFormData | IManualLabelFormData +): formData is IManualLabelFormData => { + return "targetedHosts" in formData; +}; + +const getUniqueHostIdentifier = (host: IHost) => { + return host.hardware_serial || host.uuid || host.hostname; +}; + +const generateCreateLabelBody = ( + formData: IDynamicLabelFormData | IManualLabelFormData +) => { + // we need to prepare the post body for only manual labels. + if (isManualLabelFormData(formData)) { + return { + name: formData.name, + description: formData.description, + hosts: formData.targetedHosts.map((host) => + getUniqueHostIdentifier(host) + ), + }; + } + return formData; +}; + +const generateUpdateLabelBody = generateCreateLabelBody; + export default { - create: async (formData: ILabelFormData) => { + create: ( + formData: IDynamicLabelFormData | IManualLabelFormData + ): Promise => { const { LABELS } = endpoints; - - try { - const { label: createdLabel } = await sendRequest( - "POST", - LABELS, - formData - ); - - return { - ...createdLabel, - slug: helpers.labelSlug(createdLabel), - type: "custom", - }; - } catch (error) { - console.error(error); - throw error; - } + const postBody = generateCreateLabelBody(formData); + return sendRequest("POST", LABELS, postBody); }, + destroy: (label: ILabel) => { const { LABELS } = endpoints; const path = `${LABELS}/id/${label.id}`; @@ -52,34 +77,28 @@ export default { } }, summary: (): Promise => { - const { LABELS } = endpoints; - const path = `${LABELS}/summary`; + const { LABELS_SUMMARY } = endpoints; - return sendRequest("GET", path); + return sendRequest("GET", LABELS_SUMMARY); }, - update: async (label: ILabel, updatedAttrs: ILabel) => { - const { LABELS } = endpoints; - const path = `${LABELS}/${label.id}`; - try { - const { label: updatedLabel } = await sendRequest( - "PATCH", - path, - updatedAttrs - ); - return { - ...updatedLabel, - slug: helpers.labelSlug(updatedLabel), - type: "custom", - }; - } catch (error) { - console.error(error); - throw error; - } + update: async ( + labelId: number, + formData: IDynamicLabelFormData | IManualLabelFormData + ): Promise => { + const { LABEL } = endpoints; + const updateAttrs = generateUpdateLabelBody(formData); + return sendRequest("PATCH", LABEL(labelId), updateAttrs); }, + specByName: (labelName: string) => { const { LABEL_SPEC_BY_NAME } = endpoints; const path = LABEL_SPEC_BY_NAME(labelName); return sendRequest("GET", path); }, + + getLabel: (labelId: number): Promise => { + const { LABEL } = endpoints; + return sendRequest("GET", LABEL(labelId)); + }, }; diff --git a/frontend/utilities/endpoints.ts b/frontend/utilities/endpoints.ts index f1169b31e0..5b82ebfbe2 100644 --- a/frontend/utilities/endpoints.ts +++ b/frontend/utilities/endpoints.ts @@ -48,13 +48,18 @@ export default { `/${API_VERSION}/fleet/hosts/${hostId}/configuration_profiles/resend/${profileUUID}`, INVITES: `/${API_VERSION}/fleet/invites`, + + // labels + LABEL: (id: number) => `/${API_VERSION}/fleet/labels/${id}`, LABELS: `/${API_VERSION}/fleet/labels`, + LABELS_SUMMARY: `/${API_VERSION}/fleet/labels/summary`, LABEL_HOSTS: (id: number): string => { return `/${API_VERSION}/fleet/labels/${id}/hosts`; }, LABEL_SPEC_BY_NAME: (labelName: string) => { return `/${API_VERSION}/fleet/spec/labels/${labelName}`; }, + LOGIN: `/${API_VERSION}/fleet/login`, LOGOUT: `/${API_VERSION}/fleet/logout`, MACADMINS: `/${API_VERSION}/fleet/macadmins`, diff --git a/server/datastore/mysql/hosts.go b/server/datastore/mysql/hosts.go index f3d66c7f4e..9c799edf59 100644 --- a/server/datastore/mysql/hosts.go +++ b/server/datastore/mysql/hosts.go @@ -764,7 +764,7 @@ const hostMDMSelect = `, ELSE NULL END, 'dep_profile_error', - CASE + CASE WHEN hdep.assign_profile_response = '` + string(fleet.DEPAssignProfileResponseFailed) + `' THEN CAST(TRUE AS JSON) ELSE CAST(FALSE AS JSON) END, @@ -5076,3 +5076,35 @@ func (ds *Datastore) loadHostLite(ctx context.Context, id *uint, identifier *str return host, nil } + +// HostnamesByIdentifiers returns a list of hostnames (exactly the +// "hosts.hostname" column) for the given identifiers as understood by +// HostByIdentifier. +func (ds *Datastore) HostnamesByIdentifiers(ctx context.Context, identifiers []string) ([]string, error) { + const selectStmt = ` + SELECT + DISTINCT h.hostname + FROM hosts h, + (%s) ids + WHERE ids.identifier IN (h.hostname, h.osquery_host_id, h.node_key, h.uuid, h.hardware_serial) +` + if len(identifiers) == 0 { + return nil, nil + } + + var sb strings.Builder + args := make([]any, len(identifiers)) + for i, id := range identifiers { + if i > 0 { + sb.WriteString(` UNION `) + } + sb.WriteString(`SELECT ? AS identifier`) + args[i] = id + } + var hostnames []string + stmt := fmt.Sprintf(selectStmt, sb.String()) + if err := sqlx.SelectContext(ctx, ds.reader(ctx), &hostnames, stmt, args...); err != nil { + return nil, ctxerr.Wrap(ctx, err, "get hostnames by identifiers") + } + return hostnames, nil +} diff --git a/server/datastore/mysql/hosts_test.go b/server/datastore/mysql/hosts_test.go index 16c3d08bf5..453a69e1d6 100644 --- a/server/datastore/mysql/hosts_test.go +++ b/server/datastore/mysql/hosts_test.go @@ -164,6 +164,7 @@ func TestHosts(t *testing.T) { {"LastRestarted", testLastRestarted}, {"HostHealth", testHostHealth}, {"GetHostOrbitInfo", testGetHostOrbitInfo}, + {"HostnamesByIdentifiers", testHostnamesByIdentifiers}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -8795,3 +8796,67 @@ func testGetHostOrbitInfo(t *testing.T, ds *Datastore) { require.NoError(t, err) assert.True(t, *hostOrbitInfo.ScriptsEnabled) } + +func testHostnamesByIdentifiers(t *testing.T, ds *Datastore) { + ctx := context.Background() + // create a few hosts with different identifiers + h1, err := ds.NewHost( + ctx, &fleet.Host{ + DetailUpdatedAt: time.Now(), LabelUpdatedAt: time.Now(), + PolicyUpdatedAt: time.Now(), SeenTime: time.Now(), + NodeKey: ptr.String("abc"), + UUID: "def", + Hostname: "ghi.local", + HardwareSerial: "jkl", + }, + ) + require.NoError(t, err) + require.NotNil(t, h1) + + h2, err := ds.NewHost( + ctx, &fleet.Host{ + DetailUpdatedAt: time.Now(), LabelUpdatedAt: time.Now(), + PolicyUpdatedAt: time.Now(), SeenTime: time.Now(), + NodeKey: ptr.String("def"), + UUID: "mno", + Hostname: "pqr.local", + HardwareSerial: "sty", + }, + ) + require.NoError(t, err) + require.NotNil(t, h2) + + h3, err := ds.NewHost( + ctx, &fleet.Host{ + DetailUpdatedAt: time.Now(), LabelUpdatedAt: time.Now(), + PolicyUpdatedAt: time.Now(), SeenTime: time.Now(), + NodeKey: ptr.String("mno"), + UUID: "vwx", + Hostname: "yzA.local", + HardwareSerial: "def", + }, + ) + require.NoError(t, err) + require.NotNil(t, h3) + + cases := []struct { + desc string + in []string + out []string + }{ + {desc: "no identifier", in: nil, out: nil}, + {desc: "no match", in: []string{"ZZZ"}, out: nil}, + {desc: "single match", in: []string{"abc"}, out: []string{h1.Hostname}}, + {desc: "two matches", in: []string{"mno"}, out: []string{h2.Hostname, h3.Hostname}}, + {desc: "all matches", in: []string{"def"}, out: []string{h1.Hostname, h2.Hostname, h3.Hostname}}, + {desc: "multiple identifiers", in: []string{"abc", "mno", "vwx"}, out: []string{h1.Hostname, h2.Hostname, h3.Hostname}}, + {desc: "duplicate identifiers", in: []string{"abc", "abc", "ghi"}, out: []string{h1.Hostname}}, + } + for _, c := range cases { + t.Run(c.desc, func(t *testing.T) { + got, err := ds.HostnamesByIdentifiers(ctx, c.in) + require.NoError(t, err) + require.ElementsMatch(t, c.out, got) + }) + } +} diff --git a/server/datastore/mysql/labels.go b/server/datastore/mysql/labels.go index d07290e23c..272fd7fe12 100644 --- a/server/datastore/mysql/labels.go +++ b/server/datastore/mysql/labels.go @@ -220,11 +220,11 @@ func (ds *Datastore) NewLabel(ctx context.Context, label *fleet.Label, opts ...f return label, nil } -func (ds *Datastore) SaveLabel(ctx context.Context, label *fleet.Label) (*fleet.Label, error) { +func (ds *Datastore) SaveLabel(ctx context.Context, label *fleet.Label) (*fleet.Label, []uint, error) { query := `UPDATE labels SET name = ?, description = ? WHERE id = ?` _, err := ds.writer(ctx).ExecContext(ctx, query, label.Name, label.Description, label.ID) if err != nil { - return nil, ctxerr.Wrap(ctx, err, "saving label") + return nil, nil, ctxerr.Wrap(ctx, err, "saving label") } return labelDB(ctx, label.ID, ds.writer(ctx)) } @@ -261,11 +261,11 @@ func (ds *Datastore) DeleteLabel(ctx context.Context, name string) error { } // Label returns a fleet.Label identified by lid if one exists. -func (ds *Datastore) Label(ctx context.Context, lid uint) (*fleet.Label, error) { +func (ds *Datastore) Label(ctx context.Context, lid uint) (*fleet.Label, []uint, error) { return labelDB(ctx, lid, ds.reader(ctx)) } -func labelDB(ctx context.Context, lid uint, q sqlx.QueryerContext) (*fleet.Label, error) { +func labelDB(ctx context.Context, lid uint, q sqlx.QueryerContext) (*fleet.Label, []uint, error) { stmt := ` SELECT l.*, @@ -277,12 +277,19 @@ func labelDB(ctx context.Context, lid uint, q sqlx.QueryerContext) (*fleet.Label if err := sqlx.GetContext(ctx, q, label, stmt, lid); err != nil { if err == sql.ErrNoRows { - return nil, ctxerr.Wrap(ctx, notFound("Label").WithID(lid)) + return nil, nil, ctxerr.Wrap(ctx, notFound("Label").WithID(lid)) } - return nil, ctxerr.Wrap(ctx, err, "selecting label") + return nil, nil, ctxerr.Wrap(ctx, err, "selecting label") } - return label, nil + var hostIDs []uint + if label.LabelMembershipType == fleet.LabelMembershipTypeManual && label.HostCount > 0 { + if err := sqlx.SelectContext(ctx, q, &hostIDs, `SELECT host_id FROM label_membership WHERE label_id = ?`, lid); err != nil { + return nil, nil, ctxerr.Wrap(ctx, err, "selecting label host IDs") + } + } + + return label, hostIDs, nil } // ListLabels returns all labels limited or sorted by fleet.ListOptions. diff --git a/server/datastore/mysql/labels_test.go b/server/datastore/mysql/labels_test.go index f5cb921593..4debc4c0f6 100644 --- a/server/datastore/mysql/labels_test.go +++ b/server/datastore/mysql/labels_test.go @@ -216,9 +216,9 @@ func testLabelsSearch(t *testing.T, db *Datastore) { err := db.ApplyLabelSpecs(context.Background(), specs) require.Nil(t, err) - all, err := db.Label(context.Background(), specs[len(specs)-1].ID) + all, _, err := db.Label(context.Background(), specs[len(specs)-1].ID) require.Nil(t, err) - l3, err := db.Label(context.Background(), specs[2].ID) + l3, _, err := db.Label(context.Background(), specs[2].ID) require.Nil(t, err) user := &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)} @@ -664,7 +664,7 @@ func testLabelsChangeDetails(t *testing.T, db *Datastore) { err = db.ApplyLabelSpecs(context.Background(), []*fleet.LabelSpec{&label}) require.Nil(t, err) - saved, err := db.Label(context.Background(), label.ID) + saved, _, err := db.Label(context.Background(), label.ID) require.Nil(t, err) assert.Equal(t, label.Name, saved.Name) } @@ -781,9 +781,9 @@ func testLabelsSave(t *testing.T, db *Datastore) { require.NoError(t, db.RecordLabelQueryExecutions(context.Background(), h1, map[uint]*bool{label.ID: ptr.Bool(true)}, time.Now(), false)) - _, err = db.SaveLabel(context.Background(), label) + _, _, err = db.SaveLabel(context.Background(), label) require.NoError(t, err) - saved, err := db.Label(context.Background(), label.ID) + saved, _, err := db.Label(context.Background(), label.ID) require.NoError(t, err) assert.Equal(t, label.Name, saved.Name) assert.Equal(t, label.Description, saved.Description) @@ -1479,6 +1479,10 @@ func testAddDeleteLabelsToFromHost(t *testing.T, ds *Datastore) { // Adding and removing labels. err = ds.AddLabelsToHost(ctx, host1.ID, []uint{label1.ID}) require.NoError(t, err) + lbl, hids, err := ds.Label(ctx, label1.ID) + require.NoError(t, err) + require.Equal(t, label1.ID, lbl.ID) + require.ElementsMatch(t, []uint{host1.ID}, hids) getLabelUpdatedAt := func(updatedAt *time.Time) func(q sqlx.ExtContext) error { return func(q sqlx.ExtContext) error { return sqlx.GetContext(ctx, q, updatedAt, `SELECT updated_at FROM label_membership WHERE host_id = ? AND label_id = ?`, host1.ID, label1.ID) @@ -1515,6 +1519,18 @@ func testAddDeleteLabelsToFromHost(t *testing.T, ds *Datastore) { labels, err = ds.ListLabelsForHost(ctx, host1.ID) require.NoError(t, err) require.Len(t, labels, 2) + + err = ds.AddLabelsToHost(ctx, host2.ID, []uint{label1.ID}) + require.NoError(t, err) + labels, err = ds.ListLabelsForHost(ctx, host2.ID) + require.NoError(t, err) + require.Len(t, labels, 1) + + lbl, hids, err = ds.Label(ctx, label1.ID) + require.NoError(t, err) + require.Equal(t, label1.ID, lbl.ID) + require.ElementsMatch(t, []uint{host1.ID, host2.ID}, hids) + err = ds.RemoveLabelsFromHost(ctx, host1.ID, []uint{label1.ID, label2.ID}) require.NoError(t, err) labels, err = ds.ListLabelsForHost(ctx, host1.ID) diff --git a/server/datastore/mysql/targets_test.go b/server/datastore/mysql/targets_test.go index 66e4bc14af..411fa0c2e1 100644 --- a/server/datastore/mysql/targets_test.go +++ b/server/datastore/mysql/targets_test.go @@ -381,19 +381,19 @@ func testTargetsHostIDsInTargets(t *testing.T, ds *Datastore) { h6 := initHost(nil, "darwin") // Load and record results for builtin labels. - allHosts, err := ds.Label(context.Background(), 6) + allHosts, _, err := ds.Label(context.Background(), 6) require.NoError(t, err) - macOS, err := ds.Label(context.Background(), 7) + macOS, _, err := ds.Label(context.Background(), 7) require.NoError(t, err) - ubuntuLinux, err := ds.Label(context.Background(), 8) + ubuntuLinux, _, err := ds.Label(context.Background(), 8) require.NoError(t, err) - centOSLinux, err := ds.Label(context.Background(), 9) + centOSLinux, _, err := ds.Label(context.Background(), 9) require.NoError(t, err) - msWindows, err := ds.Label(context.Background(), 10) + msWindows, _, err := ds.Label(context.Background(), 10) require.NoError(t, err) - redHatLinux, err := ds.Label(context.Background(), 11) + redHatLinux, _, err := ds.Label(context.Background(), 11) require.NoError(t, err) - allLinux, err := ds.Label(context.Background(), 12) + allLinux, _, err := ds.Label(context.Background(), 12) require.NoError(t, err) allBuiltIn := []*fleet.Label{ diff --git a/server/datastore/mysql/unicode_test.go b/server/datastore/mysql/unicode_test.go index a06a991bfa..f831780612 100644 --- a/server/datastore/mysql/unicode_test.go +++ b/server/datastore/mysql/unicode_test.go @@ -25,7 +25,7 @@ func TestUnicode(t *testing.T) { err := ds.ApplyLabelSpecs(context.Background(), []*fleet.LabelSpec{&l1}) require.Nil(t, err) - label, err := ds.Label(context.Background(), l1.ID) + label, _, err := ds.Label(context.Background(), l1.ID) require.Nil(t, err) assert.Equal(t, "測試", label.Name) diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index 61a41a4d63..c241fc4122 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -184,9 +184,12 @@ type Datastore interface { RemoveLabelsFromHost(ctx context.Context, hostID uint, labelIDs []uint) error NewLabel(ctx context.Context, Label *Label, opts ...OptionalArg) (*Label, error) - SaveLabel(ctx context.Context, label *Label) (*Label, error) + // SaveLabel updates the label and returns the label and an array of host IDs + // members of this label, or an error. + SaveLabel(ctx context.Context, label *Label) (*Label, []uint, error) DeleteLabel(ctx context.Context, name string) error - Label(ctx context.Context, lid uint) (*Label, error) + // Label returns the label and an array of host IDs members of this label, or an error. + Label(ctx context.Context, lid uint) (*Label, []uint, error) ListLabels(ctx context.Context, filter TeamFilter, opt ListOptions) ([]*Label, error) LabelsSummary(ctx context.Context) ([]*LabelSummary, error) @@ -270,15 +273,19 @@ type Datastore interface { // HostIDsByOSVersion retrieves the IDs of all host matching osVersion HostIDsByOSVersion(ctx context.Context, osVersion OSVersion, offset int, limit int) ([]uint, error) // HostByIdentifier returns one host matching the provided identifier. Possible matches can be on - // osquery_host_identifier, node_key, UUID, or hostname. + // osquery_host_id, node_key, UUID, hardware_serial or hostname. HostByIdentifier(ctx context.Context, identifier string) (*Host, error) - // HostLiteByIdentifier returns a host and a subset of its fields using an "identifier" string. - // The identifier string will be matched against the hostname, osquery_host_id, node_key, uuid and hardware_serial columns. + // HostLiteByIdentifier returns a host and a subset of its fields using an + // "identifier" string. The identifier string will be matched against the + // hostname, osquery_host_id, node_key, uuid and hardware_serial columns. HostLiteByIdentifier(ctx context.Context, identifier string) (*HostLite, error) // HostLiteByIdentifier returns a host and a subset of its fields from its id. HostLiteByID(ctx context.Context, id uint) (*HostLite, error) // AddHostsToTeam adds hosts to an existing team, clearing their team settings if teamID is nil. AddHostsToTeam(ctx context.Context, teamID *uint, hostIDs []uint) error + // HostnamesByIdentifiers returns the hostnames corresponding to the provided identifiers, + // as understood by HostByIdentifier. + HostnamesByIdentifiers(ctx context.Context, identifiers []string) ([]string, error) TotalAndUnseenHostsSince(ctx context.Context, teamID *uint, daysCount int) (total int, unseen []uint, err error) diff --git a/server/fleet/labels.go b/server/fleet/labels.go index 5925bf2c01..977eca44de 100644 --- a/server/fleet/labels.go +++ b/server/fleet/labels.go @@ -9,13 +9,27 @@ import ( type ModifyLabelPayload struct { Name *string `json:"name"` Description *string `json:"description"` + // Hosts is the new list of host identifiers to apply for this label, only + // valid for manual labels. If it is nil (not just len() == 0, but == nil), + // then the list of hosts is not modified. If it is not nil and len == 0, + // then all members are removed. + Hosts []string `json:"hosts"` } type LabelPayload struct { - Name *string `json:"name"` - Query *string `json:"query"` - Platform *string `json:"platform"` - Description *string `json:"description"` + Name string `json:"name"` + // Query is the SQL query that defines the label. This defines a dynamic + // label, where the hosts that are part of the label are determined based on + // the query result. Must be empty for a manual label, must be provided for a + // dynamic one. + Query string `json:"query"` + Platform string `json:"platform"` + Description string `json:"description"` + // Hosts is the list of host identifier (serial, uuid, name, etc. as + // supported by HostByIdentifier) that are part of the label. This defines a + // manual label. Can be empty for a manual label that doesn't target any + // host. Must be empty for a dynamic label. + Hosts []string `json:"hosts"` } // LabelType is used to catagorize the kind of label @@ -129,7 +143,7 @@ type LabelSpec struct { Platform string `json:"platform,omitempty"` LabelType LabelType `json:"label_type,omitempty" db:"label_type"` LabelMembershipType LabelMembershipType `json:"label_membership_type" db:"label_membership_type"` - Hosts []string `json:"hosts,omitempty"` + Hosts []string `json:"hosts"` } const ( diff --git a/server/fleet/service.go b/server/fleet/service.go index ad87a4f786..76c29e59d8 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -241,11 +241,11 @@ type Service interface { // GetLabelSpec gets the spec for the label with the given name. GetLabelSpec(ctx context.Context, name string) (*LabelSpec, error) - NewLabel(ctx context.Context, p LabelPayload) (label *Label, err error) - ModifyLabel(ctx context.Context, id uint, payload ModifyLabelPayload) (*Label, error) + NewLabel(ctx context.Context, p LabelPayload) (label *Label, hostIDs []uint, err error) + ModifyLabel(ctx context.Context, id uint, payload ModifyLabelPayload) (*Label, []uint, error) ListLabels(ctx context.Context, opt ListOptions) (labels []*Label, err error) LabelsSummary(ctx context.Context) (labels []*LabelSummary, err error) - GetLabel(ctx context.Context, id uint) (label *Label, err error) + GetLabel(ctx context.Context, id uint) (label *Label, hostIDs []uint, err error) DeleteLabel(ctx context.Context, name string) (err error) // DeleteLabelByID is for backwards compatibility with the UI diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index 9b5bc54a79..17f945121e 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -135,11 +135,11 @@ type RemoveLabelsFromHostFunc func(ctx context.Context, hostID uint, labelIDs [] type NewLabelFunc func(ctx context.Context, Label *fleet.Label, opts ...fleet.OptionalArg) (*fleet.Label, error) -type SaveLabelFunc func(ctx context.Context, label *fleet.Label) (*fleet.Label, error) +type SaveLabelFunc func(ctx context.Context, label *fleet.Label) (*fleet.Label, []uint, error) type DeleteLabelFunc func(ctx context.Context, name string) error -type LabelFunc func(ctx context.Context, lid uint) (*fleet.Label, error) +type LabelFunc func(ctx context.Context, lid uint) (*fleet.Label, []uint, error) type ListLabelsFunc func(ctx context.Context, filter fleet.TeamFilter, opt fleet.ListOptions) ([]*fleet.Label, error) @@ -205,6 +205,8 @@ type HostLiteByIDFunc func(ctx context.Context, id uint) (*fleet.HostLite, error type AddHostsToTeamFunc func(ctx context.Context, teamID *uint, hostIDs []uint) error +type HostnamesByIdentifiersFunc func(ctx context.Context, identifiers []string) ([]string, error) + type TotalAndUnseenHostsSinceFunc func(ctx context.Context, teamID *uint, daysCount int) (total int, unseen []uint, err error) type DeleteHostsFunc func(ctx context.Context, ids []uint) error @@ -1189,6 +1191,9 @@ type DataStore struct { AddHostsToTeamFunc AddHostsToTeamFunc AddHostsToTeamFuncInvoked bool + HostnamesByIdentifiersFunc HostnamesByIdentifiersFunc + HostnamesByIdentifiersFuncInvoked bool + TotalAndUnseenHostsSinceFunc TotalAndUnseenHostsSinceFunc TotalAndUnseenHostsSinceFuncInvoked bool @@ -2654,7 +2659,7 @@ func (s *DataStore) NewLabel(ctx context.Context, Label *fleet.Label, opts ...fl return s.NewLabelFunc(ctx, Label, opts...) } -func (s *DataStore) SaveLabel(ctx context.Context, label *fleet.Label) (*fleet.Label, error) { +func (s *DataStore) SaveLabel(ctx context.Context, label *fleet.Label) (*fleet.Label, []uint, error) { s.mu.Lock() s.SaveLabelFuncInvoked = true s.mu.Unlock() @@ -2668,7 +2673,7 @@ func (s *DataStore) DeleteLabel(ctx context.Context, name string) error { return s.DeleteLabelFunc(ctx, name) } -func (s *DataStore) Label(ctx context.Context, lid uint) (*fleet.Label, error) { +func (s *DataStore) Label(ctx context.Context, lid uint) (*fleet.Label, []uint, error) { s.mu.Lock() s.LabelFuncInvoked = true s.mu.Unlock() @@ -2899,6 +2904,13 @@ func (s *DataStore) AddHostsToTeam(ctx context.Context, teamID *uint, hostIDs [] return s.AddHostsToTeamFunc(ctx, teamID, hostIDs) } +func (s *DataStore) HostnamesByIdentifiers(ctx context.Context, identifiers []string) ([]string, error) { + s.mu.Lock() + s.HostnamesByIdentifiersFuncInvoked = true + s.mu.Unlock() + return s.HostnamesByIdentifiersFunc(ctx, identifiers) +} + func (s *DataStore) TotalAndUnseenHostsSince(ctx context.Context, teamID *uint, daysCount int) (total int, unseen []uint, err error) { s.mu.Lock() s.TotalAndUnseenHostsSinceFuncInvoked = true diff --git a/server/service/hosts.go b/server/service/hosts.go index 90480ce322..0a460e9fb9 100644 --- a/server/service/hosts.go +++ b/server/service/hosts.go @@ -2403,7 +2403,7 @@ func (svc *Service) validateLabelNames(ctx context.Context, action string, label var dynamicLabels []string for labelName, labelID := range labels { - label, err := svc.ds.Label(ctx, labelID) + label, _, err := svc.ds.Label(ctx, labelID) if err != nil { return nil, ctxerr.Wrap(ctx, err, "load label from id") } diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index 004a4ead8b..7bdbdb3483 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -3729,15 +3729,22 @@ func (s *integrationTestSuite) TestGetMacadminsData() { func (s *integrationTestSuite) TestLabels() { t := s.T() + // create some hosts to use for manual labels + hosts := s.createHosts(t, "debian", "linux", "fedora", "darwin", "darwin", "darwin") + manualHosts := hosts[:3] + lbl2Hosts := hosts[3:] + // list labels, has the built-in ones builtinsMap := fleet.ReservedLabelNames() var listResp listLabelsResponse s.DoJSON("GET", "/api/latest/fleet/labels", nil, http.StatusOK, &listResp) assert.True(t, len(listResp.Labels) > 0) + var builtinLbl fleet.Label for _, lbl := range listResp.Labels { _, ok := builtinsMap[lbl.Name] assert.True(t, ok) assert.Equal(t, fleet.LabelTypeBuiltIn, lbl.LabelType) + builtinLbl = lbl.Label } builtInsCount := len(listResp.Labels) require.Equal(t, builtInsCount, len(builtinsMap)) @@ -3754,31 +3761,76 @@ func (s *integrationTestSuite) TestLabels() { // create a label without name, an error var createResp createLabelResponse - s.DoJSON("POST", "/api/latest/fleet/labels", &fleet.LabelPayload{Query: ptr.String("select 1")}, http.StatusUnprocessableEntity, &createResp) + s.DoJSON("POST", "/api/latest/fleet/labels", &fleet.LabelPayload{Query: "select 1"}, http.StatusUnprocessableEntity, &createResp) + + // create a label with both a query and hosts, error + res := s.Do("POST", "/api/latest/fleet/labels", &fleet.LabelPayload{Name: t.Name(), Query: "select 1", Hosts: []string{manualHosts[0].UUID}}, http.StatusUnprocessableEntity) + errMsg := extractServerErrorText(res.Body) + require.Contains(t, errMsg, `Only one of either "query" or "hosts" can be included in the request.`) // create invalid label, conflicts with builtin name for n := range builtinsMap { - s.DoJSON("POST", "/api/latest/fleet/labels", &fleet.LabelPayload{Name: ptr.String(n), Query: ptr.String("select 1")}, http.StatusUnprocessableEntity, &createResp) + s.DoJSON("POST", "/api/latest/fleet/labels", &fleet.LabelPayload{Name: n, Query: "select 1"}, http.StatusUnprocessableEntity, &createResp) } - // create a valid label - s.DoJSON("POST", "/api/latest/fleet/labels", &fleet.LabelPayload{Name: ptr.String(t.Name()), Query: ptr.String("select 1")}, http.StatusOK, &createResp) + // create a valid dynamic label + s.DoJSON("POST", "/api/latest/fleet/labels", &fleet.LabelPayload{Name: t.Name(), Query: "select 1"}, http.StatusOK, &createResp) assert.NotZero(t, createResp.Label.ID) assert.Equal(t, t.Name(), createResp.Label.Name) + assert.Empty(t, createResp.Label.HostIDs) lbl1 := createResp.Label.Label // get the label var getResp getLabelResponse s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d", lbl1.ID), nil, http.StatusOK, &getResp) assert.Equal(t, lbl1.ID, getResp.Label.ID) + assert.Empty(t, getResp.Label.HostIDs) // get a non-existing label s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d", lbl1.ID+1), nil, http.StatusNotFound, &getResp) - // modify that label + // create a valid manual label + createResp = createLabelResponse{} + s.DoJSON("POST", "/api/latest/fleet/labels", &fleet.LabelPayload{Name: t.Name() + "manual", Hosts: []string{manualHosts[0].UUID, manualHosts[1].Hostname, *manualHosts[2].NodeKey}}, http.StatusOK, &createResp) + assert.NotZero(t, createResp.Label.ID) + assert.Equal(t, t.Name()+"manual", createResp.Label.Name) + assert.ElementsMatch(t, []uint{manualHosts[0].ID, manualHosts[1].ID, manualHosts[2].ID}, createResp.Label.HostIDs) + manualLbl1 := createResp.Label.Label + + // get the label + getResp = getLabelResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d", manualLbl1.ID), nil, http.StatusOK, &getResp) + assert.Equal(t, manualLbl1.ID, getResp.Label.ID) + assert.Equal(t, fleet.LabelTypeRegular, getResp.Label.LabelType) + assert.Equal(t, fleet.LabelMembershipTypeManual, getResp.Label.LabelMembershipType) + assert.ElementsMatch(t, []uint{manualHosts[0].ID, manualHosts[1].ID, manualHosts[2].ID}, getResp.Label.HostIDs) + assert.EqualValues(t, 3, getResp.Label.HostCount) + + // create a valid empty manual label + createResp = createLabelResponse{} + s.DoJSON("POST", "/api/latest/fleet/labels", &fleet.LabelPayload{Name: strings.ReplaceAll(t.Name(), "/", "_") + "manual2"}, http.StatusOK, &createResp) + assert.NotZero(t, createResp.Label.ID) + assert.Equal(t, strings.ReplaceAll(t.Name(), "/", "_")+"manual2", createResp.Label.Name) + assert.Empty(t, createResp.Label.HostIDs) + manualLbl2 := createResp.Label.Label + + // get the label + getResp = getLabelResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d", manualLbl2.ID), nil, http.StatusOK, &getResp) + assert.Equal(t, manualLbl2.ID, getResp.Label.ID) + assert.Equal(t, fleet.LabelTypeRegular, getResp.Label.LabelType) + assert.Equal(t, fleet.LabelMembershipTypeManual, getResp.Label.LabelMembershipType) + assert.Empty(t, getResp.Label.HostIDs) + assert.EqualValues(t, 0, getResp.Label.HostCount) + + // get a non-existing label + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d", 9999), nil, http.StatusNotFound, &getResp) + + // modify dynamic label lbl1 var modResp modifyLabelResponse s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/labels/%d", lbl1.ID), &fleet.ModifyLabelPayload{Name: ptr.String(t.Name() + "zzz")}, http.StatusOK, &modResp) assert.Equal(t, lbl1.ID, modResp.Label.ID) + assert.Empty(t, modResp.Label.HostIDs) assert.NotEqual(t, lbl1.Name, modResp.Label.Name) // attempt to modify a label to a reserved name @@ -3787,59 +3839,105 @@ func (s *integrationTestSuite) TestLabels() { } // modify a non-existing label - s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/labels/%d", lbl1.ID+1), &fleet.ModifyLabelPayload{Name: ptr.String("zzz")}, http.StatusNotFound, &modResp) + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/labels/%d", 9999), &fleet.ModifyLabelPayload{Name: ptr.String("zzz")}, http.StatusNotFound, &modResp) + // modify a built-in label + res = s.Do("PATCH", fmt.Sprintf("/api/latest/fleet/labels/%d", builtinLbl.ID), &fleet.ModifyLabelPayload{Name: ptr.String("zzz")}, http.StatusUnprocessableEntity) + errMsg = extractServerErrorText(res.Body) + require.Contains(t, errMsg, "cannot modify built-in label") + + // modify manual label 1 without modifying its hosts + modResp = modifyLabelResponse{} + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/labels/%d", manualLbl1.ID), &fleet.ModifyLabelPayload{Name: ptr.String("modified_manual_label1")}, http.StatusOK, &modResp) + assert.Equal(t, manualLbl1.ID, modResp.Label.ID) + assert.Equal(t, fleet.LabelTypeRegular, modResp.Label.LabelType) + assert.Equal(t, fleet.LabelMembershipTypeManual, modResp.Label.LabelMembershipType) + assert.ElementsMatch(t, []uint{manualHosts[0].ID, manualHosts[1].ID, manualHosts[2].ID}, modResp.Label.HostIDs) + assert.EqualValues(t, 3, modResp.Label.HostCount) + + // modify manual label 2 adding some hosts + modResp = modifyLabelResponse{} + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/labels/%d", manualLbl2.ID), &fleet.ModifyLabelPayload{Hosts: []string{manualHosts[0].UUID}}, http.StatusOK, &modResp) + assert.Equal(t, manualLbl2.ID, modResp.Label.ID) + assert.Equal(t, fleet.LabelTypeRegular, modResp.Label.LabelType) + assert.Equal(t, fleet.LabelMembershipTypeManual, modResp.Label.LabelMembershipType) + assert.ElementsMatch(t, []uint{manualHosts[0].ID}, modResp.Label.HostIDs) + assert.EqualValues(t, 1, modResp.Label.HostCount) + + // modify manual label 2 clearing its hosts + modResp = modifyLabelResponse{} + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/labels/%d", manualLbl2.ID), &fleet.ModifyLabelPayload{Hosts: []string{}, Description: ptr.String("desc")}, http.StatusOK, &modResp) + assert.Equal(t, manualLbl2.ID, modResp.Label.ID) + assert.Equal(t, "desc", modResp.Label.Description) + assert.Empty(t, modResp.Label.HostIDs) + assert.EqualValues(t, 0, modResp.Label.HostCount) // list labels - s.DoJSON("GET", "/api/latest/fleet/labels", nil, http.StatusOK, &listResp, "per_page", strconv.Itoa(builtInsCount+1)) - assert.Len(t, listResp.Labels, builtInsCount+1) + dynamicLabels := []fleet.Label{lbl1} + manualLabels := []fleet.Label{manualLbl1, manualLbl2} + s.DoJSON("GET", "/api/latest/fleet/labels", nil, http.StatusOK, &listResp, "per_page", strconv.Itoa(100)) + assert.Len(t, listResp.Labels, builtInsCount+len(dynamicLabels)+len(manualLabels)) // labels summary s.DoJSON("GET", "/api/latest/fleet/labels/summary", nil, http.StatusOK, &summaryResp) - assert.Len(t, summaryResp.Labels, builtInsCount+1) + assert.Len(t, summaryResp.Labels, builtInsCount+len(dynamicLabels)+len(manualLabels)) // next page is empty - s.DoJSON("GET", "/api/latest/fleet/labels", nil, http.StatusOK, &listResp, "per_page", strconv.Itoa(builtInsCount+1), "page", "1") + s.DoJSON("GET", "/api/latest/fleet/labels", nil, http.StatusOK, &listResp, "per_page", "100", "page", "1") assert.Len(t, listResp.Labels, 0) // list labels with invalid query params s.DoJSON("GET", "/api/latest/fleet/labels", nil, http.StatusBadRequest, &listResp, "per_page", strconv.Itoa(builtInsCount+1), "order_key", "id", "after", "1") s.DoJSON("GET", "/api/latest/fleet/labels", nil, http.StatusBadRequest, &listResp, "per_page", strconv.Itoa(builtInsCount+1), "query", "no match query for this endpoint") - // create another label - s.DoJSON("POST", "/api/latest/fleet/labels", &fleet.LabelPayload{Name: ptr.String(strings.ReplaceAll(t.Name(), "/", "_")), Query: ptr.String("select 1")}, http.StatusOK, &createResp) + // create another dynamic label + s.DoJSON("POST", "/api/latest/fleet/labels", &fleet.LabelPayload{Name: strings.ReplaceAll(t.Name(), "/", "_"), Query: "select 1"}, http.StatusOK, &createResp) assert.NotZero(t, createResp.Label.ID) lbl2 := createResp.Label.Label + dynamicLabels = append(dynamicLabels, lbl2) + require.Len(t, dynamicLabels, 2) // to make linter happy (dynamicLabels is not used past this point) - // create hosts and add them to that label - hosts := s.createHosts(t, "darwin", "darwin", "darwin") - for _, h := range hosts { + // add lbl2 hosts to that label + for _, h := range lbl2Hosts { err := s.ds.RecordLabelQueryExecutions(context.Background(), h, map[uint]*bool{lbl2.ID: ptr.Bool(true)}, time.Now(), false) require.NoError(t, err) } - // list hosts in label + // list hosts in dynamic label lbl2 var listHostsResp listHostsResponse s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d/hosts", lbl2.ID), nil, http.StatusOK, &listHostsResp) - assert.Len(t, listHostsResp.Hosts, len(hosts)) + assert.Len(t, listHostsResp.Hosts, len(lbl2Hosts)) - s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d/hosts", lbl2.ID), nil, http.StatusOK, &listHostsResp, "order_key", "id", "after", fmt.Sprintf("%d", hosts[0].ID)) + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d/hosts", lbl2.ID), nil, http.StatusOK, &listHostsResp, "order_key", "id", "after", fmt.Sprintf("%d", lbl2Hosts[0].ID)) assert.Len(t, listHostsResp.Hosts, 2) - assert.Equal(t, hosts[1].ID, listHostsResp.Hosts[0].ID) - assert.Equal(t, hosts[2].ID, listHostsResp.Hosts[1].ID) + assert.Equal(t, lbl2Hosts[1].ID, listHostsResp.Hosts[0].ID) + assert.Equal(t, lbl2Hosts[2].ID, listHostsResp.Hosts[1].ID) - // list hosts in label ordered by display_name + // list hosts in manual label 1 + listHostsResp = listHostsResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d/hosts", manualLbl1.ID), nil, http.StatusOK, &listHostsResp, "order_key", "id") + assert.Len(t, listHostsResp.Hosts, manualLbl1.HostCount) + assert.Equal(t, manualHosts[0].ID, listHostsResp.Hosts[0].ID) + assert.Equal(t, manualHosts[1].ID, listHostsResp.Hosts[1].ID) + assert.Equal(t, manualHosts[2].ID, listHostsResp.Hosts[2].ID) + + // list hosts in manual label 2 + listHostsResp = listHostsResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d/hosts", manualLbl2.ID), nil, http.StatusOK, &listHostsResp, "order_key", "id") + assert.Len(t, listHostsResp.Hosts, 0) + + // list hosts in dynamic label 2 searching by display_name s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d/hosts", lbl2.ID), nil, http.StatusOK, &listHostsResp, "order_key", "display_name", "order_direction", "desc") - assert.Len(t, listHostsResp.Hosts, len(hosts)) + assert.Len(t, listHostsResp.Hosts, len(lbl2Hosts)) // first in the list is the last one, as the names are ordered with the index // of creation, and vice-versa - assert.Equal(t, hosts[len(hosts)-1].ID, listHostsResp.Hosts[0].ID) - assert.Equal(t, hosts[0].ID, listHostsResp.Hosts[len(hosts)-1].ID) + assert.Equal(t, lbl2Hosts[len(lbl2Hosts)-1].ID, listHostsResp.Hosts[0].ID) + assert.Equal(t, lbl2Hosts[0].ID, listHostsResp.Hosts[len(lbl2Hosts)-1].ID) mysql.ExecAdhocSQL(t, s.ds, func(db sqlx.ExtContext) error { _, err := db.ExecContext( context.Background(), `INSERT INTO host_emails (host_id, email, source) VALUES (?, ?, ?)`, - hosts[0].ID, "a@b.c", "src1") + lbl2Hosts[0].ID, "a@b.c", "src1") return err }) @@ -3847,17 +3945,17 @@ func (s *integrationTestSuite) TestLabels() { // list hosts in label searching by email address s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d/hosts", lbl2.ID), nil, http.StatusOK, &listHostsResp, "query", "a@b.c") assert.Len(t, listHostsResp.Hosts, 1) - assert.Equal(t, hosts[0].ID, listHostsResp.Hosts[0].ID) + assert.Equal(t, lbl2Hosts[0].ID, listHostsResp.Hosts[0].ID) // list hosts in label searching by email address with leading/trailing whitespace s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d/hosts", lbl2.ID), nil, http.StatusOK, &listHostsResp, "query", " a@b.c ") assert.Len(t, listHostsResp.Hosts, 1) - assert.Equal(t, hosts[0].ID, listHostsResp.Hosts[0].ID) + assert.Equal(t, lbl2Hosts[0].ID, listHostsResp.Hosts[0].ID) // count hosts in label order by display_name var countResp countHostsResponse s.DoJSON("GET", "/api/latest/fleet/hosts/count", nil, http.StatusOK, &countResp, "label_id", fmt.Sprint(lbl2.ID), "order_key", "display_name", "order_direction", "desc") - assert.Equal(t, len(hosts), countResp.Count) + assert.Equal(t, len(lbl2Hosts), countResp.Count) // lists hosts in label without hosts s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d/hosts", lbl1.ID), nil, http.StatusOK, &listHostsResp) @@ -3872,7 +3970,7 @@ func (s *integrationTestSuite) TestLabels() { assert.Len(t, listHostsResp.Hosts, 0) // set MDM information on a host - require.NoError(t, s.ds.SetOrUpdateMDMData(context.Background(), hosts[0].ID, false, true, "https://simplemdm.com", false, fleet.WellKnownMDMSimpleMDM, "")) + require.NoError(t, s.ds.SetOrUpdateMDMData(context.Background(), lbl2Hosts[0].ID, false, true, "https://simplemdm.com", false, fleet.WellKnownMDMSimpleMDM, "")) var mdmID uint mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { return sqlx.GetContext(context.Background(), q, &mdmID, @@ -3905,6 +4003,12 @@ func (s *integrationTestSuite) TestLabels() { // delete a non-existing label by name s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/labels/%s", url.PathEscape(lbl2.Name)), nil, http.StatusNotFound, &delResp) + // delete a manual label by id + s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/labels/id/%d", manualLbl1.ID), nil, http.StatusOK, &delIDResp) + + // delete a manual label by name + s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/labels/%s", url.PathEscape(manualLbl2.Name)), nil, http.StatusOK, &delResp) + // list labels, only the built-ins remain s.DoJSON("GET", "/api/latest/fleet/labels", nil, http.StatusOK, &listResp, "per_page", strconv.Itoa(builtInsCount+1)) assert.Len(t, listResp.Labels, builtInsCount) diff --git a/server/service/integration_ddm_test.go b/server/service/integration_ddm_test.go index 43a9e798ff..b4fc49ab65 100644 --- a/server/service/integration_ddm_test.go +++ b/server/service/integration_ddm_test.go @@ -158,12 +158,12 @@ func (s *integrationMDMTestSuite) TestAppleDDMBatchUpload() { require.Equal(t, "darwin", resp.Profiles[1].Platform) var createResp createLabelResponse - s.DoJSON("POST", "/api/latest/fleet/labels", &fleet.LabelPayload{Name: ptr.String("label_1"), Query: ptr.String("select 1")}, http.StatusOK, &createResp) + s.DoJSON("POST", "/api/latest/fleet/labels", &fleet.LabelPayload{Name: "label_1", Query: "select 1"}, http.StatusOK, &createResp) require.NotZero(t, createResp.Label.ID) require.Equal(t, "label_1", createResp.Label.Name) lbl1 := createResp.Label.Label - s.DoJSON("POST", "/api/latest/fleet/labels", &fleet.LabelPayload{Name: ptr.String("label_2"), Query: ptr.String("select 1")}, http.StatusOK, &createResp) + s.DoJSON("POST", "/api/latest/fleet/labels", &fleet.LabelPayload{Name: "label_2", Query: "select 1"}, http.StatusOK, &createResp) require.NotZero(t, createResp.Label.ID) require.Equal(t, "label_2", createResp.Label.Name) lbl2 := createResp.Label.Label diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index 39d15e08cc..a69771912e 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -4378,8 +4378,8 @@ func (s *integrationEnterpriseTestSuite) TestGitOpsUserActions() { clr := createLabelResponse{} s.DoJSON("POST", "/api/latest/fleet/labels", createLabelRequest{ LabelPayload: fleet.LabelPayload{ - Name: ptr.String("foo"), - Query: ptr.String("SELECT 1;"), + Name: "foo", + Query: "SELECT 1;", }, }, http.StatusOK, &clr) diff --git a/server/service/labels.go b/server/service/labels.go index 6b15ec0787..63243a76db 100644 --- a/server/service/labels.go +++ b/server/service/labels.go @@ -28,12 +28,12 @@ func (r createLabelResponse) error() error { return r.Err } func createLabelEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { req := request.(*createLabelRequest) - label, err := svc.NewLabel(ctx, req.LabelPayload) + label, hostIDs, err := svc.NewLabel(ctx, req.LabelPayload) if err != nil { return createLabelResponse{Err: err}, nil } - labelResp, err := labelResponseForLabel(ctx, svc, label) + labelResp, err := labelResponseForLabel(ctx, svc, label, hostIDs) if err != nil { return createLabelResponse{Err: err}, nil } @@ -41,42 +41,78 @@ func createLabelEndpoint(ctx context.Context, request interface{}, svc fleet.Ser return createLabelResponse{Label: *labelResp}, nil } -func (svc *Service) NewLabel(ctx context.Context, p fleet.LabelPayload) (*fleet.Label, error) { +func (svc *Service) NewLabel(ctx context.Context, p fleet.LabelPayload) (*fleet.Label, []uint, error) { if err := svc.authz.Authorize(ctx, &fleet.Label{}, fleet.ActionWrite); err != nil { - return nil, err + return nil, nil, err } - label := &fleet.Label{} - - if p.Name == nil { - return nil, fleet.NewInvalidArgumentError("name", "missing required argument") - } - label.Name = *p.Name - - if p.Query == nil { - return nil, fleet.NewInvalidArgumentError("query", "missing required argument") - } - label.Query = *p.Query - - if p.Platform != nil { - label.Platform = *p.Platform + label := &fleet.Label{ + LabelType: fleet.LabelTypeRegular, + LabelMembershipType: fleet.LabelMembershipTypeDynamic, } - if p.Description != nil { - label.Description = *p.Description + if p.Name == "" { + return nil, nil, fleet.NewInvalidArgumentError("name", "missing required argument") } + label.Name = p.Name + + if p.Query != "" && len(p.Hosts) > 0 { + return nil, nil, fleet.NewInvalidArgumentError("query", `Only one of either "query" or "hosts" can be included in the request.`) + } + label.Query = p.Query + if p.Query == "" { + label.LabelMembershipType = fleet.LabelMembershipTypeManual + } + + label.Platform = p.Platform + label.Description = p.Description for name := range fleet.ReservedLabelNames() { if label.Name == name { - return nil, fleet.NewInvalidArgumentError("name", fmt.Sprintf("cannot add label '%s' because it conflicts with the name of a built-in label", name)) + return nil, nil, fleet.NewInvalidArgumentError("name", fmt.Sprintf("cannot add label '%s' because it conflicts with the name of a built-in label", name)) } } - label, err := svc.ds.NewLabel(ctx, label) - if err != nil { - return nil, err + // if membership type is manual, must use ApplyLabelSpecs (as NewLabel does + // not create label memberships), otherwise NewLabel works for dynamic + // membership. Must resolve the host identifiers to hostname so that + // ApplySpecs can be used. + var hostIDs []uint + if label.LabelMembershipType == fleet.LabelMembershipTypeManual { + spec := fleet.LabelSpec{ + Name: label.Name, + Description: label.Description, + Query: label.Query, + Platform: label.Platform, + LabelType: label.LabelType, + LabelMembershipType: label.LabelMembershipType, + } + hostnames, err := svc.ds.HostnamesByIdentifiers(ctx, p.Hosts) + if err != nil { + return nil, nil, err + } + spec.Hosts = hostnames + if err := svc.ds.ApplyLabelSpecs(ctx, []*fleet.LabelSpec{&spec}); err != nil { + return nil, nil, err + } + + // must reload it to get the id, and the host IDs + lblIDsByName, err := svc.ds.LabelIDsByName(ctx, []string{label.Name}) + if err != nil { + return nil, nil, err + } + label, hostIDs, err = svc.ds.Label(ctx, lblIDsByName[label.Name]) + if err != nil { + return nil, nil, err + } + } else { + newLbl, err := svc.ds.NewLabel(ctx, label) + if err != nil { + return nil, nil, err + } + label = newLbl } - return label, nil + return label, hostIDs, nil } //////////////////////////////////////////////////////////////////////////////// @@ -97,12 +133,12 @@ func (r modifyLabelResponse) error() error { return r.Err } func modifyLabelEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { req := request.(*modifyLabelRequest) - label, err := svc.ModifyLabel(ctx, req.ID, req.ModifyLabelPayload) + label, hostIDs, err := svc.ModifyLabel(ctx, req.ID, req.ModifyLabelPayload) if err != nil { return modifyLabelResponse{Err: err}, nil } - labelResp, err := labelResponseForLabel(ctx, svc, label) + labelResp, err := labelResponseForLabel(ctx, svc, label, hostIDs) if err != nil { return modifyLabelResponse{Err: err}, nil } @@ -110,23 +146,23 @@ func modifyLabelEndpoint(ctx context.Context, request interface{}, svc fleet.Ser return modifyLabelResponse{Label: *labelResp}, err } -func (svc *Service) ModifyLabel(ctx context.Context, id uint, payload fleet.ModifyLabelPayload) (*fleet.Label, error) { +func (svc *Service) ModifyLabel(ctx context.Context, id uint, payload fleet.ModifyLabelPayload) (*fleet.Label, []uint, error) { if err := svc.authz.Authorize(ctx, &fleet.Label{}, fleet.ActionWrite); err != nil { - return nil, err + return nil, nil, err } - label, err := svc.ds.Label(ctx, id) + label, _, err := svc.ds.Label(ctx, id) if err != nil { - return nil, err + return nil, nil, err } if label.LabelType == fleet.LabelTypeBuiltIn { - return nil, fleet.NewInvalidArgumentError("label_type", fmt.Sprintf("cannot modify built-in label '%s'", label.Name)) + return nil, nil, fleet.NewInvalidArgumentError("label_type", fmt.Sprintf("cannot modify built-in label '%s'", label.Name)) } if payload.Name != nil { // Check if the new name is a reserved label name for name := range fleet.ReservedLabelNames() { if *payload.Name == name { - return nil, fleet.NewInvalidArgumentError("name", fmt.Sprintf("cannot rename label to '%s' because it conflicts with the name of a built-in label", name)) + return nil, nil, fleet.NewInvalidArgumentError("name", fmt.Sprintf("cannot rename label to '%s' because it conflicts with the name of a built-in label", name)) } } label.Name = *payload.Name @@ -134,6 +170,36 @@ func (svc *Service) ModifyLabel(ctx context.Context, id uint, payload fleet.Modi if payload.Description != nil { label.Description = *payload.Description } + if len(payload.Hosts) > 0 && label.LabelMembershipType != fleet.LabelMembershipTypeManual { + return nil, nil, fleet.NewInvalidArgumentError("hosts", "cannot provide a list of hosts for a dynamic label") + } + + // if membership type is manual and the Hosts membership is provided, must + // use ApplyLabelSpecs (as SaveLabel does not update label memberships), + // otherwise SaveLabel works for dynamic membership. Must resolve the host + // identifiers to hostname so that ApplySpecs can be used (it expects only + // hostnames). + if label.LabelMembershipType == fleet.LabelMembershipTypeManual && payload.Hosts != nil { + spec := fleet.LabelSpec{ + Name: label.Name, + Description: label.Description, + Query: label.Query, + Platform: label.Platform, + LabelType: label.LabelType, + LabelMembershipType: label.LabelMembershipType, + } + hostnames, err := svc.ds.HostnamesByIdentifiers(ctx, payload.Hosts) + if err != nil { + return nil, nil, err + } + spec.Hosts = hostnames + if err := svc.ds.ApplyLabelSpecs(ctx, []*fleet.LabelSpec{&spec}); err != nil { + return nil, nil, err + } + + // must reload it to get the host counts information + return svc.ds.Label(ctx, id) + } return svc.ds.SaveLabel(ctx, label) } @@ -161,20 +227,20 @@ func (r getLabelResponse) error() error { return r.Err } func getLabelEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { req := request.(*getLabelRequest) - label, err := svc.GetLabel(ctx, req.ID) + label, hostIDs, err := svc.GetLabel(ctx, req.ID) if err != nil { return getLabelResponse{Err: err}, nil } - resp, err := labelResponseForLabel(ctx, svc, label) + resp, err := labelResponseForLabel(ctx, svc, label, hostIDs) if err != nil { return getLabelResponse{Err: err}, nil } return getLabelResponse{Label: *resp}, nil } -func (svc *Service) GetLabel(ctx context.Context, id uint) (*fleet.Label, error) { +func (svc *Service) GetLabel(ctx context.Context, id uint) (*fleet.Label, []uint, error) { if err := svc.authz.Authorize(ctx, &fleet.Label{}, fleet.ActionRead); err != nil { - return nil, err + return nil, nil, err } return svc.ds.Label(ctx, id) @@ -205,7 +271,7 @@ func listLabelsEndpoint(ctx context.Context, request interface{}, svc fleet.Serv resp := listLabelsResponse{} for _, label := range labels { - labelResp, err := labelResponseForLabel(ctx, svc, label) + labelResp, err := labelResponseForLabel(ctx, svc, label, nil) if err != nil { return listLabelsResponse{Err: err}, nil } @@ -224,14 +290,21 @@ func (svc *Service) ListLabels(ctx context.Context, opt fleet.ListOptions) ([]*f } filter := fleet.TeamFilter{User: vc.User, IncludeObserver: true} + // TODO(mna): ListLabels doesn't currently return the hostIDs members of the + // label, the quick approach would be an N+1 queries endpoint. Leaving like + // that for now because we're in a hurry before merge freeze but the solution + // would probably be to do it in 2 queries : grab all label IDs from the + // list, then select hostID+labelID tuples in one query (where labelID IN + // )and fill the hostIDs per label. return svc.ds.ListLabels(ctx, filter, opt) } -func labelResponseForLabel(ctx context.Context, svc fleet.Service, label *fleet.Label) (*labelResponse, error) { +func labelResponseForLabel(ctx context.Context, svc fleet.Service, label *fleet.Label, hostIDs []uint) (*labelResponse, error) { return &labelResponse{ Label: *label, DisplayText: label.Name, Count: label.HostCount, + HostIDs: hostIDs, }, nil } @@ -374,7 +447,7 @@ func (svc *Service) DeleteLabelByID(ctx context.Context, id uint) error { return err } - label, err := svc.ds.Label(ctx, id) + label, _, err := svc.ds.Label(ctx, id) if err != nil { return err } diff --git a/server/service/labels_test.go b/server/service/labels_test.go index fef7164026..362bfb2434 100644 --- a/server/service/labels_test.go +++ b/server/service/labels_test.go @@ -21,8 +21,8 @@ func TestLabelsAuth(t *testing.T) { ds.NewLabelFunc = func(ctx context.Context, lbl *fleet.Label, opts ...fleet.OptionalArg) (*fleet.Label, error) { return lbl, nil } - ds.SaveLabelFunc = func(ctx context.Context, lbl *fleet.Label) (*fleet.Label, error) { - return lbl, nil + ds.SaveLabelFunc = func(ctx context.Context, lbl *fleet.Label) (*fleet.Label, []uint, error) { + return lbl, nil, nil } ds.DeleteLabelFunc = func(ctx context.Context, nm string) error { return nil @@ -30,8 +30,8 @@ func TestLabelsAuth(t *testing.T) { ds.ApplyLabelSpecsFunc = func(ctx context.Context, specs []*fleet.LabelSpec) error { return nil } - ds.LabelFunc = func(ctx context.Context, id uint) (*fleet.Label, error) { - return &fleet.Label{}, nil + ds.LabelFunc = func(ctx context.Context, id uint) (*fleet.Label, []uint, error) { + return &fleet.Label{}, nil, nil } ds.ListLabelsFunc = func(ctx context.Context, filter fleet.TeamFilter, opts fleet.ListOptions) ([]*fleet.Label, error) { return nil, nil @@ -90,16 +90,16 @@ func TestLabelsAuth(t *testing.T) { t.Run(tt.name, func(t *testing.T) { ctx := viewer.NewContext(ctx, viewer.Viewer{User: tt.user}) - _, err := svc.NewLabel(ctx, fleet.LabelPayload{Name: ptr.String(t.Name()), Query: ptr.String(`SELECT 1`)}) + _, _, err := svc.NewLabel(ctx, fleet.LabelPayload{Name: t.Name(), Query: `SELECT 1`}) checkAuthErr(t, tt.shouldFailWrite, err) - _, err = svc.ModifyLabel(ctx, 1, fleet.ModifyLabelPayload{}) + _, _, err = svc.ModifyLabel(ctx, 1, fleet.ModifyLabelPayload{}) checkAuthErr(t, tt.shouldFailWrite, err) err = svc.ApplyLabelSpecs(ctx, []*fleet.LabelSpec{}) checkAuthErr(t, tt.shouldFailWrite, err) - _, err = svc.GetLabel(ctx, 1) + _, _, err = svc.GetLabel(ctx, 1) checkAuthErr(t, tt.shouldFailRead, err) _, err = svc.GetLabelSpecs(ctx) @@ -155,7 +155,7 @@ func testLabelsGetLabel(t *testing.T, ds *mysql.Datastore) { assert.Nil(t, err) assert.NotZero(t, label.ID) - labelVerify, err := svc.GetLabel(test.UserContext(ctx, test.UserAdmin), label.ID) + labelVerify, _, err := svc.GetLabel(test.UserContext(ctx, test.UserAdmin), label.ID) assert.Nil(t, err) assert.Equal(t, label.ID, labelVerify.ID) } diff --git a/server/service/metrics_labels.go b/server/service/metrics_labels.go index afbf9d7a7a..ad253d2c7b 100644 --- a/server/service/metrics_labels.go +++ b/server/service/metrics_labels.go @@ -8,17 +8,17 @@ import ( "github.com/fleetdm/fleet/v4/server/fleet" ) -func (mw metricsMiddleware) ModifyLabel(ctx context.Context, id uint, p fleet.ModifyLabelPayload) (*fleet.Label, error) { +func (mw metricsMiddleware) ModifyLabel(ctx context.Context, id uint, p fleet.ModifyLabelPayload) (*fleet.Label, []uint, error) { var ( - lic *fleet.Label - err error + lic *fleet.Label + hids []uint + err error ) defer func(begin time.Time) { lvs := []string{"method", "ModifyLabel", "error", fmt.Sprint(err != nil)} mw.requestCount.With(lvs...).Add(1) mw.requestLatency.With(lvs...).Observe(time.Since(begin).Seconds()) }(time.Now()) - lic, err = mw.Service.ModifyLabel(ctx, id, p) - return lic, err - + lic, hids, err = mw.Service.ModifyLabel(ctx, id, p) + return lic, hids, err }