From 7d535a251cf9d62351626602894152c0b31a50b1 Mon Sep 17 00:00:00 2001 From: Noah Talerman <47070608+noahtalerman@users.noreply.github.com> Date: Wed, 15 Jul 2026 15:24:20 -0700 Subject: [PATCH 1/4] Community PR process (#49372) --- handbook/engineering/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/handbook/engineering/README.md b/handbook/engineering/README.md index 3281a86345..6dd01f3d5d 100644 --- a/handbook/engineering/README.md +++ b/handbook/engineering/README.md @@ -139,7 +139,7 @@ Make sure to create a Github issue and link it to the PR so that we can track th **For PRs that change the product:** - Assign the PR to the appropriate Product Designer (PD). -- Notify the relevant PD in the #g-apple-at-work, #g-auto-patching, #g-orchestration, or #g-security-compliance Slack channel. +- @ mention the relevant PD in a comment on the PR. The PD will be the contact point for the contributor and will ensure the PR is reviewed by the appropriate team member when ready. The PD should: From 9909a64ce94357757f09c67ab6579fc3d76aa333 Mon Sep 17 00:00:00 2001 From: Noah Talerman <47070608+noahtalerman@users.noreply.github.com> Date: Wed, 15 Jul 2026 15:32:18 -0700 Subject: [PATCH 2/4] Tweak tooltip language for "Hosts online" (#48971) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use platforms (macOS) instead of hardware (Mac) to be consistent with other spots in UI - Include Windows and Linux - Just say "online" instead of "check in and count as online". Reduce word count. We're looking at the "Hosts online chart" anyways. Context is in [this Slack thread](https://fleetdm.slack.com/archives/C084F4MKYSJ/p1783370095727719). ## Summary by CodeRabbit * **Documentation** * Updated the uptime explanation text to better reflect how “online” status varies by platform when devices are locked vs. unlocked. * Clarified wording for iOS/iPadOS, macOS/Windows/Linux, and Android to match actual check-in behavior. --------- Co-authored-by: Steven Palmesano <3100993+spalmesano0@users.noreply.github.com> --- .../pages/DashboardPage/cards/ChartCard/ChartCard.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/pages/DashboardPage/cards/ChartCard/ChartCard.tsx b/frontend/pages/DashboardPage/cards/ChartCard/ChartCard.tsx index 2a53651742..21f56a7ad0 100644 --- a/frontend/pages/DashboardPage/cards/ChartCard/ChartCard.tsx +++ b/frontend/pages/DashboardPage/cards/ChartCard/ChartCard.tsx @@ -235,10 +235,10 @@ const ChartCard = ({ given hour.

- iPhones/iPads check in and count as online anytime they have power and - an internet connection (including locked). Macs count as online - sometimes (infrequently) when the lid is closed. Android hosts never - show online when locked. + iOS/iPadOS hosts are online anytime they have power and an internet + connection (including locked). macOS, Windows, and Linux hosts can be + online when locked (lid closed), but less frequently than when the lid + is open. Android hosts are never online when locked. ), tooltipFormatter: ({ value }: { value: number }) => From b10642be3cc85cd61dd5ef4116436336562c868d Mon Sep 17 00:00:00 2001 From: Nico <32375741+nulmete@users.noreply.github.com> Date: Wed, 15 Jul 2026 19:35:27 -0300 Subject: [PATCH 3/4] 44954 custom host vitals (#49334) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Related issue:** Resolves #44954 # Checklist for submitter - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files) for more information. ## Testing - [x] Added/updated automated tests - [x] QA'd all new/changed functionality manually ## Summary by CodeRabbit * **New Features** * Added Custom Host Vitals management (create/edit/delete, search) with copyable variable tokens and per-host values. * Show Custom Host Vitals on host details, including role-based editing. * Enabled Custom Host Vitals in host-vitals labels and the activity feed. * Extended GitOps to manage global Custom Host Vitals declaratively (including dry-run behavior). * Split Controls → Variables into Global Variables and Custom Host Vitals, including routing updates. * **Bug Fixes** * Improved validation and expansion of Custom Host Vital references across scripts, profiles, installers, and deployments, with clearer failures when values are missing or invalid. --- changes/44954-custom-host-vitals.md | 1 + cmd/fleetctl/fleetctl/generate_gitops.go | 37 + cmd/fleetctl/fleetctl/generate_gitops_test.go | 7 + cmd/fleetctl/fleetctl/get_test.go | 6 + cmd/fleetctl/fleetctl/mdm_test.go | 6 + cmd/fleetctl/fleetctl/scripts_test.go | 3 + .../generateGitops/test_dir_free/default.yml | 3 + .../test_dir_premium/default.yml | 3 + .../fleetctl/testing_utils/testing_utils.go | 11 + .../gitops/gitops_integration_test.go | 122 ++++ docs/REST API/rest-api.md | 4 +- ee/server/service/maintained_apps.go | 10 + ee/server/service/setup_experience.go | 3 + ee/server/service/setup_experience_test.go | 21 + ee/server/service/software_installers.go | 42 ++ ee/server/service/software_installers_test.go | 10 + .../CommandPalette/CommandPalette.tsx | 12 + .../CommandPalette/groups/controls.ts | 37 +- .../CommandPalette/helpers.tests.ts | 33 + frontend/components/CommandPalette/helpers.ts | 1 + frontend/interfaces/activity.ts | 11 + frontend/interfaces/custom_host_vitals.ts | 17 + frontend/interfaces/host.ts | 2 + frontend/interfaces/label.ts | 33 +- .../GlobalActivityItem.tests.tsx | 39 + .../GlobalActivityItem/GlobalActivityItem.tsx | 33 + .../Variables/Variables.tsx | 263 +------ .../Variables/VariablesNavItems.tsx | 40 ++ .../ManageControlsPage/Variables/_styles.scss | 104 +-- .../CustomHostVitalsTab.tests.tsx | 190 +++++ .../CustomHostVitalsTab.tsx | 267 +++++++ .../CustomHostVitalsTableConfig.tsx | 124 ++++ .../cards/CustomHostVitalsTab/index.ts | 1 + .../GlobalVariables.tests.tsx} | 97 +-- .../cards/GlobalVariables/GlobalVariables.tsx | 227 ++++++ .../GlobalVariablesTableConfig.tsx | 114 +++ .../Variables/cards/GlobalVariables/index.ts | 1 + .../AddCustomHostVitalModal.tsx | 105 +++ .../AddCustomHostVitalModal/index.ts | 1 + .../DeleteCustomHostVitalModal.tsx | 82 +++ .../DeleteCustomHostVitalModal/index.ts | 1 + .../EditCustomHostVitalModal.tsx | 111 +++ .../EditCustomHostVitalModal/index.ts | 1 + .../ManageControlsPage/Variables/helpers.ts | 69 ++ .../ManageHostsPage/ManageHostsPage.tests.tsx | 2 +- .../hosts/ManageHostsPage/ManageHostsPage.tsx | 24 +- .../HostDetailsPage/HostDetailsPage.tsx | 31 + .../details/cards/Activity/ActivityConfig.tsx | 2 + ...CustomHostVitalValueActivityItem.tests.tsx | 39 + ...EditedCustomHostVitalValueActivityItem.tsx | 25 + .../index.ts | 1 + .../details/cards/Vitals/Vitals.tests.tsx | 57 ++ .../hosts/details/cards/Vitals/Vitals.tsx | 37 + .../hosts/details/cards/Vitals/_styles.scss | 6 + .../EditHostVitalModal.tests.tsx | 112 +++ .../EditHostVitalModal/EditHostVitalModal.tsx | 79 +++ .../modals/EditHostVitalModal/_styles.scss | 5 + .../modals/EditHostVitalModal/index.ts | 1 + .../labels/NewLabelPage/NewLabelPage.tsx | 162 ++++- .../labels/NewLabelPage/helpers.tests.ts | 75 ++ frontend/pages/labels/NewLabelPage/helpers.ts | 60 ++ frontend/router/index.tsx | 1 + frontend/router/paths.ts | 2 + .../services/entities/custom_host_vitals.ts | 61 ++ frontend/services/entities/labels.ts | 12 +- frontend/services/entities/variables.ts | 12 +- frontend/utilities/endpoints.ts | 8 +- pkg/spec/gitops.go | 63 +- pkg/spec/gitops_test.go | 82 +++ server/authz/policy.rego | 45 ++ server/authz/policy_test.go | 43 ++ server/datastore/mysql/apple_mdm_batched.go | 23 + server/datastore/mysql/custom_host_vitals.go | 623 ++++++++++++++++ .../mysql/custom_host_vitals_test.go | 667 ++++++++++++++++++ server/datastore/mysql/hosts.go | 1 + server/datastore/mysql/hosts_test.go | 11 + server/datastore/mysql/labels_test.go | 100 +++ .../tables/20260715144547_CustomHostVitals.go | 53 ++ .../20260715144547_CustomHostVitals_test.go | 53 ++ server/datastore/mysql/schema.sql | 30 +- server/datastore/mysql/secret_variables.go | 37 +- .../datastore/mysql/secret_variables_test.go | 2 + server/datastore/mysql/software_installers.go | 35 +- .../mysql/software_installers_test.go | 60 ++ server/fleet/activities.go | 42 ++ server/fleet/android.go | 14 + server/fleet/android_test.go | 12 + server/fleet/api_custom_host_vitals.go | 97 +++ server/fleet/apple_mdm.go | 9 +- server/fleet/custom_host_vitals.go | 204 ++++++ server/fleet/custom_host_vitals_test.go | 59 ++ server/fleet/datastore.go | 22 + server/fleet/embedded_variables.go | 16 + server/fleet/hosts.go | 12 + server/fleet/labels.go | 34 +- server/fleet/secret_variables.go | 1 + server/fleet/service.go | 9 + server/mdm/apple/profile_processor.go | 43 +- .../mdm/microsoft/custom_host_vitals_test.go | 65 ++ server/mdm/microsoft/profile_variables.go | 27 +- server/mock/datastore_mock.go | 120 ++++ server/mock/service/service_mock.go | 72 ++ server/service/apple_mdm.go | 35 +- server/service/apple_mdm_ddm_test.go | 37 + server/service/apple_mdm_test.go | 3 + server/service/client.go | 74 ++ server/service/client_custom_host_vitals.go | 44 ++ server/service/custom_host_vitals.go | 308 ++++++++ .../custom_host_vitals_resolution_test.go | 125 ++++ server/service/custom_host_vitals_test.go | 265 +++++++ server/service/devices_endpoint_test.go | 9 + server/service/handler.go | 8 + server/service/hosts.go | 6 + server/service/hosts_test.go | 36 +- .../integration_custom_host_vitals_test.go | 120 ++++ server/service/labels.go | 42 ++ server/service/labels_test.go | 94 +++ server/service/mdm.go | 16 + server/service/microsoft_mdm.go | 11 +- server/service/orbit.go | 5 + server/service/scripts.go | 8 +- server/service/testing_utils_test.go | 10 + server/service/windows_mdm_profiles.go | 2 +- 123 files changed, 6685 insertions(+), 478 deletions(-) create mode 100644 changes/44954-custom-host-vitals.md create mode 100644 frontend/interfaces/custom_host_vitals.ts create mode 100644 frontend/pages/ManageControlsPage/Variables/VariablesNavItems.tsx create mode 100644 frontend/pages/ManageControlsPage/Variables/cards/CustomHostVitalsTab/CustomHostVitalsTab.tests.tsx create mode 100644 frontend/pages/ManageControlsPage/Variables/cards/CustomHostVitalsTab/CustomHostVitalsTab.tsx create mode 100644 frontend/pages/ManageControlsPage/Variables/cards/CustomHostVitalsTab/CustomHostVitalsTableConfig.tsx create mode 100644 frontend/pages/ManageControlsPage/Variables/cards/CustomHostVitalsTab/index.ts rename frontend/pages/ManageControlsPage/Variables/{Variables.tests.tsx => cards/GlobalVariables/GlobalVariables.tests.tsx} (78%) create mode 100644 frontend/pages/ManageControlsPage/Variables/cards/GlobalVariables/GlobalVariables.tsx create mode 100644 frontend/pages/ManageControlsPage/Variables/cards/GlobalVariables/GlobalVariablesTableConfig.tsx create mode 100644 frontend/pages/ManageControlsPage/Variables/cards/GlobalVariables/index.ts create mode 100644 frontend/pages/ManageControlsPage/Variables/components/AddCustomHostVitalModal/AddCustomHostVitalModal.tsx create mode 100644 frontend/pages/ManageControlsPage/Variables/components/AddCustomHostVitalModal/index.ts create mode 100644 frontend/pages/ManageControlsPage/Variables/components/DeleteCustomHostVitalModal/DeleteCustomHostVitalModal.tsx create mode 100644 frontend/pages/ManageControlsPage/Variables/components/DeleteCustomHostVitalModal/index.ts create mode 100644 frontend/pages/ManageControlsPage/Variables/components/EditCustomHostVitalModal/EditCustomHostVitalModal.tsx create mode 100644 frontend/pages/ManageControlsPage/Variables/components/EditCustomHostVitalModal/index.ts create mode 100644 frontend/pages/ManageControlsPage/Variables/helpers.ts create mode 100644 frontend/pages/hosts/details/cards/Activity/ActivityItems/EditedCustomHostVitalValueActivityItem/EditedCustomHostVitalValueActivityItem.tests.tsx create mode 100644 frontend/pages/hosts/details/cards/Activity/ActivityItems/EditedCustomHostVitalValueActivityItem/EditedCustomHostVitalValueActivityItem.tsx create mode 100644 frontend/pages/hosts/details/cards/Activity/ActivityItems/EditedCustomHostVitalValueActivityItem/index.ts create mode 100644 frontend/pages/hosts/details/modals/EditHostVitalModal/EditHostVitalModal.tests.tsx create mode 100644 frontend/pages/hosts/details/modals/EditHostVitalModal/EditHostVitalModal.tsx create mode 100644 frontend/pages/hosts/details/modals/EditHostVitalModal/_styles.scss create mode 100644 frontend/pages/hosts/details/modals/EditHostVitalModal/index.ts create mode 100644 frontend/pages/labels/NewLabelPage/helpers.tests.ts create mode 100644 frontend/services/entities/custom_host_vitals.ts create mode 100644 server/datastore/mysql/custom_host_vitals.go create mode 100644 server/datastore/mysql/custom_host_vitals_test.go create mode 100644 server/datastore/mysql/migrations/tables/20260715144547_CustomHostVitals.go create mode 100644 server/datastore/mysql/migrations/tables/20260715144547_CustomHostVitals_test.go create mode 100644 server/fleet/api_custom_host_vitals.go create mode 100644 server/fleet/custom_host_vitals.go create mode 100644 server/fleet/custom_host_vitals_test.go create mode 100644 server/fleet/embedded_variables.go create mode 100644 server/mdm/microsoft/custom_host_vitals_test.go create mode 100644 server/service/client_custom_host_vitals.go create mode 100644 server/service/custom_host_vitals.go create mode 100644 server/service/custom_host_vitals_resolution_test.go create mode 100644 server/service/custom_host_vitals_test.go create mode 100644 server/service/integration_custom_host_vitals_test.go diff --git a/changes/44954-custom-host-vitals.md b/changes/44954-custom-host-vitals.md new file mode 100644 index 0000000000..73fcc4552f --- /dev/null +++ b/changes/44954-custom-host-vitals.md @@ -0,0 +1 @@ +- Added custom host vitals: admins can define custom host fields, set their values per host manually or via the API, and reference them as `$FLEET_HOST_VITAL_` variables in scripts and configuration profiles. diff --git a/cmd/fleetctl/fleetctl/generate_gitops.go b/cmd/fleetctl/fleetctl/generate_gitops.go index 8f11f418db..4a273f4a17 100644 --- a/cmd/fleetctl/fleetctl/generate_gitops.go +++ b/cmd/fleetctl/fleetctl/generate_gitops.go @@ -86,6 +86,7 @@ type generateGitopsClient interface { GetPolicies(teamID *uint) ([]*fleet.Policy, error) GetQueries(teamID *uint, name *string) ([]fleet.Query, error) GetLabels(teamID uint) ([]*fleet.LabelSpec, error) + ListCustomHostVitals(query string) ([]fleet.CustomHostVital, error) Me() (*fleet.User, error) GetSetupExperienceSoftware(platform string, teamID uint) ([]fleet.SoftwareTitleListResult, error) GetBootstrapPackageMetadata(teamID uint, forUpdate bool) (*fleet.MDMAppleBootstrapPackage, error) @@ -523,6 +524,13 @@ func (cmd *GenerateGitopsCommand) Run() error { } cmd.FilesToWrite[fileName].(map[string]interface{})["agent_options"] = cmd.AppConfig.AgentOptions + + customHostVitals, err := cmd.generateCustomHostVitals() + if err != nil { + fmt.Fprintf(cmd.CLI.App.ErrWriter, "Error generating custom host vitals: %s\n", err) + return ErrGeneric + } + cmd.FilesToWrite[fileName].(map[string]any)["custom_host_vitals"] = customHostVitals } else { // Generate team settings and agent options for the team (including "No team" with ID 0). teamSettings, err := cmd.generateTeamSettings(fileName, team) @@ -2456,6 +2464,35 @@ func (cmd *GenerateGitopsCommand) generateLabels(team *fleet.Team) ([]map[string return result, nil } +// generateCustomHostVitals emits the existing custom host vital definitions +// (names only; per-host values are never part of GitOps) so a round-trip +// (apply -> generate -> re-apply) is a no-op. Global-only, like the +// `custom_host_vitals:` GitOps key itself. +func (cmd *GenerateGitopsCommand) generateCustomHostVitals() ([]map[string]any, error) { + const perPage = 1000 + var vitals []fleet.CustomHostVital + for page := 0; ; page++ { + query := fmt.Sprintf("per_page=%d&page=%d", perPage, page) + pageVitals, err := cmd.Client.ListCustomHostVitals(query) + if err != nil { + fmt.Fprintf(cmd.CLI.App.ErrWriter, "Error getting custom host vitals: %s\n", err) + return nil, err + } + vitals = append(vitals, pageVitals...) + if len(pageVitals) < perPage { + break + } + } + t := reflect.TypeFor[spec.GitOpsCustomHostVital]() + result := make([]map[string]any, 0, len(vitals)) + for _, vital := range vitals { + result = append(result, map[string]any{ + jsonFieldName(t, "Name"): vital.Name, + }) + } + return result, nil +} + var uniEscape = regexp.MustCompile(`\\U([0-9A-Fa-f]{8})`) // Utility function to unescape Unicode U+XXXX sequences added by the YAML marshaler. diff --git a/cmd/fleetctl/fleetctl/generate_gitops_test.go b/cmd/fleetctl/fleetctl/generate_gitops_test.go index c57c2daa15..d7ab167d1e 100644 --- a/cmd/fleetctl/fleetctl/generate_gitops_test.go +++ b/cmd/fleetctl/fleetctl/generate_gitops_test.go @@ -738,6 +738,13 @@ func (MockClient) GetLabels(teamID uint) ([]*fleet.LabelSpec, error) { }}, nil } +func (MockClient) ListCustomHostVitals(query string) ([]fleet.CustomHostVital, error) { + return []fleet.CustomHostVital{ + {ID: 1, Name: "Asset tag"}, + {ID: 2, Name: "Department"}, + }, nil +} + func (MockClient) Me() (*fleet.User, error) { return &fleet.User{ ID: 1, diff --git a/cmd/fleetctl/fleetctl/get_test.go b/cmd/fleetctl/fleetctl/get_test.go index ba16742540..2c621d93da 100644 --- a/cmd/fleetctl/fleetctl/get_test.go +++ b/cmd/fleetctl/fleetctl/get_test.go @@ -527,6 +527,9 @@ func TestGetHosts(t *testing.T) { ds.ConditionalAccessBypassedAtFunc = func(ctx context.Context, hostID uint) (*time.Time, error) { return nil, nil } + ds.GetHostCustomHostVitalsFunc = func(ctx context.Context, hostID uint) ([]fleet.HostCustomHostVital, error) { + return nil, nil + } defaultPolicyQuery := "select 1 from osquery_info where start_time > 1;" ds.ListPoliciesForHostFunc = func(ctx context.Context, host *fleet.Host) ([]*fleet.HostPolicy, error) { return []*fleet.HostPolicy{ @@ -753,6 +756,9 @@ func TestGetHostsMDM(t *testing.T) { ds.ListPoliciesForHostFunc = func(ctx context.Context, host *fleet.Host) ([]*fleet.HostPolicy, error) { return nil, nil } + ds.GetHostCustomHostVitalsFunc = func(ctx context.Context, hostID uint) ([]fleet.HostCustomHostVital, error) { + return nil, nil + } ds.GetHostsLockWipeStatusBatchFunc = func(ctx context.Context, hosts []*fleet.Host) (map[uint]*fleet.HostLockWipeStatus, error) { return make(map[uint]*fleet.HostLockWipeStatus), nil } diff --git a/cmd/fleetctl/fleetctl/mdm_test.go b/cmd/fleetctl/fleetctl/mdm_test.go index e0b7c7d16a..0904154ec9 100644 --- a/cmd/fleetctl/fleetctl/mdm_test.go +++ b/cmd/fleetctl/fleetctl/mdm_test.go @@ -214,6 +214,9 @@ func TestMDMRunCommand(t *testing.T) { ds.ListPoliciesForHostFunc = func(ctx context.Context, host *fleet.Host) ([]*fleet.HostPolicy, error) { return nil, nil } + ds.GetHostCustomHostVitalsFunc = func(ctx context.Context, hostID uint) ([]fleet.HostCustomHostVital, error) { + return nil, nil + } ds.GetHostMDMAppleProfilesFunc = func(ctx context.Context, hostUUID string) ([]fleet.HostMDMAppleProfile, error) { return nil, nil } @@ -1462,6 +1465,9 @@ func setupDSMocks(ds *mock.Store, hostByUUID map[string]testhost, hostsByID map[ ds.ListPoliciesForHostFunc = func(ctx context.Context, host *fleet.Host) ([]*fleet.HostPolicy, error) { return nil, nil } + ds.GetHostCustomHostVitalsFunc = func(ctx context.Context, hostID uint) ([]fleet.HostCustomHostVital, error) { + return nil, nil + } ds.ListLabelsForHostFunc = func(ctx context.Context, hid uint) ([]*fleet.Label, error) { return nil, nil } diff --git a/cmd/fleetctl/fleetctl/scripts_test.go b/cmd/fleetctl/fleetctl/scripts_test.go index 0f4fef8251..8f1595f776 100644 --- a/cmd/fleetctl/fleetctl/scripts_test.go +++ b/cmd/fleetctl/fleetctl/scripts_test.go @@ -42,6 +42,9 @@ func TestRunScriptCommand(t *testing.T) { ds.ListPoliciesForHostFunc = func(ctx context.Context, host *fleet.Host) ([]*fleet.HostPolicy, error) { return nil, nil } + ds.GetHostCustomHostVitalsFunc = func(ctx context.Context, hostID uint) ([]fleet.HostCustomHostVital, error) { + return nil, nil + } ds.ListHostBatteriesFunc = func(ctx context.Context, hid uint) ([]*fleet.HostBattery, error) { return nil, nil } diff --git a/cmd/fleetctl/fleetctl/testdata/generateGitops/test_dir_free/default.yml b/cmd/fleetctl/fleetctl/testdata/generateGitops/test_dir_free/default.yml index 42510290fe..6a44cdf07f 100644 --- a/cmd/fleetctl/fleetctl/testdata/generateGitops/test_dir_free/default.yml +++ b/cmd/fleetctl/fleetctl/testdata/generateGitops/test_dir_free/default.yml @@ -30,6 +30,9 @@ controls: - labels_include_any: - Label D path: ./lib/profiles/global-windows-profile.xml +custom_host_vitals: +- name: Asset tag +- name: Department labels: - description: Label A description label_membership_type: dynamic diff --git a/cmd/fleetctl/fleetctl/testdata/generateGitops/test_dir_premium/default.yml b/cmd/fleetctl/fleetctl/testdata/generateGitops/test_dir_premium/default.yml index 4a056d5f29..bd13e10de6 100644 --- a/cmd/fleetctl/fleetctl/testdata/generateGitops/test_dir_premium/default.yml +++ b/cmd/fleetctl/fleetctl/testdata/generateGitops/test_dir_premium/default.yml @@ -12,6 +12,9 @@ agent_options: logger_tls_endpoint: /api/osquery/log logger_tls_period: 10 pack_delimiter: / +custom_host_vitals: +- name: Asset tag +- name: Department labels: - description: Label A description label_membership_type: dynamic diff --git a/cmd/fleetctl/fleetctl/testing_utils/testing_utils.go b/cmd/fleetctl/fleetctl/testing_utils/testing_utils.go index 492cc6d1e4..dd0f03c8b5 100644 --- a/cmd/fleetctl/fleetctl/testing_utils/testing_utils.go +++ b/cmd/fleetctl/fleetctl/testing_utils/testing_utils.go @@ -50,6 +50,14 @@ const ( // NOTE: Assumes the current session is always from the admin user (see ds.SessionByKeyFunc below). func RunServerWithMockedDS(t *testing.T, opts ...*service.TestServerOpts) (*httptest.Server, *mock.Store) { ds := new(mock.Store) + // Custom host vitals are declarative and global-only, so every `fleetctl gitops` + // run against a global config calls these, even when custom_host_vitals: is absent. + ds.ListCustomHostVitalsFunc = func(ctx context.Context, opt fleet.ListOptions) ([]fleet.CustomHostVital, *fleet.PaginationMetadata, int, error) { + return nil, &fleet.PaginationMetadata{}, 0, nil + } + ds.UpsertCustomHostVitalsFunc = func(ctx context.Context, vitals []fleet.CustomHostVital) ([]fleet.CustomHostVital, []fleet.CustomHostVital, error) { + return nil, nil, nil + } var users []*fleet.User var admin *fleet.User ds.NewUserFunc = func(ctx context.Context, user *fleet.User) (*fleet.User, error) { @@ -138,6 +146,9 @@ func RunServerWithMockedDS(t *testing.T, opts ...*service.TestServerOpts) (*http ds.ValidateEmbeddedSecretsFunc = func(ctx context.Context, documents []string) error { return nil } + ds.ValidateReferencedCustomHostVitalsFunc = func(ctx context.Context, documents []string) error { + return nil + } ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { return nil, nil } diff --git a/cmd/fleetctl/integrationtest/gitops/gitops_integration_test.go b/cmd/fleetctl/integrationtest/gitops/gitops_integration_test.go index d9a987ba71..a7ece31d35 100644 --- a/cmd/fleetctl/integrationtest/gitops/gitops_integration_test.go +++ b/cmd/fleetctl/integrationtest/gitops/gitops_integration_test.go @@ -263,3 +263,125 @@ queries: _, err = fleetctltest.RunAppNoChecks([]string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name()}) require.ErrorContains(t, err, "missing or invalid license") } + +// customHostVitalsGlobalYAML writes a minimal global GitOps file with the +// given `custom_host_vitals:` body (e.g. "- name: Foo\n- name: Bar", or "" +// to omit the key entirely, which is the declarative clear-all case). +func (s *integrationGitopsTestSuite) customHostVitalsGlobalYAML(customHostVitalsBody string) string { + t := s.T() + f, err := os.CreateTemp(t.TempDir(), "*.yml") + require.NoError(t, err) + customHostVitalsKey := "" + if customHostVitalsBody != "" { + customHostVitalsKey = "custom_host_vitals:\n" + customHostVitalsBody + } + _, err = f.WriteString(fmt.Sprintf(` +policies: +queries: +agent_options: +controls: +org_settings: + server_settings: + server_url: $FLEET_URL + org_info: + org_name: Fleet + secrets: +%s +`, customHostVitalsKey)) + require.NoError(t, err) + return f.Name() +} + +func (s *integrationGitopsTestSuite) TestFleetGitopsCustomHostVitals() { + t := s.T() + ctx := t.Context() + fleetctlConfig := s.createFleetctlConfig() + t.Setenv("FLEET_URL", s.Server.URL) + + listVitals := func() []fleet.CustomHostVital { + vitals, _, _, err := s.DS.ListCustomHostVitals(ctx, fleet.ListOptions{}) + require.NoError(t, err) + return vitals + } + names := func(vitals []fleet.CustomHostVital) []string { + out := make([]string, 0, len(vitals)) + for _, v := range vitals { + out = append(out, v.Name) + } + return out + } + + // Ensure a clean slate: this is global state shared across the suite's tests. + defer func() { + globalFile := s.customHostVitalsGlobalYAML("") + fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile}) + require.Empty(t, listVitals()) + }() + + // Dry run validates without persisting, and logs what would've been created. + globalFileV1 := s.customHostVitalsGlobalYAML(" - name: Asset tag\n - name: Department\n") + out := fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFileV1, "--dry-run"}) + assert.Contains(t, out, "[+] would've created 2 custom host vitals") + assert.Contains(t, out, "gitops dry run succeeded") + assert.Empty(t, listVitals()) + + // Real run creates both, and logs what it created. + out = fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFileV1}) + assert.Contains(t, out, "[+] creating 2 custom host vitals") + assert.Contains(t, out, "gitops succeeded") + require.ElementsMatch(t, []string{"Asset tag", "Department"}, names(listVitals())) + + byName := make(map[string]uint) + for _, v := range listVitals() { + byName[v.Name] = v.ID + } + assetTagID := byName["Asset tag"] + + // Re-applying with "Department" dropped and "Role" added: Department is + // deleted, Role is created, Asset tag is retained with the same ID. + globalFileV2 := s.customHostVitalsGlobalYAML(" - name: Asset tag\n - name: Role\n") + out = fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFileV2}) + assert.Contains(t, out, "[-] deleting custom host vital 'Department'") + assert.Contains(t, out, "[+] creating 1 custom host vital") + require.ElementsMatch(t, []string{"Asset tag", "Role"}, names(listVitals())) + for _, v := range listVitals() { + if v.Name == "Asset tag" { + require.Equal(t, assetTagID, v.ID) + } + } + + // A dry run still sends the request to the server (with DryRun set), so + // server-side validation -- like the collation-aware duplicate-name check, + // which is not enforced by the local diff -- is still exercised without + // persisting. + globalFileDupe := s.customHostVitalsGlobalYAML(" - name: Asset tag\n - name: asset tag\n") + _, err := fleetctltest.RunAppNoChecks([]string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFileDupe, "--dry-run"}) + require.ErrorContains(t, err, "duplicate custom host vital names") + require.ElementsMatch(t, []string{"Asset tag", "Role"}, names(listVitals())) + + // A script referencing $FLEET_HOST_VITAL_ for "Asset tag" blocks its + // removal: applying a config that drops it from custom_host_vitals errors + // the whole run, and no vitals are removed. + script, err := s.DS.NewScript(ctx, &fleet.Script{ + Name: "collect-asset-tag.sh", + ScriptContents: fmt.Sprintf("echo $%s%d", fleet.CustomHostVitalPrefix, assetTagID), + }) + require.NoError(t, err) + + globalFileV3 := s.customHostVitalsGlobalYAML(" - name: Role\n") + _, err = fleetctltest.RunAppNoChecks([]string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFileV3}) + require.ErrorContains(t, err, "Couldn't delete") + require.ErrorContains(t, err, "Asset tag") + require.ElementsMatch(t, []string{"Asset tag", "Role"}, names(listVitals())) + + require.NoError(t, s.DS.DeleteScript(ctx, script.ID)) + + // With the reference gone, the same config now succeeds. + fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFileV3}) + require.ElementsMatch(t, []string{"Role"}, names(listVitals())) + + // An absent `custom_host_vitals:` key is a declarative clear-all. + globalFileAbsent := s.customHostVitalsGlobalYAML("") + fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFileAbsent}) + require.Empty(t, listVitals()) +} diff --git a/docs/REST API/rest-api.md b/docs/REST API/rest-api.md index a392abddd3..5950ae9b32 100644 --- a/docs/REST API/rest-api.md +++ b/docs/REST API/rest-api.md @@ -15802,7 +15802,9 @@ Lists all custom variables that can be used in scripts and profiles prefixed wit "custom_variables": [ { "id": 123, - "name": "SOME_API_TOKEN" + "name": "SOME_API_TOKEN", + "created_at": "2020-11-13T22:57:12Z", + "updated_at": "2020-11-13T22:57:12Z" } ], "meta": { diff --git a/ee/server/service/maintained_apps.go b/ee/server/service/maintained_apps.go index 9936e1db37..f9278bea0e 100644 --- a/ee/server/service/maintained_apps.go +++ b/ee/server/service/maintained_apps.go @@ -57,6 +57,16 @@ func (svc *Service) AddFleetMaintainedApp( // We should not get to this point. If we did, it means we have another issue, such as large read replica latency. return 0, ctxerr.Wrap(ctx, err, "transient server issue validating embedded secrets") } + if err := svc.ds.ValidateReferencedCustomHostVitals(ctx, []string{installScript, postInstallScript, uninstallScript}); err != nil { + var argErr *fleet.InvalidArgumentError + argErr = svc.validateReferencedCustomHostVitalsOnScript(ctx, "install script", &installScript, argErr) + argErr = svc.validateReferencedCustomHostVitalsOnScript(ctx, "post-install script", &postInstallScript, argErr) + argErr = svc.validateReferencedCustomHostVitalsOnScript(ctx, "uninstall script", &uninstallScript, argErr) + if argErr != nil { + return 0, argErr + } + return 0, ctxerr.Wrap(ctx, err, "transient server issue validating custom host vitals") + } app, err := svc.ds.GetMaintainedAppByID(ctx, appID, teamID) if err != nil { diff --git a/ee/server/service/setup_experience.go b/ee/server/service/setup_experience.go index 1444f8e258..6f3a61b692 100644 --- a/ee/server/service/setup_experience.go +++ b/ee/server/service/setup_experience.go @@ -140,6 +140,9 @@ func (svc *Service) SetSetupExperienceScript(ctx context.Context, teamID *uint, if err := svc.ds.ValidateEmbeddedSecrets(ctx, []string{script.ScriptContents}); err != nil { return fleet.NewInvalidArgumentError("script", err.Error()) } + if err := svc.ds.ValidateReferencedCustomHostVitals(ctx, []string{script.ScriptContents}); err != nil { + return fleet.NewInvalidArgumentError("script", err.Error()) + } // setup experience is only supported for macOS currently so we need to override the file // extension check in the general script validation diff --git a/ee/server/service/setup_experience_test.go b/ee/server/service/setup_experience_test.go index 455525f4b0..896957a891 100644 --- a/ee/server/service/setup_experience_test.go +++ b/ee/server/service/setup_experience_test.go @@ -312,6 +312,27 @@ func TestSetupExperienceSetWithManualAgentInstall(t *testing.T) { }) } +func TestSetupExperienceScriptRejectsUnknownCustomHostVital(t *testing.T) { + ctx := test.UserContext(context.Background(), test.UserAdmin) + ds := new(mock.Store) + svc, baseSvc := newTestServiceWithMock(t, ds) + + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return &fleet.AppConfig{}, nil + } + ds.ValidateEmbeddedSecretsFunc = func(ctx context.Context, documents []string) error { return nil } + ds.ValidateReferencedCustomHostVitalsFunc = func(ctx context.Context, documents []string) error { + return &fleet.MissingCustomHostVitalsError{MissingIDs: []uint{99}} + } + ds.SetSetupExperienceScriptFunc = func(ctx context.Context, script *fleet.Script) error { return nil } + baseSvc.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error { return nil } + + err := svc.SetSetupExperienceScript(ctx, nil, "potato.sh", bytes.NewReader([]byte("echo $FLEET_HOST_VITAL_99"))) + require.Error(t, err) + require.ErrorContains(t, err, "Custom host vital") + require.False(t, ds.SetSetupExperienceScriptFuncInvoked) +} + // TestSetupExperienceNextStepPolicyGated covers the policy-gated (Windows/Linux) branch of SetupExperienceNextStep: the policy is // used only as a gate (pass -> skip, fail -> install via the normal ForSetupExperience path), the item is held running while // awaiting a fresh result, an out-of-scope gating policy falls back to installing, and the host policy clock is reset once when a diff --git a/ee/server/service/software_installers.go b/ee/server/service/software_installers.go index b99894eb33..f3b3edfd47 100644 --- a/ee/server/service/software_installers.go +++ b/ee/server/service/software_installers.go @@ -117,6 +117,18 @@ func (svc *Service) UploadSoftwareInstaller(ctx context.Context, payload *fleet. } } + if err := svc.ds.ValidateReferencedCustomHostVitals(ctx, []string{payload.InstallScript, payload.PostInstallScript, payload.UninstallScript}); err != nil { + // Redo per-script to report which script references the undefined custom host vital. + var argErr *fleet.InvalidArgumentError + argErr = svc.validateReferencedCustomHostVitalsOnScript(ctx, "install script", &payload.InstallScript, argErr) + argErr = svc.validateReferencedCustomHostVitalsOnScript(ctx, "post-install script", &payload.PostInstallScript, argErr) + argErr = svc.validateReferencedCustomHostVitalsOnScript(ctx, "uninstall script", &payload.UninstallScript, argErr) + if argErr != nil { + return nil, argErr + } + return nil, ctxerr.Wrap(ctx, err, "transient server issue validating custom host vitals") + } + if payload.AutomaticInstall && payload.AutomaticInstallQuery == "" { switch { // @@ -385,6 +397,16 @@ func (svc *Service) UpdateSoftwareInstaller(ctx context.Context, payload *fleet. // We should not get to this point. If we did, it means we have another issue, such as large read replica latency. return nil, ctxerr.Wrap(ctx, err, "transient server issue validating embedded secrets") } + if err := svc.ds.ValidateReferencedCustomHostVitals(ctx, scripts); err != nil { + var argErr *fleet.InvalidArgumentError + argErr = svc.validateReferencedCustomHostVitalsOnScript(ctx, "install script", payload.InstallScript, argErr) + argErr = svc.validateReferencedCustomHostVitalsOnScript(ctx, "post-install script", payload.PostInstallScript, argErr) + argErr = svc.validateReferencedCustomHostVitalsOnScript(ctx, "uninstall script", payload.UninstallScript, argErr) + if argErr != nil { + return nil, argErr + } + return nil, ctxerr.Wrap(ctx, err, "transient server issue validating custom host vitals") + } // get software by ID, fail if it does not exist or does not have an existing installer software, err := svc.ds.SoftwareTitleByID(ctx, payload.TitleID, payload.TeamID, fleet.TeamFilter{ @@ -855,6 +877,23 @@ func (svc *Service) validateEmbeddedSecretsOnScript(ctx context.Context, scriptN return argErr } +// validateReferencedCustomHostVitalsOnScript mirrors validateEmbeddedSecretsOnScript +// so callers can report which specific script references an undefined custom host vital. +func (svc *Service) validateReferencedCustomHostVitalsOnScript(ctx context.Context, scriptName string, script *string, + argErr *fleet.InvalidArgumentError, +) *fleet.InvalidArgumentError { + if script != nil { + if errScript := svc.ds.ValidateReferencedCustomHostVitals(ctx, []string{*script}); errScript != nil { + if argErr != nil { + argErr.Append(scriptName, errScript.Error()) + } else { + argErr = fleet.NewInvalidArgumentError(scriptName, errScript.Error()) + } + } + } + return argErr +} + func ValidateSoftwareLabelsForUpdate(ctx context.Context, svc fleet.Service, existingInstaller *fleet.SoftwareInstaller, includeAny, excludeAny, includeAll []string) (shouldUpdate bool, validatedLabels *fleet.LabelIdentsWithScope, err error) { if authctx, ok := authz_ctx.FromContext(ctx); !ok { return false, nil, fleet.NewAuthRequiredError("batch validate labels: missing authorization context") @@ -2503,6 +2542,9 @@ func (svc *Service) BatchSetSoftwareInstallers( if err := svc.ds.ValidateEmbeddedSecrets(ctx, allScripts); err != nil { return "", ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("script", err.Error())) } + if err := svc.ds.ValidateReferencedCustomHostVitals(ctx, allScripts); err != nil { + return "", ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("script", err.Error())) + } } requestUUID := uuid.NewString() diff --git a/ee/server/service/software_installers_test.go b/ee/server/service/software_installers_test.go index 6f560787dd..365b5261b3 100644 --- a/ee/server/service/software_installers_test.go +++ b/ee/server/service/software_installers_test.go @@ -718,6 +718,7 @@ func newTestService(t *testing.T, ds fleet.Datastore) *Service { t.Helper() authorizer, err := authz.NewAuthorizer() require.NoError(t, err) + defaultMockCustomHostVitalsValidation(ds) svc := &Service{ authz: authorizer, ds: ds, @@ -730,6 +731,7 @@ func newTestServiceWithMock(t *testing.T, ds fleet.Datastore) (*Service, *svcmoc t.Helper() authorizer, err := authz.NewAuthorizer() require.NoError(t, err) + defaultMockCustomHostVitalsValidation(ds) baseSvc := new(svcmock.Service) svc := &Service{ Service: baseSvc, @@ -739,6 +741,14 @@ func newTestServiceWithMock(t *testing.T, ds fleet.Datastore) (*Service, *svcmoc return svc, baseSvc } +// Software installer and setup-experience uploads validate referenced custom +// host vitals, so mock-backed tests that don't care about it needn't stub it. +func defaultMockCustomHostVitalsValidation(ds fleet.Datastore) { + if mockDS, ok := ds.(*mock.Store); ok && mockDS.ValidateReferencedCustomHostVitalsFunc == nil { + mockDS.ValidateReferencedCustomHostVitalsFunc = func(ctx context.Context, documents []string) error { return nil } + } +} + func TestAddScriptPackageMetadata(t *testing.T) { t.Parallel() ctx := context.Background() diff --git a/frontend/components/CommandPalette/CommandPalette.tsx b/frontend/components/CommandPalette/CommandPalette.tsx index 6e76ec4d50..c7cd1b23b4 100644 --- a/frontend/components/CommandPalette/CommandPalette.tsx +++ b/frontend/components/CommandPalette/CommandPalette.tsx @@ -138,6 +138,16 @@ const CommandPalette = (): JSX.Element | null => { isAnyTeamMaintainer || isTechnician; + // The Variables section (and its Global variables / Custom host vitals + // sub-tabs) is hidden from technicians in the Controls sub-nav — they only + // get OS settings and Scripts (see ManageControlsPage). canAccessControls + // includes technicians, so use this narrower flag for Variables. + const canAccessVariables = + isGlobalAdmin || + isGlobalMaintainer || + isAnyTeamAdmin || + isAnyTeamMaintainer; + // Custom variables are admin-tier global config (mirrors Variables.tsx // `canEdit`). Team admins/maintainers/technicians lack the role even // though they have `canWrite`, so the destination page would render @@ -413,6 +423,7 @@ const CommandPalette = (): JSX.Element | null => { availableTeams, config, canAccessControls, + canAccessVariables, canWrite, canRunLiveReport, canAccessSettings, @@ -449,6 +460,7 @@ const CommandPalette = (): JSX.Element | null => { availableTeams, config, canAccessControls, + canAccessVariables, canWrite, canRunLiveReport, canAccessSettings, diff --git a/frontend/components/CommandPalette/groups/controls.ts b/frontend/components/CommandPalette/groups/controls.ts index 146dcf172a..d5a7d68d97 100644 --- a/frontend/components/CommandPalette/groups/controls.ts +++ b/frontend/components/CommandPalette/groups/controls.ts @@ -9,6 +9,7 @@ const buildControlsItems = ( ): ICommandItem[] => { const { canAccessControls, + canAccessVariables, isPremiumTier, isAdminOrMaintainer, withTeamId, @@ -220,14 +221,34 @@ const buildControlsItems = ( }, ], }, - // Variables - { - id: "controls-variables", - label: "Variables", - group: "Controls" as const, - path: withTeamId(paths.CONTROLS_VARIABLES), - keywords: ["custom", "scripts", "profiles"], - }, + // Variables — including its Global variables and Custom host vitals + // sub-tabs. Gated on canAccessVariables (admins + maintainers) since the + // Controls sub-nav hides this section from technicians. + ...(canAccessVariables + ? [ + { + id: "controls-variables", + label: "Variables", + group: "Controls" as const, + path: withTeamId(paths.CONTROLS_VARIABLES), + keywords: ["custom", "scripts", "profiles"], + subItems: [ + { + id: "controls-global-variables", + label: "Global variables", + path: withTeamId(paths.CONTROLS_VARIABLES_GLOBAL_VARIABLES), + keywords: ["custom", "secret"], + }, + { + id: "controls-custom-host-vitals", + label: "Custom host vitals", + path: withTeamId(paths.CONTROLS_VARIABLES_CUSTOM_HOST_VITALS), + keywords: ["host", "vital", "custom", "host vital"], + }, + ], + }, + ] + : []), ]; }; diff --git a/frontend/components/CommandPalette/helpers.tests.ts b/frontend/components/CommandPalette/helpers.tests.ts index 355f5ede41..e487ecfdbf 100644 --- a/frontend/components/CommandPalette/helpers.tests.ts +++ b/frontend/components/CommandPalette/helpers.tests.ts @@ -26,6 +26,7 @@ const BASE_CONTEXT: ICommandPaletteContext = { currentTeam: undefined, config: createMockConfig(), canAccessControls: true, + canAccessVariables: true, canWrite: true, canRunLiveReport: true, canAccessSettings: true, @@ -391,6 +392,32 @@ describe("CommandPalette helpers", () => { expect(subIds).toContain("controls-passwords"); }); + it("includes Variables with its Global variables and Custom host vitals sub-tabs when canAccessVariables", () => { + const items = buildPaletteItems({ + ...BASE_CONTEXT, + hasTeamSelected: true, + currentTeam: { id: 1, name: "Engineering" }, + }); + + const variables = items.find((i) => i.id === "controls-variables"); + const subIds = variables?.subItems?.map((s) => s.id) ?? []; + expect(subIds).toContain("controls-global-variables"); + expect(subIds).toContain("controls-custom-host-vitals"); + }); + + it("hides Variables entirely when !canAccessVariables (technicians)", () => { + const items = buildPaletteItems({ + ...BASE_CONTEXT, + canAccessVariables: false, + isTechnician: true, + hasTeamSelected: true, + currentTeam: { id: 1, name: "Engineering" }, + }); + + const ids = items.map((i) => i.id); + expect(ids).not.toContain("controls-variables"); + }); + it("includes Host names for admin/maintainer on a fleet", () => { const items = buildPaletteItems({ ...BASE_CONTEXT, @@ -752,6 +779,12 @@ describe("CommandPalette helpers", () => { ?.subItems?.map((s) => s.id) ?? []; expect(scriptsSubIds).toContain("controls-scripts-library"); expect(scriptsSubIds).toContain("controls-scripts-batch-progress"); + const variablesSubIds = + items + .find((i) => i.id === "controls-variables") + ?.subItems?.map((s) => s.id) ?? []; + expect(variablesSubIds).toContain("controls-global-variables"); + expect(variablesSubIds).toContain("controls-custom-host-vitals"); }); it("surfaces Add script on Free (script library is Free-available)", () => { diff --git a/frontend/components/CommandPalette/helpers.ts b/frontend/components/CommandPalette/helpers.ts index bb8ef7f822..a18dd38550 100644 --- a/frontend/components/CommandPalette/helpers.ts +++ b/frontend/components/CommandPalette/helpers.ts @@ -40,6 +40,7 @@ export interface ICommandPaletteContext { availableTeams?: ITeamSummary[]; config: IConfig | null; canAccessControls?: boolean; + canAccessVariables?: boolean; canWrite?: boolean; canRunLiveReport?: boolean; canAccessSettings?: boolean; diff --git a/frontend/interfaces/activity.ts b/frontend/interfaces/activity.ts index fce2a226d8..b261784a4e 100644 --- a/frontend/interfaces/activity.ts +++ b/frontend/interfaces/activity.ts @@ -161,6 +161,7 @@ export enum ActivityType { EscrowedDiskEncryptionKey = "escrowed_disk_encryption_key", CreatedCustomVariable = "created_custom_variable", DeletedCustomVariable = "deleted_custom_variable", + EditedCustomHostVitalValue = "edited_custom_host_vital_value", EditedSetupExperienceSoftware = "edited_setup_experience_software", EditedHostIdpData = "edited_host_idp_data", AddedGoogleWorkspaceIntegration = "added_google_workspace_integration", @@ -197,6 +198,9 @@ export enum ActivityType { RanAutomationTicket = "ran_automation_ticket", RanAutomationCalendarEvent = "ran_automation_calendar_event", RanAutomationConditionalAccess = "ran_automation_conditional_access", + CreatedCustomHostVital = "created_custom_host_vital", + EditedCustomHostVital = "edited_custom_host_vital", + DeletedCustomHostVital = "deleted_custom_host_vital", } /** This is a subset of ActivityType that are shown only for the host past activities */ @@ -230,6 +234,7 @@ export type IHostPastActivityType = | ActivityType.FailedToRotateManagedLocalAccountPassword | ActivityType.FailedEnrollmentProfileRenewal | ActivityType.RanCustomMdmCommand + | ActivityType.EditedCustomHostVitalValue | ActivityType.RanAutomationWebhook | ActivityType.RanAutomationTicket | ActivityType.RanAutomationCalendarEvent @@ -362,6 +367,8 @@ export interface IActivityDetails { ticket_key?: string; ticket_id?: number; custom_variable_name?: string; + custom_host_vital_id?: number; + custom_host_vital_name?: string; domain?: string; host_idp_username?: string; idp_full_name?: string; @@ -532,6 +539,7 @@ export const ACTIVITY_TYPE_TO_FILTER_LABEL: Record = { escrowed_disk_encryption_key: "Escrowed disk encryption key", created_custom_variable: "Created custom variable", deleted_custom_variable: "Deleted custom variable", + [ActivityType.EditedCustomHostVitalValue]: "Edited custom host vital value", [ActivityType.HostDeleted]: "Host deleted", [ActivityType.AddedHydrant]: "Added certificate authority (CA): Hydrant", [ActivityType.DeletedHydrant]: "Deleted certificate authority (CA): Hydrant", @@ -604,4 +612,7 @@ export const ACTIVITY_TYPE_TO_FILTER_LABEL: Record = { "Policy automation: calendar event created", [ActivityType.RanAutomationConditionalAccess]: "Policy automation: single sign-on blocked", + [ActivityType.CreatedCustomHostVital]: "Created custom host vital", + [ActivityType.EditedCustomHostVital]: "Edited custom host vital", + [ActivityType.DeletedCustomHostVital]: "Deleted custom host vital", }; diff --git a/frontend/interfaces/custom_host_vitals.ts b/frontend/interfaces/custom_host_vitals.ts new file mode 100644 index 0000000000..6ce596b683 --- /dev/null +++ b/frontend/interfaces/custom_host_vitals.ts @@ -0,0 +1,17 @@ +export interface ICustomHostVital { + id: number; + name: string; + created_at: string; + updated_at: string; +} + +export interface ICustomHostVitalFormData { + name: string; +} + +// The per-host projection of a custom host vital +export interface IHostCustomVital { + custom_host_vital_id: number; + name: string; + value: string; +} diff --git a/frontend/interfaces/host.ts b/frontend/interfaces/host.ts index 68592a4200..527b5dcd61 100644 --- a/frontend/interfaces/host.ts +++ b/frontend/interfaces/host.ts @@ -15,6 +15,7 @@ import { HostNameSettingStatus, } from "./mdm"; import { HostPlatform } from "./platform"; +import { IHostCustomVital } from "./custom_host_vitals"; export default PropTypes.shape({ created_at: PropTypes.string, @@ -393,6 +394,7 @@ export interface IHost { device_mapping: IDeviceUser[] | null; /** There will be at most 1 end user */ end_users?: IHostEndUser[]; + custom_host_vitals?: IHostCustomVital[]; conditional_access_bypassed: boolean; mdm_enrollment_hardware_attested?: boolean; } diff --git a/frontend/interfaces/label.ts b/frontend/interfaces/label.ts index 61789b3131..6cabd2cdba 100644 --- a/frontend/interfaces/label.ts +++ b/frontend/interfaces/label.ts @@ -25,15 +25,40 @@ export const LabelMembershipTypeToDisplayCopy: Record< host_vitals: "Host vitals", }; -export type LabelHostVitalsCriterion = +export type LabelHostVitalsIdpCriterion = | "end_user_idp_group" - | "end_user_idp_department"; // for now, may expand to be configurable + | "end_user_idp_department"; -export type LabelLeafCriterion = { - vital: LabelHostVitalsCriterion; +// A custom host vital is selected as a label criterion by referencing its +// definition id. The IdP enum values self-identify their vital; the custom path +// is a single sentinel, so the id (below) is what distinguishes one custom vital +// from another. +export const CUSTOM_HOST_VITAL_CRITERION = "custom_host_vital" as const; +export type LabelHostVitalsCustomCriterion = typeof CUSTOM_HOST_VITAL_CRITERION; + +export type LabelHostVitalsCriterion = + | LabelHostVitalsIdpCriterion + | LabelHostVitalsCustomCriterion; + +// An IdP-based leaf: the vital enum self-identifies which vital, so no id. +type LabelIdpLeafCriterion = { + vital: LabelHostVitalsIdpCriterion; value: string; // from user input + custom_host_vital_id?: never; }; +// A custom-host-vital leaf: the sentinel `vital` alone doesn't identify which +// vital, so `custom_host_vital_id` is required. +type LabelCustomLeafCriterion = { + vital: LabelHostVitalsCustomCriterion; + value: string; // from user input + custom_host_vital_id: number; +}; + +export type LabelLeafCriterion = + | LabelIdpLeafCriterion + | LabelCustomLeafCriterion; + type LabelAndCriterion = { and: LabelHostVitalsCriteria[]; }; diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tests.tsx b/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tests.tsx index 39b1c4dbe4..3d22b80414 100644 --- a/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tests.tsx +++ b/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tests.tsx @@ -2206,4 +2206,43 @@ describe("Activity Feed", () => { expect(screen.getByText(/a custom MDM command/i)).toBeInTheDocument(); expect(screen.getByText("Huck's MacBook Pro")).toBeInTheDocument(); }); + + it("renders a created_custom_host_vital activity", () => { + const activity = createMockActivity({ + type: ActivityType.CreatedCustomHostVital, + details: { custom_host_vital_id: 1, custom_host_vital_name: "Function" }, + }); + render(); + + expect( + screen.getByText("created a custom host vital", { exact: false }) + ).toBeInTheDocument(); + expect(screen.getByText("Function")).toBeInTheDocument(); + }); + + it("renders an edited_custom_host_vital activity", () => { + const activity = createMockActivity({ + type: ActivityType.EditedCustomHostVital, + details: { custom_host_vital_id: 1, custom_host_vital_name: "Asset tag" }, + }); + render(); + + expect( + screen.getByText("edited custom host vital", { exact: false }) + ).toBeInTheDocument(); + expect(screen.getByText("Asset tag")).toBeInTheDocument(); + }); + + it("renders a deleted_custom_host_vital activity", () => { + const activity = createMockActivity({ + type: ActivityType.DeletedCustomHostVital, + details: { custom_host_vital_id: 1, custom_host_vital_name: "Asset tag" }, + }); + render(); + + expect( + screen.getByText("deleted custom host vital", { exact: false }) + ).toBeInTheDocument(); + expect(screen.getByText("Asset tag")).toBeInTheDocument(); + }); }); diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tsx b/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tsx index d88522c123..419641572a 100644 --- a/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tsx +++ b/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tsx @@ -2043,6 +2043,30 @@ const TAGGED_TEMPLATES = { ); }, + createdCustomHostVital: (activity: IActivity) => { + const { custom_host_vital_name } = activity.details || {}; + return ( + <> + created a custom host vital {custom_host_vital_name}. + + ); + }, + editedCustomHostVital: (activity: IActivity) => { + const { custom_host_vital_name } = activity.details || {}; + return ( + <> + edited custom host vital {custom_host_vital_name}. + + ); + }, + deletedCustomHostVital: (activity: IActivity) => { + const { custom_host_vital_name } = activity.details || {}; + return ( + <> + deleted custom host vital {custom_host_vital_name}. + + ); + }, editedSetupExperienceSoftware: (activity: IActivity) => { const { platform, team_name, team_id } = activity.details || {}; @@ -2629,6 +2653,15 @@ const getDetail = (activity: IActivity, isPremiumTier: boolean) => { case ActivityType.DeletedCustomVariable: { return TAGGED_TEMPLATES.deletedCustomVariable(activity); } + case ActivityType.CreatedCustomHostVital: { + return TAGGED_TEMPLATES.createdCustomHostVital(activity); + } + case ActivityType.EditedCustomHostVital: { + return TAGGED_TEMPLATES.editedCustomHostVital(activity); + } + case ActivityType.DeletedCustomHostVital: { + return TAGGED_TEMPLATES.deletedCustomHostVital(activity); + } case ActivityType.EditedSetupExperienceSoftware: { return TAGGED_TEMPLATES.editedSetupExperienceSoftware(activity); } diff --git a/frontend/pages/ManageControlsPage/Variables/Variables.tsx b/frontend/pages/ManageControlsPage/Variables/Variables.tsx index e26f229c6b..d7566773ea 100644 --- a/frontend/pages/ManageControlsPage/Variables/Variables.tsx +++ b/frontend/pages/ManageControlsPage/Variables/Variables.tsx @@ -1,255 +1,66 @@ -import React, { useContext, useEffect, useRef, useState } from "react"; - -import { useQuery } from "react-query"; +import React, { useContext, useEffect, useMemo } from "react"; import { InjectedRouter } from "react-router"; - -import variablesAPI, { - IListVariablesResponse, -} from "services/entities/variables"; -import { IVariable } from "interfaces/variables"; +import { Params } from "react-router/lib/Router"; import { AppContext } from "context/app"; -import { - DEFAULT_USE_QUERY_OPTIONS, - FLEET_WEBSITE_URL, -} from "utilities/constants"; -import CustomLink from "components/CustomLink"; -import { HumanTimeDiffWithDateTip } from "components/HumanTimeDiffWithDateTip"; -import ListItem from "components/ListItem/ListItem"; -import PaginatedList, { IPaginatedListHandle } from "components/PaginatedList"; -import Button from "components/buttons/Button"; -import CopyButton from "components/buttons/CopyButton"; -import Spinner from "components/Spinner"; -import EmptyState from "components/EmptyState"; -import GitOpsModeTooltipWrapper from "components/GitOpsModeTooltipWrapper"; -import Icon from "components/Icon"; +import SideNav from "pages/admin/components/SideNav"; import PageDescription from "components/PageDescription"; -import AddCustomVariableModal from "./components/AddCustomVariableModal"; -import DeleteCustomVariableModal from "./components/DeleteCustomVariableModal"; + +import getVariablesNavItems from "./VariablesNavItems"; const baseClass = "variables"; -export const VARIABLES_PAGE_SIZE = 20; - interface IVariablesProps { router: InjectedRouter; + params: Params; location: { pathname: string; + search: string; query: { add_variable?: string }; }; } -const Variables = ({ router, location }: IVariablesProps) => { - const paginatedListRef = useRef>(null); +const Variables = ({ router, params, location }: IVariablesProps) => { + const { section } = params; - const [showDeleteModal, setShowDeleteModal] = useState(false); - const [variableToDelete, setVariableToDelete] = useState< - IVariable | undefined - >(); - const [showAddModal, setShowAddModal] = useState(false); - const [pageNumber, setPageNumber] = useState(0); + const { isPremiumTier } = useContext(AppContext); - const { isGlobalAdmin, isGlobalMaintainer, isPremiumTier } = useContext( - AppContext - ); + const navItems = useMemo(() => getVariablesNavItems(), []); - const canEdit = isGlobalAdmin || isGlobalMaintainer; + const defaultSection = navItems[0]; + const matchedSection = navItems.find((item) => item.urlSection === section); + const currentSection = matchedSection ?? defaultSection; - const apiParams = { page: pageNumber, per_page: VARIABLES_PAGE_SIZE }; - const { data, isFetching: isLoading, refetch } = useQuery< - IListVariablesResponse, - Error, - IListVariablesResponse - >(["variables", apiParams], () => variablesAPI.getVariables(apiParams), { - ...DEFAULT_USE_QUERY_OPTIONS, - }); - - // Open the Add variable modal via deep-link (e.g. from the command - // palette). Gate on the same predicate the in-page button uses — the - // param must not bypass admin/maintainer-only authoring. Strip the - // param either way so refreshes don't keep trying. + // Redirect the bare route (no section) and unknown sections to the default + // section, preserving the query string (e.g. the ?add_variable deep-link). useEffect(() => { - if (location.query.add_variable !== "1") return; - if (canEdit) { - setShowAddModal(true); + if (!matchedSection) { + router.replace(`${defaultSection.path}${location.search}`); } - const { add_variable, ...rest } = location.query; - router.replace({ pathname: location.pathname, query: rest }); - }, [location.query, location.pathname, router, canEdit]); + }, [matchedSection, defaultSection.path, location.search, router]); - const onClickAddVariable = () => { - setShowAddModal(true); - }; - - const onSaveVariable = () => { - setShowAddModal(false); - refetch(); - }; - - const onDeleteVariable = () => { - setShowDeleteModal(false); - refetch(); - }; - - const onClickDeleteVariable = (variable: IVariable) => { - setVariableToDelete(variable); - setShowDeleteModal(true); - }; - - const getTokenFromVariableName = (variableName: string): string => { - return `$FLEET_SECRET_${variableName.toUpperCase()}`; - }; - - const renderVariableRow = (variable: IVariable) => ( - <> - - - Updated{" "} - {" "} - • {getTokenFromVariableName(variable.name)} - - - - } - /> - {canEdit && ( - - )} - - ); - - const isEmpty = !isLoading && data?.count === 0; - - const renderContent = () => { - if (isLoading) { - return ( -
- -
- ); - } - - if (isEmpty) { - return ( - ( - - )} - /> - ) : undefined - } - /> - ); - } - - return ( - - ref={paginatedListRef} - pageSize={VARIABLES_PAGE_SIZE} - renderItemRow={renderVariableRow} - count={data?.count || 0} - data={data?.custom_variables || []} - currentPage={pageNumber} - onChangePage={setPageNumber} - heading={ -
- Custom variables -
- } - helpText={ - - Profiles can also use any of Fleet’s{" "} - - - } - /> - ); - }; + const CurrentCard = currentSection.Card; return (
-
- - {isPremiumTier - ? "Manage custom variables that will be available in scripts and profiles across all fleets." - : "Manage custom variables that will be available in scripts and profiles."}{" "} - - - } - /> - {canEdit && ( - ( - - )} - /> - )} -
- {renderContent()} - {showAddModal && ( - setShowAddModal(false)} - onSave={onSaveVariable} - /> - )} - {showDeleteModal && ( - setShowDeleteModal(false)} - onDeleteVariable={onDeleteVariable} - /> - )} + + ({ + ...navItem, + path: `${navItem.path}${location.search}`, + }))} + activeItem={currentSection.urlSection} + CurrentCard={} + />
); }; diff --git a/frontend/pages/ManageControlsPage/Variables/VariablesNavItems.tsx b/frontend/pages/ManageControlsPage/Variables/VariablesNavItems.tsx new file mode 100644 index 0000000000..d5b59aa5a2 --- /dev/null +++ b/frontend/pages/ManageControlsPage/Variables/VariablesNavItems.tsx @@ -0,0 +1,40 @@ +import { InjectedRouter } from "react-router"; + +import PATHS from "router/paths"; +import { ISideNavItem } from "pages/admin/components/SideNav/SideNav"; + +import GlobalVariables from "./cards/GlobalVariables"; +import CustomHostVitalsTab from "./cards/CustomHostVitalsTab"; + +export interface IVariablesCardProps { + router: InjectedRouter; + location: { + pathname: string; + query: { + add_variable?: string; + query?: string; + page?: string; + order_key?: string; + order_direction?: string; + }; + }; +} + +const getVariablesNavItems = (): ISideNavItem[] => { + return [ + { + title: "Global variables", + urlSection: "global-variables", + path: PATHS.CONTROLS_VARIABLES_GLOBAL_VARIABLES, + Card: GlobalVariables, + }, + { + title: "Custom host vitals", + urlSection: "custom-host-vitals", + path: PATHS.CONTROLS_VARIABLES_CUSTOM_HOST_VITALS, + Card: CustomHostVitalsTab, + }, + ]; +}; + +export default getVariablesNavItems; diff --git a/frontend/pages/ManageControlsPage/Variables/_styles.scss b/frontend/pages/ManageControlsPage/Variables/_styles.scss index e336842916..bdffa16a98 100644 --- a/frontend/pages/ManageControlsPage/Variables/_styles.scss +++ b/frontend/pages/ManageControlsPage/Variables/_styles.scss @@ -1,91 +1,51 @@ .variables { @include vertical-page-tab-panel-layout; margin-top: $gap-page-component; // Required as these Tabs don't use TabPanel +} - &__page-header { +.global-variables { + @include vertical-card-layout; + + &__token { display: flex; - justify-content: space-between; + align-items: center; + gap: $pad-xsmall; + width: fit-content; } - .paginated-list__row { + &__actions { display: flex; - justify-content: space-between; - padding: $pad-small $pad-large; - - .button > .children-wrapper { - opacity: 0; - transition: opacity 250ms; - pointer-events: none; - } - - &.paginated-list__header { - .button > .children-wrapper { - opacity: 1; - pointer-events: auto; - } - } - - &:hover, - &:focus-within { - cursor: default; - .button > .children-wrapper { - opacity: 1; - } - } - - .list-item__details { - color: $ui-fleet-black-75; - min-width: 0; - > span { - display: flex; - align-items: center; - gap: $pad-xsmall; - } - } - - /* Make the main content column actually able to shrink so truncation can happen */ - .list-item { - display: flex; - flex: 1 1 auto; - min-width: 0; - } - - .list-item__main-content { - flex: 1 1 auto; - min-width: 0; /* for truncation */ - } - - .list-item__info { - min-width: 0; /* for truncation */ - } - - .list-item__title { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - min-width: 0; - } - - .variable-details__text { - flex: 1 1 auto; - min-width: 0; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } + align-items: center; + justify-content: flex-end; + gap: $pad-xsmall; } .loading-spinner.centered { margin: auto; } +} - &__header { +.custom-host-vitals-tab { + @include vertical-card-layout; + + &__token { display: flex; - flex-direction: row; - justify-content: space-between; - width: 100%; - font-weight: $bold; align-items: center; + gap: $pad-xsmall; + // Shrink to content so the copy button sits next to the token value + // instead of being pushed to the right edge of the (wider) cell. + width: fit-content; + } + + &__actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: $pad-xsmall; + } + + .loading-spinner.centered { + margin: auto; } } diff --git a/frontend/pages/ManageControlsPage/Variables/cards/CustomHostVitalsTab/CustomHostVitalsTab.tests.tsx b/frontend/pages/ManageControlsPage/Variables/cards/CustomHostVitalsTab/CustomHostVitalsTab.tests.tsx new file mode 100644 index 0000000000..dd9fac480b --- /dev/null +++ b/frontend/pages/ManageControlsPage/Variables/cards/CustomHostVitalsTab/CustomHostVitalsTab.tests.tsx @@ -0,0 +1,190 @@ +import React from "react"; +import { screen, waitFor } from "@testing-library/react"; +import { http, HttpResponse } from "msw"; + +import { + createCustomRenderer, + createMockRouter, + baseUrl, +} from "test/test-utils"; +import mockServer from "test/mock-server"; + +import CustomHostVitalsTab, { + CUSTOM_HOST_VITALS_PAGE_SIZE, +} from "./CustomHostVitalsTab"; + +// The tab filters server-side, so intercept GET /custom_host_vitals and return +// the seeded vitals matching the `query` param — by name or the derived +// $FLEET_HOST_VITAL_ token — mirroring the backend's search behavior. +const SEED = [ + { id: 1, name: "Asset tag", created_at: "", updated_at: "" }, + { id: 2, name: "Department", created_at: "", updated_at: "" }, + { id: 3, name: "Purchase date", created_at: "", updated_at: "" }, +]; + +const customHostVitalsHandler = http.get( + baseUrl("/custom_host_vitals"), + ({ request }) => { + const q = (new URL(request.url).searchParams.get("query") ?? "") + .trim() + .toLowerCase(); + const filtered = q + ? SEED.filter( + (v) => + v.name.toLowerCase().includes(q) || + `$fleet_host_vital_${v.id}`.includes(q) + ) + : SEED; + return HttpResponse.json({ + custom_host_vitals: filtered, + count: filtered.length, + meta: { has_next_results: false, has_previous_results: false }, + }); + } +); + +const makeProps = (query: Record = {}) => ({ + router: createMockRouter(), + location: { + pathname: "/controls/variables/custom-host-vitals", + query, + }, +}); + +describe("CustomHostVitalsTab - URL-persistent search", () => { + const render = createCustomRenderer({ + withBackendMock: true, + context: { + app: { + isGlobalAdmin: true, + }, + }, + }); + + beforeEach(() => { + mockServer.use(customHostVitalsHandler); + }); + + it("pre-fills the search input from the URL and filters the list on mount", async () => { + const props = makeProps({ query: "depart" }); + render(); + + await waitFor(() => { + const searchInput = screen.getByPlaceholderText( + "Search by name" + ) as HTMLInputElement; + expect(searchInput.value).toBe("depart"); + }); + + await waitFor(() => { + expect(screen.getByText("Department")).toBeInTheDocument(); + expect(screen.queryByText("Asset tag")).not.toBeInTheDocument(); + expect(screen.queryByText("Purchase date")).not.toBeInTheDocument(); + }); + }); + + it("matches the variable token as well as the name", async () => { + // "Department" seeds as id 2 -> token $FLEET_HOST_VITAL_2. + const props = makeProps({ query: "fleet_host_vital_2" }); + render(); + + await waitFor(() => { + expect(screen.getByText("Department")).toBeInTheDocument(); + expect(screen.queryByText("Asset tag")).not.toBeInTheDocument(); + }); + }); + + it("renders the full list when no search param is present", async () => { + const props = makeProps(); + render(); + + await waitFor(() => { + expect(screen.getByText("Asset tag")).toBeInTheDocument(); + expect(screen.getByText("Department")).toBeInTheDocument(); + expect(screen.getByText("Purchase date")).toBeInTheDocument(); + }); + + const searchInput = screen.getByPlaceholderText( + "Search by name" + ) as HTMLInputElement; + expect(searchInput.value).toBe(""); + }); +}); + +describe("CustomHostVitalsTab - server-side pagination and sort", () => { + // One full page plus a few, so there's a real second page. Names are + // zero-padded so page 0 starts at "Vital 01". + const PAGED_SEED = Array.from( + { length: CUSTOM_HOST_VITALS_PAGE_SIZE + 3 }, + (_, i) => ({ + id: i + 1, + name: `Vital ${String(i + 1).padStart(2, "0")}`, + created_at: "", + updated_at: "", + }) + ); + + const render = createCustomRenderer({ + withBackendMock: true, + context: { app: { isGlobalAdmin: true } }, + }); + + // Capture the last request's params so we can assert the tab forwards the + // URL-derived page/sort to the API. + let lastParams: URLSearchParams | undefined; + + beforeEach(() => { + lastParams = undefined; + mockServer.use( + http.get(baseUrl("/custom_host_vitals"), ({ request }) => { + const url = new URL(request.url); + lastParams = url.searchParams; + const page = parseInt(url.searchParams.get("page") ?? "0", 10); + const perPage = parseInt( + url.searchParams.get("per_page") ?? + String(CUSTOM_HOST_VITALS_PAGE_SIZE), + 10 + ); + const start = page * perPage; + const rows = PAGED_SEED.slice(start, start + perPage); + return HttpResponse.json({ + custom_host_vitals: rows, + count: PAGED_SEED.length, + meta: { + has_next_results: start + perPage < PAGED_SEED.length, + has_previous_results: page > 0, + }, + }); + }) + ); + }); + + it("forwards the page and sort params from the URL to the API", async () => { + const props = makeProps({ page: "1", order_direction: "desc" }); + render(); + + await waitFor(() => { + expect(lastParams?.get("page")).toBe("1"); + }); + expect(lastParams?.get("per_page")).toBe( + String(CUSTOM_HOST_VITALS_PAGE_SIZE) + ); + expect(lastParams?.get("order_key")).toBe("name"); + expect(lastParams?.get("order_direction")).toBe("desc"); + }); + + it("appends page to the URL when navigating to the next page", async () => { + const props = makeProps(); + const { user } = render(); + + await screen.findByText("Vital 01"); + const nextButton = screen.getByRole("button", { name: /next/i }); + expect(nextButton).toBeEnabled(); + + await user.click(nextButton); + + expect(props.router.replace).toHaveBeenCalledWith( + expect.stringContaining("page=1") + ); + }); +}); diff --git a/frontend/pages/ManageControlsPage/Variables/cards/CustomHostVitalsTab/CustomHostVitalsTab.tsx b/frontend/pages/ManageControlsPage/Variables/cards/CustomHostVitalsTab/CustomHostVitalsTab.tsx new file mode 100644 index 0000000000..04d3a51e56 --- /dev/null +++ b/frontend/pages/ManageControlsPage/Variables/cards/CustomHostVitalsTab/CustomHostVitalsTab.tsx @@ -0,0 +1,267 @@ +import React, { useCallback, useContext, useMemo, useState } from "react"; +import { useQuery } from "react-query"; + +import PATHS from "router/paths"; +import { AppContext } from "context/app"; +import useGitOpsMode from "hooks/useGitOpsMode"; +import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants"; +import { getNextLocationPath } from "utilities/helpers"; +import { ICustomHostVital } from "interfaces/custom_host_vitals"; +import customHostVitalsAPI, { + IListCustomHostVitalsResponse, +} from "services/entities/custom_host_vitals"; + +import Button from "components/buttons/Button"; +import Icon from "components/Icon"; +import Spinner from "components/Spinner"; +import EmptyState from "components/EmptyState"; +import PageDescription from "components/PageDescription"; +import SectionHeader from "components/SectionHeader"; +import GitOpsModeTooltipWrapper from "components/GitOpsModeTooltipWrapper"; +import TableContainer from "components/TableContainer"; +import { ITableQueryData } from "components/TableContainer/TableContainer"; +import TableCount from "components/TableContainer/TableCount"; + +import generateTableHeaders from "./CustomHostVitalsTableConfig"; +import { IVariablesCardProps } from "../../VariablesNavItems"; +import AddCustomHostVitalModal from "../../components/AddCustomHostVitalModal"; +import EditCustomHostVitalModal from "../../components/EditCustomHostVitalModal"; +import DeleteCustomHostVitalModal from "../../components/DeleteCustomHostVitalModal"; + +const baseClass = "custom-host-vitals-tab"; + +export const CUSTOM_HOST_VITALS_PAGE_SIZE = 20; + +const CustomHostVitalsTab: React.FC = ({ + router, + location, +}) => { + const { isGlobalAdmin, isGlobalMaintainer } = useContext(AppContext); + const { gitOpsModeEnabled } = useGitOpsMode(); + + const canEdit = isGlobalAdmin || isGlobalMaintainer; + + const searchQuery = location.query.query ?? ""; + const parsedPage = parseInt(location.query.page ?? "", 10); + const pageNumber = + Number.isNaN(parsedPage) || parsedPage < 0 ? 0 : parsedPage; + const sortHeader = location.query.order_key || "name"; + const sortDirection: "asc" | "desc" = + location.query.order_direction === "desc" ? "desc" : "asc"; + + const [showAddModal, setShowAddModal] = useState(false); + const [vitalToEdit, setVitalToEdit] = useState< + ICustomHostVital | undefined + >(); + const [vitalToDelete, setVitalToDelete] = useState< + ICustomHostVital | undefined + >(); + + const apiParams = { + query: searchQuery, + page: pageNumber, + per_page: CUSTOM_HOST_VITALS_PAGE_SIZE, + order_key: sortHeader, + order_direction: sortDirection, + }; + const { data, isLoading, isFetching, refetch } = useQuery< + IListCustomHostVitalsResponse, + Error, + IListCustomHostVitalsResponse + >( + ["customHostVitals", apiParams], + () => customHostVitalsAPI.getCustomHostVitals(apiParams), + // keepPreviousData keeps `data` populated across page/search key changes + // so TableContainer (and its search box) doesn't unmount/remount empty. + { ...DEFAULT_USE_QUERY_OPTIONS, keepPreviousData: true } + ); + + const vitals = useMemo(() => data?.custom_host_vitals ?? [], [data]); + const count = data?.count ?? 0; + + const onQueryChange = useCallback( + (queryData: ITableQueryData) => { + const { + searchQuery: nextSearchQuery, + pageIndex, + sortHeader: nextSortHeader, + sortDirection: nextSortDirection, + } = queryData; + + const searchChanged = nextSearchQuery !== searchQuery; + const nextPage = searchChanged ? 0 : pageIndex; + const nextOrderKey = nextSortHeader || "name"; + const nextOrderDirection = nextSortDirection === "desc" ? "desc" : "asc"; + + if ( + !searchChanged && + nextPage === pageNumber && + nextOrderKey === sortHeader && + nextOrderDirection === sortDirection + ) { + return; + } + + router.replace( + getNextLocationPath({ + pathPrefix: PATHS.CONTROLS_VARIABLES_CUSTOM_HOST_VITALS, + queryParams: { + ...location.query, + query: nextSearchQuery || undefined, + page: nextPage || undefined, + order_key: nextOrderKey !== "name" ? nextOrderKey : undefined, + order_direction: + nextOrderDirection !== "asc" ? nextOrderDirection : undefined, + }, + }) + ); + }, + [searchQuery, pageNumber, sortHeader, sortDirection, router, location.query] + ); + + const onClickAdd = () => setShowAddModal(true); + + const onSaveAdd = () => { + setShowAddModal(false); + refetch(); + }; + + const onSaveEdit = () => { + setVitalToEdit(undefined); + refetch(); + }; + + const onDeleted = () => { + setVitalToDelete(undefined); + refetch(); + }; + + const tableHeaders = useMemo( + () => + generateTableHeaders({ + canEdit: !!canEdit, + gitOpsModeEnabled, + onEdit: setVitalToEdit, + onDelete: setVitalToDelete, + }), + [canEdit, gitOpsModeEnabled, setVitalToEdit, setVitalToDelete] + ); + + const renderCount = useCallback( + () => , + [count] + ); + + const isSearching = searchQuery !== ""; + // "No vitals at all" is distinct from "no vitals match the search". The + // former is only known when the unfiltered list is empty, so treat any active + // search as the search-empty case. + const isEmpty = !isLoading && count === 0 && !isSearching; + + const renderAddButton = (variant: "inverse" | "default") => + canEdit ? ( + ( + + )} + /> + ) : undefined; + + const renderContent = () => { + if (isLoading) { + return ( +
+ +
+ ); + } + + if (isEmpty) { + return ( + + ); + } + + return ( + ( + + )} + showMarkAllPages={false} + isAllPagesSelected={false} + searchable + renderCount={renderCount} + manualSortBy + pageIndex={pageNumber} + pageSize={CUSTOM_HOST_VITALS_PAGE_SIZE} + disableNextPage={ + (pageNumber + 1) * CUSTOM_HOST_VITALS_PAGE_SIZE >= count + } + /> + ); + }; + + return ( +
+ + + {renderContent()} + {showAddModal && ( + setShowAddModal(false)} + onSave={onSaveAdd} + /> + )} + {vitalToEdit && ( + setVitalToEdit(undefined)} + onSave={onSaveEdit} + /> + )} + {vitalToDelete && ( + setVitalToDelete(undefined)} + onDelete={onDeleted} + /> + )} +
+ ); +}; + +export default CustomHostVitalsTab; diff --git a/frontend/pages/ManageControlsPage/Variables/cards/CustomHostVitalsTab/CustomHostVitalsTableConfig.tsx b/frontend/pages/ManageControlsPage/Variables/cards/CustomHostVitalsTab/CustomHostVitalsTableConfig.tsx new file mode 100644 index 0000000000..444c733a7b --- /dev/null +++ b/frontend/pages/ManageControlsPage/Variables/cards/CustomHostVitalsTab/CustomHostVitalsTableConfig.tsx @@ -0,0 +1,124 @@ +import React from "react"; + +import { ICustomHostVital } from "interfaces/custom_host_vitals"; + +import HeaderCell from "components/TableContainer/DataTable/HeaderCell/HeaderCell"; +import TextCell from "components/TableContainer/DataTable/TextCell"; +import { HumanTimeDiffWithDateTip } from "components/HumanTimeDiffWithDateTip"; +import Button from "components/buttons/Button"; +import CopyButton from "components/buttons/CopyButton"; +import Icon from "components/Icon"; + +export const getTokenFromVitalId = (id: number): string => + `$FLEET_HOST_VITAL_${id}`; + +interface IHeaderProps { + column: { + title: string; + isSortedDesc: boolean; + }; +} + +interface IStringCellProps { + cell: { value: string }; + row: { original: ICustomHostVital }; +} + +interface IDataColumn { + title?: string; + Header: ((props: IHeaderProps) => JSX.Element) | string; + accessor: string; + Cell: (props: IStringCellProps) => JSX.Element; + disableHidden?: boolean; + disableSortBy?: boolean; + sortType?: string; +} + +interface IGenerateTableHeadersParams { + canEdit: boolean; + gitOpsModeEnabled: boolean; + onEdit: (vital: ICustomHostVital) => void; + onDelete: (vital: ICustomHostVital) => void; +} + +const generateTableHeaders = ({ + canEdit, + gitOpsModeEnabled, + onEdit, + onDelete, +}: IGenerateTableHeadersParams): IDataColumn[] => { + const columns: IDataColumn[] = [ + { + title: "Name", + Header: (cellProps) => ( + + ), + disableSortBy: false, + sortType: "caseInsensitive", + accessor: "name", + Cell: (cellProps) => , + }, + { + title: "Variable", + Header: "Variable", + disableSortBy: true, + accessor: "id", + Cell: (cellProps) => { + const token = getTokenFromVitalId(cellProps.row.original.id); + return ( +
+ + +
+ ); + }, + }, + { + title: "Updated", + Header: "Updated", + disableSortBy: true, + accessor: "updated_at", + Cell: (cellProps) => ( + + ), + }, + ]; + + // Non-write roles and GitOps mode don't get row actions. + if (canEdit && !gitOpsModeEnabled) { + columns.push({ + title: "Actions", + Header: "", + disableSortBy: true, + accessor: "actions", + Cell: (cellProps) => { + const vital = cellProps.row.original; + return ( +
+ + +
+ ); + }, + }); + } + + return columns; +}; + +export default generateTableHeaders; diff --git a/frontend/pages/ManageControlsPage/Variables/cards/CustomHostVitalsTab/index.ts b/frontend/pages/ManageControlsPage/Variables/cards/CustomHostVitalsTab/index.ts new file mode 100644 index 0000000000..4abae42cd1 --- /dev/null +++ b/frontend/pages/ManageControlsPage/Variables/cards/CustomHostVitalsTab/index.ts @@ -0,0 +1 @@ +export { default } from "./CustomHostVitalsTab"; diff --git a/frontend/pages/ManageControlsPage/Variables/Variables.tests.tsx b/frontend/pages/ManageControlsPage/Variables/cards/GlobalVariables/GlobalVariables.tests.tsx similarity index 78% rename from frontend/pages/ManageControlsPage/Variables/Variables.tests.tsx rename to frontend/pages/ManageControlsPage/Variables/cards/GlobalVariables/GlobalVariables.tests.tsx index 2a4afaf567..af582d0740 100644 --- a/frontend/pages/ManageControlsPage/Variables/Variables.tests.tsx +++ b/frontend/pages/ManageControlsPage/Variables/cards/GlobalVariables/GlobalVariables.tests.tsx @@ -7,7 +7,7 @@ import { createCustomRenderer, createMockRouter } from "test/test-utils"; import { http, HttpResponse } from "msw"; import mockServer from "test/mock-server"; -import Variables from "./Variables"; +import GlobalVariables from "./GlobalVariables"; const baseUrl = (path: string) => { return `/api/latest/fleet${path}`; @@ -61,7 +61,7 @@ describe("Custom variables", () => { it("renders with Add CTA and edit info when user can edit", async () => { mockServer.use(emptyVariablesHandler); - render(); + render(); await waitFor(() => { expect(screen.getByText(/No custom variables/i)).toBeInTheDocument(); expect( @@ -69,9 +69,10 @@ describe("Custom variables", () => { "Add a custom variable to make it available in scripts and profiles." ) ).toBeInTheDocument(); - // Header button and EmptyState CTA button both render + // The header Add button stays visible on the empty state, alongside + // the EmptyState CTA, so both render. const addButtons = screen.getAllByRole("button", { - name: /Add custom variable/, + name: /Add variable/, }); expect(addButtons).toHaveLength(2); }); @@ -90,7 +91,7 @@ describe("Custom variables", () => { }, }); - renderReadOnly(); + renderReadOnly(); await waitFor(() => { expect(screen.getByText("No custom variables")).toBeInTheDocument(); expect( @@ -99,7 +100,7 @@ describe("Custom variables", () => { ) ).toBeInTheDocument(); expect( - screen.queryByRole("button", { name: "Add custom variable" }) + screen.queryByRole("button", { name: "Add variable" }) ).not.toBeInTheDocument(); }); }); @@ -175,7 +176,7 @@ describe("Custom variables", () => { }); it("renders when variables are saved", async () => { - render(); + render(); await waitFor( () => { expect(screen.getByText("SECRET_UNO")).toBeInTheDocument(); @@ -189,18 +190,18 @@ describe("Custom variables", () => { describe("gitops mode", () => { it("renders the add button disabled in GitOps mode", async () => { - renderInGOM(); + renderInGOM(); let addVariableButton; await waitFor(() => { addVariableButton = screen.getByRole("button", { - name: /Add custom variable/, + name: /Add variable/, }); expect(addVariableButton).toBeInTheDocument(); }); if (!addVariableButton) { - throw new Error("Add custom variable button not found"); + throw new Error("Add variable button not found"); } expect(addVariableButton).toHaveAttribute("disabled"); @@ -210,38 +211,15 @@ describe("Custom variables", () => { }); it("deleting a variable is successful in GitOps mode", async () => { - const { user } = renderInGOM(); + const { user } = renderInGOM(); await waitFor(() => { - expect(screen.getByText("Add custom variable")).toBeInTheDocument(); + expect(screen.getByText("SECRET_UNO")).toBeInTheDocument(); }); - // Get the element with SECRET_UNO in it. - let variableUno: HTMLElement | null = null; - await waitFor(() => { - variableUno = screen.getByText("SECRET_UNO"); - expect(variableUno).toBeInTheDocument(); + const deleteButton = screen.getByRole("button", { + name: "Delete SECRET_UNO", }); - if (variableUno === null) { - throw new Error("Variable not found"); - } - // Find the element with .paginated-list__row class that is ancestor to that element. - const variableUnoRow = (variableUno as HTMLElement).closest( - ".paginated-list__row" - ); - expect(variableUnoRow).toBeInTheDocument(); - if (!variableUnoRow) { - throw new Error("Variable row not found"); - } - // Find the element with data-id="trash-icon" - const trashIcon = variableUnoRow.querySelector( - "[data-testid='trash-icon']" - ); - expect(trashIcon).toBeInTheDocument(); - if (!trashIcon) { - throw new Error("Trash icon not found"); - } - // Click it. - await user.click(trashIcon); - // Confirm the deletion. + expect(deleteButton).toBeInTheDocument(); + await user.click(deleteButton); await waitFor(() => { expect( screen.getByText(/Delete custom variable\?/) @@ -281,16 +259,16 @@ describe("Custom variables", () => { let user: UserEvent; beforeEach(async () => { - ({ user } = render()); + ({ user } = render()); let addVariableButton; await waitFor(() => { addVariableButton = screen.getByRole("button", { - name: /Add custom variable/, + name: /Add variable/, }); expect(addVariableButton).toBeInTheDocument(); }); if (!addVariableButton) { - throw new Error("Add custom variable button not found"); + throw new Error("Add variable button not found"); } await user.click(addVariableButton); }); @@ -352,37 +330,20 @@ describe("Custom variables", () => { }); it("deleting a variable is successful", async () => { - const { user } = render(); + const { user } = render(); await waitFor(() => { - expect(screen.getByText("Add custom variable")).toBeInTheDocument(); + expect(screen.getByText("Add variable")).toBeInTheDocument(); }); - // Get the element with SECRET_UNO in it. - let variableUno: HTMLElement | null = null; await waitFor(() => { - variableUno = screen.getByText("SECRET_UNO"); - expect(variableUno).toBeInTheDocument(); + expect(screen.getByText("SECRET_UNO")).toBeInTheDocument(); }); - if (variableUno === null) { - throw new Error("Variable not found"); - } - // Find the element with .paginated-list__row class that is ancestor to that element. - const variableUnoRow = (variableUno as HTMLElement).closest( - ".paginated-list__row" - ); - expect(variableUnoRow).toBeInTheDocument(); - if (!variableUnoRow) { - throw new Error("Variable row not found"); - } - // Find the element with data-id="trash-icon" - const trashIcon = variableUnoRow.querySelector( - "[data-testid='trash-icon']" - ); - expect(trashIcon).toBeInTheDocument(); - if (!trashIcon) { - throw new Error("Trash icon not found"); - } + // The row action is a trash-icon button labeled "Delete ". + const deleteButton = screen.getByRole("button", { + name: "Delete SECRET_UNO", + }); + expect(deleteButton).toBeInTheDocument(); // Click it. - await user.click(trashIcon); + await user.click(deleteButton); // Confirm the deletion. await waitFor(() => { expect( diff --git a/frontend/pages/ManageControlsPage/Variables/cards/GlobalVariables/GlobalVariables.tsx b/frontend/pages/ManageControlsPage/Variables/cards/GlobalVariables/GlobalVariables.tsx new file mode 100644 index 0000000000..0ad97159a2 --- /dev/null +++ b/frontend/pages/ManageControlsPage/Variables/cards/GlobalVariables/GlobalVariables.tsx @@ -0,0 +1,227 @@ +import React, { + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from "react"; + +import { useQuery } from "react-query"; +import { InjectedRouter } from "react-router"; + +import variablesAPI, { + IListVariablesResponse, +} from "services/entities/variables"; +import { IVariable } from "interfaces/variables"; + +import { AppContext } from "context/app"; + +import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants"; +import SectionHeader from "components/SectionHeader"; +import Button from "components/buttons/Button"; +import Spinner from "components/Spinner"; +import EmptyState from "components/EmptyState"; +import GitOpsModeTooltipWrapper from "components/GitOpsModeTooltipWrapper"; +import Icon from "components/Icon"; +import PageDescription from "components/PageDescription"; +import TableContainer from "components/TableContainer"; +import { ITableQueryData } from "components/TableContainer/TableContainer"; +import TableCount from "components/TableContainer/TableCount"; + +import generateTableHeaders from "./GlobalVariablesTableConfig"; +import AddCustomVariableModal from "../../components/AddCustomVariableModal"; +import DeleteCustomVariableModal from "../../components/DeleteCustomVariableModal"; + +const baseClass = "global-variables"; + +export const VARIABLES_PAGE_SIZE = 20; + +export interface IGlobalVariablesProps { + router: InjectedRouter; + location: { + pathname: string; + query: { add_variable?: string }; + }; +} + +const GlobalVariables = ({ router, location }: IGlobalVariablesProps) => { + const { isGlobalAdmin, isGlobalMaintainer } = useContext(AppContext); + + const canEdit = isGlobalAdmin || isGlobalMaintainer; + + const [showDeleteModal, setShowDeleteModal] = useState(false); + const [variableToDelete, setVariableToDelete] = useState< + IVariable | undefined + >(); + const [showAddModal, setShowAddModal] = useState(false); + const [pageNumber, setPageNumber] = useState(0); + + const apiParams = { page: pageNumber, per_page: VARIABLES_PAGE_SIZE }; + const { data, isLoading, isFetching, refetch } = useQuery< + IListVariablesResponse, + Error, + IListVariablesResponse + >(["variables", apiParams], () => variablesAPI.getVariables(apiParams), { + // keepPreviousData keeps the current page visible while the next page + // loads, so the table doesn't flip to a spinner between page changes. + ...DEFAULT_USE_QUERY_OPTIONS, + keepPreviousData: true, + }); + + const variables = useMemo(() => data?.custom_variables ?? [], [data]); + const count = data?.count ?? 0; + + // Open the Add variable modal via deep-link (e.g. from the command + // palette). Gate on the same predicate the in-page button uses — the + // param must not bypass admin/maintainer-only authoring. Strip the + // param either way so refreshes don't keep trying. + useEffect(() => { + if (location.query.add_variable !== "1") return; + if (canEdit) { + setShowAddModal(true); + } + const { add_variable, ...rest } = location.query; + router.replace({ pathname: location.pathname, query: rest }); + }, [location.query, location.pathname, router, canEdit]); + + const onClickAddVariable = () => { + setShowAddModal(true); + }; + + const onSaveVariable = () => { + setShowAddModal(false); + refetch(); + }; + + const onDeleteVariable = () => { + setShowDeleteModal(false); + refetch(); + }; + + const onClickDeleteVariable = useCallback((variable: IVariable) => { + setVariableToDelete(variable); + setShowDeleteModal(true); + }, []); + + const onQueryChange = useCallback((newTableQuery: ITableQueryData) => { + setPageNumber(newTableQuery.pageIndex); + }, []); + + const tableHeaders = useMemo( + () => + generateTableHeaders({ + canEdit: !!canEdit, + onDelete: onClickDeleteVariable, + }), + [canEdit, onClickDeleteVariable] + ); + + const renderCount = useCallback( + () => , + [count] + ); + + const isEmpty = !isLoading && count === 0; + + const renderContent = () => { + if (isLoading) { + return ( +
+ +
+ ); + } + + if (isEmpty) { + return ( + ( + + )} + /> + ) : undefined + } + /> + ); + } + + return ( + } + showMarkAllPages={false} + isAllPagesSelected={false} + renderCount={renderCount} + onQueryChange={onQueryChange} + pageIndex={pageNumber} + pageSize={VARIABLES_PAGE_SIZE} + disableNextPage={(pageNumber + 1) * VARIABLES_PAGE_SIZE >= count} + /> + ); + }; + + return ( +
+ ( + + )} + /> + ) : undefined + } + /> + + {renderContent()} + {showAddModal && ( + setShowAddModal(false)} + onSave={onSaveVariable} + /> + )} + {showDeleteModal && ( + setShowDeleteModal(false)} + onDeleteVariable={onDeleteVariable} + /> + )} +
+ ); +}; + +export default GlobalVariables; diff --git a/frontend/pages/ManageControlsPage/Variables/cards/GlobalVariables/GlobalVariablesTableConfig.tsx b/frontend/pages/ManageControlsPage/Variables/cards/GlobalVariables/GlobalVariablesTableConfig.tsx new file mode 100644 index 0000000000..0fad1f1011 --- /dev/null +++ b/frontend/pages/ManageControlsPage/Variables/cards/GlobalVariables/GlobalVariablesTableConfig.tsx @@ -0,0 +1,114 @@ +import React from "react"; + +import { IVariable } from "interfaces/variables"; + +import HeaderCell from "components/TableContainer/DataTable/HeaderCell/HeaderCell"; +import TextCell from "components/TableContainer/DataTable/TextCell"; +import { HumanTimeDiffWithDateTip } from "components/HumanTimeDiffWithDateTip"; +import Button from "components/buttons/Button"; +import CopyButton from "components/buttons/CopyButton"; +import Icon from "components/Icon"; + +export const getTokenFromVariableName = (variableName: string): string => + `$FLEET_SECRET_${variableName.toUpperCase()}`; + +interface IHeaderProps { + column: { + title: string; + isSortedDesc: boolean; + }; +} + +interface IStringCellProps { + cell: { value: string }; + row: { original: IVariable }; +} + +interface IDataColumn { + title?: string; + Header: ((props: IHeaderProps) => JSX.Element) | string; + accessor: string; + Cell: (props: IStringCellProps) => JSX.Element; + disableHidden?: boolean; + disableSortBy?: boolean; + sortType?: string; +} + +interface IGenerateTableHeadersParams { + canEdit: boolean; + onDelete: (variable: IVariable) => void; +} + +const generateTableHeaders = ({ + canEdit, + onDelete, +}: IGenerateTableHeadersParams): IDataColumn[] => { + const columns: IDataColumn[] = [ + { + title: "Name", + Header: (cellProps) => ( + + ), + disableSortBy: false, + sortType: "caseInsensitive", + accessor: "name", + Cell: (cellProps) => , + }, + { + title: "Variable name", + Header: "Variable name", + disableSortBy: true, + accessor: "id", + Cell: (cellProps) => { + const token = getTokenFromVariableName(cellProps.row.original.name); + return ( +
+ + +
+ ); + }, + }, + { + title: "Created", + Header: "Created", + disableSortBy: true, + accessor: "created_at", + Cell: (cellProps) => ( + + ), + }, + ]; + + // Non-write roles don't get row actions. Global variables support delete + // only (no edit). Delete is allowed in GitOps mode, matching prior behavior. + if (canEdit) { + columns.push({ + title: "Actions", + Header: "", + disableSortBy: true, + accessor: "actions", + Cell: (cellProps) => { + const variable = cellProps.row.original; + return ( +
+ +
+ ); + }, + }); + } + + return columns; +}; + +export default generateTableHeaders; diff --git a/frontend/pages/ManageControlsPage/Variables/cards/GlobalVariables/index.ts b/frontend/pages/ManageControlsPage/Variables/cards/GlobalVariables/index.ts new file mode 100644 index 0000000000..0789c035d2 --- /dev/null +++ b/frontend/pages/ManageControlsPage/Variables/cards/GlobalVariables/index.ts @@ -0,0 +1 @@ +export { default } from "./GlobalVariables"; diff --git a/frontend/pages/ManageControlsPage/Variables/components/AddCustomHostVitalModal/AddCustomHostVitalModal.tsx b/frontend/pages/ManageControlsPage/Variables/components/AddCustomHostVitalModal/AddCustomHostVitalModal.tsx new file mode 100644 index 0000000000..afc303bf64 --- /dev/null +++ b/frontend/pages/ManageControlsPage/Variables/components/AddCustomHostVitalModal/AddCustomHostVitalModal.tsx @@ -0,0 +1,105 @@ +import React, { useState } from "react"; +import { useMutation } from "react-query"; + +import { hasStatusKey } from "interfaces/errors"; +import customHostVitalsAPI from "services/entities/custom_host_vitals"; +import { notify } from "components/ToastNotification"; +import Modal from "components/Modal"; +import Button from "components/buttons/Button"; +import InputField from "components/forms/fields/InputField"; + +import { + validateFormData, + ICustomHostVitalFormValidation, + CUSTOM_HOST_VITAL_NAME_MAX_LENGTH, +} from "../../helpers"; + +const baseClass = "add-custom-host-vital-modal"; + +interface IAddCustomHostVitalModalProps { + onCancel: () => void; + onSave: () => void; +} + +const AddCustomHostVitalModal = ({ + onCancel, + onSave, +}: IAddCustomHostVitalModalProps) => { + const [name, setName] = useState(""); + const [ + formValidation, + setFormValidation, + ] = useState(() => + validateFormData({ name: "" }) + ); + + const { mutate: addCustomHostVital, isLoading: isSaving } = useMutation( + () => customHostVitalsAPI.addCustomHostVital({ name: name.trim() }), + { + onSuccess: () => { + notify.success("Custom host vital created."); + onSave(); + }, + onError: (error) => { + if (hasStatusKey(error) && error.status === 409) { + notify.error("Couldn't save. Host vital name must be unique.", { + response: error, + }); + } else { + notify.error( + "An error occurred while saving the custom host vital. Please try again.", + { response: error } + ); + } + }, + } + ); + + const onInputChange = (value: string) => { + setName(value); + setFormValidation(validateFormData({ name: value })); + }; + + const onClickSave = () => { + const validation = validateFormData({ name }, true); + if (!validation.isValid) { + setFormValidation(validation); + return; + } + addCustomHostVital(); + }; + + return ( + +
+ +
+ + +
+ +
+ ); +}; + +export default AddCustomHostVitalModal; diff --git a/frontend/pages/ManageControlsPage/Variables/components/AddCustomHostVitalModal/index.ts b/frontend/pages/ManageControlsPage/Variables/components/AddCustomHostVitalModal/index.ts new file mode 100644 index 0000000000..e6001aee52 --- /dev/null +++ b/frontend/pages/ManageControlsPage/Variables/components/AddCustomHostVitalModal/index.ts @@ -0,0 +1 @@ +export { default } from "./AddCustomHostVitalModal"; diff --git a/frontend/pages/ManageControlsPage/Variables/components/DeleteCustomHostVitalModal/DeleteCustomHostVitalModal.tsx b/frontend/pages/ManageControlsPage/Variables/components/DeleteCustomHostVitalModal/DeleteCustomHostVitalModal.tsx new file mode 100644 index 0000000000..78d1e960fb --- /dev/null +++ b/frontend/pages/ManageControlsPage/Variables/components/DeleteCustomHostVitalModal/DeleteCustomHostVitalModal.tsx @@ -0,0 +1,82 @@ +import React from "react"; +import { useMutation } from "react-query"; + +import { ICustomHostVital } from "interfaces/custom_host_vitals"; +import { hasStatusKey } from "interfaces/errors"; +import customHostVitalsAPI from "services/entities/custom_host_vitals"; +import { notify } from "components/ToastNotification"; +import Modal from "components/Modal"; +import Button from "components/buttons/Button"; + +interface IDeleteCustomHostVitalModalProps { + vital: ICustomHostVital; + onExit: () => void; + onDelete: () => void; +} + +const baseClass = "delete-custom-host-vital-modal"; + +const DeleteCustomHostVitalModal = ({ + vital, + onExit, + onDelete, +}: IDeleteCustomHostVitalModalProps) => { + const { mutate: deleteCustomHostVital, isLoading: isDeleting } = useMutation( + () => customHostVitalsAPI.deleteCustomHostVital(vital.id), + { + onSuccess: () => { + notify.success("Custom host vital successfully deleted."); + onDelete(); + }, + onError: (error) => { + // TODO(#48559): once the backend reports the specific reference on a + // 409, surface its detail instead of this generic copy, e.g. + // "Couldn't delete. Host vital is referenced in label criteria for the + // Envoy iPads label." + const message = + hasStatusKey(error) && error.status === 409 + ? "This custom host vital is referenced in a configuration profile or script and can't be deleted. To resolve, edit the configuration profile or script." + : "An error occurred while deleting the custom host vital. Please try again."; + notify.error(message, { response: error }); + onExit(); + }, + } + ); + + const onClickDelete = () => { + deleteCustomHostVital(); + }; + + return ( + +
+ + Are you sure you want to delete the {vital.name} host vital? + +
+
+ Any references to the {`$FLEET_HOST_VITAL_${vital.id}`} variable + will break. +
+
+ + +
+
+ ); +}; + +export default DeleteCustomHostVitalModal; diff --git a/frontend/pages/ManageControlsPage/Variables/components/DeleteCustomHostVitalModal/index.ts b/frontend/pages/ManageControlsPage/Variables/components/DeleteCustomHostVitalModal/index.ts new file mode 100644 index 0000000000..188365e355 --- /dev/null +++ b/frontend/pages/ManageControlsPage/Variables/components/DeleteCustomHostVitalModal/index.ts @@ -0,0 +1 @@ +export { default } from "./DeleteCustomHostVitalModal"; diff --git a/frontend/pages/ManageControlsPage/Variables/components/EditCustomHostVitalModal/EditCustomHostVitalModal.tsx b/frontend/pages/ManageControlsPage/Variables/components/EditCustomHostVitalModal/EditCustomHostVitalModal.tsx new file mode 100644 index 0000000000..c5a9833486 --- /dev/null +++ b/frontend/pages/ManageControlsPage/Variables/components/EditCustomHostVitalModal/EditCustomHostVitalModal.tsx @@ -0,0 +1,111 @@ +import React, { useState } from "react"; +import { useMutation } from "react-query"; + +import { hasStatusKey } from "interfaces/errors"; +import { ICustomHostVital } from "interfaces/custom_host_vitals"; +import customHostVitalsAPI from "services/entities/custom_host_vitals"; +import { notify } from "components/ToastNotification"; +import Modal from "components/Modal"; +import Button from "components/buttons/Button"; +import InputField from "components/forms/fields/InputField"; + +import { + validateFormData, + ICustomHostVitalFormValidation, + CUSTOM_HOST_VITAL_NAME_MAX_LENGTH, +} from "../../helpers"; + +const baseClass = "edit-custom-host-vital-modal"; + +interface IEditCustomHostVitalModalProps { + vital: ICustomHostVital; + onCancel: () => void; + onSave: () => void; +} + +const EditCustomHostVitalModal = ({ + vital, + onCancel, + onSave, +}: IEditCustomHostVitalModalProps) => { + const [name, setName] = useState(vital.name); + const [ + formValidation, + setFormValidation, + ] = useState(() => + validateFormData({ name: vital.name }) + ); + + const { mutate: updateCustomHostVital, isLoading: isSaving } = useMutation( + () => + customHostVitalsAPI.updateCustomHostVital(vital.id, { + name: name.trim(), + }), + { + onSuccess: () => { + notify.success("Custom host vital updated."); + onSave(); + }, + onError: (error) => { + if (hasStatusKey(error) && error.status === 409) { + notify.error("Couldn't save. Host vital name must be unique.", { + response: error, + }); + } else { + notify.error( + "An error occurred while updating the custom host vital. Please try again.", + { response: error } + ); + } + }, + } + ); + + const onInputChange = (value: string) => { + setName(value); + setFormValidation(validateFormData({ name: value })); + }; + + const onClickSave = () => { + const validation = validateFormData({ name }, true); + if (!validation.isValid) { + setFormValidation(validation); + return; + } + updateCustomHostVital(); + }; + + return ( + +
+ +
+ + +
+ +
+ ); +}; + +export default EditCustomHostVitalModal; diff --git a/frontend/pages/ManageControlsPage/Variables/components/EditCustomHostVitalModal/index.ts b/frontend/pages/ManageControlsPage/Variables/components/EditCustomHostVitalModal/index.ts new file mode 100644 index 0000000000..794bb17180 --- /dev/null +++ b/frontend/pages/ManageControlsPage/Variables/components/EditCustomHostVitalModal/index.ts @@ -0,0 +1 @@ +export { default } from "./EditCustomHostVitalModal"; diff --git a/frontend/pages/ManageControlsPage/Variables/helpers.ts b/frontend/pages/ManageControlsPage/Variables/helpers.ts new file mode 100644 index 0000000000..b2e3003644 --- /dev/null +++ b/frontend/pages/ManageControlsPage/Variables/helpers.ts @@ -0,0 +1,69 @@ +export const CUSTOM_HOST_VITAL_NAME_MAX_LENGTH = 255; + +export interface ICustomHostVitalFormValidation { + isValid: boolean; + name?: { isValid: boolean; message?: string }; +} + +interface INameFormData { + name: string; +} + +type IMessageFunc = (formData: INameFormData) => string; +type IValidationMessage = string | IMessageFunc; + +interface IValidation { + name: string; + isValid: (formData: INameFormData) => boolean; + message?: IValidationMessage; +} + +const NAME_VALIDATIONS: IValidation[] = [ + { + name: "required", + isValid: (formData) => formData.name.trim().length > 0, + message: "Name is required", + }, + { + name: "notTooLong", + isValid: (formData) => + formData.name.trim().length <= CUSTOM_HOST_VITAL_NAME_MAX_LENGTH, + message: `Name may not exceed ${CUSTOM_HOST_VITAL_NAME_MAX_LENGTH} characters`, + }, +]; + +const getErrorMessage = ( + formData: INameFormData, + message?: IValidationMessage +) => { + if (message === undefined || typeof message === "string") { + return message; + } + return message(formData); +}; + +export const validateFormData = ( + formData: INameFormData, + isSaving = false +): ICustomHostVitalFormValidation => { + const formValidation: ICustomHostVitalFormValidation = { isValid: true }; + + const failedValidation = NAME_VALIDATIONS.find((validation) => { + if (!isSaving && validation.name === "required") { + return false; // Skip required check until the user attempts to save. + } + return !validation.isValid(formData); + }); + + if (!failedValidation) { + formValidation.name = { isValid: true }; + } else { + formValidation.isValid = false; + formValidation.name = { + isValid: false, + message: getErrorMessage(formData, failedValidation.message), + }; + } + + return formValidation; +}; diff --git a/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tests.tsx b/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tests.tsx index 3a3724f236..e32c13e215 100644 --- a/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tests.tsx +++ b/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tests.tsx @@ -148,7 +148,7 @@ describe("ManageHostsPage", () => { // Add hosts button still visible in the page header const headerWrap = screen - .getByText("Manage enroll secret") + .getByText("Enroll secrets") .closest(".manage-hosts__button-wrap"); expect( within(headerWrap as HTMLElement).getByText("Add hosts") diff --git a/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx b/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx index aca3fb6fd7..bf2527afa4 100644 --- a/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx +++ b/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx @@ -80,6 +80,7 @@ import { PolicyResponse, } from "utilities/constants"; import { getNextLocationPath } from "utilities/helpers"; +import { getPathWithQueryParams } from "utilities/url"; import getDeleteLabelErrorMessages from "pages/labels/helpers"; import { strToBool } from "utilities/strings/stringUtils"; @@ -446,6 +447,9 @@ const ManageHostsPage = ({ // ========= derived permissions // canEnrollHosts is hoisted above the deep-link effects (see earlier) const canEnrollGlobalHosts = isGlobalAdmin || isGlobalMaintainer; + // Mirrors the Controls > Variables > Custom host vitals tab, which only lets + // global admins/maintainers manage vital definitions. + const canManageCustomHostVitals = isGlobalAdmin || isGlobalMaintainer; const canAddNewLabels = (isGlobalAdmin || isGlobalMaintainer || @@ -2064,13 +2068,31 @@ const ManageHostsPage = ({
{renderHeader()}
+ {canManageCustomHostVitals && !hasErrors && ( + + )} {canEnrollHosts && !hasErrors && ( )} {showAddHostsButton && ( diff --git a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx index 3b403378e3..c53b0065ff 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx +++ b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx @@ -22,6 +22,7 @@ import teamAPI, { ILoadTeamsResponse } from "services/entities/teams"; import commandAPI from "services/entities/command"; import { IHost, IMacadminsResponse, IHostResponse } from "interfaces/host"; +import { IHostCustomVital } from "interfaces/custom_host_vitals"; import { ILabel } from "interfaces/label"; import { IListSort } from "interfaces/list_options"; import { IHostPolicy } from "interfaces/policy"; @@ -148,6 +149,7 @@ import HostHeader from "../cards/HostHeader"; import InventoryVersionsModal from "../modals/InventoryVersionsModal"; import UpdateEndUserModal from "../cards/User/components/UpdateEndUserModal"; import LocationModal from "../modals/LocationModal"; +import EditHostVitalModal from "../modals/EditHostVitalModal"; import MDMStatusModal from "../modals/MDMStatusModal"; import ClearPasscodeModal from "./modals/ClearPasscodeModal"; @@ -257,6 +259,11 @@ const HostDetailsPage = ({ const [showClearPasscodeModal, setShowClearPasscodeModal] = useState(false); + const [ + editingCustomHostVital, + setEditingCustomHostVital, + ] = useState(null); + // General-use updating state const [isUpdating, setIsUpdating] = useState(false); @@ -1317,6 +1324,12 @@ const HostDetailsPage = ({ // had one — so we don't gate visibility on orbit/MDM state. const canViewMyDeviceLink = isGlobalAdmin; + const canEditCustomHostVitals = + isGlobalAdmin || + isGlobalMaintainer || + isHostTeamAdmin || + isHostTeamMaintainer; + const showSoftwareLibraryTab = isPremiumTier; const showReportsEmptyState = host.mdm?.enrollment_status === "Pending"; const showAgentOptionsCard = !isIosOrIpadosHost && !isAndroidHost; @@ -1515,6 +1528,12 @@ const HostDetailsPage = ({ )} toggleLocationModal={toggleLocationModal} toggleMDMStatusModal={toggleMDMStatusModal} + customHostVitals={host.custom_host_vitals} + onEditCustomHostVital={ + canEditCustomHostVitals + ? setEditingCustomHostVital + : undefined + } /> )} + {editingCustomHostVital && ( + setEditingCustomHostVital(null)} + onSave={() => { + refetchHostDetails(); + refetchPastActivities(); + setEditingCustomHostVital(null); + }} + /> + )} {showMDMStatusModal && host.mdm.enrollment_status && ( { + const activity = createMockHostPastActivity({ + actor_full_name: "Test User", + type: ActivityType.EditedCustomHostVitalValue, + details: { custom_host_vital_name: "Asset tag" }, + }); + + it("renders the activity content", () => { + render( + + ); + + expect(screen.getByText("Test User")).toBeVisible(); + expect(screen.getByText("Asset tag")).toBeVisible(); + }); + + it("does not render the cancel icon", () => { + render( + + ); + + expect(screen.queryByTestId("close-icon")).not.toBeInTheDocument(); + }); + + it("does not render the show details icon", () => { + render( + + ); + + expect(screen.queryByTestId("info-outline-icon")).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/pages/hosts/details/cards/Activity/ActivityItems/EditedCustomHostVitalValueActivityItem/EditedCustomHostVitalValueActivityItem.tsx b/frontend/pages/hosts/details/cards/Activity/ActivityItems/EditedCustomHostVitalValueActivityItem/EditedCustomHostVitalValueActivityItem.tsx new file mode 100644 index 0000000000..244e4c6403 --- /dev/null +++ b/frontend/pages/hosts/details/cards/Activity/ActivityItems/EditedCustomHostVitalValueActivityItem/EditedCustomHostVitalValueActivityItem.tsx @@ -0,0 +1,25 @@ +import React from "react"; + +import ActivityItem from "components/ActivityItem"; + +import { IHostActivityItemComponentProps } from "../../ActivityConfig"; + +const baseClass = "edited-custom-host-vital-value-activity-item"; + +const EditedCustomHostVitalValueActivityItem = ({ + activity, +}: IHostActivityItemComponentProps) => { + return ( + + {activity.actor_full_name} edited the value for custom host vital{" "} + {activity.details?.custom_host_vital_name}. + + ); +}; + +export default EditedCustomHostVitalValueActivityItem; diff --git a/frontend/pages/hosts/details/cards/Activity/ActivityItems/EditedCustomHostVitalValueActivityItem/index.ts b/frontend/pages/hosts/details/cards/Activity/ActivityItems/EditedCustomHostVitalValueActivityItem/index.ts new file mode 100644 index 0000000000..ddf94b8855 --- /dev/null +++ b/frontend/pages/hosts/details/cards/Activity/ActivityItems/EditedCustomHostVitalValueActivityItem/index.ts @@ -0,0 +1 @@ +export { default } from "./EditedCustomHostVitalValueActivityItem"; diff --git a/frontend/pages/hosts/details/cards/Vitals/Vitals.tests.tsx b/frontend/pages/hosts/details/cards/Vitals/Vitals.tests.tsx index ac9611f24b..c7eb996a07 100644 --- a/frontend/pages/hosts/details/cards/Vitals/Vitals.tests.tsx +++ b/frontend/pages/hosts/details/cards/Vitals/Vitals.tests.tsx @@ -615,3 +615,60 @@ describe("Disk space field visibility", () => { expect(screen.queryByText("Disk space available")).not.toBeInTheDocument(); }); }); + +describe("Custom host vitals", () => { + const customHostVitals = [ + { custom_host_vital_id: 1, name: "Asset tag", value: "FLEET-001234" }, + { custom_host_vital_id: 2, name: "Purchase date", value: "" }, + ]; + + it("renders each custom host vital as a name/value row, falling back to the empty cell value when unset", () => { + const mockHost = createMockHost({ platform: "darwin" }); + + render( + + ); + + expect(screen.getByText("Asset tag")).toBeInTheDocument(); + expect(screen.getByText("FLEET-001234")).toBeInTheDocument(); + expect(screen.getByText("Purchase date")).toBeInTheDocument(); + // The vital with no value falls back to the default empty cell value. + expect(screen.getByText(DEFAULT_EMPTY_CELL_VALUE)).toBeInTheDocument(); + }); + + it("renders values as plain text (no edit affordance) when no edit handler is provided", () => { + const mockHost = createMockHost({ platform: "darwin" }); + + render( + + ); + + expect( + screen.queryByRole("button", { name: "Edit Asset tag" }) + ).not.toBeInTheDocument(); + expect(screen.getByText("FLEET-001234")).toBeInTheDocument(); + }); + + it("renders an edit pencil next to the label and calls the edit handler on click", async () => { + const mockHost = createMockHost({ platform: "darwin" }); + const onEditCustomHostVital = jest.fn(); + const customRender = createCustomRenderer({}); + + const { user } = customRender( + + ); + + expect(screen.getByText("FLEET-001234")).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "FLEET-001234" }) + ).not.toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Edit Asset tag" })); + + expect(onEditCustomHostVital).toHaveBeenCalledWith(customHostVitals[0]); + }); +}); diff --git a/frontend/pages/hosts/details/cards/Vitals/Vitals.tsx b/frontend/pages/hosts/details/cards/Vitals/Vitals.tsx index 8ad1798579..6895245ff7 100644 --- a/frontend/pages/hosts/details/cards/Vitals/Vitals.tsx +++ b/frontend/pages/hosts/details/cards/Vitals/Vitals.tsx @@ -2,6 +2,7 @@ import React from "react"; import classnames from "classnames"; import { IAppleDeviceUpdates } from "interfaces/config"; +import { IHostCustomVital } from "interfaces/custom_host_vitals"; import { IHostMdmData, IMunkiData } from "interfaces/host"; import { isAndroid, @@ -54,6 +55,8 @@ interface IVitalsProps { * the My device page) so the row renders as plain text instead of a link. */ toggleMDMStatusModal?: () => void; + customHostVitals?: IHostCustomVital[]; + onEditCustomHostVital?: (vital: IHostCustomVital) => void; } type VitalForSort = { sortKey: string; element: React.ReactNode }; @@ -127,6 +130,8 @@ const Vitals = ({ className, toggleLocationModal, toggleMDMStatusModal, + customHostVitals, + onEditCustomHostVital, }: IVitalsProps) => { const isIosOrIpadosHost = isIPadOrIPhone(vitalsData.platform); const isAndroidHost = isAndroid(vitalsData.platform); @@ -631,6 +636,38 @@ const Vitals = ({ }); } + customHostVitals?.forEach((vital) => { + const displayValue = + vital.value === "" ? DEFAULT_EMPTY_CELL_VALUE : vital.value; + const title = onEditCustomHostVital ? ( + + {vital.name} + + + ) : ( + vital.name + ); + + vitals.push({ + sortKey: vital.name, + element: ( + + ), + }); + }); + // Sort alphabetically by title and render return ( <> diff --git a/frontend/pages/hosts/details/cards/Vitals/_styles.scss b/frontend/pages/hosts/details/cards/Vitals/_styles.scss index 637656c01f..b00f826bcd 100644 --- a/frontend/pages/hosts/details/cards/Vitals/_styles.scss +++ b/frontend/pages/hosts/details/cards/Vitals/_styles.scss @@ -105,6 +105,12 @@ } } + &__custom-vital-title { + display: inline-flex; + align-items: center; + gap: $pad-xsmall; + } + .text-muted { color: $ui-fleet-black-50; } diff --git a/frontend/pages/hosts/details/modals/EditHostVitalModal/EditHostVitalModal.tests.tsx b/frontend/pages/hosts/details/modals/EditHostVitalModal/EditHostVitalModal.tests.tsx new file mode 100644 index 0000000000..8b96f533e9 --- /dev/null +++ b/frontend/pages/hosts/details/modals/EditHostVitalModal/EditHostVitalModal.tests.tsx @@ -0,0 +1,112 @@ +import React from "react"; + +import { screen, waitFor } from "@testing-library/react"; +import { createCustomRenderer } from "test/test-utils"; + +import customHostVitalsAPI from "services/entities/custom_host_vitals"; + +import EditHostVitalModal from "./EditHostVitalModal"; + +jest.mock("services/entities/custom_host_vitals"); + +const vital = { + custom_host_vital_id: 5, + name: "Asset tag", + value: "FLEET-001234", +}; + +describe("EditHostVitalModal", () => { + const render = createCustomRenderer({ withBackendMock: true }); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it("renders the static title with the vital name as the field label, prefilled with its value", () => { + render( + + ); + + expect(screen.getByText("Edit host vital")).toBeVisible(); + expect(screen.getByRole("textbox", { name: "Asset tag" })).toHaveValue( + "FLEET-001234" + ); + }); + + it("saves the edited value and calls onSave on success", async () => { + (customHostVitalsAPI.updateHostCustomHostVitalValue as jest.Mock).mockResolvedValue( + undefined + ); + const onSave = jest.fn(); + + const { user } = render( + + ); + + const input = screen.getByRole("textbox", { name: "Asset tag" }); + await user.clear(input); + await user.type(input, "FLEET-999"); + await user.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => { + expect( + customHostVitalsAPI.updateHostCustomHostVitalValue + ).toHaveBeenCalledWith(7, 5, "FLEET-999"); + }); + await waitFor(() => { + expect(onSave).toHaveBeenCalled(); + }); + }); + + it("does not call onSave when the update errors", async () => { + (customHostVitalsAPI.updateHostCustomHostVitalValue as jest.Mock).mockRejectedValue( + new Error("boom") + ); + const onSave = jest.fn(); + + const { user } = render( + + ); + + await user.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => { + expect( + customHostVitalsAPI.updateHostCustomHostVitalValue + ).toHaveBeenCalledWith(7, 5, "FLEET-001234"); + }); + expect(onSave).not.toHaveBeenCalled(); + }); + + it("calls onCancel when the Cancel button is clicked", async () => { + const onCancel = jest.fn(); + + const { user } = render( + + ); + + await user.click(screen.getByRole("button", { name: "Cancel" })); + + expect(onCancel).toHaveBeenCalled(); + }); +}); diff --git a/frontend/pages/hosts/details/modals/EditHostVitalModal/EditHostVitalModal.tsx b/frontend/pages/hosts/details/modals/EditHostVitalModal/EditHostVitalModal.tsx new file mode 100644 index 0000000000..3fdb080145 --- /dev/null +++ b/frontend/pages/hosts/details/modals/EditHostVitalModal/EditHostVitalModal.tsx @@ -0,0 +1,79 @@ +import React, { useState } from "react"; +import { useMutation } from "react-query"; + +import { IHostCustomVital } from "interfaces/custom_host_vitals"; +import { getErrorReason } from "interfaces/errors"; +import customHostVitalsAPI from "services/entities/custom_host_vitals"; + +import Modal from "components/Modal"; +import Button from "components/buttons/Button"; +import InputField from "components/forms/fields/InputField"; +import { notify } from "components/ToastNotification"; + +const baseClass = "edit-host-vital-modal"; + +interface IEditHostVitalModalProps { + hostId: number; + vital: IHostCustomVital; + onCancel: () => void; + onSave: () => void; +} + +const EditHostVitalModal = ({ + hostId, + vital, + onCancel, + onSave, +}: IEditHostVitalModalProps) => { + const [value, setValue] = useState(vital.value); + + const { mutate: saveValue, isLoading: isSaving } = useMutation( + () => + customHostVitalsAPI.updateHostCustomHostVitalValue( + hostId, + vital.custom_host_vital_id, + value + ), + { + onSuccess: () => { + notify.success("Successfully updated custom host vital."); + onSave(); + }, + onError: (error) => { + notify.error( + getErrorReason(error) || + "Couldn't update custom host vital. Please try again.", + { response: error } + ); + }, + } + ); + + const onSubmit = (e: React.FormEvent) => { + e.preventDefault(); + saveValue(); + }; + + return ( + +
+ +
+ + +
+ +
+ ); +}; + +export default EditHostVitalModal; diff --git a/frontend/pages/hosts/details/modals/EditHostVitalModal/_styles.scss b/frontend/pages/hosts/details/modals/EditHostVitalModal/_styles.scss new file mode 100644 index 0000000000..5e44171027 --- /dev/null +++ b/frontend/pages/hosts/details/modals/EditHostVitalModal/_styles.scss @@ -0,0 +1,5 @@ +.edit-host-vital-modal { + &__form { + @include vertical-modal-layout; + } +} diff --git a/frontend/pages/hosts/details/modals/EditHostVitalModal/index.ts b/frontend/pages/hosts/details/modals/EditHostVitalModal/index.ts new file mode 100644 index 0000000000..e484fe7acf --- /dev/null +++ b/frontend/pages/hosts/details/modals/EditHostVitalModal/index.ts @@ -0,0 +1 @@ +export { default } from "./EditHostVitalModal"; diff --git a/frontend/pages/labels/NewLabelPage/NewLabelPage.tsx b/frontend/pages/labels/NewLabelPage/NewLabelPage.tsx index bc29b01d16..ab947a337d 100644 --- a/frontend/pages/labels/NewLabelPage/NewLabelPage.tsx +++ b/frontend/pages/labels/NewLabelPage/NewLabelPage.tsx @@ -10,6 +10,9 @@ import PATHS from "router/paths"; import targetsAPI, { ITargetsSearchResponse } from "services/entities/targets"; import idpAPI from "services/entities/idp"; import labelsAPI from "services/entities/labels"; +import customHostVitalsAPI, { + IListCustomHostVitalsApiParams, +} from "services/entities/custom_host_vitals"; import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants"; // TODO - move this table config near here once expanded this logic to encompass editing and @@ -27,6 +30,7 @@ import useToggleSidePanel from "hooks/useToggleSidePanel"; import { RouteComponentProps } from "react-router"; import { + CUSTOM_HOST_VITAL_CRITERION, LabelHostVitalsCriterion, LabelMembershipType, } from "interfaces/label"; @@ -47,12 +51,24 @@ import Icon from "components/Icon"; import TargetsInput from "components/TargetsInput"; import Radio from "components/forms/fields/Radio"; import PlatformField from "../components/PlatformField"; -import { validateNewLabelFormData, INewLabelFormValidation } from "./helpers"; +import { + validateNewLabelFormData, + INewLabelFormValidation, + buildCriterionOptionValue, + parseCriterionOptionValue, + getVitalValuePlaceholder, + getCriterionHelpText, +} from "./helpers"; -const availableCriteria: { +interface ICriterionOption { label: string; - value: LabelHostVitalsCriterion; -}[] = [ + // Dropdown value: an IdP criterion's stable enum value, or a synthetic + // `custom_host_vital:` value for a custom host vital (see + // buildCriterionOptionValue / parseCriterionOptionValue). + value: string; +} + +const IDP_CRITERIA: ICriterionOption[] = [ { label: "Identity provider (IdP) group", value: "end_user_idp_group" }, { label: "IdP department", value: "end_user_idp_department" }, ]; @@ -81,6 +97,9 @@ export interface INewLabelFormData { // host vitals vital: LabelHostVitalsCriterion; // TODO - make use of recursive `LabelHostVitalsCriteria` type in future iterations to support logical combinations of different criteria vitalValue: string; + // Set only when `vital === CUSTOM_HOST_VITAL_CRITERION`; identifies the + // selected custom host vital definition. + customHostVitalId?: number; // manual targetedHosts: IHost[]; @@ -143,6 +162,7 @@ const NewLabelPage = ({ platform, vital, vitalValue, + customHostVitalId, targetedHosts, } = formData; @@ -205,29 +225,50 @@ const NewLabelPage = ({ ); const idpConfigured = !!scimIdPDetails?.last_request?.requested_at; + // Custom host vitals are a Fleet Free feature, so this query runs on all + // tiers. We fetch the full list (no search/pagination) to both gate the + // "Host vitals" label type and populate the criteria selector. + const customHostVitalsParams: IListCustomHostVitalsApiParams = {}; + const { data: customHostVitalsData } = useQuery( + ["custom_host_vitals", customHostVitalsParams], + () => customHostVitalsAPI.getCustomHostVitals(customHostVitalsParams), + { + ...DEFAULT_USE_QUERY_OPTIONS, + } + ); + const customHostVitals = customHostVitalsData?.custom_host_vitals ?? []; + const hasCustomHostVitals = customHostVitals.length > 0; + + // Host vitals labels can be based on IdP groups/departments (Premium, once an + // IdP is configured) OR custom host vitals (any tier). The type is disabled + // only when neither source is available. let hostVitalsTooltipContent: React.ReactNode; - if (!isPremiumTier) { - hostVitalsTooltipContent = ( + if (!idpConfigured && !hasCustomHostVitals) { + // IdP criteria are Premium-only, so a Free-tier admin can't "configure your + // IdP" — point them at the custom host vital path instead. + hostVitalsTooltipContent = isPremiumTier ? ( <> - Currently, host vitals labels are based on -
- identity provider (IdP) groups or departments. -
- IdP integration available in Fleet Premium. + To use host vitals labels, configure your IdP in integration settings or + add a custom host vital. - ); - } else if (!idpConfigured) { - hostVitalsTooltipContent = ( + ) : ( <> - Currently, host vitals labels are based on -
- identity provider (IdP) groups or departments. -
- IdP has not been configured in integration settings. + To use host vitals labels, add a custom host vital. Identity provider + (IdP) group and department criteria are available in Fleet Premium. ); } + // Each custom host vital becomes its own selectable criterion. IdP criteria + // only appear when an IdP is configured. + const criterionOptions: ICriterionOption[] = [ + ...(idpConfigured ? IDP_CRITERIA : []), + ...customHostVitals.map((customHostVital) => ({ + label: customHostVital.name, + value: buildCriterionOptionValue(customHostVital.id), + })), + ]; + useEffect(() => { if (location.pathname.includes("dynamic")) { router.replace(PATHS.NEW_LABEL); @@ -289,13 +330,65 @@ const NewLabelPage = ({ }); }; - const onTypeChange = (value: string): void => { - const newFormData = { + // The criteria dropdown carries a synthetic value for custom host vitals, so + // it can't reuse the generic `onInputChange` (which would set `vital` to the + // encoded string). Decode it back into `vital` + `customHostVitalId`. + const onCriterionChange = (optionValue: string): void => { + const { + vital: nextVital, + customHostVitalId: nextId, + } = parseCriterionOptionValue(optionValue); + + const newFormData: INewLabelFormData = { ...formData, - type: value as LabelMembershipType, + vital: nextVital, + customHostVitalId: nextId, }; setFormData(newFormData); + const fullValidation = validateNewLabelFormData(newFormData); + setFormErrors((prev) => { + const next: INewLabelFormValidation = { ...prev, isValid: true }; + + if (prev.name) next.name = prev.name; + if (prev.description) next.description = prev.description; + if (prev.labelQuery) next.labelQuery = prev.labelQuery; + if (prev.criteria && fullValidation.criteria?.isValid) { + next.criteria = undefined; + } else if (prev.criteria) { + next.criteria = prev.criteria; + } + + const fields = [ + next.name, + next.description, + next.labelQuery, + next.criteria, + ]; + next.isValid = fields.every((f) => !f || f.isValid); + + return next; + }); + }; + + const onTypeChange = (value: string): void => { + const nextType = value as LabelMembershipType; + const newFormData: INewLabelFormData = { + ...formData, + type: nextType, + }; + + // When switching to "host vitals", ensure the selected criterion is one the + // dropdown actually offers: the default `end_user_idp_group` is invalid when + // no IdP is configured (custom-host-vital-only case), so fall back to the + // first custom host vital. + if (nextType === "host_vitals" && !idpConfigured && hasCustomHostVitals) { + newFormData.vital = CUSTOM_HOST_VITAL_CRITERION; + newFormData.customHostVitalId = customHostVitals[0].id; + } + + setFormData(newFormData); + const fullValidation = validateNewLabelFormData(newFormData); setFormErrors((prev) => { @@ -474,7 +567,16 @@ const NewLabelPage = ({ ); - case "host_vitals": + case "host_vitals": { + // The selected criterion is identified by the dropdown's string value: + // IdP criteria use their stable enum value; each custom host vital uses + // a synthetic `custom_host_vital:` value so multiple custom vitals + // are distinguishable in a single dropdown. + const selectedCriterionValue = + vital === CUSTOM_HOST_VITAL_CRITERION && customHostVitalId != null + ? buildCriterionOptionValue(customHostVitalId) + : vital; + return (
); + } case "manual": return ( diff --git a/frontend/pages/labels/NewLabelPage/helpers.tests.ts b/frontend/pages/labels/NewLabelPage/helpers.tests.ts new file mode 100644 index 0000000000..5f15873919 --- /dev/null +++ b/frontend/pages/labels/NewLabelPage/helpers.tests.ts @@ -0,0 +1,75 @@ +import { CUSTOM_HOST_VITAL_CRITERION } from "interfaces/label"; + +import { + buildCriterionOptionValue, + parseCriterionOptionValue, + getVitalValuePlaceholder, + getCriterionHelpText, +} from "./helpers"; + +describe("NewLabelPage helpers", () => { + describe("buildCriterionOptionValue / parseCriterionOptionValue", () => { + it("encodes a custom host vital id into the option value", () => { + expect(buildCriterionOptionValue(5)).toBe("custom_host_vital:5"); + }); + + it("decodes a custom host vital option value back to vital + id", () => { + expect(parseCriterionOptionValue("custom_host_vital:5")).toEqual({ + vital: CUSTOM_HOST_VITAL_CRITERION, + customHostVitalId: 5, + }); + }); + + it("decodes an IdP option value with no custom id", () => { + expect(parseCriterionOptionValue("end_user_idp_group")).toEqual({ + vital: "end_user_idp_group", + }); + expect(parseCriterionOptionValue("end_user_idp_department")).toEqual({ + vital: "end_user_idp_department", + }); + }); + + it("round-trips any custom host vital id", () => { + [1, 42, 1000, 999999].forEach((id) => { + const parsed = parseCriterionOptionValue(buildCriterionOptionValue(id)); + expect(parsed.vital).toBe(CUSTOM_HOST_VITAL_CRITERION); + expect(parsed.customHostVitalId).toBe(id); + }); + }); + + it("does not treat an IdP value as a custom vital", () => { + const parsed = parseCriterionOptionValue("end_user_idp_group"); + expect(parsed.vital).toBe("end_user_idp_group"); + expect(parsed.customHostVitalId).toBeUndefined(); + }); + }); + + describe("getVitalValuePlaceholder", () => { + it("returns IdP-specific placeholders", () => { + expect(getVitalValuePlaceholder("end_user_idp_group")).toBe("IT admins"); + expect(getVitalValuePlaceholder("end_user_idp_department")).toBe( + "Engineering" + ); + }); + + it("returns a generic placeholder for custom host vitals", () => { + expect(getVitalValuePlaceholder(CUSTOM_HOST_VITAL_CRITERION)).toBe( + "Value" + ); + }); + }); + + describe("getCriterionHelpText", () => { + it("is specific to the selected criterion", () => { + expect(getCriterionHelpText("end_user_idp_group")).toBe( + "Label criteria is based on the end user's IdP group." + ); + expect(getCriterionHelpText("end_user_idp_department")).toBe( + "Label criteria is based on the end user's IdP department." + ); + expect(getCriterionHelpText(CUSTOM_HOST_VITAL_CRITERION)).toBe( + "Label criteria is based on the selected custom host vital." + ); + }); + }); +}); diff --git a/frontend/pages/labels/NewLabelPage/helpers.ts b/frontend/pages/labels/NewLabelPage/helpers.ts index 9934cfc62f..29e2ef7147 100644 --- a/frontend/pages/labels/NewLabelPage/helpers.ts +++ b/frontend/pages/labels/NewLabelPage/helpers.ts @@ -1,5 +1,51 @@ +import { + CUSTOM_HOST_VITAL_CRITERION, + LabelHostVitalsCriterion, +} from "interfaces/label"; + import { INewLabelFormData } from "./NewLabelPage"; +// The criteria dropdown needs a single string per option, but custom host +// vitals all share the `custom_host_vital` criterion value, so the definition +// id is encoded into the option value and decoded on selection. +export const buildCriterionOptionValue = (customHostVitalId: number) => + `${CUSTOM_HOST_VITAL_CRITERION}:${customHostVitalId}`; + +export const parseCriterionOptionValue = ( + optionValue: string +): { vital: LabelHostVitalsCriterion; customHostVitalId?: number } => { + if (optionValue.startsWith(`${CUSTOM_HOST_VITAL_CRITERION}:`)) { + // Guard against a malformed/missing id: an unparseable value would become + // NaN, later pass `!= null`, and serialize to `null` in the request body. + const parsedId = Number(optionValue.split(":")[1]); + return { + vital: CUSTOM_HOST_VITAL_CRITERION, + customHostVitalId: Number.isFinite(parsedId) ? parsedId : undefined, + }; + } + return { vital: optionValue as LabelHostVitalsCriterion }; +}; + +export const getVitalValuePlaceholder = (vital: LabelHostVitalsCriterion) => { + if (vital === "end_user_idp_group") { + return "IT admins"; + } + if (vital === "end_user_idp_department") { + return "Engineering"; + } + return "Value"; +}; + +export const getCriterionHelpText = (vital: LabelHostVitalsCriterion) => { + if (vital === "end_user_idp_group") { + return "Label criteria is based on the end user's IdP group."; + } + if (vital === "end_user_idp_department") { + return "Label criteria is based on the end user's IdP department."; + } + return "Label criteria is based on the selected custom host vital."; +}; + export interface INewLabelFormValidation { isValid: boolean; name?: { isValid: boolean; message?: string }; @@ -85,6 +131,20 @@ const FORM_VALIDATIONS: IFormValidations = { }, message: "Label criteria must be completed", }, + { + // A custom-vital criterion is incomplete without a selected definition id. + name: "customVitalRequiresId", + isValid: (formData) => { + if ( + formData.type !== "host_vitals" || + formData.vital !== CUSTOM_HOST_VITAL_CRITERION + ) { + return true; + } + return formData.customHostVitalId != null; + }, + message: "Label criteria must be completed", + }, ], }, }; diff --git a/frontend/router/index.tsx b/frontend/router/index.tsx index 599e2331da..72e2aed36d 100644 --- a/frontend/router/index.tsx +++ b/frontend/router/index.tsx @@ -350,6 +350,7 @@ const routes = ( + `${URL_PREFIX}/controls/scripts/progress/${batchExecutionId}`, CONTROLS_VARIABLES: `${URL_PREFIX}/controls/variables`, + CONTROLS_VARIABLES_GLOBAL_VARIABLES: `${URL_PREFIX}/controls/variables/global-variables`, + CONTROLS_VARIABLES_CUSTOM_HOST_VITALS: `${URL_PREFIX}/controls/variables/custom-host-vitals`, // Dashboard pages DASHBOARD: `${URL_PREFIX}/dashboard`, diff --git a/frontend/services/entities/custom_host_vitals.ts b/frontend/services/entities/custom_host_vitals.ts new file mode 100644 index 0000000000..6f29a1d9bf --- /dev/null +++ b/frontend/services/entities/custom_host_vitals.ts @@ -0,0 +1,61 @@ +import { + ICustomHostVital, + ICustomHostVitalFormData, +} from "interfaces/custom_host_vitals"; +import sendRequest from "services"; +import { getPathWithQueryParams } from "utilities/url"; +import endpoints from "utilities/endpoints"; + +export interface IListCustomHostVitalsApiParams { + page?: number; + per_page?: number; + query?: string; + order_key?: string; + order_direction?: "asc" | "desc"; +} + +export interface IListCustomHostVitalsResponse { + custom_host_vitals: ICustomHostVital[] | null; + meta: { + has_next_results: boolean; + has_previous_results: boolean; + }; + count: number; +} + +export default { + getCustomHostVitals( + params: IListCustomHostVitalsApiParams + ): Promise { + const { CUSTOM_HOST_VITALS } = endpoints; + const path = getPathWithQueryParams(CUSTOM_HOST_VITALS, { + page: params.page, + per_page: params.per_page, + query: params.query, + order_key: params.order_key, + order_direction: params.order_direction, + }); + + return sendRequest("GET", path); + }, + + addCustomHostVital(vital: ICustomHostVitalFormData) { + const { CUSTOM_HOST_VITALS } = endpoints; + return sendRequest("POST", CUSTOM_HOST_VITALS, vital); + }, + + updateCustomHostVital(id: number, vital: ICustomHostVitalFormData) { + const { CUSTOM_HOST_VITALS } = endpoints; + return sendRequest("PATCH", `${CUSTOM_HOST_VITALS}/${id}`, vital); + }, + + deleteCustomHostVital(id: number) { + const { CUSTOM_HOST_VITALS } = endpoints; + return sendRequest("DELETE", `${CUSTOM_HOST_VITALS}/${id}`); + }, + + updateHostCustomHostVitalValue(hostId: number, id: number, value: string) { + const { HOST_CUSTOM_HOST_VITAL } = endpoints; + return sendRequest("PUT", HOST_CUSTOM_HOST_VITAL(hostId, id), { value }); + }, +}; diff --git a/frontend/services/entities/labels.ts b/frontend/services/entities/labels.ts index 83805490ce..e584c4ad44 100644 --- a/frontend/services/entities/labels.ts +++ b/frontend/services/entities/labels.ts @@ -2,7 +2,11 @@ import sendRequest from "services"; import endpoints from "utilities/endpoints"; import helpers from "utilities/helpers"; -import { ILabel, ILabelSummary } from "interfaces/label"; +import { + CUSTOM_HOST_VITAL_CRITERION, + 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"; @@ -66,9 +70,15 @@ const generateCreateLabelBody = (formData: INewLabelFormData) => { return { name: formData.name, description: formData.description, + // `custom_host_vital_id` is only sent for the custom-vital path criteria: { vital: formData.vital, value: formData.vitalValue, + ...(formData.vital === CUSTOM_HOST_VITAL_CRITERION && + formData.customHostVitalId != null && + Number.isFinite(formData.customHostVitalId) + ? { custom_host_vital_id: formData.customHostVitalId } + : {}), }, }; default: diff --git a/frontend/services/entities/variables.ts b/frontend/services/entities/variables.ts index 6e8a18513a..5f35ca36ba 100644 --- a/frontend/services/entities/variables.ts +++ b/frontend/services/entities/variables.ts @@ -21,8 +21,8 @@ export default { getVariables( params: IListVariablesApiParams ): Promise { - const { VARIABLES } = endpoints; - const path = `${VARIABLES}?${buildQueryStringFromParams({ + const { GLOBAL_VARIABLES } = endpoints; + const path = `${GLOBAL_VARIABLES}?${buildQueryStringFromParams({ page: params.page, per_page: params.per_page, })}`; @@ -31,12 +31,12 @@ export default { }, addVariable(variable: IVariableFormData) { - const { VARIABLES } = endpoints; - return sendRequest("POST", VARIABLES, variable); + const { GLOBAL_VARIABLES } = endpoints; + return sendRequest("POST", GLOBAL_VARIABLES, variable); }, deleteVariable(variableId: number) { - const { VARIABLES } = endpoints; - return sendRequest("DELETE", `${VARIABLES}/${variableId}`); + const { GLOBAL_VARIABLES } = endpoints; + return sendRequest("DELETE", `${GLOBAL_VARIABLES}/${variableId}`); }, }; diff --git a/frontend/utilities/endpoints.ts b/frontend/utilities/endpoints.ts index 20eb6d7ef2..2688d28c25 100644 --- a/frontend/utilities/endpoints.ts +++ b/frontend/utilities/endpoints.ts @@ -94,6 +94,8 @@ export default { HOSTS_REPORT: `/${API_VERSION}/fleet/hosts/report`, HOSTS_TRANSFER: `/${API_VERSION}/fleet/hosts/transfer`, HOSTS_TRANSFER_BY_FILTER: `/${API_VERSION}/fleet/hosts/transfer/filter`, + HOST_CUSTOM_HOST_VITAL: (hostId: number, vitalId: number) => + `/${API_VERSION}/fleet/hosts/${hostId}/custom_host_vitals/${vitalId}`, HOST_LOCK: (id: number) => `/${API_VERSION}/fleet/hosts/${id}/lock`, HOST_UNLOCK: (id: number) => `/${API_VERSION}/fleet/hosts/${id}/unlock`, HOST_WIPE: (id: number) => `/${API_VERSION}/fleet/hosts/${id}/wipe`, @@ -366,6 +368,8 @@ export default { CERTIFICATE_AUTHORITY_REQUEST_CERT: (id: number) => { return `/${API_VERSION}/fleet/certificate_authorities/${id}/request_certificate`; }, - // custom variables endpoints - VARIABLES: `/${API_VERSION}/fleet/custom_variables`, + // global variables endpoints + GLOBAL_VARIABLES: `/${API_VERSION}/fleet/custom_variables`, + // custom host vitals endpoints + CUSTOM_HOST_VITALS: `/${API_VERSION}/fleet/custom_host_vitals`, }; diff --git a/pkg/spec/gitops.go b/pkg/spec/gitops.go index 90d1f70344..945ae44561 100644 --- a/pkg/spec/gitops.go +++ b/pkg/spec/gitops.go @@ -381,6 +381,10 @@ type GitOps struct { Labels []*fleet.LabelSpec LabelChangesSummary LabelChangesSummary + // CustomHostVitals are the custom host vital definitions (names only; per-host + // values are never set via GitOps). Global-only: cannot be set on a team/fleet file. + CustomHostVitals []fleet.CustomHostVital + // Software is only allowed on teams, not on global config. Software GitOpsSoftware // FleetSecrets is a map of secret names to their values, extracted from FLEET_SECRET_ environment variables used in profiles and scripts. @@ -392,6 +396,15 @@ type GitOps struct { SoftwarePresent bool // SecretsPresent indicates that the `secrets:` key was explicitly present in the YAML file. SecretsPresent bool + // CustomHostVitalsPresent indicates that the `custom_host_vitals:` key was explicitly present in the YAML file. + CustomHostVitalsPresent bool +} + +// GitOpsCustomHostVital defines the valid keys for an item in the top-level +// `custom_host_vitals:` list. Definitions only (a name) -- per-host values are +// never set via GitOps. +type GitOpsCustomHostVital struct { + Name string `json:"name"` } type GitOpsSoftware struct { @@ -465,7 +478,7 @@ func GitOpsFromFile(filePath, baseDir string, appConfig *fleet.EnrichedAppConfig result := &GitOps{} result.FleetSecrets = make(map[string]string) - topKeys := []string{"name", "settings", "org_settings", "agent_options", "controls", "policies", "reports", "software", "labels"} + topKeys := []string{"name", "settings", "org_settings", "agent_options", "controls", "policies", "reports", "software", "labels", "custom_host_vitals"} for k := range top { if !slices.Contains(topKeys, k) { multiError = multierror.Append(multiError, fmt.Errorf("unknown top-level field: %s", k)) @@ -528,9 +541,12 @@ func GitOpsFromFile(filePath, baseDir string, appConfig *fleet.EnrichedAppConfig for _, topKey := range topKeys { // "name" is handled later with special logic based on the filename. - // "labels" and "software" are special cases where omitting may be a no-op (based on exception settings), - // rather than a directive to clear settings. settings keys were handled above. - if topKey == "name" || topKey == "labels" || topKey == "software" || topKey == "settings" || topKey == "org_settings" { + // "labels" and "software" are special cases where omitting may be a no-op (based on + // exception settings), rather than a directive to clear settings. + // "custom_host_vitals" has no exception setting -- omitting it always means clear-all -- but still needs its own + // presence tracking (parseCustomHostVitals below), so it's excluded from the generic + // null-default handling too. settings keys were handled above. + if topKey == "name" || topKey == "labels" || topKey == "software" || topKey == "custom_host_vitals" || topKey == "settings" || topKey == "org_settings" { continue } // "controls" can be set on _either_ global or "no team" file, and we can't say which it is if both @@ -558,6 +574,11 @@ func GitOpsFromFile(filePath, baseDir string, appConfig *fleet.EnrichedAppConfig multiError = parseLabels(top, result, baseDir, logFn, filePath, multiError) } } + // Get the custom host vitals. CustomHostVitalsPresent tracks whether the key was in the YAML. + if _, ok := top["custom_host_vitals"]; ok { + result.CustomHostVitalsPresent = true + multiError = parseCustomHostVitals(top, result, filePath, multiError) + } // Get other top-level entities. multiError = parseControls(top, result, logFn, filePath, multiError) multiError = parseAgentOptions(top, result, baseDir, logFn, filePath, multiError) @@ -1067,6 +1088,40 @@ func parseSecrets(result *GitOps, multiError *multierror.Error) *multierror.Erro return multiError } +// parseCustomHostVitals parses the top-level `custom_host_vitals:` key. +// Global-only: custom host vital definitions aren't team-scoped, so the key +// isn't valid on a team file. An empty (or explicitly null) list is a +// declarative clear-all, same as an absent secrets/yara_rules list. +func parseCustomHostVitals(top map[string]json.RawMessage, result *GitOps, filePath string, multiError *multierror.Error) *multierror.Error { + raw := top["custom_host_vitals"] + + if !result.global() { + return multierror.Append(multiError, errors.New("'custom_host_vitals' cannot be set on a team file")) + } + + result.CustomHostVitals = []fleet.CustomHostVital{} + if len(raw) == 0 || string(raw) == "null" { + return multiError + } + + var vitals []GitOpsCustomHostVital + if err := json.Unmarshal(raw, &vitals); err != nil { + return multierror.Append(multiError, MaybeParseTypeError(filePath, []string{"custom_host_vitals"}, err)) + } + // Validate unknown keys in the custom_host_vitals section. + multiError = multierror.Append(multiError, validateRawKeys(raw, reflect.TypeFor[[]GitOpsCustomHostVital](), filePath, []string{"custom_host_vitals"})...) + + for _, v := range vitals { + if err := fleet.ValidateCustomHostVitalName(v.Name); err != nil { + multiError = multierror.Append(multiError, fmt.Errorf("'custom_host_vitals': %w", err)) + continue + } + result.CustomHostVitals = append(result.CustomHostVitals, fleet.CustomHostVital{Name: v.Name}) + } + + return multiError +} + func parseAgentOptions(top map[string]json.RawMessage, result *GitOps, baseDir string, logFn Logf, filePath string, multiError *multierror.Error) *multierror.Error { agentOptionsRaw, ok := top["agent_options"] if result.IsNoTeam() { diff --git a/pkg/spec/gitops_test.go b/pkg/spec/gitops_test.go index 0f71d8b3ca..e2701172d1 100644 --- a/pkg/spec/gitops_test.go +++ b/pkg/spec/gitops_test.go @@ -4948,6 +4948,88 @@ name: TestTeam require.NoError(t, err) assert.False(t, gitops.SoftwarePresent) }) + + t.Run("custom host vitals present", func(t *testing.T) { + gitops, err := gitOpsFromString(t, ` +org_settings: + server_settings: + server_url: https://example.com + org_info: + org_name: Test +custom_host_vitals: + - name: Asset tag + - name: Department +`) + require.NoError(t, err) + assert.True(t, gitops.CustomHostVitalsPresent) + assert.ElementsMatch(t, []fleet.CustomHostVital{{Name: "Asset tag"}, {Name: "Department"}}, gitops.CustomHostVitals) + }) + + t.Run("custom host vitals absent", func(t *testing.T) { + gitops, err := gitOpsFromString(t, ` +org_settings: + server_settings: + server_url: https://example.com + org_info: + org_name: Test +`) + require.NoError(t, err) + assert.False(t, gitops.CustomHostVitalsPresent) + assert.Nil(t, gitops.CustomHostVitals, "absent custom_host_vitals should be nil") + }) + + t.Run("custom host vitals present but empty", func(t *testing.T) { + gitops, err := gitOpsFromString(t, ` +org_settings: + server_settings: + server_url: https://example.com + org_info: + org_name: Test +custom_host_vitals: +`) + require.NoError(t, err) + assert.True(t, gitops.CustomHostVitalsPresent) + assert.Empty(t, gitops.CustomHostVitals) + }) +} + +func TestGitOpsCustomHostVitals(t *testing.T) { + t.Run("rejected on a team file", func(t *testing.T) { + path, basePath := createTempFile(t, "", ` +name: TestTeam +custom_host_vitals: + - name: Asset tag +`) + _, err := GitOpsFromFile(path, basePath, nil, nopLogf) + require.ErrorContains(t, err, "'custom_host_vitals' cannot be set on a team file") + }) + + t.Run("rejects an invalid name", func(t *testing.T) { + _, err := gitOpsFromString(t, ` +org_settings: + server_settings: + server_url: https://example.com + org_info: + org_name: Test +custom_host_vitals: + - name: " Asset tag" +`) + require.ErrorContains(t, err, "custom host vital name cannot have leading or trailing whitespace") + }) + + t.Run("rejects an unknown key", func(t *testing.T) { + _, err := gitOpsFromString(t, ` +org_settings: + server_settings: + server_url: https://example.com + org_info: + org_name: Test +custom_host_vitals: + - name: Asset tag + id: 1 +`) + require.Error(t, err) + }) } func TestGitOpsFMACategoriesPresence(t *testing.T) { diff --git a/server/authz/policy.rego b/server/authz/policy.rego index 3445327b7a..4417d39add 100644 --- a/server/authz/policy.rego +++ b/server/authz/policy.rego @@ -1307,6 +1307,51 @@ allow { action == read } +## +# Custom host vitals +## + +# Global admins, maintainers, and gitops can write custom host vital definitions. +allow { + object.type == "custom_vital" + subject.global_role == [admin, maintainer, gitops][_] + action == write +} + +# Any global user can read custom host vital definitions. +allow { + object.type == "custom_vital" + subject.global_role == [admin, maintainer, gitops, technician, observer_plus, observer][_] + action == read +} + +# Any team user can read custom host vital definitions for hosts in its team. +allow { + object.type == "custom_vital" + team_role(subject, subject.teams[_].id) == [admin, maintainer, gitops, technician, observer_plus, observer][_] + action == read +} + +## +# Host custom host vital values (per-host) +## + +# Global admins and maintainers can set a host's custom host vital value (not +# gitops — setting a host value is not a fleetctl gitops operation). +allow { + object.type == "host_custom_vital" + subject.global_role == [admin, maintainer][_] + action == write +} + +# Team admins and maintainers can set the value for hosts in their team. +allow { + object.type == "host_custom_vital" + not is_null(object.team_id) + team_role(subject, object.team_id) == [admin, maintainer][_] + action == write +} + ## # Android ## diff --git a/server/authz/policy_test.go b/server/authz/policy_test.go index 9b4ef960ea..4d92e039ce 100644 --- a/server/authz/policy_test.go +++ b/server/authz/policy_test.go @@ -3355,6 +3355,49 @@ func TestAuthorizeSecretVariables(t *testing.T) { }) } +func TestAuthorizeCustomHostVitals(t *testing.T) { + t.Parallel() + + customHostVital := &fleet.CustomHostVital{} + runTestCases(t, []authTestCase{ + {user: nil, object: customHostVital, action: read, allow: false}, + + {user: test.UserNoRoles, object: customHostVital, action: read, allow: false}, + + // Global admins, maintainers, and gitops can read/write. + {user: test.UserAdmin, object: customHostVital, action: read, allow: true}, + {user: test.UserAdmin, object: customHostVital, action: write, allow: true}, + {user: test.UserMaintainer, object: customHostVital, action: read, allow: true}, + {user: test.UserMaintainer, object: customHostVital, action: write, allow: true}, + {user: test.UserGitOps, object: customHostVital, action: read, allow: true}, + {user: test.UserGitOps, object: customHostVital, action: write, allow: true}, + + // Global observers and observer_plus can read but cannot write. + {user: test.UserObserver, object: customHostVital, action: read, allow: true}, + {user: test.UserObserver, object: customHostVital, action: write, allow: false}, + {user: test.UserObserverPlus, object: customHostVital, action: read, allow: true}, + {user: test.UserObserverPlus, object: customHostVital, action: write, allow: false}, + + // Global technicians can read but cannot write. + {user: test.UserTechnician, object: customHostVital, action: read, allow: true}, + {user: test.UserTechnician, object: customHostVital, action: write, allow: false}, + + // Team users can read but cannot write. + {user: test.UserTeamAdminTeam1, object: customHostVital, action: read, allow: true}, + {user: test.UserTeamAdminTeam1, object: customHostVital, action: write, allow: false}, + {user: test.UserTeamMaintainerTeam1, object: customHostVital, action: read, allow: true}, + {user: test.UserTeamMaintainerTeam1, object: customHostVital, action: write, allow: false}, + {user: test.UserTeamGitOpsTeam1, object: customHostVital, action: read, allow: true}, + {user: test.UserTeamGitOpsTeam1, object: customHostVital, action: write, allow: false}, + {user: test.UserTeamObserverPlusTeam1, object: customHostVital, action: read, allow: true}, + {user: test.UserTeamObserverPlusTeam1, object: customHostVital, action: write, allow: false}, + {user: test.UserTeamObserverTeam1, object: customHostVital, action: read, allow: true}, + {user: test.UserTeamObserverTeam1, object: customHostVital, action: write, allow: false}, + {user: test.UserTeamTechnicianTeam1, object: customHostVital, action: read, allow: true}, + {user: test.UserTeamTechnicianTeam1, object: customHostVital, action: write, allow: false}, + }) +} + func TestAuthorizeAPIEndpoint(t *testing.T) { t.Parallel() diff --git a/server/datastore/mysql/apple_mdm_batched.go b/server/datastore/mysql/apple_mdm_batched.go index b52804af86..7f4132b169 100644 --- a/server/datastore/mysql/apple_mdm_batched.go +++ b/server/datastore/mysql/apple_mdm_batched.go @@ -657,6 +657,29 @@ func (ds *Datastore) listAppleDeclarationsForReconcileTransaction(ctx context.Co } } + // Custom host vitals ($FLEET_HOST_VITAL_) are a separate variable + // namespace that isn't recorded in mdm_configuration_profile_variables, so + // detect them by scanning the declaration body. Marking them + // HasFleetVariables makes the reconciler stamp variables_updated_at, which + // (a) lets handleDeclarationItems load raw_json and drop declarations it + // can't resolve for a host from the manifest, and (b) cache-busts the DDM + // token so a per-host value change is re-delivered. INSTR matches the + // prefix without the leading '$' so it catches both $FOO and ${FOO} forms. + const vitalsStmt = `SELECT declaration_uuid FROM mdm_apple_declarations WHERE declaration_uuid IN (?) AND INSTR(raw_json, ?) > 0` + q, args, err = sqlx.In(vitalsStmt, declUUIDs, fleet.CustomHostVitalPrefix) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "build apple declaration custom host vitals query") + } + var withVitals []string + if err := sqlx.SelectContext(ctx, tx, &withVitals, q, args...); err != nil { + return nil, ctxerr.Wrap(ctx, err, "select apple declarations with custom host vitals") + } + for _, u := range withVitals { + if d, ok := byUUID[u]; ok { + d.HasFleetVariables = true + } + } + // For declarations that reference DDM assets, load the most recent // uploaded_at across their referenced assets. The reconciler stamps this // onto host_mdm_apple_declarations.assets_updated_at so that editing an diff --git a/server/datastore/mysql/custom_host_vitals.go b/server/datastore/mysql/custom_host_vitals.go new file mode 100644 index 0000000000..32aacfaf74 --- /dev/null +++ b/server/datastore/mysql/custom_host_vitals.go @@ -0,0 +1,623 @@ +package mysql + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" + "github.com/fleetdm/fleet/v4/server/fleet" + common_mysql "github.com/fleetdm/fleet/v4/server/platform/mysql" + "github.com/jmoiron/sqlx" + "golang.org/x/text/unicode/norm" +) + +var customHostVitalAllowedOrderKeys = common_mysql.OrderKeyAllowlist{ + "name": "name", + "id": "id", + "updated_at": "updated_at", +} + +func (ds *Datastore) CreateCustomHostVital(ctx context.Context, name string) (fleet.CustomHostVital, error) { + res, err := ds.writer(ctx).ExecContext(ctx, + `INSERT INTO custom_host_vitals (name) VALUES (?)`, + name, + ) + if err != nil { + if IsDuplicate(err) { + return fleet.CustomHostVital{}, ctxerr.Wrap(ctx, alreadyExists("name", name), "found duplicate") + } + return fleet.CustomHostVital{}, ctxerr.Wrap(ctx, err, "insert custom host vital") + } + id, _ := res.LastInsertId() + return fleet.CustomHostVital{ID: uint(id), Name: name}, nil //nolint:gosec // dismiss G115 +} + +func (ds *Datastore) ListCustomHostVitals(ctx context.Context, opt fleet.ListOptions) ( + customHostVitals []fleet.CustomHostVital, meta *fleet.PaginationMetadata, count int, err error, +) { + stmt := `SELECT id, name, created_at, updated_at FROM custom_host_vitals WHERE true` + + // normalize the name for full Unicode support (Unicode equivalence). + // Search matches the name OR the variable name (the derived + // `$FLEET_HOST_VITAL_` token). The second column is a hardcoded SQL + // expression (not user input); searchLike escapes the LIKE pattern. + normMatch := norm.NFC.String(opt.MatchQuery) + whereClauses, args := searchLike("", nil, normMatch, "name", `CONCAT('$FLEET_HOST_VITAL_', id)`) + stmt += whereClauses + + // perform a second query to grab the count + // build the count statement before adding pagination constraints + countStmt := fmt.Sprintf("SELECT COUNT(DISTINCT id) FROM (%s) AS s", stmt) + + stmt, args, err = appendListOptionsWithCursorToSQLSecure(stmt, args, &opt, customHostVitalAllowedOrderKeys) + if err != nil { + return nil, nil, 0, ctxerr.Wrap(ctx, err, "apply list options") + } + + dbReader := ds.reader(ctx) + if err := sqlx.SelectContext(ctx, dbReader, &customHostVitals, stmt, args...); err != nil { + return nil, nil, 0, ctxerr.Wrap(ctx, err, "listing custom host vitals") + } + if err := sqlx.GetContext(ctx, dbReader, &count, countStmt, args...); err != nil { + return nil, nil, 0, ctxerr.Wrap(ctx, err, "get custom host vitals count") + } + + if opt.IncludeMetadata { + meta = &fleet.PaginationMetadata{ + HasPreviousResults: opt.Page > 0, + TotalResults: uint(count), //nolint:gosec // dismiss G115 + } + // `appendListOptionsWithCursorToSQL` used above to build the query statement will cause this discrepancy. + if len(customHostVitals) > int(opt.PerPage) { //nolint:gosec // dismiss G115 + meta.HasNextResults = true + customHostVitals = customHostVitals[:len(customHostVitals)-1] + } + } + + return customHostVitals, meta, count, nil +} + +func (ds *Datastore) UpdateCustomHostVital(ctx context.Context, id uint, name string) (fleet.CustomHostVital, error) { + res, err := ds.writer(ctx).ExecContext(ctx, + `UPDATE custom_host_vitals SET name = ? WHERE id = ?`, + name, id, + ) + if err != nil { + if IsDuplicate(err) { + return fleet.CustomHostVital{}, ctxerr.Wrap(ctx, alreadyExists("name", name), "found duplicate") + } + return fleet.CustomHostVital{}, ctxerr.Wrap(ctx, err, "update custom host vital") + } + affected, _ := res.RowsAffected() + if affected == 0 { + // No rows affected can mean the id was not found, or the name is unchanged. + // Distinguish the two so a no-op rename doesn't surface as NotFound. + // Check on the writer: the UPDATE above targeted the primary, so a replica + // lagging behind it could otherwise report a false NotFound. + var exists bool + if err := sqlx.GetContext(ctx, ds.writer(ctx), &exists, + `SELECT 1 FROM custom_host_vitals WHERE id = ?`, id); err != nil { + if err == sql.ErrNoRows { + return fleet.CustomHostVital{}, ctxerr.Wrap(ctx, notFound("CustomHostVital").WithID(id)) + } + return fleet.CustomHostVital{}, ctxerr.Wrap(ctx, err, "check custom host vital exists") + } + } + return fleet.CustomHostVital{ID: id, Name: name}, nil +} + +func (ds *Datastore) DeleteCustomHostVital(ctx context.Context, id uint) (name string, err error) { + if err := ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { + err := sqlx.GetContext(ctx, tx, &name, `SELECT name FROM custom_host_vitals WHERE id = ?`, id) + if err != nil { + if err == sql.ErrNoRows { + return ctxerr.Wrap(ctx, notFound("CustomHostVital").WithID(id)) + } + return ctxerr.Wrap(ctx, err, "getting name of custom host vital to delete") + } + + // Refuse to delete a definition still referenced by a script/profile. + if usedByInfo, err := ds.customHostVitalUsedBy(ctx, tx, id, name); err != nil { + return ctxerr.Wrap(ctx, err, "checking custom host vital references") + } else if usedByInfo != nil { + return ctxerr.Wrap(ctx, &fleet.CustomHostVitalUsedError{CustomHostVitalUsedInfo: *usedByInfo}, "found custom host vital in use") + } + + if _, err := tx.ExecContext(ctx, `DELETE FROM custom_host_vitals WHERE id = ?`, id); err != nil { + return ctxerr.Wrap(ctx, err, "delete custom host vital") + } + return nil + }); err != nil { + return "", ctxerr.Wrap(ctx, err, "delete custom host vital") + } + + return name, nil +} + +// customHostVitalRefEntity is a script or profile scanned for $FLEET_HOST_VITAL_ +// references during delete-protection. +type customHostVitalRefEntity struct { + // Type is the entity type, "script", "apple_profile", "apple_declaration", or "windows_profile". + Type string `db:"entity"` + // Name is the name of the entity. + Name string `db:"name"` + // FleetName is the name of the fleet (team) the entity belongs to. + FleetName string `db:"team_name"` + // Contents is the content of the entity (script's/profile's body). + Contents string `db:"contents"` +} + +// customHostVitalUsedBy scans script_contents, Apple configuration profiles, +// Apple declarations, and Windows configuration profiles for a +// $FLEET_HOST_VITAL_ (or ${FLEET_HOST_VITAL_}) reference to the given +// vital id. It returns a *fleet.CustomHostVitalUsedInfo describing the first +// referencing entity found, or nil if unreferenced. Mirrors the scan structure +// of DeleteSecretVariable. The second return is a real DB error. +func (ds *Datastore) customHostVitalUsedBy(ctx context.Context, tx sqlx.ExtContext, id uint, name string) (*fleet.CustomHostVitalUsedInfo, error) { + // The token embeds the numeric id (survives renames), so match by id, not name. + token := fmt.Sprintf("%s%d", fleet.CustomHostVitalPrefix, id) + + // Each scan mirrors DeleteSecretVariable: pull the content column of every + // script/profile/declaration and check for the token in Go (os.Expand-based, + // so it matches both $VAR and ${VAR} forms). + scans := []struct { + desc string + stmt string + }{ + { + desc: "get script contents", + stmt: `SELECT 'script' AS entity, s.name, + COALESCE(t.name, 'Unassigned') AS team_name, sc.contents + FROM script_contents sc + JOIN scripts s ON s.script_content_id = sc.id + LEFT JOIN teams t ON t.id = s.team_id;`, + }, + { + desc: "get apple profile contents", + stmt: `SELECT 'apple_profile' AS entity, p.name, + COALESCE(t.name, 'Unassigned') AS team_name, p.mobileconfig AS contents + FROM mdm_apple_configuration_profiles p + LEFT JOIN teams t ON t.id = p.team_id;`, + }, + { + desc: "get apple declaration contents", + stmt: `SELECT 'apple_declaration' AS entity, d.name, + COALESCE(t.name, 'Unassigned') AS team_name, d.raw_json AS contents + FROM mdm_apple_declarations d + LEFT JOIN teams t ON t.id = d.team_id;`, + }, + { + desc: "get windows profile contents", + stmt: `SELECT 'windows_profile' AS entity, p.name, + COALESCE(t.name, 'Unassigned') AS team_name, p.syncml AS contents + FROM mdm_windows_configuration_profiles p + LEFT JOIN teams t ON t.id = p.team_id;`, + }, + // Software installer and setup-experience scripts exceed secret-variable + // delete-protection (which doesn't scan them), so a vital can't be deleted + // while a script that runs it would silently start failing on hosts. + { + desc: "get software installer script contents", + stmt: `SELECT 'software_installer' AS entity, COALESCE(st.name, si.filename) AS name, + COALESCE(t.name, 'Unassigned') AS team_name, sc.contents + FROM software_installers si + JOIN script_contents sc ON sc.id IN (si.install_script_content_id, si.post_install_script_content_id, si.uninstall_script_content_id) + LEFT JOIN software_titles st ON st.id = si.title_id + LEFT JOIN teams t ON t.id = si.team_id;`, + }, + { + desc: "get setup experience script contents", + stmt: `SELECT 'setup_experience_script' AS entity, ses.name, + COALESCE(t.name, 'Unassigned') AS team_name, sc.contents + FROM setup_experience_scripts ses + JOIN script_contents sc ON sc.id = ses.script_content_id + LEFT JOIN teams t ON t.id = ses.team_id;`, + }, + } + + for _, scan := range scans { + var entities []customHostVitalRefEntity + if err := sqlx.SelectContext(ctx, tx, &entities, scan.stmt); err != nil { + return nil, ctxerr.Wrap(ctx, err, scan.desc) + } + for _, e := range entities { + if fleet.ContainsVar(e.Contents, token) { + return &fleet.CustomHostVitalUsedInfo{ + CustomHostVitalID: id, + CustomHostVitalName: name, + Entity: fleet.EntityUsingCustomHostVital{ + Type: fleet.CustomHostVitalEntity(e.Type), + Name: e.Name, + FleetName: e.FleetName, + }, + }, nil + } + } + } + + // Host-vitals labels reference the vital by id inside their criteria JSON + // (not by the $FLEET_HOST_VITAL_ token), so they need a structured check + // rather than the content-token scan above. + var labels []struct { + Name string `db:"name"` + FleetName string `db:"team_name"` + Criteria json.RawMessage `db:"criteria"` + } + labelStmt := `SELECT l.name, COALESCE(t.name, 'Unassigned') AS team_name, l.criteria + FROM labels l + LEFT JOIN teams t ON t.id = l.team_id + WHERE l.label_membership_type = ? AND l.criteria IS NOT NULL` + if err := sqlx.SelectContext(ctx, tx, &labels, labelStmt, fleet.LabelMembershipTypeHostVitals); err != nil { + return nil, ctxerr.Wrap(ctx, err, "get host vitals label criteria") + } + for _, l := range labels { + var criteria fleet.HostVitalCriteria + // A label with malformed criteria is already broken; skip it rather than + // block the delete on it. + if err := json.Unmarshal(l.Criteria, &criteria); err != nil { + ds.logger.WarnContext(ctx, "skipping host vitals label with unparseable criteria during custom host vital delete-protection scan", + "label", l.Name, "error", err) + continue + } + if criteria.CustomHostVitalID != nil && *criteria.CustomHostVitalID == id { + return &fleet.CustomHostVitalUsedInfo{ + CustomHostVitalID: id, + CustomHostVitalName: name, + Entity: fleet.EntityUsingCustomHostVital{ + Type: fleet.CustomHostVitalEntityLabel, + Name: l.Name, + FleetName: l.FleetName, + }, + }, nil + } + } + + return nil, nil +} + +func (ds *Datastore) SetHostCustomHostVitalValue(ctx context.Context, hostID uint, vitalID uint, value string) error { + return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { + if _, err := tx.ExecContext(ctx, ` + INSERT INTO host_custom_host_vitals (host_id, custom_host_vital_id, value) + VALUES (?, ?, ?) + ON DUPLICATE KEY UPDATE value = VALUES(value)`, + hostID, vitalID, value, + ); err != nil { + return ctxerr.Wrap(ctx, err, "set host custom host vital value") + } + + // Re-queue any MDM profiles/declarations already delivered to this host + // that reference the vital, so the reconcilers re-expand + // $FLEET_HOST_VITAL_ with the new value. Runs in the same transaction + // as the value write so the reconciler never reads a stale value. + if err := resendMDMProfilesForCustomHostVital(ctx, tx, hostID, vitalID); err != nil { + return ctxerr.Wrap(ctx, err, "resend mdm profiles for custom host vital value change") + } + return nil + }) +} + +// resendMDMProfilesForCustomHostVital resets the status of the Apple/Windows +// configuration profiles and Apple DDM declarations already delivered to the +// host that reference $FLEET_HOST_VITAL_, so the reconcilers resend +// them with the host's newly-set value. Mirrors triggerResendProfilesUsingVariables, +// but matches by profile/declaration content because custom host vitals aren't +// tracked in mdm_configuration_profile_variables. Declarations only reset status +// (the DDM reconciler re-stamps variables_updated_at, cache-busting the token). +// +// Unlike the IdP resend, this deliberately omits certificate templates and +// Android managed configs: a vital can't reach either (cert templates only take +// fleet_variables, and Android rejects $FLEET_HOST_VITAL_ at upload), so there's +// nothing on those surfaces to resend. +func resendMDMProfilesForCustomHostVital(ctx context.Context, tx sqlx.ExtContext, hostID, vitalID uint) error { + var hostUUID string + if err := sqlx.GetContext(ctx, tx, &hostUUID, `SELECT uuid FROM hosts WHERE id = ?`, hostID); err != nil { + if err == sql.ErrNoRows { + return nil + } + return ctxerr.Wrap(ctx, err, "get host uuid for custom host vital resend") + } + + // varName is the exact token (id included) matched precisely, id-boundary and + // ${...}-aware, by ContainsVar in Go. The INSTR prefix filter in SQL only + // narrows candidates; it deliberately over-matches (ignores the id) so the + // Go pass does the authoritative match. + varName := fmt.Sprintf("%s%d", fleet.CustomHostVitalPrefix, vitalID) + + // These SELECTs filter on host_uuid first — the leftmost column of each + // host-profile table's PRIMARY KEY (host_uuid, {profile,declaration}_uuid) — + // so the INSTR content match only evaluates this one host's rows, not the + // whole table; cost is independent of fleet size. + const ( + customHostVitalResendAppleProfilesSelectStmt = `SELECT hmap.profile_uuid AS uuid, macp.mobileconfig AS contents + FROM host_mdm_apple_profiles hmap + JOIN mdm_apple_configuration_profiles macp ON macp.profile_uuid = hmap.profile_uuid + WHERE hmap.host_uuid = ? AND hmap.operation_type = ? AND hmap.status IS NOT NULL AND INSTR(macp.mobileconfig, ?) > 0` + + customHostVitalResendWindowsProfilesSelectStmt = `SELECT hmwp.profile_uuid AS uuid, mwcp.syncml AS contents + FROM host_mdm_windows_profiles hmwp + JOIN mdm_windows_configuration_profiles mwcp ON mwcp.profile_uuid = hmwp.profile_uuid + WHERE hmwp.host_uuid = ? AND hmwp.operation_type = ? AND hmwp.status IS NOT NULL AND INSTR(mwcp.syncml, ?) > 0` + + customHostVitalResendAppleDeclarationsSelectStmt = `SELECT hmad.declaration_uuid AS uuid, mad.raw_json AS contents + FROM host_mdm_apple_declarations hmad + JOIN mdm_apple_declarations mad ON mad.declaration_uuid = hmad.declaration_uuid + WHERE hmad.host_uuid = ? AND hmad.operation_type = ? AND hmad.status IS NOT NULL AND INSTR(mad.raw_json, ?) > 0` + ) + + targets := []struct { + desc string + selectStmt string + updateStmt string + }{ + { + desc: "apple profiles", + selectStmt: customHostVitalResendAppleProfilesSelectStmt, + updateStmt: `UPDATE host_mdm_apple_profiles + SET status = NULL, detail = NULL, command_uuid = '' + WHERE host_uuid = ? AND operation_type = ? AND profile_uuid IN (?)`, + }, + { + desc: "windows profiles", + selectStmt: customHostVitalResendWindowsProfilesSelectStmt, + updateStmt: `UPDATE host_mdm_windows_profiles + SET status = NULL, detail = NULL, command_uuid = '' + WHERE host_uuid = ? AND operation_type = ? AND profile_uuid IN (?)`, + }, + { + desc: "apple declarations", + selectStmt: customHostVitalResendAppleDeclarationsSelectStmt, + updateStmt: `UPDATE host_mdm_apple_declarations + SET status = NULL, detail = NULL + WHERE host_uuid = ? AND operation_type = ? AND declaration_uuid IN (?)`, + }, + } + + for _, tgt := range targets { + var rows []struct { + UUID string `db:"uuid"` + Contents string `db:"contents"` + } + if err := sqlx.SelectContext(ctx, tx, &rows, tgt.selectStmt, + hostUUID, fleet.MDMOperationTypeInstall, fleet.CustomHostVitalPrefix); err != nil { + return ctxerr.Wrap(ctx, err, "select "+tgt.desc+" referencing custom host vital") + } + + var uuids []string + for _, r := range rows { + if fleet.ContainsVar(r.Contents, varName) { + uuids = append(uuids, r.UUID) + } + } + if len(uuids) == 0 { + continue + } + + stmt, args, err := sqlx.In(tgt.updateStmt, hostUUID, fleet.MDMOperationTypeInstall, uuids) + if err != nil { + return ctxerr.Wrap(ctx, err, "build resend update for "+tgt.desc) + } + if _, err := tx.ExecContext(ctx, stmt, args...); err != nil { + return ctxerr.Wrap(ctx, err, "reset "+tgt.desc+" for custom host vital resend") + } + } + + return nil +} + +func (ds *Datastore) GetHostCustomHostVitals(ctx context.Context, hostID uint) ([]fleet.HostCustomHostVital, error) { + var vitals []fleet.HostCustomHostVital + err := sqlx.SelectContext(ctx, ds.reader(ctx), &vitals, ` + SELECT chv.id AS custom_host_vital_id, chv.name, COALESCE(hchv.value, '') AS value + FROM custom_host_vitals chv + LEFT JOIN host_custom_host_vitals hchv + ON hchv.custom_host_vital_id = chv.id AND hchv.host_id = ? + ORDER BY chv.name`, + hostID, + ) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "get host custom host vitals") + } + return vitals, nil +} + +// ExpandCustomHostVitals substitutes $FLEET_HOST_VITAL_ tokens in the +// document with the given host's stored values, applying format-aware escaping +// (JSON/XML) like expandEmbeddedSecrets. If a referenced vital has no value for +// the host (no row, or an empty value), it returns a MissingCustomHostVitalValueError +// so delivery fails rather than substituting an empty value (product decision). +func (ds *Datastore) ExpandCustomHostVitals(ctx context.Context, hostID uint, document string) (string, error) { + refIDs := fleet.ContainsCustomHostVitalIDs(document) + if len(refIDs) == 0 { + return document, nil + } + + vitals, err := ds.GetHostCustomHostVitals(ctx, hostID) + if err != nil { + return "", ctxerr.Wrap(ctx, err, "expanding custom host vitals") + } + + // A vital with an empty value counts as missing: we refuse to ship an empty + // substitution. + valueByID := make(map[uint]string, len(vitals)) + for _, v := range vitals { + if v.Value == "" { + continue + } + valueByID[v.CustomHostVitalID] = v.Value + } + + var missingIDs []uint + for _, id := range refIDs { + if _, ok := valueByID[id]; !ok { + missingIDs = append(missingIDs, id) + } + } + if len(missingIDs) > 0 { + // The vital exists (validated on upload); the host just has no value for it. + return "", &fleet.MissingCustomHostVitalValueError{MissingIDs: missingIDs} + } + + expanded := expandDocumentVars(document, func(s string) (string, bool) { + if !strings.HasPrefix(s, fleet.CustomHostVitalPrefix) { + return "", false + } + id, parseErr := strconv.ParseUint(strings.TrimPrefix(s, fleet.CustomHostVitalPrefix), 10, strconv.IntSize) + if parseErr != nil { + return "", false + } + val, ok := valueByID[uint(id)] + return val, ok + }) + + return expanded, nil +} + +// ValidateReferencedCustomHostVitals parses $FLEET_HOST_VITAL_ tokens from +// the given documents and verifies every referenced id resolves to a definition. +// Mirrors ValidateEmbeddedSecrets. Returns a MissingCustomHostVitalsError listing +// any unknown ids. +func (ds *Datastore) ValidateReferencedCustomHostVitals(ctx context.Context, documents []string) error { + wantIDs := make(map[uint]struct{}) + var malformed []string + seenMalformed := make(map[string]struct{}) + for _, document := range documents { + // A $FLEET_HOST_VITAL_ token whose isn't a valid ID (e.g. a typo like + // $FLEET_HOST_VITAL_asset_tag) is rejected rather than silently delivered as + // a literal token, matching how $FLEET_VAR_*/$FLEET_SECRET_* reject unknowns. + for _, ref := range fleet.ContainsMalformedCustomHostVitalRefs(document) { + if _, ok := seenMalformed[ref]; ok { + continue + } + seenMalformed[ref] = struct{}{} + malformed = append(malformed, ref) + } + for _, id := range fleet.ContainsCustomHostVitalIDs(document) { + wantIDs[id] = struct{}{} + } + } + if len(malformed) > 0 { + return &fleet.InvalidCustomHostVitalRefError{Refs: malformed} + } + if len(wantIDs) == 0 { + return nil + } + + wantIDsList := make([]uint, 0, len(wantIDs)) + for id := range wantIDs { + wantIDsList = append(wantIDsList, id) + } + + dbVitals, err := ds.GetCustomHostVitals(ctx, wantIDsList) + if err != nil { + return ctxerr.Wrap(ctx, err, "validating document referenced custom host vitals") + } + + haveIDs := make(map[uint]struct{}, len(dbVitals)) + for _, v := range dbVitals { + haveIDs[v.ID] = struct{}{} + } + + var missingIDs []uint + for id := range wantIDs { + if _, ok := haveIDs[id]; !ok { + missingIDs = append(missingIDs, id) + } + } + if len(missingIDs) > 0 { + return &fleet.MissingCustomHostVitalsError{MissingIDs: missingIDs} + } + return nil +} + +func (ds *Datastore) GetCustomHostVitals(ctx context.Context, ids []uint) ([]fleet.CustomHostVital, error) { + stmt, args, err := sqlx.In(` + SELECT id, name, created_at, updated_at + FROM custom_host_vitals + WHERE id IN (?)`, ids) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "build custom host vitals query") + } + + var vitals []fleet.CustomHostVital + if err := sqlx.SelectContext(ctx, ds.reader(ctx), &vitals, stmt, args...); err != nil { + return nil, ctxerr.Wrap(ctx, err, "get custom host vitals") + } + return vitals, nil +} + +func (ds *Datastore) UpsertCustomHostVitals(ctx context.Context, vitals []fleet.CustomHostVital) (created []fleet.CustomHostVital, deleted []fleet.CustomHostVital, err error) { + incomingNames := make(map[string]struct{}, len(vitals)) + for _, v := range vitals { + incomingNames[v.Name] = struct{}{} + } + + err = ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { + created, deleted = nil, nil + + var existing []fleet.CustomHostVital + if err := sqlx.SelectContext(ctx, tx, &existing, `SELECT id, name FROM custom_host_vitals`); err != nil { + return ctxerr.Wrap(ctx, err, "list existing custom host vitals") + } + + existingNames := make(map[string]struct{}, len(existing)) + for _, e := range existing { + existingNames[e.Name] = struct{}{} + if _, ok := incomingNames[e.Name]; !ok { + deleted = append(deleted, e) + } + } + + var toInsert []string + for _, v := range vitals { + if _, ok := existingNames[v.Name]; !ok { + toInsert = append(toInsert, v.Name) + } + } + + for _, v := range deleted { + usedByInfo, err := ds.customHostVitalUsedBy(ctx, tx, v.ID, v.Name) + if err != nil { + return ctxerr.Wrap(ctx, err, "checking custom host vital references") + } + if usedByInfo != nil { + return ctxerr.Wrap(ctx, &fleet.CustomHostVitalUsedError{CustomHostVitalUsedInfo: *usedByInfo}, "found custom host vital in use") + } + } + + if len(deleted) > 0 { + ids := make([]uint, 0, len(deleted)) + for _, v := range deleted { + ids = append(ids, v.ID) + } + stmt, args, err := sqlx.In(`DELETE FROM custom_host_vitals WHERE id IN (?)`, ids) + if err != nil { + return ctxerr.Wrap(ctx, err, "build delete custom host vitals query") + } + if _, err := tx.ExecContext(ctx, stmt, args...); err != nil { + return ctxerr.Wrap(ctx, err, "delete custom host vitals") + } + } + + // Inserted one at a time (rather than a single multi-row INSERT) so each + // row's LastInsertId can be captured for the returned `created` list. + for _, name := range toInsert { + res, err := tx.ExecContext(ctx, `INSERT INTO custom_host_vitals (name) VALUES (?)`, name) + if err != nil { + return ctxerr.Wrap(ctx, err, "insert custom host vital") + } + id, _ := res.LastInsertId() + created = append(created, fleet.CustomHostVital{ID: uint(id), Name: name}) //nolint:gosec // dismiss G115 + } + + return nil + }) + if err != nil { + return nil, nil, err + } + return created, deleted, nil +} diff --git a/server/datastore/mysql/custom_host_vitals_test.go b/server/datastore/mysql/custom_host_vitals_test.go new file mode 100644 index 0000000000..76141fb8ce --- /dev/null +++ b/server/datastore/mysql/custom_host_vitals_test.go @@ -0,0 +1,667 @@ +package mysql + +import ( + "encoding/json" + "fmt" + "strings" + "testing" + "time" + + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/test" + "github.com/jmoiron/sqlx" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCustomHostVitals(t *testing.T) { + ds := CreateMySQLDS(t) + + cases := []struct { + name string + fn func(t *testing.T, ds *Datastore) + }{ + {"CreateCustomHostVital", testCreateCustomHostVital}, + {"UpsertCustomHostVitals", testUpsertCustomHostVitals}, + {"ListCustomHostVitals", testListCustomHostVitals}, + {"UpdateCustomHostVital", testUpdateCustomHostVital}, + {"SetAndGetHostCustomHostVitals", testSetAndGetHostCustomHostVitals}, + {"GetCustomHostVitals", testGetCustomHostVitals}, + {"DeleteCustomHostVital", testDeleteCustomHostVital}, + {"DeleteUsedCustomHostVital", testDeleteUsedCustomHostVital}, + {"SetHostValueResendsReferencingProfiles", testSetHostCustomHostVitalValueResendsProfiles}, + {"ReconcileSnapshotMarksVitalDeclarations", testReconcileSnapshotMarksVitalDeclarations}, + {"ValidateReferencedCustomHostVitalsRejectsMalformed", testValidateReferencedCustomHostVitalsRejectsMalformed}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + defer TruncateTables(t, ds) + c.fn(t, ds) + }) + } +} + +// createCustomHostVital is a test helper that creates a definition and returns its id. +func createCustomHostVital(t *testing.T, ds *Datastore, name string) uint { + v, err := ds.CreateCustomHostVital(t.Context(), name) + require.NoError(t, err) + return v.ID +} + +func testCreateCustomHostVital(t *testing.T, ds *Datastore) { + ctx := t.Context() + + vital, err := ds.CreateCustomHostVital(ctx, "Asset tag") + require.NoError(t, err) + require.NotZero(t, vital.ID) + require.Equal(t, "Asset tag", vital.Name) + + // Duplicate name surfaces AlreadyExistsError. + dup, err := ds.CreateCustomHostVital(ctx, "Asset tag") + require.Error(t, err) + var aee fleet.AlreadyExistsError + require.ErrorAs(t, err, &aee) + require.Zero(t, dup.ID) +} + +// vitalNames is a test helper that extracts the Name of each vital, for use with require.ElementsMatch. +func vitalNames(vitals []fleet.CustomHostVital) []string { + out := make([]string, 0, len(vitals)) + for _, v := range vitals { + out = append(out, v.Name) + } + return out +} + +func testUpsertCustomHostVitals(t *testing.T, ds *Datastore) { + ctx := t.Context() + + list := func() []fleet.CustomHostVital { + vitals, _, _, err := ds.ListCustomHostVitals(ctx, fleet.ListOptions{}) + require.NoError(t, err) + return vitals + } + + // Empty incoming set against no existing definitions is a no-op. + created, deleted, err := ds.UpsertCustomHostVitals(ctx, nil) + require.NoError(t, err) + require.Empty(t, created) + require.Empty(t, deleted) + require.Empty(t, list()) + + // Initial apply creates all named vitals. + created, deleted, err = ds.UpsertCustomHostVitals(ctx, []fleet.CustomHostVital{{Name: "Function"}, {Name: "Department"}}) + require.NoError(t, err) + require.ElementsMatch(t, []string{"Function", "Department"}, vitalNames(created)) + require.Empty(t, deleted) + require.ElementsMatch(t, []string{"Function", "Department"}, vitalNames(list())) + + byName := make(map[string]uint) + for _, v := range list() { + byName[v.Name] = v.ID + } + funcID := byName["Function"] + + // Re-applying the same set keeps the existing rows (matched by name), not + // recreated, and reports no created/deleted vitals. + created, deleted, err = ds.UpsertCustomHostVitals(ctx, []fleet.CustomHostVital{{Name: "Function"}, {Name: "Department"}}) + require.NoError(t, err) + require.Empty(t, created) + require.Empty(t, deleted) + for _, v := range list() { + if v.Name == "Function" { + require.Equal(t, funcID, v.ID) + } + } + + // A name absent from the incoming set ("Department") is deleted; a new name + // ("Role") is inserted. + created, deleted, err = ds.UpsertCustomHostVitals(ctx, []fleet.CustomHostVital{{Name: "Function"}, {Name: "Role"}}) + require.NoError(t, err) + require.ElementsMatch(t, []string{"Role"}, vitalNames(created)) + require.ElementsMatch(t, []string{"Department"}, vitalNames(deleted)) + require.ElementsMatch(t, []string{"Function", "Role"}, vitalNames(list())) + + // Dropping a still-referenced name errors the whole call and leaves state unchanged. + script, err := ds.NewScript(ctx, &fleet.Script{ + Name: "collect.sh", + ScriptContents: fmt.Sprintf("echo $%s%d", fleet.CustomHostVitalPrefix, funcID), + }) + require.NoError(t, err) + + _, _, err = ds.UpsertCustomHostVitals(ctx, []fleet.CustomHostVital{{Name: "Role"}}) + require.Error(t, err) + var useErr *fleet.CustomHostVitalUsedError + require.ErrorAs(t, err, &useErr) + require.Equal(t, funcID, useErr.CustomHostVitalID) //nolint:nilaway // cannot be nil due to require.ErrorAs above + require.Equal(t, "Function", useErr.CustomHostVitalName) //nolint:nilaway // cannot be nil due to require.ErrorAs above + require.Equal(t, fleet.CustomHostVitalEntityScript, useErr.Entity.Type) //nolint:nilaway // cannot be nil due to require.ErrorAs above + require.ElementsMatch(t, []string{"Function", "Role"}, vitalNames(list())) + + require.NoError(t, ds.DeleteScript(ctx, script.ID)) + + // An empty incoming set (the absent-key GitOps case) clears all definitions. + created, deleted, err = ds.UpsertCustomHostVitals(ctx, nil) + require.NoError(t, err) + require.Empty(t, created) + require.ElementsMatch(t, []string{"Function", "Role"}, vitalNames(deleted)) + require.Empty(t, list()) +} + +func testListCustomHostVitals(t *testing.T, ds *Datastore) { + ctx := t.Context() + + funcID := createCustomHostVital(t, ds, "Function") + deptID := createCustomHostVital(t, ds, "Department") + + list := func(opt fleet.ListOptions) []fleet.CustomHostVital { + vitals, _, _, err := ds.ListCustomHostVitals(ctx, opt) + require.NoError(t, err) + return vitals + } + + names := func(vitals []fleet.CustomHostVital) []string { + out := make([]string, 0, len(vitals)) + for _, v := range vitals { + out = append(out, v.Name) + } + return out + } + + // No filter: both definitions returned. + require.ElementsMatch(t, []string{"Function", "Department"}, names(list(fleet.ListOptions{}))) + + // Count is returned. + _, _, count, err := ds.ListCustomHostVitals(ctx, fleet.ListOptions{}) + require.NoError(t, err) + require.Equal(t, 2, count) + + // Search by name (case-insensitive via the collation, substring). + require.ElementsMatch(t, []string{"Function"}, names(list(fleet.ListOptions{MatchQuery: "func"}))) + require.ElementsMatch(t, []string{"Department"}, names(list(fleet.ListOptions{MatchQuery: "depart"}))) + + // Search by the derived $FLEET_HOST_VITAL_ variable token. The token is + // not stored; ListCustomHostVitals matches CONCAT('$FLEET_HOST_VITAL_', id). + funcToken := fmt.Sprintf("$%s%d", fleet.CustomHostVitalPrefix, funcID) + require.ElementsMatch(t, []string{"Function"}, names(list(fleet.ListOptions{MatchQuery: funcToken}))) + deptToken := fmt.Sprintf("$%s%d", fleet.CustomHostVitalPrefix, deptID) + require.ElementsMatch(t, []string{"Department"}, names(list(fleet.ListOptions{MatchQuery: deptToken}))) + + // A partial token prefix (the shared namespace) matches both. + require.ElementsMatch(t, []string{"Function", "Department"}, + names(list(fleet.ListOptions{MatchQuery: "$" + fleet.CustomHostVitalPrefix}))) + + // A token for a non-existent id matches nothing. + require.Empty(t, list(fleet.ListOptions{MatchQuery: fmt.Sprintf("$%s999999", fleet.CustomHostVitalPrefix)})) +} + +func testUpdateCustomHostVital(t *testing.T, ds *Datastore) { + ctx := t.Context() + + id := createCustomHostVital(t, ds, "Function") + createCustomHostVital(t, ds, "Other") + + // Rename succeeds and returns the updated definition. + updated, err := ds.UpdateCustomHostVital(ctx, id, "Role") + require.NoError(t, err) + require.Equal(t, id, updated.ID) + require.Equal(t, "Role", updated.Name) + vitals, err := ds.GetCustomHostVitals(ctx, []uint{id}) + require.NoError(t, err) + require.Len(t, vitals, 1) + require.Equal(t, "Role", vitals[0].Name) + + // No-op rename (same name) is not treated as NotFound. + _, err = ds.UpdateCustomHostVital(ctx, id, "Role") + require.NoError(t, err) + + // Renaming to an existing name surfaces AlreadyExistsError. + _, err = ds.UpdateCustomHostVital(ctx, id, "Other") + require.Error(t, err) + var aee fleet.AlreadyExistsError + require.ErrorAs(t, err, &aee) + + // Updating a non-existent id surfaces NotFoundError. + _, err = ds.UpdateCustomHostVital(ctx, 999999, "Whatever") + require.Error(t, err) + var nfe fleet.NotFoundError + require.ErrorAs(t, err, &nfe) +} + +func testSetAndGetHostCustomHostVitals(t *testing.T, ds *Datastore) { + ctx := t.Context() + + host, err := ds.NewHost(ctx, &fleet.Host{ + Hostname: "chv-host", + UUID: "chv-host-uuid", + OsqueryHostID: new("chv-host-osquery-id"), + NodeKey: new("chv-host-node-key"), + DetailUpdatedAt: time.Now(), + Platform: "darwin", + }) + require.NoError(t, err) + + funcID := createCustomHostVital(t, ds, "Function") + deptID := createCustomHostVital(t, ds, "Department") + + // With no per-host values set yet, every definition is still returned for + // the host with an empty value. + got, err := ds.GetHostCustomHostVitals(ctx, host.ID) + require.NoError(t, err) + require.Len(t, got, 2) + for _, v := range got { + require.Empty(t, v.Value) + } + + // Insert two values. + require.NoError(t, ds.SetHostCustomHostVitalValue(ctx, host.ID, funcID, "engineering")) + require.NoError(t, ds.SetHostCustomHostVitalValue(ctx, host.ID, deptID, "R&D")) + + got, err = ds.GetHostCustomHostVitals(ctx, host.ID) + require.NoError(t, err) + byID := make(map[uint]fleet.HostCustomHostVital, len(got)) + for _, v := range got { + byID[v.CustomHostVitalID] = v + } + require.Len(t, byID, 2) + require.Equal(t, "Function", byID[funcID].Name) + require.Equal(t, "engineering", byID[funcID].Value) + require.Equal(t, "Department", byID[deptID].Name) + require.Equal(t, "R&D", byID[deptID].Value) + + // Upsert overwrites the existing value for (host, vital). + require.NoError(t, ds.SetHostCustomHostVitalValue(ctx, host.ID, funcID, "sales")) + got, err = ds.GetHostCustomHostVitals(ctx, host.ID) + require.NoError(t, err) + require.Len(t, got, 2) + for _, v := range got { + if v.CustomHostVitalID == funcID { + require.Equal(t, "sales", v.Value) + } + } +} + +func testGetCustomHostVitals(t *testing.T, ds *Datastore) { + ctx := t.Context() + + funcID := createCustomHostVital(t, ds, "Function") + deptID := createCustomHostVital(t, ds, "Department") + + // Known ids resolve; an unknown id is silently omitted. + got, err := ds.GetCustomHostVitals(ctx, []uint{funcID, deptID, 999999}) + require.NoError(t, err) + names := make([]string, 0, len(got)) + for _, v := range got { + names = append(names, v.Name) + } + require.ElementsMatch(t, []string{"Function", "Department"}, names) +} + +func testDeleteCustomHostVital(t *testing.T, ds *Datastore) { + ctx := t.Context() + + host, err := ds.NewHost(ctx, &fleet.Host{ + Hostname: "chv-del-host", + UUID: "chv-del-host-uuid", + OsqueryHostID: new("chv-del-host-osquery-id"), + NodeKey: new("chv-del-host-node-key"), + DetailUpdatedAt: time.Now(), + Platform: "darwin", + }) + require.NoError(t, err) + + id := createCustomHostVital(t, ds, "Function") + require.NoError(t, ds.SetHostCustomHostVitalValue(ctx, host.ID, id, "engineering")) + + name, err := ds.DeleteCustomHostVital(ctx, id) + require.NoError(t, err) + require.Equal(t, "Function", name) + + got, err := ds.GetHostCustomHostVitals(ctx, host.ID) + require.NoError(t, err) + require.Empty(t, got) + + // Deleting a non-existent id surfaces NotFoundError. + _, err = ds.DeleteCustomHostVital(ctx, 999999) + require.Error(t, err) + var nfe fleet.NotFoundError + require.ErrorAs(t, err, &nfe) +} + +func testDeleteUsedCustomHostVital(t *testing.T, ds *Datastore) { + ctx := t.Context() + + foobarTeam, err := ds.NewTeam(ctx, &fleet.Team{Name: "Foobar"}) + require.NoError(t, err) + + id := createCustomHostVital(t, ds, "FUNCTION") + id2 := createCustomHostVital(t, ds, "OTHER") + + // $FLEET_HOST_VITAL_ token that references FUNCTION. + token := fmt.Sprintf("$%s%d", fleet.CustomHostVitalPrefix, id) + // ${FLEET_HOST_VITAL_} braced form. + bracedToken := fmt.Sprintf("${%s%d}", fleet.CustomHostVitalPrefix, id) + + t.Run("apple configuration profiles", func(t *testing.T) { + appleProfile, err := ds.NewMDMAppleConfigProfile(ctx, fleet.MDMAppleConfigProfile{ + Name: "Name0", + Identifier: "Identifier0", + Mobileconfig: []byte(token), + }, nil) + require.NoError(t, err) + + _, err = ds.DeleteCustomHostVital(ctx, id) + require.Error(t, err) + var useErr *fleet.CustomHostVitalUsedError + require.ErrorAs(t, err, &useErr) + require.Equal(t, id, useErr.CustomHostVitalID) + require.Equal(t, "FUNCTION", useErr.CustomHostVitalName) + require.Equal(t, fleet.CustomHostVitalEntityAppleProfile, useErr.Entity.Type) + require.Equal(t, "Name0", useErr.Entity.Name) + require.Equal(t, "Unassigned", useErr.Entity.FleetName) + + // Deleting an unreferenced vital is allowed. + _, err = ds.DeleteCustomHostVital(ctx, id2) + require.NoError(t, err) + // Recreate for later subtests. + id2 = createCustomHostVital(t, ds, "OTHER") + + require.NoError(t, ds.DeleteMDMAppleConfigProfile(ctx, appleProfile.ProfileUUID)) + }) + + t.Run("apple declarations", func(t *testing.T) { + decl, err := ds.NewMDMAppleDeclaration(ctx, &fleet.MDMAppleDeclaration{ + Identifier: "decl-1", + Name: "decl-1", + RawJSON: json.RawMessage(fmt.Sprintf(`{"Identifier": "%s"}`, bracedToken)), + TeamID: &foobarTeam.ID, + }, nil) + require.NoError(t, err) + + _, err = ds.DeleteCustomHostVital(ctx, id) + require.Error(t, err) + var useErr *fleet.CustomHostVitalUsedError + require.ErrorAs(t, err, &useErr) + require.Equal(t, id, useErr.CustomHostVitalID) + require.Equal(t, "FUNCTION", useErr.CustomHostVitalName) + require.Equal(t, fleet.CustomHostVitalEntityAppleDeclaration, useErr.Entity.Type) + require.Equal(t, "decl-1", useErr.Entity.Name) + require.Equal(t, "Foobar", useErr.Entity.FleetName) + + require.NoError(t, ds.DeleteMDMAppleDeclaration(ctx, decl.DeclarationUUID)) + }) + + t.Run("windows profiles", func(t *testing.T) { + winProfile, err := ds.NewMDMWindowsConfigProfile(ctx, fleet.MDMWindowsConfigProfile{ + Name: "zoo", + SyncML: []byte(fmt.Sprintf("%s", token)), + }, nil) + require.NoError(t, err) + + _, err = ds.DeleteCustomHostVital(ctx, id) + require.Error(t, err) + var useErr *fleet.CustomHostVitalUsedError + require.ErrorAs(t, err, &useErr) + require.Equal(t, id, useErr.CustomHostVitalID) + require.Equal(t, "FUNCTION", useErr.CustomHostVitalName) + require.Equal(t, fleet.CustomHostVitalEntityWindowsProfile, useErr.Entity.Type) + require.Equal(t, "zoo", useErr.Entity.Name) + require.Equal(t, "Unassigned", useErr.Entity.FleetName) + + require.NoError(t, ds.DeleteMDMWindowsConfigProfile(ctx, winProfile.ProfileUUID)) + }) + + t.Run("scripts", func(t *testing.T) { + script, err := ds.NewScript(ctx, &fleet.Script{ + Name: "collect.sh", + ScriptContents: fmt.Sprintf("echo %s", token), + TeamID: &foobarTeam.ID, + }) + require.NoError(t, err) + + _, err = ds.DeleteCustomHostVital(ctx, id) + require.Error(t, err) + var useErr *fleet.CustomHostVitalUsedError + require.ErrorAs(t, err, &useErr) + require.Equal(t, id, useErr.CustomHostVitalID) + require.Equal(t, "FUNCTION", useErr.CustomHostVitalName) + require.Equal(t, fleet.CustomHostVitalEntityScript, useErr.Entity.Type) + require.Equal(t, "collect.sh", useErr.Entity.Name) + require.Equal(t, "Foobar", useErr.Entity.FleetName) + + require.NoError(t, ds.DeleteScript(ctx, script.ID)) + }) + + t.Run("software installers", func(t *testing.T) { + user := test.NewUser(t, ds, "Installer Author", "chv-del-installer@example.com", true) + tfr, err := fleet.NewTempFileReader(strings.NewReader("hello"), t.TempDir) + require.NoError(t, err) + installerID, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ + InstallScript: fmt.Sprintf("install %s", token), + UninstallScript: "uninstall", + InstallerFile: tfr, + StorageID: "chv-del-storage", + Filename: "chv-del.pkg", + Title: "chv-del-title", + Version: "1.0", + Source: "apps", + TeamID: &foobarTeam.ID, + UserID: user.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, + }) + require.NoError(t, err) + + _, err = ds.DeleteCustomHostVital(ctx, id) + require.Error(t, err) + var useErr *fleet.CustomHostVitalUsedError + require.ErrorAs(t, err, &useErr) + require.Equal(t, id, useErr.CustomHostVitalID) + require.Equal(t, "FUNCTION", useErr.CustomHostVitalName) + require.Equal(t, fleet.CustomHostVitalEntitySoftwareInstaller, useErr.Entity.Type) + require.Equal(t, "chv-del-title", useErr.Entity.Name) + require.Equal(t, "Foobar", useErr.Entity.FleetName) + + require.NoError(t, ds.DeleteSoftwareInstaller(ctx, installerID)) + }) + + t.Run("setup experience scripts", func(t *testing.T) { + require.NoError(t, ds.SetSetupExperienceScript(ctx, &fleet.Script{ + Name: "setup.sh", + ScriptContents: fmt.Sprintf("echo %s", token), + TeamID: &foobarTeam.ID, + })) + + _, err := ds.DeleteCustomHostVital(ctx, id) + require.Error(t, err) + var useErr *fleet.CustomHostVitalUsedError + require.ErrorAs(t, err, &useErr) + require.Equal(t, id, useErr.CustomHostVitalID) + require.Equal(t, "FUNCTION", useErr.CustomHostVitalName) + require.Equal(t, fleet.CustomHostVitalEntitySetupExperienceScript, useErr.Entity.Type) + require.Equal(t, "setup.sh", useErr.Entity.Name) + require.Equal(t, "Foobar", useErr.Entity.FleetName) + + require.NoError(t, ds.DeleteSetupExperienceScript(ctx, &foobarTeam.ID)) + }) + + t.Run("host vitals labels", func(t *testing.T) { + // A host-vitals label references the vital by id in its criteria JSON, + // not via the $FLEET_HOST_VITAL_ token. + criteria, err := json.Marshal(&fleet.HostVitalCriteria{ + Vital: new("custom_host_vital"), + Value: new("Engineering"), + CustomHostVitalID: &id, + }) + require.NoError(t, err) + label, err := ds.NewLabel(ctx, &fleet.Label{ + Name: "chv-del-label", + LabelType: fleet.LabelTypeRegular, + LabelMembershipType: fleet.LabelMembershipTypeHostVitals, + HostVitalsCriteria: new(json.RawMessage(criteria)), + }) + require.NoError(t, err) + + _, err = ds.DeleteCustomHostVital(ctx, id) + require.Error(t, err) + var useErr *fleet.CustomHostVitalUsedError + require.ErrorAs(t, err, &useErr) + require.Equal(t, id, useErr.CustomHostVitalID) + require.Equal(t, "FUNCTION", useErr.CustomHostVitalName) + require.Equal(t, fleet.CustomHostVitalEntityLabel, useErr.Entity.Type) + require.Equal(t, "chv-del-label", useErr.Entity.Name) + require.Equal(t, "Unassigned", useErr.Entity.FleetName) + + require.NoError(t, ds.DeleteLabel(ctx, label.Name, fleet.TeamFilter{User: test.UserAdmin})) + }) + + // With all references removed, delete now succeeds. + name, err := ds.DeleteCustomHostVital(ctx, id) + require.NoError(t, err) + require.Equal(t, "FUNCTION", name) +} + +// Setting a host's value for a vital must re-queue the MDM profiles and DDM +// declarations already delivered to that host that reference the vital, so the +// reconcilers re-expand $FLEET_HOST_VITAL_ with the new value. Profiles +// referencing a different vital (or none), and other hosts, must be untouched. +func testSetHostCustomHostVitalValueResendsProfiles(t *testing.T, ds *Datastore) { + ctx := t.Context() + + host := test.NewHost(t, ds, "mac", "1", "mackey", "macuuid", time.Now()) + winHost := test.NewHost(t, ds, "win", "2", "winkey", "winuuid", time.Now(), test.WithPlatform("windows")) + + vitalID := createCustomHostVital(t, ds, "FUNCTION") + otherID := createCustomHostVital(t, ds, "OTHER") + token := fmt.Sprintf("$%s%d", fleet.CustomHostVitalPrefix, vitalID) + otherToken := fmt.Sprintf("$%s%d", fleet.CustomHostVitalPrefix, otherID) + + // generateAppleCP/generateWindowsCP embed name+identifier in the profile + // body, so passing the token there puts the reference in the content the + // resend scan matches on. + profVital, err := ds.NewMDMAppleConfigProfile(ctx, *generateAppleCP("pv", token, 0), nil) + require.NoError(t, err) + profOther, err := ds.NewMDMAppleConfigProfile(ctx, *generateAppleCP("po", otherToken, 0), nil) + require.NoError(t, err) + profNone, err := ds.NewMDMAppleConfigProfile(ctx, *generateAppleCP("pn", "plain", 0), nil) + require.NoError(t, err) + + profWVital, err := ds.NewMDMWindowsConfigProfile(ctx, *generateWindowsCP("wv", token, 0), nil) + require.NoError(t, err) + profWNone, err := ds.NewMDMWindowsConfigProfile(ctx, *generateWindowsCP("wn", "plain", 0), nil) + require.NoError(t, err) + + forceSetAppleHostProfileStatus(t, ds, host.UUID, profVital, fleet.MDMOperationTypeInstall, fleet.MDMDeliveryVerifying) + forceSetAppleHostProfileStatus(t, ds, host.UUID, profOther, fleet.MDMOperationTypeInstall, fleet.MDMDeliveryVerifying) + forceSetAppleHostProfileStatus(t, ds, host.UUID, profNone, fleet.MDMOperationTypeInstall, fleet.MDMDeliveryVerifying) + forceSetWindowsHostProfileStatus(t, ds, winHost.UUID, profWVital, fleet.MDMOperationTypeInstall, fleet.MDMDeliveryVerifying) + forceSetWindowsHostProfileStatus(t, ds, winHost.UUID, profWNone, fleet.MDMOperationTypeInstall, fleet.MDMDeliveryVerifying) + + // DDM declaration referencing the vital, delivered (verifying) to the mac host. + bracedToken := fmt.Sprintf("${%s%d}", fleet.CustomHostVitalPrefix, vitalID) + declVital, err := ds.NewMDMAppleDeclaration(ctx, &fleet.MDMAppleDeclaration{ + Identifier: "decl-vital", Name: "decl-vital", + RawJSON: json.RawMessage(fmt.Sprintf(`{"note":"%s"}`, bracedToken)), + }, nil) + require.NoError(t, err) + forceSetAppleHostDeclarationStatus(t, ds, host.UUID, declVital, fleet.MDMOperationTypeInstall, fleet.MDMDeliveryVerifying) + + // Set the value on both hosts; only referencing entities on the same host reset. + require.NoError(t, ds.SetHostCustomHostVitalValue(ctx, host.ID, vitalID, "Engineering")) + require.NoError(t, ds.SetHostCustomHostVitalValue(ctx, winHost.ID, vitalID, "Engineering")) + + // A reset row has NULL status, which assertHostProfileStatus reports as + // pending. The declaration surfaces through GetHostMDMAppleProfiles too, so + // it's included here; it should also be reset. + assertHostProfileStatus(t, ds, host.UUID, + hostProfileStatus{profVital.ProfileUUID, fleet.MDMDeliveryPending}, + hostProfileStatus{profOther.ProfileUUID, fleet.MDMDeliveryVerifying}, + hostProfileStatus{profNone.ProfileUUID, fleet.MDMDeliveryVerifying}, + hostProfileStatus{declVital.DeclarationUUID, fleet.MDMDeliveryPending}) + assertHostProfileStatus(t, ds, winHost.UUID, + hostProfileStatus{profWVital.ProfileUUID, fleet.MDMDeliveryPending}, + hostProfileStatus{profWNone.ProfileUUID, fleet.MDMDeliveryVerifying}) + + var declStatus *fleet.MDMDeliveryStatus + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + return sqlx.GetContext(ctx, q, &declStatus, + `SELECT status FROM host_mdm_apple_declarations WHERE host_uuid = ? AND declaration_uuid = ?`, + host.UUID, declVital.DeclarationUUID) + }) + require.Nil(t, declStatus, "declaration should be reset (NULL status) so the DDM reconciler re-delivers it") +} + +// The DDM reconcile snapshot must flag declarations that reference a custom host +// vital as HasFleetVariables, so the reconciler stamps variables_updated_at on +// the host declaration row — the signal handleDeclarationItems relies on to load +// raw_json, drop unresolvable declarations from the manifest, and cache-bust the +// DDM token. Custom host vitals aren't in mdm_configuration_profile_variables, so +// this depends on the body scan rather than the variables join. +func testReconcileSnapshotMarksVitalDeclarations(t *testing.T, ds *Datastore) { + ctx := t.Context() + + // A macOS host must be MDM-enrolled to enter the reconcile window; otherwise + // the snapshot skips loading declarations entirely. + host := test.NewHost(t, ds, "macos-1", "1", "macos-1-key", "macos-1-uuid", time.Now()) + nanoEnroll(t, ds, host, false) + + vitalID := createCustomHostVital(t, ds, "FUNCTION") + bracedToken := fmt.Sprintf("${%s%d}", fleet.CustomHostVitalPrefix, vitalID) + + declVital, err := ds.NewMDMAppleDeclaration(ctx, &fleet.MDMAppleDeclaration{ + Identifier: "decl-vital", Name: "decl-vital", + RawJSON: json.RawMessage(fmt.Sprintf(`{"note":"%s"}`, bracedToken)), + }, nil) + require.NoError(t, err) + declPlain, err := ds.NewMDMAppleDeclaration(ctx, &fleet.MDMAppleDeclaration{ + Identifier: "decl-plain", Name: "decl-plain", + RawJSON: json.RawMessage(`{"note":"static"}`), + }, nil) + require.NoError(t, err) + + _, allDecls, _, _, err := ds.GetAppleDeclarationReconcileSnapshot(ctx, "", 100) + require.NoError(t, err) + + byUUID := make(map[string]*fleet.AppleDeclarationForReconcile, len(allDecls)) + for _, d := range allDecls { + byUUID[d.DeclarationUUID] = d + } + vitalDecl := byUUID[declVital.DeclarationUUID] + plainDecl := byUUID[declPlain.DeclarationUUID] + require.NotNil(t, vitalDecl, "vital declaration missing from reconcile snapshot") + require.NotNil(t, plainDecl, "plain declaration missing from reconcile snapshot") + assert.True(t, vitalDecl.HasFleetVariables, //nolint:nilaway // cannot be nil due to require.NotNil above + "declaration referencing a custom host vital should be marked HasFleetVariables") + assert.False(t, plainDecl.HasFleetVariables, //nolint:nilaway // cannot be nil due to require.NotNil above + "declaration without any variables should not be marked") +} + +// A $FLEET_HOST_VITAL_ token whose suffix isn't a valid vital ID must be rejected on upload +func testValidateReferencedCustomHostVitalsRejectsMalformed(t *testing.T, ds *Datastore) { + ctx := t.Context() + vitalID := createCustomHostVital(t, ds, "Function") + + // Valid numeric reference to an existing vital passes. + require.NoError(t, ds.ValidateReferencedCustomHostVitals(ctx, + []string{fmt.Sprintf("echo $%s%d", fleet.CustomHostVitalPrefix, vitalID)})) + + // Non-numeric suffix is a malformed reference -> InvalidCustomHostVitalRefError. + var invalidErr *fleet.InvalidCustomHostVitalRefError + err := ds.ValidateReferencedCustomHostVitals(ctx, + []string{fmt.Sprintf("echo $%sasset_tag", fleet.CustomHostVitalPrefix)}) + require.ErrorAs(t, err, &invalidErr) + + // Braced malformed form -> also InvalidCustomHostVitalRefError. + invalidErr = nil + err = ds.ValidateReferencedCustomHostVitals(ctx, + []string{fmt.Sprintf("echo ${%sFOOBAR}", fleet.CustomHostVitalPrefix)}) + require.ErrorAs(t, err, &invalidErr) + + // Numeric-but-nonexistent id -> MissingCustomHostVitalsError (distinct from malformed). + var missingErr *fleet.MissingCustomHostVitalsError + err = ds.ValidateReferencedCustomHostVitals(ctx, + []string{fmt.Sprintf("echo $%s999999", fleet.CustomHostVitalPrefix)}) + require.ErrorAs(t, err, &missingErr) + + // No token at all -> no error. + require.NoError(t, ds.ValidateReferencedCustomHostVitals(ctx, []string{"echo hello"})) +} diff --git a/server/datastore/mysql/hosts.go b/server/datastore/mysql/hosts.go index 3182a23fcf..0b7ac14a07 100644 --- a/server/datastore/mysql/hosts.go +++ b/server/datastore/mysql/hosts.go @@ -609,6 +609,7 @@ var hostRefs = []string{ "host_vpp_software_installs", "host_last_known_locations", "host_issues", + "host_custom_host_vitals", } // NOTE: The following tables are explicity excluded from hostRefs list and accordingly are not diff --git a/server/datastore/mysql/hosts_test.go b/server/datastore/mysql/hosts_test.go index 725b83783c..f29a63455b 100644 --- a/server/datastore/mysql/hosts_test.go +++ b/server/datastore/mysql/hosts_test.go @@ -9735,6 +9735,17 @@ func testHostsDeleteHosts(t *testing.T, ds *Datastore) { err = ds.UpdateHostIssuesFailingPoliciesForSingleHost(ctx, host.ID) require.NoError(t, err) + // Insert into host_custom_host_vitals table (no host FK, cleaned up via hostRefs). + vitalRes, err := ds.writer(context.Background()).Exec(`INSERT INTO custom_host_vitals (name) VALUES (?)`, "delete-host-vital") + require.NoError(t, err) + vitalID, err := vitalRes.LastInsertId() + require.NoError(t, err) + _, err = ds.writer(context.Background()).Exec( + `INSERT INTO host_custom_host_vitals (host_id, custom_host_vital_id, value) VALUES (?, ?, ?)`, + host.ID, vitalID, "engineering", + ) + require.NoError(t, err) + // Check there's an entry for the host in all the associated tables. for _, hostRef := range hostRefs { var ok bool diff --git a/server/datastore/mysql/labels_test.go b/server/datastore/mysql/labels_test.go index a5b3020849..8bd36343c8 100644 --- a/server/datastore/mysql/labels_test.go +++ b/server/datastore/mysql/labels_test.go @@ -102,6 +102,7 @@ func TestLabels(t *testing.T) { {"ApplyLabelSpecsWithPlatformChange", testApplyLabelSpecsWithPlatformChange}, {"UpdateLabelMembershipByHostCriteria", testUpdateLabelMembershipByHostCriteria}, {"UpdateLabelMembershipByHostCriteriaIDP", testUpdateLabelMembershipByHostCriteriaIDP}, + {"UpdateLabelMembershipByHostCriteriaCustomHostVital", testUpdateLabelMembershipByHostCriteriaCustomHostVital}, {"TeamLabels", testTeamLabels}, {"UpdateLabelMembershipForTransferredHost", testUpdateLabelMembershipForTransferredHost}, {"SetAsideLabels", testSetAsideLabels}, @@ -2972,6 +2973,105 @@ func testUpdateLabelMembershipByHostCriteriaIDP(t *testing.T, ds *Datastore) { _ = team2 // team2 is used only to give host2 an out-of-team membership. } +// testUpdateLabelMembershipByHostCriteriaCustomHostVital exercises the real +// custom-host-vital query path: membership is a host's stored value for a +// specific custom_host_vital_id matching the criterion value. It verifies +// multi-host isolation (one host's value doesn't leak to another), that the +// criterion is scoped to its vital id (a matching value on a different vital +// does not count), and that team-scoped labels only include in-team hosts. +func testUpdateLabelMembershipByHostCriteriaCustomHostVital(t *testing.T, ds *Datastore) { + ctx := t.Context() + + team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "chv-team1"}) + require.NoError(t, err) + team2, err := ds.NewTeam(ctx, &fleet.Team{Name: "chv-team2"}) + require.NoError(t, err) + + // host1 -> team1, host2 -> team2, host3 -> global, host4 -> global. + hosts := make([]*fleet.Host, 4) + teamIDs := []*uint{&team1.ID, &team2.ID, nil, nil} + for i := range 4 { + host, err := ds.NewHost(ctx, &fleet.Host{ + OsqueryHostID: new(fmt.Sprintf("chv-%d", i)), + NodeKey: new(fmt.Sprintf("chv-%d", i)), + UUID: fmt.Sprintf("chv-uuid%d", i), + Hostname: fmt.Sprintf("chv-host%d.local", i), + HardwareSerial: fmt.Sprintf("chv-hwd%d", i), + Platform: "darwin", + TeamID: teamIDs[i], + }) + require.NoError(t, err) + hosts[i] = host + } + + vitalA, err := ds.CreateCustomHostVital(ctx, "Department") + require.NoError(t, err) + vitalB, err := ds.CreateCustomHostVital(ctx, "Function") + require.NoError(t, err) + + // host1 & host4: vitalA = "Engineering" (should match). + // host2: vitalA = "Sales" (wrong value -> excluded). + // host3: vitalB = "Engineering" (right value but wrong vital -> excluded), + // and vitalA = "Sales" so it also has a value for the target vital. + require.NoError(t, ds.SetHostCustomHostVitalValue(ctx, hosts[0].ID, vitalA.ID, "Engineering")) + require.NoError(t, ds.SetHostCustomHostVitalValue(ctx, hosts[3].ID, vitalA.ID, "Engineering")) + require.NoError(t, ds.SetHostCustomHostVitalValue(ctx, hosts[1].ID, vitalA.ID, "Sales")) + require.NoError(t, ds.SetHostCustomHostVitalValue(ctx, hosts[2].ID, vitalA.ID, "Sales")) + require.NoError(t, ds.SetHostCustomHostVitalValue(ctx, hosts[2].ID, vitalB.ID, "Engineering")) + + criteria, err := json.Marshal(&fleet.HostVitalCriteria{ + Vital: new("custom_host_vital"), + Value: new("Engineering"), + CustomHostVitalID: &vitalA.ID, + }) + require.NoError(t, err) + + newCHVLabel := func(name string, teamID *uint) *fleet.Label { + lbl, err := ds.NewLabel(ctx, &fleet.Label{ + Name: name, + TeamID: teamID, + LabelType: fleet.LabelTypeRegular, + LabelMembershipType: fleet.LabelMembershipTypeHostVitals, + HostVitalsCriteria: new(json.RawMessage(criteria)), + }) + require.NoError(t, err) + return lbl + } + + filter := fleet.TeamFilter{User: test.UserAdmin} + + // Global label: host1 and host4 (vitalA = "Engineering"). host2 has the + // wrong value, host3 matches the value only on vitalB. + globalLabel := newCHVLabel("chv-global", nil) + updated, err := ds.UpdateLabelMembershipByHostCriteria(ctx, globalLabel) + require.NoError(t, err) + require.Equal(t, 2, updated.HostCount) + globalHosts, err := ds.ListHostsInLabel(ctx, filter, globalLabel.ID, fleet.HostListOptions{}) + require.NoError(t, err) + require.ElementsMatch(t, []uint{hosts[0].ID, hosts[3].ID}, hostIDs(globalHosts)) + + // Team1 label: only host1, even though host4 also matches (it's global). + team1Label := newCHVLabel("chv-team1-label", &team1.ID) + updated, err = ds.UpdateLabelMembershipByHostCriteria(ctx, team1Label) + require.NoError(t, err) + require.Equal(t, 1, updated.HostCount) + team1Hosts, err := ds.ListHostsInLabel(ctx, filter, team1Label.ID, fleet.HostListOptions{}) + require.NoError(t, err) + require.ElementsMatch(t, []uint{hosts[0].ID}, hostIDs(team1Hosts)) + + // Changing a host's value re-computes membership: host4 leaves, host2 joins. + require.NoError(t, ds.SetHostCustomHostVitalValue(ctx, hosts[3].ID, vitalA.ID, "Sales")) + require.NoError(t, ds.SetHostCustomHostVitalValue(ctx, hosts[1].ID, vitalA.ID, "Engineering")) + updated, err = ds.UpdateLabelMembershipByHostCriteria(ctx, globalLabel) + require.NoError(t, err) + require.Equal(t, 2, updated.HostCount) + globalHosts, err = ds.ListHostsInLabel(ctx, filter, globalLabel.ID, fleet.HostListOptions{}) + require.NoError(t, err) + require.ElementsMatch(t, []uint{hosts[0].ID, hosts[1].ID}, hostIDs(globalHosts)) + + _ = team2 // team2 only gives host2 an out-of-team membership. +} + func hostIDs(hosts []*fleet.Host) []uint { ids := make([]uint, 0, len(hosts)) for _, h := range hosts { diff --git a/server/datastore/mysql/migrations/tables/20260715144547_CustomHostVitals.go b/server/datastore/mysql/migrations/tables/20260715144547_CustomHostVitals.go new file mode 100644 index 0000000000..14389c2ae2 --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20260715144547_CustomHostVitals.go @@ -0,0 +1,53 @@ +package tables + +import ( + "database/sql" + "fmt" +) + +func init() { + MigrationClient.AddMigration(Up_20260715144547, Down_20260715144547) +} + +func Up_20260715144547(tx *sql.Tx) error { + _, err := tx.Exec(` + CREATE TABLE custom_host_vitals ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + name VARCHAR(255) COLLATE utf8mb4_unicode_ci NOT NULL, + -- Using DATETIME instead of TIMESTAMP to prevent future Y2K38 issues. + created_at DATETIME(6) NOT NULL DEFAULT NOW(6), + updated_at DATETIME(6) NOT NULL DEFAULT NOW(6) ON UPDATE NOW(6), + PRIMARY KEY (id), + CONSTRAINT idx_custom_host_vitals_name UNIQUE (name) + ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci`, + ) + if err != nil { + return fmt.Errorf("failed to create custom_host_vitals table: %w", err) + } + + _, err = tx.Exec(` + CREATE TABLE host_custom_host_vitals ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + host_id INT UNSIGNED NOT NULL, + custom_host_vital_id INT UNSIGNED NOT NULL, + value TEXT COLLATE utf8mb4_unicode_ci NOT NULL, + created_at DATETIME(6) NOT NULL DEFAULT NOW(6), + updated_at DATETIME(6) NOT NULL DEFAULT NOW(6) ON UPDATE NOW(6), + PRIMARY KEY (id), + CONSTRAINT idx_host_custom_host_vitals_host_vital UNIQUE (host_id, custom_host_vital_id), + -- No FK on host_id (see handbook/engineering/scaling-fleet.md): rows are + -- cleaned up on host deletion via the hostRefs list instead. + CONSTRAINT fk_host_custom_host_vitals_custom_host_vital_id + FOREIGN KEY (custom_host_vital_id) REFERENCES custom_host_vitals (id) ON DELETE CASCADE + ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci`, + ) + if err != nil { + return fmt.Errorf("failed to create host_custom_host_vitals table: %w", err) + } + + return nil +} + +func Down_20260715144547(tx *sql.Tx) error { + return nil +} diff --git a/server/datastore/mysql/migrations/tables/20260715144547_CustomHostVitals_test.go b/server/datastore/mysql/migrations/tables/20260715144547_CustomHostVitals_test.go new file mode 100644 index 0000000000..d520af0b64 --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20260715144547_CustomHostVitals_test.go @@ -0,0 +1,53 @@ +package tables + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestUp_20260715144547(t *testing.T) { + db := applyUpToPrev(t) + + // Seed a host so we can attach a per-host value. + res, err := db.Exec(` + INSERT INTO hosts (osquery_host_id, node_key, hostname, uuid) + VALUES (?, ?, ?, ?)`, + "host-1-osquery-id", "host-1-node-key", "host-1", "host-1-uuid", + ) + require.NoError(t, err) + hostIDInt, err := res.LastInsertId() + require.NoError(t, err) + hostID := uint(hostIDInt) //nolint:gosec + + // Apply current migration. + applyNext(t, db) + + // Insert a definition; the name is unique. + res, err = db.Exec(`INSERT INTO custom_host_vitals (name) VALUES ('Asset tag')`) + require.NoError(t, err) + vitalIDInt, err := res.LastInsertId() + require.NoError(t, err) + vitalID := uint(vitalIDInt) //nolint:gosec + + _, err = db.Exec(`INSERT INTO custom_host_vitals (name) VALUES ('Asset tag')`) + require.Error(t, err, "duplicate name should be rejected") + + // value is NOT NULL. + _, err = db.Exec(`INSERT INTO host_custom_host_vitals (host_id, custom_host_vital_id, value) VALUES (?, ?, NULL)`, hostID, vitalID) + require.Error(t, err, "NULL value should be rejected") + + // Insert a per-host value; (host_id, custom_host_vital_id) is unique. + _, err = db.Exec(`INSERT INTO host_custom_host_vitals (host_id, custom_host_vital_id, value) VALUES (?, ?, 'engineering')`, hostID, vitalID) + require.NoError(t, err) + _, err = db.Exec(`INSERT INTO host_custom_host_vitals (host_id, custom_host_vital_id, value) VALUES (?, ?, 'other')`, hostID, vitalID) + require.Error(t, err, "duplicate (host_id, custom_host_vital_id) should be rejected") + + // Deleting the definition cascades to the per-host value. + _, err = db.Exec(`DELETE FROM custom_host_vitals WHERE id = ?`, vitalID) + require.NoError(t, err) + var count int + err = db.QueryRow(`SELECT COUNT(*) FROM host_custom_host_vitals WHERE custom_host_vital_id = ?`, vitalID).Scan(&count) + require.NoError(t, err) + require.Zero(t, count) +} diff --git a/server/datastore/mysql/schema.sql b/server/datastore/mysql/schema.sql index 7fa24eacbe..4e83e02ba7 100644 --- a/server/datastore/mysql/schema.sql +++ b/server/datastore/mysql/schema.sql @@ -433,6 +433,17 @@ CREATE TABLE `cron_stats` ( /*!40101 SET character_set_client = @saved_cs_client */; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `custom_host_vitals` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + `updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + PRIMARY KEY (`id`), + UNIQUE KEY `idx_custom_host_vitals_name` (`name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `cve_meta` ( `cve` varchar(20) COLLATE utf8mb4_unicode_ci NOT NULL, `cvss_score` double DEFAULT NULL, @@ -664,6 +675,21 @@ CREATE TABLE `host_conditional_access` ( /*!40101 SET character_set_client = @saved_cs_client */; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `host_custom_host_vitals` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `host_id` int unsigned NOT NULL, + `custom_host_vital_id` int unsigned NOT NULL, + `value` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + `updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + PRIMARY KEY (`id`), + UNIQUE KEY `idx_host_custom_host_vitals_host_vital` (`host_id`,`custom_host_vital_id`), + KEY `fk_host_custom_host_vitals_custom_host_vital_id` (`custom_host_vital_id`), + CONSTRAINT `fk_host_custom_host_vitals_custom_host_vital_id` FOREIGN KEY (`custom_host_vital_id`) REFERENCES `custom_host_vitals` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `host_dep_assignments` ( `host_id` int unsigned NOT NULL, `added_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -2156,9 +2182,9 @@ CREATE TABLE `migration_status_tables` ( `is_applied` tinyint(1) NOT NULL, `tstamp` timestamp NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) -) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB AUTO_INCREMENT=569 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB AUTO_INCREMENT=570 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; -INSERT INTO `migration_status_tables` VALUES (1,0,1,'2020-01-01 01:01:01'),(2,20161118193812,1,'2020-01-01 01:01:01'),(3,20161118211713,1,'2020-01-01 01:01:01'),(4,20161118212436,1,'2020-01-01 01:01:01'),(5,20161118212515,1,'2020-01-01 01:01:01'),(6,20161118212528,1,'2020-01-01 01:01:01'),(7,20161118212538,1,'2020-01-01 01:01:01'),(8,20161118212549,1,'2020-01-01 01:01:01'),(9,20161118212557,1,'2020-01-01 01:01:01'),(10,20161118212604,1,'2020-01-01 01:01:01'),(11,20161118212613,1,'2020-01-01 01:01:01'),(12,20161118212621,1,'2020-01-01 01:01:01'),(13,20161118212630,1,'2020-01-01 01:01:01'),(14,20161118212641,1,'2020-01-01 01:01:01'),(15,20161118212649,1,'2020-01-01 01:01:01'),(16,20161118212656,1,'2020-01-01 01:01:01'),(17,20161118212758,1,'2020-01-01 01:01:01'),(18,20161128234849,1,'2020-01-01 01:01:01'),(19,20161230162221,1,'2020-01-01 01:01:01'),(20,20170104113816,1,'2020-01-01 01:01:01'),(21,20170105151732,1,'2020-01-01 01:01:01'),(22,20170108191242,1,'2020-01-01 01:01:01'),(23,20170109094020,1,'2020-01-01 01:01:01'),(24,20170109130438,1,'2020-01-01 01:01:01'),(25,20170110202752,1,'2020-01-01 01:01:01'),(26,20170111133013,1,'2020-01-01 01:01:01'),(27,20170117025759,1,'2020-01-01 01:01:01'),(28,20170118191001,1,'2020-01-01 01:01:01'),(29,20170119234632,1,'2020-01-01 01:01:01'),(30,20170124230432,1,'2020-01-01 01:01:01'),(31,20170127014618,1,'2020-01-01 01:01:01'),(32,20170131232841,1,'2020-01-01 01:01:01'),(33,20170223094154,1,'2020-01-01 01:01:01'),(34,20170306075207,1,'2020-01-01 01:01:01'),(35,20170309100733,1,'2020-01-01 01:01:01'),(36,20170331111922,1,'2020-01-01 01:01:01'),(37,20170502143928,1,'2020-01-01 01:01:01'),(38,20170504130602,1,'2020-01-01 01:01:01'),(39,20170509132100,1,'2020-01-01 01:01:01'),(40,20170519105647,1,'2020-01-01 01:01:01'),(41,20170519105648,1,'2020-01-01 01:01:01'),(42,20170831234300,1,'2020-01-01 01:01:01'),(43,20170831234301,1,'2020-01-01 01:01:01'),(44,20170831234303,1,'2020-01-01 01:01:01'),(45,20171116163618,1,'2020-01-01 01:01:01'),(46,20171219164727,1,'2020-01-01 01:01:01'),(47,20180620164811,1,'2020-01-01 01:01:01'),(48,20180620175054,1,'2020-01-01 01:01:01'),(49,20180620175055,1,'2020-01-01 01:01:01'),(50,20191010101639,1,'2020-01-01 01:01:01'),(51,20191010155147,1,'2020-01-01 01:01:01'),(52,20191220130734,1,'2020-01-01 01:01:01'),(53,20200311140000,1,'2020-01-01 01:01:01'),(54,20200405120000,1,'2020-01-01 01:01:01'),(55,20200407120000,1,'2020-01-01 01:01:01'),(56,20200420120000,1,'2020-01-01 01:01:01'),(57,20200504120000,1,'2020-01-01 01:01:01'),(58,20200512120000,1,'2020-01-01 01:01:01'),(59,20200707120000,1,'2020-01-01 01:01:01'),(60,20201011162341,1,'2020-01-01 01:01:01'),(61,20201021104586,1,'2020-01-01 01:01:01'),(62,20201102112520,1,'2020-01-01 01:01:01'),(63,20201208121729,1,'2020-01-01 01:01:01'),(64,20201215091637,1,'2020-01-01 01:01:01'),(65,20210119174155,1,'2020-01-01 01:01:01'),(66,20210326182902,1,'2020-01-01 01:01:01'),(67,20210421112652,1,'2020-01-01 01:01:01'),(68,20210506095025,1,'2020-01-01 01:01:01'),(69,20210513115729,1,'2020-01-01 01:01:01'),(70,20210526113559,1,'2020-01-01 01:01:01'),(71,20210601000001,1,'2020-01-01 01:01:01'),(72,20210601000002,1,'2020-01-01 01:01:01'),(73,20210601000003,1,'2020-01-01 01:01:01'),(74,20210601000004,1,'2020-01-01 01:01:01'),(75,20210601000005,1,'2020-01-01 01:01:01'),(76,20210601000006,1,'2020-01-01 01:01:01'),(77,20210601000007,1,'2020-01-01 01:01:01'),(78,20210601000008,1,'2020-01-01 01:01:01'),(79,20210606151329,1,'2020-01-01 01:01:01'),(80,20210616163757,1,'2020-01-01 01:01:01'),(81,20210617174723,1,'2020-01-01 01:01:01'),(82,20210622160235,1,'2020-01-01 01:01:01'),(83,20210623100031,1,'2020-01-01 01:01:01'),(84,20210623133615,1,'2020-01-01 01:01:01'),(85,20210708143152,1,'2020-01-01 01:01:01'),(86,20210709124443,1,'2020-01-01 01:01:01'),(87,20210712155608,1,'2020-01-01 01:01:01'),(88,20210714102108,1,'2020-01-01 01:01:01'),(89,20210719153709,1,'2020-01-01 01:01:01'),(90,20210721171531,1,'2020-01-01 01:01:01'),(91,20210723135713,1,'2020-01-01 01:01:01'),(92,20210802135933,1,'2020-01-01 01:01:01'),(93,20210806112844,1,'2020-01-01 01:01:01'),(94,20210810095603,1,'2020-01-01 01:01:01'),(95,20210811150223,1,'2020-01-01 01:01:01'),(96,20210818151827,1,'2020-01-01 01:01:01'),(97,20210818151828,1,'2020-01-01 01:01:01'),(98,20210818182258,1,'2020-01-01 01:01:01'),(99,20210819131107,1,'2020-01-01 01:01:01'),(100,20210819143446,1,'2020-01-01 01:01:01'),(101,20210903132338,1,'2020-01-01 01:01:01'),(102,20210915144307,1,'2020-01-01 01:01:01'),(103,20210920155130,1,'2020-01-01 01:01:01'),(104,20210927143115,1,'2020-01-01 01:01:01'),(105,20210927143116,1,'2020-01-01 01:01:01'),(106,20211013133706,1,'2020-01-01 01:01:01'),(107,20211013133707,1,'2020-01-01 01:01:01'),(108,20211102135149,1,'2020-01-01 01:01:01'),(109,20211109121546,1,'2020-01-01 01:01:01'),(110,20211110163320,1,'2020-01-01 01:01:01'),(111,20211116184029,1,'2020-01-01 01:01:01'),(112,20211116184030,1,'2020-01-01 01:01:01'),(113,20211202092042,1,'2020-01-01 01:01:01'),(114,20211202181033,1,'2020-01-01 01:01:01'),(115,20211207161856,1,'2020-01-01 01:01:01'),(116,20211216131203,1,'2020-01-01 01:01:01'),(117,20211221110132,1,'2020-01-01 01:01:01'),(118,20220107155700,1,'2020-01-01 01:01:01'),(119,20220125105650,1,'2020-01-01 01:01:01'),(120,20220201084510,1,'2020-01-01 01:01:01'),(121,20220208144830,1,'2020-01-01 01:01:01'),(122,20220208144831,1,'2020-01-01 01:01:01'),(123,20220215152203,1,'2020-01-01 01:01:01'),(124,20220223113157,1,'2020-01-01 01:01:01'),(125,20220307104655,1,'2020-01-01 01:01:01'),(126,20220309133956,1,'2020-01-01 01:01:01'),(127,20220316155700,1,'2020-01-01 01:01:01'),(128,20220323152301,1,'2020-01-01 01:01:01'),(129,20220330100659,1,'2020-01-01 01:01:01'),(130,20220404091216,1,'2020-01-01 01:01:01'),(131,20220419140750,1,'2020-01-01 01:01:01'),(132,20220428140039,1,'2020-01-01 01:01:01'),(133,20220503134048,1,'2020-01-01 01:01:01'),(134,20220524102918,1,'2020-01-01 01:01:01'),(135,20220526123327,1,'2020-01-01 01:01:01'),(136,20220526123328,1,'2020-01-01 01:01:01'),(137,20220526123329,1,'2020-01-01 01:01:01'),(138,20220608113128,1,'2020-01-01 01:01:01'),(139,20220627104817,1,'2020-01-01 01:01:01'),(140,20220704101843,1,'2020-01-01 01:01:01'),(141,20220708095046,1,'2020-01-01 01:01:01'),(142,20220713091130,1,'2020-01-01 01:01:01'),(143,20220802135510,1,'2020-01-01 01:01:01'),(144,20220818101352,1,'2020-01-01 01:01:01'),(145,20220822161445,1,'2020-01-01 01:01:01'),(146,20220831100036,1,'2020-01-01 01:01:01'),(147,20220831100151,1,'2020-01-01 01:01:01'),(148,20220908181826,1,'2020-01-01 01:01:01'),(149,20220914154915,1,'2020-01-01 01:01:01'),(150,20220915165115,1,'2020-01-01 01:01:01'),(151,20220915165116,1,'2020-01-01 01:01:01'),(152,20220928100158,1,'2020-01-01 01:01:01'),(153,20221014084130,1,'2020-01-01 01:01:01'),(154,20221027085019,1,'2020-01-01 01:01:01'),(155,20221101103952,1,'2020-01-01 01:01:01'),(156,20221104144401,1,'2020-01-01 01:01:01'),(157,20221109100749,1,'2020-01-01 01:01:01'),(158,20221115104546,1,'2020-01-01 01:01:01'),(159,20221130114928,1,'2020-01-01 01:01:01'),(160,20221205112142,1,'2020-01-01 01:01:01'),(161,20221216115820,1,'2020-01-01 01:01:01'),(162,20221220195934,1,'2020-01-01 01:01:01'),(163,20221220195935,1,'2020-01-01 01:01:01'),(164,20221223174807,1,'2020-01-01 01:01:01'),(165,20221227163855,1,'2020-01-01 01:01:01'),(166,20221227163856,1,'2020-01-01 01:01:01'),(167,20230202224725,1,'2020-01-01 01:01:01'),(168,20230206163608,1,'2020-01-01 01:01:01'),(169,20230214131519,1,'2020-01-01 01:01:01'),(170,20230303135738,1,'2020-01-01 01:01:01'),(171,20230313135301,1,'2020-01-01 01:01:01'),(172,20230313141819,1,'2020-01-01 01:01:01'),(173,20230315104937,1,'2020-01-01 01:01:01'),(174,20230317173844,1,'2020-01-01 01:01:01'),(175,20230320133602,1,'2020-01-01 01:01:01'),(176,20230330100011,1,'2020-01-01 01:01:01'),(177,20230330134823,1,'2020-01-01 01:01:01'),(178,20230405232025,1,'2020-01-01 01:01:01'),(179,20230408084104,1,'2020-01-01 01:01:01'),(180,20230411102858,1,'2020-01-01 01:01:01'),(181,20230421155932,1,'2020-01-01 01:01:01'),(182,20230425082126,1,'2020-01-01 01:01:01'),(183,20230425105727,1,'2020-01-01 01:01:01'),(184,20230501154913,1,'2020-01-01 01:01:01'),(185,20230503101418,1,'2020-01-01 01:01:01'),(186,20230515144206,1,'2020-01-01 01:01:01'),(187,20230517140952,1,'2020-01-01 01:01:01'),(188,20230517152807,1,'2020-01-01 01:01:01'),(189,20230518114155,1,'2020-01-01 01:01:01'),(190,20230520153236,1,'2020-01-01 01:01:01'),(191,20230525151159,1,'2020-01-01 01:01:01'),(192,20230530122103,1,'2020-01-01 01:01:01'),(193,20230602111827,1,'2020-01-01 01:01:01'),(194,20230608103123,1,'2020-01-01 01:01:01'),(195,20230629140529,1,'2020-01-01 01:01:01'),(196,20230629140530,1,'2020-01-01 01:01:01'),(197,20230711144622,1,'2020-01-01 01:01:01'),(198,20230721135421,1,'2020-01-01 01:01:01'),(199,20230721161508,1,'2020-01-01 01:01:01'),(200,20230726115701,1,'2020-01-01 01:01:01'),(201,20230807100822,1,'2020-01-01 01:01:01'),(202,20230814150442,1,'2020-01-01 01:01:01'),(203,20230823122728,1,'2020-01-01 01:01:01'),(204,20230906152143,1,'2020-01-01 01:01:01'),(205,20230911163618,1,'2020-01-01 01:01:01'),(206,20230912101759,1,'2020-01-01 01:01:01'),(207,20230915101341,1,'2020-01-01 01:01:01'),(208,20230918132351,1,'2020-01-01 01:01:01'),(209,20231004144339,1,'2020-01-01 01:01:01'),(210,20231009094541,1,'2020-01-01 01:01:01'),(211,20231009094542,1,'2020-01-01 01:01:01'),(212,20231009094543,1,'2020-01-01 01:01:01'),(213,20231009094544,1,'2020-01-01 01:01:01'),(214,20231016091915,1,'2020-01-01 01:01:01'),(215,20231024174135,1,'2020-01-01 01:01:01'),(216,20231025120016,1,'2020-01-01 01:01:01'),(217,20231025160156,1,'2020-01-01 01:01:01'),(218,20231031165350,1,'2020-01-01 01:01:01'),(219,20231106144110,1,'2020-01-01 01:01:01'),(220,20231107130934,1,'2020-01-01 01:01:01'),(221,20231109115838,1,'2020-01-01 01:01:01'),(222,20231121054530,1,'2020-01-01 01:01:01'),(223,20231122101320,1,'2020-01-01 01:01:01'),(224,20231130132828,1,'2020-01-01 01:01:01'),(225,20231130132931,1,'2020-01-01 01:01:01'),(226,20231204155427,1,'2020-01-01 01:01:01'),(227,20231206142340,1,'2020-01-01 01:01:01'),(228,20231207102320,1,'2020-01-01 01:01:01'),(229,20231207102321,1,'2020-01-01 01:01:01'),(230,20231207133731,1,'2020-01-01 01:01:01'),(231,20231212094238,1,'2020-01-01 01:01:01'),(232,20231212095734,1,'2020-01-01 01:01:01'),(233,20231212161121,1,'2020-01-01 01:01:01'),(234,20231215122713,1,'2020-01-01 01:01:01'),(235,20231219143041,1,'2020-01-01 01:01:01'),(236,20231224070653,1,'2020-01-01 01:01:01'),(237,20240110134315,1,'2020-01-01 01:01:01'),(238,20240119091637,1,'2020-01-01 01:01:01'),(239,20240126020642,1,'2020-01-01 01:01:01'),(240,20240126020643,1,'2020-01-01 01:01:01'),(241,20240129162819,1,'2020-01-01 01:01:01'),(242,20240130115133,1,'2020-01-01 01:01:01'),(243,20240131083822,1,'2020-01-01 01:01:01'),(244,20240205095928,1,'2020-01-01 01:01:01'),(245,20240205121956,1,'2020-01-01 01:01:01'),(246,20240209110212,1,'2020-01-01 01:01:01'),(247,20240212111533,1,'2020-01-01 01:01:01'),(248,20240221112844,1,'2020-01-01 01:01:01'),(249,20240222073518,1,'2020-01-01 01:01:01'),(250,20240222135115,1,'2020-01-01 01:01:01'),(251,20240226082255,1,'2020-01-01 01:01:01'),(252,20240228082706,1,'2020-01-01 01:01:01'),(253,20240301173035,1,'2020-01-01 01:01:01'),(254,20240302111134,1,'2020-01-01 01:01:01'),(255,20240312103753,1,'2020-01-01 01:01:01'),(256,20240313143416,1,'2020-01-01 01:01:01'),(257,20240314085226,1,'2020-01-01 01:01:01'),(258,20240314151747,1,'2020-01-01 01:01:01'),(259,20240320145650,1,'2020-01-01 01:01:01'),(260,20240327115530,1,'2020-01-01 01:01:01'),(261,20240327115617,1,'2020-01-01 01:01:01'),(262,20240408085837,1,'2020-01-01 01:01:01'),(263,20240415104633,1,'2020-01-01 01:01:01'),(264,20240430111727,1,'2020-01-01 01:01:01'),(265,20240515200020,1,'2020-01-01 01:01:01'),(266,20240521143023,1,'2020-01-01 01:01:01'),(267,20240521143024,1,'2020-01-01 01:01:01'),(268,20240601174138,1,'2020-01-01 01:01:01'),(269,20240607133721,1,'2020-01-01 01:01:01'),(270,20240612150059,1,'2020-01-01 01:01:01'),(271,20240613162201,1,'2020-01-01 01:01:01'),(272,20240613172616,1,'2020-01-01 01:01:01'),(273,20240618142419,1,'2020-01-01 01:01:01'),(274,20240625093543,1,'2020-01-01 01:01:01'),(275,20240626195531,1,'2020-01-01 01:01:01'),(276,20240702123921,1,'2020-01-01 01:01:01'),(277,20240703154849,1,'2020-01-01 01:01:01'),(278,20240707134035,1,'2020-01-01 01:01:01'),(279,20240707134036,1,'2020-01-01 01:01:01'),(280,20240709124958,1,'2020-01-01 01:01:01'),(281,20240709132642,1,'2020-01-01 01:01:01'),(282,20240709183940,1,'2020-01-01 01:01:01'),(283,20240710155623,1,'2020-01-01 01:01:01'),(284,20240723102712,1,'2020-01-01 01:01:01'),(285,20240725152735,1,'2020-01-01 01:01:01'),(286,20240725182118,1,'2020-01-01 01:01:01'),(287,20240726100517,1,'2020-01-01 01:01:01'),(288,20240730171504,1,'2020-01-01 01:01:01'),(289,20240730174056,1,'2020-01-01 01:01:01'),(290,20240730215453,1,'2020-01-01 01:01:01'),(291,20240730374423,1,'2020-01-01 01:01:01'),(292,20240801115359,1,'2020-01-01 01:01:01'),(293,20240802101043,1,'2020-01-01 01:01:01'),(294,20240802113716,1,'2020-01-01 01:01:01'),(295,20240814135330,1,'2020-01-01 01:01:01'),(296,20240815000000,1,'2020-01-01 01:01:01'),(297,20240815000001,1,'2020-01-01 01:01:01'),(298,20240816103247,1,'2020-01-01 01:01:01'),(299,20240820091218,1,'2020-01-01 01:01:01'),(300,20240826111228,1,'2020-01-01 01:01:01'),(301,20240826160025,1,'2020-01-01 01:01:01'),(302,20240829165448,1,'2020-01-01 01:01:01'),(303,20240829165605,1,'2020-01-01 01:01:01'),(304,20240829165715,1,'2020-01-01 01:01:01'),(305,20240829165930,1,'2020-01-01 01:01:01'),(306,20240829170023,1,'2020-01-01 01:01:01'),(307,20240829170033,1,'2020-01-01 01:01:01'),(308,20240829170044,1,'2020-01-01 01:01:01'),(309,20240905105135,1,'2020-01-01 01:01:01'),(310,20240905140514,1,'2020-01-01 01:01:01'),(311,20240905200000,1,'2020-01-01 01:01:01'),(312,20240905200001,1,'2020-01-01 01:01:01'),(313,20241002104104,1,'2020-01-01 01:01:01'),(314,20241002104105,1,'2020-01-01 01:01:01'),(315,20241002104106,1,'2020-01-01 01:01:01'),(316,20241002210000,1,'2020-01-01 01:01:01'),(317,20241003145349,1,'2020-01-01 01:01:01'),(318,20241004005000,1,'2020-01-01 01:01:01'),(319,20241008083925,1,'2020-01-01 01:01:01'),(320,20241009090010,1,'2020-01-01 01:01:01'),(321,20241017163402,1,'2020-01-01 01:01:01'),(322,20241021224359,1,'2020-01-01 01:01:01'),(323,20241022140321,1,'2020-01-01 01:01:01'),(324,20241025111236,1,'2020-01-01 01:01:01'),(325,20241025112748,1,'2020-01-01 01:01:01'),(326,20241025141855,1,'2020-01-01 01:01:01'),(327,20241110152839,1,'2020-01-01 01:01:01'),(328,20241110152840,1,'2020-01-01 01:01:01'),(329,20241110152841,1,'2020-01-01 01:01:01'),(330,20241116233322,1,'2020-01-01 01:01:01'),(331,20241122171434,1,'2020-01-01 01:01:01'),(332,20241125150614,1,'2020-01-01 01:01:01'),(333,20241203125346,1,'2020-01-01 01:01:01'),(334,20241203130032,1,'2020-01-01 01:01:01'),(335,20241205122800,1,'2020-01-01 01:01:01'),(336,20241209164540,1,'2020-01-01 01:01:01'),(337,20241210140021,1,'2020-01-01 01:01:01'),(338,20241219180042,1,'2020-01-01 01:01:01'),(339,20241220100000,1,'2020-01-01 01:01:01'),(340,20241220114903,1,'2020-01-01 01:01:01'),(341,20241220114904,1,'2020-01-01 01:01:01'),(342,20241224000000,1,'2020-01-01 01:01:01'),(343,20241230000000,1,'2020-01-01 01:01:01'),(344,20241231112624,1,'2020-01-01 01:01:01'),(345,20250102121439,1,'2020-01-01 01:01:01'),(346,20250121094045,1,'2020-01-01 01:01:01'),(347,20250121094500,1,'2020-01-01 01:01:01'),(348,20250121094600,1,'2020-01-01 01:01:01'),(349,20250121094700,1,'2020-01-01 01:01:01'),(350,20250124194347,1,'2020-01-01 01:01:01'),(351,20250127162751,1,'2020-01-01 01:01:01'),(352,20250213104005,1,'2020-01-01 01:01:01'),(353,20250214205657,1,'2020-01-01 01:01:01'),(354,20250217093329,1,'2020-01-01 01:01:01'),(355,20250219090511,1,'2020-01-01 01:01:01'),(356,20250219100000,1,'2020-01-01 01:01:01'),(357,20250219142401,1,'2020-01-01 01:01:01'),(358,20250224184002,1,'2020-01-01 01:01:01'),(359,20250225085436,1,'2020-01-01 01:01:01'),(360,20250226000000,1,'2020-01-01 01:01:01'),(361,20250226153445,1,'2020-01-01 01:01:01'),(362,20250304162702,1,'2020-01-01 01:01:01'),(363,20250306144233,1,'2020-01-01 01:01:01'),(364,20250313163430,1,'2020-01-01 01:01:01'),(365,20250317130944,1,'2020-01-01 01:01:01'),(366,20250318165922,1,'2020-01-01 01:01:01'),(367,20250320132525,1,'2020-01-01 01:01:01'),(368,20250320200000,1,'2020-01-01 01:01:01'),(369,20250326161930,1,'2020-01-01 01:01:01'),(370,20250326161931,1,'2020-01-01 01:01:01'),(371,20250331042354,1,'2020-01-01 01:01:01'),(372,20250331154206,1,'2020-01-01 01:01:01'),(373,20250401155831,1,'2020-01-01 01:01:01'),(374,20250408133233,1,'2020-01-01 01:01:01'),(375,20250410104321,1,'2020-01-01 01:01:01'),(376,20250421085116,1,'2020-01-01 01:01:01'),(377,20250422095806,1,'2020-01-01 01:01:01'),(378,20250424153059,1,'2020-01-01 01:01:01'),(379,20250430103833,1,'2020-01-01 01:01:01'),(380,20250430112622,1,'2020-01-01 01:01:01'),(381,20250501162727,1,'2020-01-01 01:01:01'),(382,20250502154517,1,'2020-01-01 01:01:01'),(383,20250502222222,1,'2020-01-01 01:01:01'),(384,20250507170845,1,'2020-01-01 01:01:01'),(385,20250513162912,1,'2020-01-01 01:01:01'),(386,20250519161614,1,'2020-01-01 01:01:01'),(387,20250519170000,1,'2020-01-01 01:01:01'),(388,20250520153848,1,'2020-01-01 01:01:01'),(389,20250528115932,1,'2020-01-01 01:01:01'),(390,20250529102706,1,'2020-01-01 01:01:01'),(391,20250603105558,1,'2020-01-01 01:01:01'),(392,20250609102714,1,'2020-01-01 01:01:01'),(393,20250609112613,1,'2020-01-01 01:01:01'),(394,20250613103810,1,'2020-01-01 01:01:01'),(395,20250616193950,1,'2020-01-01 01:01:01'),(396,20250624140757,1,'2020-01-01 01:01:01'),(397,20250626130239,1,'2020-01-01 01:01:01'),(398,20250629131032,1,'2020-01-01 01:01:01'),(399,20250701155654,1,'2020-01-01 01:01:01'),(400,20250707095725,1,'2020-01-01 01:01:01'),(401,20250716152435,1,'2020-01-01 01:01:01'),(402,20250718091828,1,'2020-01-01 01:01:01'),(403,20250728122229,1,'2020-01-01 01:01:01'),(404,20250731122715,1,'2020-01-01 01:01:01'),(405,20250731151000,1,'2020-01-01 01:01:01'),(406,20250803000000,1,'2020-01-01 01:01:01'),(407,20250805083116,1,'2020-01-01 01:01:01'),(408,20250807140441,1,'2020-01-01 01:01:01'),(409,20250808000000,1,'2020-01-01 01:01:01'),(410,20250811155036,1,'2020-01-01 01:01:01'),(411,20250813205039,1,'2020-01-01 01:01:01'),(412,20250814123333,1,'2020-01-01 01:01:01'),(413,20250815130115,1,'2020-01-01 01:01:01'),(414,20250816115553,1,'2020-01-01 01:01:01'),(415,20250817154557,1,'2020-01-01 01:01:01'),(416,20250825113751,1,'2020-01-01 01:01:01'),(417,20250827113140,1,'2020-01-01 01:01:01'),(418,20250828120836,1,'2020-01-01 01:01:01'),(419,20250902112642,1,'2020-01-01 01:01:01'),(420,20250904091745,1,'2020-01-01 01:01:01'),(421,20250905090000,1,'2020-01-01 01:01:01'),(422,20250922083056,1,'2020-01-01 01:01:01'),(423,20250923120000,1,'2020-01-01 01:01:01'),(424,20250926123048,1,'2020-01-01 01:01:01'),(425,20251015103505,1,'2020-01-01 01:01:01'),(426,20251015103600,1,'2020-01-01 01:01:01'),(427,20251015103700,1,'2020-01-01 01:01:01'),(428,20251015103800,1,'2020-01-01 01:01:01'),(429,20251015103900,1,'2020-01-01 01:01:01'),(430,20251028140000,1,'2020-01-01 01:01:01'),(431,20251028140100,1,'2020-01-01 01:01:01'),(432,20251028140110,1,'2020-01-01 01:01:01'),(433,20251028140200,1,'2020-01-01 01:01:01'),(434,20251028140300,1,'2020-01-01 01:01:01'),(435,20251028140400,1,'2020-01-01 01:01:01'),(436,20251031154558,1,'2020-01-01 01:01:01'),(437,20251103160848,1,'2020-01-01 01:01:01'),(438,20251104112849,1,'2020-01-01 01:01:01'),(439,20251106000000,1,'2020-01-01 01:01:01'),(440,20251107164629,1,'2020-01-01 01:01:01'),(441,20251107170854,1,'2020-01-01 01:01:01'),(442,20251110172137,1,'2020-01-01 01:01:01'),(443,20251111153133,1,'2020-01-01 01:01:01'),(444,20251117020000,1,'2020-01-01 01:01:01'),(445,20251117020100,1,'2020-01-01 01:01:01'),(446,20251117020200,1,'2020-01-01 01:01:01'),(447,20251121100000,1,'2020-01-01 01:01:01'),(448,20251121124239,1,'2020-01-01 01:01:01'),(449,20251124090450,1,'2020-01-01 01:01:01'),(450,20251124135808,1,'2020-01-01 01:01:01'),(451,20251124140138,1,'2020-01-01 01:01:01'),(452,20251124162948,1,'2020-01-01 01:01:01'),(453,20251127113559,1,'2020-01-01 01:01:01'),(454,20251202162232,1,'2020-01-01 01:01:01'),(455,20251203170808,1,'2020-01-01 01:01:01'),(456,20251207050413,1,'2020-01-01 01:01:01'),(457,20251208215800,1,'2020-01-01 01:01:01'),(458,20251209221730,1,'2020-01-01 01:01:01'),(459,20251209221850,1,'2020-01-01 01:01:01'),(460,20251215163721,1,'2020-01-01 01:01:01'),(461,20251217000000,1,'2020-01-01 01:01:01'),(462,20251217120000,1,'2020-01-01 01:01:01'),(463,20251229000000,1,'2020-01-01 01:01:01'),(464,20251229000010,1,'2020-01-01 01:01:01'),(465,20251229000020,1,'2020-01-01 01:01:01'),(466,20260106000000,1,'2020-01-01 01:01:01'),(467,20260108200708,1,'2020-01-01 01:01:01'),(468,20260108214732,1,'2020-01-01 01:01:01'),(469,20260109231821,1,'2020-01-01 01:01:01'),(470,20260113012054,1,'2020-01-01 01:01:01'),(471,20260124200020,1,'2020-01-01 01:01:01'),(472,20260126150840,1,'2020-01-01 01:01:01'),(473,20260126210724,1,'2020-01-01 01:01:01'),(474,20260202151756,1,'2020-01-01 01:01:01'),(475,20260205184907,1,'2020-01-01 01:01:01'),(476,20260210151544,1,'2020-01-01 01:01:01'),(477,20260210155109,1,'2020-01-01 01:01:01'),(478,20260210181120,1,'2020-01-01 01:01:01'),(479,20260211200153,1,'2020-01-01 01:01:01'),(480,20260217141240,1,'2020-01-01 01:01:01'),(481,20260217200906,1,'2020-01-01 01:01:01'),(482,20260218175704,1,'2020-01-01 01:01:01'),(483,20260314120000,1,'2020-01-01 01:01:01'),(484,20260316120000,1,'2020-01-01 01:01:01'),(485,20260316120001,1,'2020-01-01 01:01:01'),(486,20260316120002,1,'2020-01-01 01:01:01'),(487,20260316120003,1,'2020-01-01 01:01:01'),(488,20260316120004,1,'2020-01-01 01:01:01'),(489,20260316120005,1,'2020-01-01 01:01:01'),(490,20260316120006,1,'2020-01-01 01:01:01'),(491,20260316120007,1,'2020-01-01 01:01:01'),(492,20260316120008,1,'2020-01-01 01:01:01'),(493,20260316120009,1,'2020-01-01 01:01:01'),(494,20260316120010,1,'2020-01-01 01:01:01'),(495,20260317120000,1,'2020-01-01 01:01:01'),(496,20260318184559,1,'2020-01-01 01:01:01'),(497,20260319120000,1,'2020-01-01 01:01:01'),(498,20260323144117,1,'2020-01-01 01:01:01'),(499,20260324161944,1,'2020-01-01 01:01:01'),(500,20260324223334,1,'2020-01-01 01:01:01'),(501,20260326131501,1,'2020-01-01 01:01:01'),(502,20260326210603,1,'2020-01-01 01:01:01'),(503,20260331000000,1,'2020-01-01 01:01:01'),(504,20260401153000,1,'2020-01-01 01:01:01'),(505,20260401153001,1,'2020-01-01 01:01:01'),(506,20260401153503,1,'2020-01-01 01:01:01'),(507,20260403120000,1,'2020-01-01 01:01:01'),(508,20260409153713,1,'2020-01-01 01:01:01'),(509,20260409153714,1,'2020-01-01 01:01:01'),(510,20260409153715,1,'2020-01-01 01:01:01'),(511,20260409153716,1,'2020-01-01 01:01:01'),(512,20260409153717,1,'2020-01-01 01:01:01'),(513,20260409183610,1,'2020-01-01 01:01:01'),(514,20260410173222,1,'2020-01-01 01:01:01'),(515,20260422181702,1,'2020-01-01 01:01:01'),(516,20260423161823,1,'2020-01-01 01:01:01'),(517,20260423161824,1,'2020-01-01 01:01:01'),(518,20260518194422,1,'2020-01-01 01:01:01'),(519,20260522195224,1,'2020-01-01 01:01:01'),(520,20260522195225,1,'2020-01-01 01:01:01'),(521,20260522195226,1,'2020-01-01 01:01:01'),(522,20260522195227,1,'2020-01-01 01:01:01'),(523,20260522195229,1,'2020-01-01 01:01:01'),(524,20260522195230,1,'2020-01-01 01:01:01'),(525,20260522195231,1,'2020-01-01 01:01:01'),(526,20260522195232,1,'2020-01-01 01:01:01'),(527,20260522195233,1,'2020-01-01 01:01:01'),(528,20260522195234,1,'2020-01-01 01:01:01'),(529,20260522195235,1,'2020-01-01 01:01:01'),(530,20260527215817,1,'2020-01-01 01:01:01'),(531,20260527215818,1,'2020-01-01 01:01:01'),(532,20260528201143,1,'2020-01-01 01:01:01'),(533,20260528201150,1,'2020-01-01 01:01:01'),(534,20260528211626,1,'2020-01-01 01:01:01'),(535,20260528213326,1,'2020-01-01 01:01:01'),(536,20260529091823,1,'2020-01-01 01:01:01'),(537,20260529120000,1,'2020-01-01 01:01:01'),(538,20260601200727,1,'2020-01-01 01:01:01'),(539,20260603101320,1,'2020-01-01 01:01:01'),(540,20260603120000,1,'2020-01-01 01:01:01'),(541,20260604221206,1,'2020-01-01 01:01:01'),(542,20260605195941,1,'2020-01-01 01:01:01'),(543,20260606051849,1,'2020-01-01 01:01:01'),(544,20260608160653,1,'2020-01-01 01:01:01'),(545,20260608202705,1,'2020-01-01 01:01:01'),(546,20260608210432,1,'2020-01-01 01:01:01'),(547,20260610172952,1,'2020-01-01 01:01:01'),(548,20260624210253,1,'2020-01-01 01:01:01'),(549,20260624210311,1,'2020-01-01 01:01:01'),(550,20260626120000,1,'2020-01-01 01:01:01'),(551,20260702013055,1,'2020-01-01 01:01:01'),(552,20260702013056,1,'2020-01-01 01:01:01'),(553,20260702013057,1,'2020-01-01 01:01:01'),(554,20260702013058,1,'2020-01-01 01:01:01'),(555,20260702013059,1,'2020-01-01 01:01:01'),(556,20260702013100,1,'2020-01-01 01:01:01'),(557,20260702013101,1,'2020-01-01 01:01:01'),(558,20260702013102,1,'2020-01-01 01:01:01'),(559,20260702164518,1,'2020-01-01 01:01:01'),(560,20260704213830,1,'2020-01-01 01:01:01'),(561,20260706174522,1,'2020-01-01 01:01:01'),(562,20260707071142,1,'2020-01-01 01:01:01'),(563,20260707140752,1,'2020-01-01 01:01:01'),(564,20260708153912,1,'2020-01-01 01:01:01'),(565,20260708185958,1,'2020-01-01 01:01:01'),(566,20260708190008,1,'2020-01-01 01:01:01'),(567,20260708192536,1,'2020-01-01 01:01:01'),(568,20260713115453,1,'2020-01-01 01:01:01'); +INSERT INTO `migration_status_tables` VALUES (1,0,1,'2020-01-01 01:01:01'),(2,20161118193812,1,'2020-01-01 01:01:01'),(3,20161118211713,1,'2020-01-01 01:01:01'),(4,20161118212436,1,'2020-01-01 01:01:01'),(5,20161118212515,1,'2020-01-01 01:01:01'),(6,20161118212528,1,'2020-01-01 01:01:01'),(7,20161118212538,1,'2020-01-01 01:01:01'),(8,20161118212549,1,'2020-01-01 01:01:01'),(9,20161118212557,1,'2020-01-01 01:01:01'),(10,20161118212604,1,'2020-01-01 01:01:01'),(11,20161118212613,1,'2020-01-01 01:01:01'),(12,20161118212621,1,'2020-01-01 01:01:01'),(13,20161118212630,1,'2020-01-01 01:01:01'),(14,20161118212641,1,'2020-01-01 01:01:01'),(15,20161118212649,1,'2020-01-01 01:01:01'),(16,20161118212656,1,'2020-01-01 01:01:01'),(17,20161118212758,1,'2020-01-01 01:01:01'),(18,20161128234849,1,'2020-01-01 01:01:01'),(19,20161230162221,1,'2020-01-01 01:01:01'),(20,20170104113816,1,'2020-01-01 01:01:01'),(21,20170105151732,1,'2020-01-01 01:01:01'),(22,20170108191242,1,'2020-01-01 01:01:01'),(23,20170109094020,1,'2020-01-01 01:01:01'),(24,20170109130438,1,'2020-01-01 01:01:01'),(25,20170110202752,1,'2020-01-01 01:01:01'),(26,20170111133013,1,'2020-01-01 01:01:01'),(27,20170117025759,1,'2020-01-01 01:01:01'),(28,20170118191001,1,'2020-01-01 01:01:01'),(29,20170119234632,1,'2020-01-01 01:01:01'),(30,20170124230432,1,'2020-01-01 01:01:01'),(31,20170127014618,1,'2020-01-01 01:01:01'),(32,20170131232841,1,'2020-01-01 01:01:01'),(33,20170223094154,1,'2020-01-01 01:01:01'),(34,20170306075207,1,'2020-01-01 01:01:01'),(35,20170309100733,1,'2020-01-01 01:01:01'),(36,20170331111922,1,'2020-01-01 01:01:01'),(37,20170502143928,1,'2020-01-01 01:01:01'),(38,20170504130602,1,'2020-01-01 01:01:01'),(39,20170509132100,1,'2020-01-01 01:01:01'),(40,20170519105647,1,'2020-01-01 01:01:01'),(41,20170519105648,1,'2020-01-01 01:01:01'),(42,20170831234300,1,'2020-01-01 01:01:01'),(43,20170831234301,1,'2020-01-01 01:01:01'),(44,20170831234303,1,'2020-01-01 01:01:01'),(45,20171116163618,1,'2020-01-01 01:01:01'),(46,20171219164727,1,'2020-01-01 01:01:01'),(47,20180620164811,1,'2020-01-01 01:01:01'),(48,20180620175054,1,'2020-01-01 01:01:01'),(49,20180620175055,1,'2020-01-01 01:01:01'),(50,20191010101639,1,'2020-01-01 01:01:01'),(51,20191010155147,1,'2020-01-01 01:01:01'),(52,20191220130734,1,'2020-01-01 01:01:01'),(53,20200311140000,1,'2020-01-01 01:01:01'),(54,20200405120000,1,'2020-01-01 01:01:01'),(55,20200407120000,1,'2020-01-01 01:01:01'),(56,20200420120000,1,'2020-01-01 01:01:01'),(57,20200504120000,1,'2020-01-01 01:01:01'),(58,20200512120000,1,'2020-01-01 01:01:01'),(59,20200707120000,1,'2020-01-01 01:01:01'),(60,20201011162341,1,'2020-01-01 01:01:01'),(61,20201021104586,1,'2020-01-01 01:01:01'),(62,20201102112520,1,'2020-01-01 01:01:01'),(63,20201208121729,1,'2020-01-01 01:01:01'),(64,20201215091637,1,'2020-01-01 01:01:01'),(65,20210119174155,1,'2020-01-01 01:01:01'),(66,20210326182902,1,'2020-01-01 01:01:01'),(67,20210421112652,1,'2020-01-01 01:01:01'),(68,20210506095025,1,'2020-01-01 01:01:01'),(69,20210513115729,1,'2020-01-01 01:01:01'),(70,20210526113559,1,'2020-01-01 01:01:01'),(71,20210601000001,1,'2020-01-01 01:01:01'),(72,20210601000002,1,'2020-01-01 01:01:01'),(73,20210601000003,1,'2020-01-01 01:01:01'),(74,20210601000004,1,'2020-01-01 01:01:01'),(75,20210601000005,1,'2020-01-01 01:01:01'),(76,20210601000006,1,'2020-01-01 01:01:01'),(77,20210601000007,1,'2020-01-01 01:01:01'),(78,20210601000008,1,'2020-01-01 01:01:01'),(79,20210606151329,1,'2020-01-01 01:01:01'),(80,20210616163757,1,'2020-01-01 01:01:01'),(81,20210617174723,1,'2020-01-01 01:01:01'),(82,20210622160235,1,'2020-01-01 01:01:01'),(83,20210623100031,1,'2020-01-01 01:01:01'),(84,20210623133615,1,'2020-01-01 01:01:01'),(85,20210708143152,1,'2020-01-01 01:01:01'),(86,20210709124443,1,'2020-01-01 01:01:01'),(87,20210712155608,1,'2020-01-01 01:01:01'),(88,20210714102108,1,'2020-01-01 01:01:01'),(89,20210719153709,1,'2020-01-01 01:01:01'),(90,20210721171531,1,'2020-01-01 01:01:01'),(91,20210723135713,1,'2020-01-01 01:01:01'),(92,20210802135933,1,'2020-01-01 01:01:01'),(93,20210806112844,1,'2020-01-01 01:01:01'),(94,20210810095603,1,'2020-01-01 01:01:01'),(95,20210811150223,1,'2020-01-01 01:01:01'),(96,20210818151827,1,'2020-01-01 01:01:01'),(97,20210818151828,1,'2020-01-01 01:01:01'),(98,20210818182258,1,'2020-01-01 01:01:01'),(99,20210819131107,1,'2020-01-01 01:01:01'),(100,20210819143446,1,'2020-01-01 01:01:01'),(101,20210903132338,1,'2020-01-01 01:01:01'),(102,20210915144307,1,'2020-01-01 01:01:01'),(103,20210920155130,1,'2020-01-01 01:01:01'),(104,20210927143115,1,'2020-01-01 01:01:01'),(105,20210927143116,1,'2020-01-01 01:01:01'),(106,20211013133706,1,'2020-01-01 01:01:01'),(107,20211013133707,1,'2020-01-01 01:01:01'),(108,20211102135149,1,'2020-01-01 01:01:01'),(109,20211109121546,1,'2020-01-01 01:01:01'),(110,20211110163320,1,'2020-01-01 01:01:01'),(111,20211116184029,1,'2020-01-01 01:01:01'),(112,20211116184030,1,'2020-01-01 01:01:01'),(113,20211202092042,1,'2020-01-01 01:01:01'),(114,20211202181033,1,'2020-01-01 01:01:01'),(115,20211207161856,1,'2020-01-01 01:01:01'),(116,20211216131203,1,'2020-01-01 01:01:01'),(117,20211221110132,1,'2020-01-01 01:01:01'),(118,20220107155700,1,'2020-01-01 01:01:01'),(119,20220125105650,1,'2020-01-01 01:01:01'),(120,20220201084510,1,'2020-01-01 01:01:01'),(121,20220208144830,1,'2020-01-01 01:01:01'),(122,20220208144831,1,'2020-01-01 01:01:01'),(123,20220215152203,1,'2020-01-01 01:01:01'),(124,20220223113157,1,'2020-01-01 01:01:01'),(125,20220307104655,1,'2020-01-01 01:01:01'),(126,20220309133956,1,'2020-01-01 01:01:01'),(127,20220316155700,1,'2020-01-01 01:01:01'),(128,20220323152301,1,'2020-01-01 01:01:01'),(129,20220330100659,1,'2020-01-01 01:01:01'),(130,20220404091216,1,'2020-01-01 01:01:01'),(131,20220419140750,1,'2020-01-01 01:01:01'),(132,20220428140039,1,'2020-01-01 01:01:01'),(133,20220503134048,1,'2020-01-01 01:01:01'),(134,20220524102918,1,'2020-01-01 01:01:01'),(135,20220526123327,1,'2020-01-01 01:01:01'),(136,20220526123328,1,'2020-01-01 01:01:01'),(137,20220526123329,1,'2020-01-01 01:01:01'),(138,20220608113128,1,'2020-01-01 01:01:01'),(139,20220627104817,1,'2020-01-01 01:01:01'),(140,20220704101843,1,'2020-01-01 01:01:01'),(141,20220708095046,1,'2020-01-01 01:01:01'),(142,20220713091130,1,'2020-01-01 01:01:01'),(143,20220802135510,1,'2020-01-01 01:01:01'),(144,20220818101352,1,'2020-01-01 01:01:01'),(145,20220822161445,1,'2020-01-01 01:01:01'),(146,20220831100036,1,'2020-01-01 01:01:01'),(147,20220831100151,1,'2020-01-01 01:01:01'),(148,20220908181826,1,'2020-01-01 01:01:01'),(149,20220914154915,1,'2020-01-01 01:01:01'),(150,20220915165115,1,'2020-01-01 01:01:01'),(151,20220915165116,1,'2020-01-01 01:01:01'),(152,20220928100158,1,'2020-01-01 01:01:01'),(153,20221014084130,1,'2020-01-01 01:01:01'),(154,20221027085019,1,'2020-01-01 01:01:01'),(155,20221101103952,1,'2020-01-01 01:01:01'),(156,20221104144401,1,'2020-01-01 01:01:01'),(157,20221109100749,1,'2020-01-01 01:01:01'),(158,20221115104546,1,'2020-01-01 01:01:01'),(159,20221130114928,1,'2020-01-01 01:01:01'),(160,20221205112142,1,'2020-01-01 01:01:01'),(161,20221216115820,1,'2020-01-01 01:01:01'),(162,20221220195934,1,'2020-01-01 01:01:01'),(163,20221220195935,1,'2020-01-01 01:01:01'),(164,20221223174807,1,'2020-01-01 01:01:01'),(165,20221227163855,1,'2020-01-01 01:01:01'),(166,20221227163856,1,'2020-01-01 01:01:01'),(167,20230202224725,1,'2020-01-01 01:01:01'),(168,20230206163608,1,'2020-01-01 01:01:01'),(169,20230214131519,1,'2020-01-01 01:01:01'),(170,20230303135738,1,'2020-01-01 01:01:01'),(171,20230313135301,1,'2020-01-01 01:01:01'),(172,20230313141819,1,'2020-01-01 01:01:01'),(173,20230315104937,1,'2020-01-01 01:01:01'),(174,20230317173844,1,'2020-01-01 01:01:01'),(175,20230320133602,1,'2020-01-01 01:01:01'),(176,20230330100011,1,'2020-01-01 01:01:01'),(177,20230330134823,1,'2020-01-01 01:01:01'),(178,20230405232025,1,'2020-01-01 01:01:01'),(179,20230408084104,1,'2020-01-01 01:01:01'),(180,20230411102858,1,'2020-01-01 01:01:01'),(181,20230421155932,1,'2020-01-01 01:01:01'),(182,20230425082126,1,'2020-01-01 01:01:01'),(183,20230425105727,1,'2020-01-01 01:01:01'),(184,20230501154913,1,'2020-01-01 01:01:01'),(185,20230503101418,1,'2020-01-01 01:01:01'),(186,20230515144206,1,'2020-01-01 01:01:01'),(187,20230517140952,1,'2020-01-01 01:01:01'),(188,20230517152807,1,'2020-01-01 01:01:01'),(189,20230518114155,1,'2020-01-01 01:01:01'),(190,20230520153236,1,'2020-01-01 01:01:01'),(191,20230525151159,1,'2020-01-01 01:01:01'),(192,20230530122103,1,'2020-01-01 01:01:01'),(193,20230602111827,1,'2020-01-01 01:01:01'),(194,20230608103123,1,'2020-01-01 01:01:01'),(195,20230629140529,1,'2020-01-01 01:01:01'),(196,20230629140530,1,'2020-01-01 01:01:01'),(197,20230711144622,1,'2020-01-01 01:01:01'),(198,20230721135421,1,'2020-01-01 01:01:01'),(199,20230721161508,1,'2020-01-01 01:01:01'),(200,20230726115701,1,'2020-01-01 01:01:01'),(201,20230807100822,1,'2020-01-01 01:01:01'),(202,20230814150442,1,'2020-01-01 01:01:01'),(203,20230823122728,1,'2020-01-01 01:01:01'),(204,20230906152143,1,'2020-01-01 01:01:01'),(205,20230911163618,1,'2020-01-01 01:01:01'),(206,20230912101759,1,'2020-01-01 01:01:01'),(207,20230915101341,1,'2020-01-01 01:01:01'),(208,20230918132351,1,'2020-01-01 01:01:01'),(209,20231004144339,1,'2020-01-01 01:01:01'),(210,20231009094541,1,'2020-01-01 01:01:01'),(211,20231009094542,1,'2020-01-01 01:01:01'),(212,20231009094543,1,'2020-01-01 01:01:01'),(213,20231009094544,1,'2020-01-01 01:01:01'),(214,20231016091915,1,'2020-01-01 01:01:01'),(215,20231024174135,1,'2020-01-01 01:01:01'),(216,20231025120016,1,'2020-01-01 01:01:01'),(217,20231025160156,1,'2020-01-01 01:01:01'),(218,20231031165350,1,'2020-01-01 01:01:01'),(219,20231106144110,1,'2020-01-01 01:01:01'),(220,20231107130934,1,'2020-01-01 01:01:01'),(221,20231109115838,1,'2020-01-01 01:01:01'),(222,20231121054530,1,'2020-01-01 01:01:01'),(223,20231122101320,1,'2020-01-01 01:01:01'),(224,20231130132828,1,'2020-01-01 01:01:01'),(225,20231130132931,1,'2020-01-01 01:01:01'),(226,20231204155427,1,'2020-01-01 01:01:01'),(227,20231206142340,1,'2020-01-01 01:01:01'),(228,20231207102320,1,'2020-01-01 01:01:01'),(229,20231207102321,1,'2020-01-01 01:01:01'),(230,20231207133731,1,'2020-01-01 01:01:01'),(231,20231212094238,1,'2020-01-01 01:01:01'),(232,20231212095734,1,'2020-01-01 01:01:01'),(233,20231212161121,1,'2020-01-01 01:01:01'),(234,20231215122713,1,'2020-01-01 01:01:01'),(235,20231219143041,1,'2020-01-01 01:01:01'),(236,20231224070653,1,'2020-01-01 01:01:01'),(237,20240110134315,1,'2020-01-01 01:01:01'),(238,20240119091637,1,'2020-01-01 01:01:01'),(239,20240126020642,1,'2020-01-01 01:01:01'),(240,20240126020643,1,'2020-01-01 01:01:01'),(241,20240129162819,1,'2020-01-01 01:01:01'),(242,20240130115133,1,'2020-01-01 01:01:01'),(243,20240131083822,1,'2020-01-01 01:01:01'),(244,20240205095928,1,'2020-01-01 01:01:01'),(245,20240205121956,1,'2020-01-01 01:01:01'),(246,20240209110212,1,'2020-01-01 01:01:01'),(247,20240212111533,1,'2020-01-01 01:01:01'),(248,20240221112844,1,'2020-01-01 01:01:01'),(249,20240222073518,1,'2020-01-01 01:01:01'),(250,20240222135115,1,'2020-01-01 01:01:01'),(251,20240226082255,1,'2020-01-01 01:01:01'),(252,20240228082706,1,'2020-01-01 01:01:01'),(253,20240301173035,1,'2020-01-01 01:01:01'),(254,20240302111134,1,'2020-01-01 01:01:01'),(255,20240312103753,1,'2020-01-01 01:01:01'),(256,20240313143416,1,'2020-01-01 01:01:01'),(257,20240314085226,1,'2020-01-01 01:01:01'),(258,20240314151747,1,'2020-01-01 01:01:01'),(259,20240320145650,1,'2020-01-01 01:01:01'),(260,20240327115530,1,'2020-01-01 01:01:01'),(261,20240327115617,1,'2020-01-01 01:01:01'),(262,20240408085837,1,'2020-01-01 01:01:01'),(263,20240415104633,1,'2020-01-01 01:01:01'),(264,20240430111727,1,'2020-01-01 01:01:01'),(265,20240515200020,1,'2020-01-01 01:01:01'),(266,20240521143023,1,'2020-01-01 01:01:01'),(267,20240521143024,1,'2020-01-01 01:01:01'),(268,20240601174138,1,'2020-01-01 01:01:01'),(269,20240607133721,1,'2020-01-01 01:01:01'),(270,20240612150059,1,'2020-01-01 01:01:01'),(271,20240613162201,1,'2020-01-01 01:01:01'),(272,20240613172616,1,'2020-01-01 01:01:01'),(273,20240618142419,1,'2020-01-01 01:01:01'),(274,20240625093543,1,'2020-01-01 01:01:01'),(275,20240626195531,1,'2020-01-01 01:01:01'),(276,20240702123921,1,'2020-01-01 01:01:01'),(277,20240703154849,1,'2020-01-01 01:01:01'),(278,20240707134035,1,'2020-01-01 01:01:01'),(279,20240707134036,1,'2020-01-01 01:01:01'),(280,20240709124958,1,'2020-01-01 01:01:01'),(281,20240709132642,1,'2020-01-01 01:01:01'),(282,20240709183940,1,'2020-01-01 01:01:01'),(283,20240710155623,1,'2020-01-01 01:01:01'),(284,20240723102712,1,'2020-01-01 01:01:01'),(285,20240725152735,1,'2020-01-01 01:01:01'),(286,20240725182118,1,'2020-01-01 01:01:01'),(287,20240726100517,1,'2020-01-01 01:01:01'),(288,20240730171504,1,'2020-01-01 01:01:01'),(289,20240730174056,1,'2020-01-01 01:01:01'),(290,20240730215453,1,'2020-01-01 01:01:01'),(291,20240730374423,1,'2020-01-01 01:01:01'),(292,20240801115359,1,'2020-01-01 01:01:01'),(293,20240802101043,1,'2020-01-01 01:01:01'),(294,20240802113716,1,'2020-01-01 01:01:01'),(295,20240814135330,1,'2020-01-01 01:01:01'),(296,20240815000000,1,'2020-01-01 01:01:01'),(297,20240815000001,1,'2020-01-01 01:01:01'),(298,20240816103247,1,'2020-01-01 01:01:01'),(299,20240820091218,1,'2020-01-01 01:01:01'),(300,20240826111228,1,'2020-01-01 01:01:01'),(301,20240826160025,1,'2020-01-01 01:01:01'),(302,20240829165448,1,'2020-01-01 01:01:01'),(303,20240829165605,1,'2020-01-01 01:01:01'),(304,20240829165715,1,'2020-01-01 01:01:01'),(305,20240829165930,1,'2020-01-01 01:01:01'),(306,20240829170023,1,'2020-01-01 01:01:01'),(307,20240829170033,1,'2020-01-01 01:01:01'),(308,20240829170044,1,'2020-01-01 01:01:01'),(309,20240905105135,1,'2020-01-01 01:01:01'),(310,20240905140514,1,'2020-01-01 01:01:01'),(311,20240905200000,1,'2020-01-01 01:01:01'),(312,20240905200001,1,'2020-01-01 01:01:01'),(313,20241002104104,1,'2020-01-01 01:01:01'),(314,20241002104105,1,'2020-01-01 01:01:01'),(315,20241002104106,1,'2020-01-01 01:01:01'),(316,20241002210000,1,'2020-01-01 01:01:01'),(317,20241003145349,1,'2020-01-01 01:01:01'),(318,20241004005000,1,'2020-01-01 01:01:01'),(319,20241008083925,1,'2020-01-01 01:01:01'),(320,20241009090010,1,'2020-01-01 01:01:01'),(321,20241017163402,1,'2020-01-01 01:01:01'),(322,20241021224359,1,'2020-01-01 01:01:01'),(323,20241022140321,1,'2020-01-01 01:01:01'),(324,20241025111236,1,'2020-01-01 01:01:01'),(325,20241025112748,1,'2020-01-01 01:01:01'),(326,20241025141855,1,'2020-01-01 01:01:01'),(327,20241110152839,1,'2020-01-01 01:01:01'),(328,20241110152840,1,'2020-01-01 01:01:01'),(329,20241110152841,1,'2020-01-01 01:01:01'),(330,20241116233322,1,'2020-01-01 01:01:01'),(331,20241122171434,1,'2020-01-01 01:01:01'),(332,20241125150614,1,'2020-01-01 01:01:01'),(333,20241203125346,1,'2020-01-01 01:01:01'),(334,20241203130032,1,'2020-01-01 01:01:01'),(335,20241205122800,1,'2020-01-01 01:01:01'),(336,20241209164540,1,'2020-01-01 01:01:01'),(337,20241210140021,1,'2020-01-01 01:01:01'),(338,20241219180042,1,'2020-01-01 01:01:01'),(339,20241220100000,1,'2020-01-01 01:01:01'),(340,20241220114903,1,'2020-01-01 01:01:01'),(341,20241220114904,1,'2020-01-01 01:01:01'),(342,20241224000000,1,'2020-01-01 01:01:01'),(343,20241230000000,1,'2020-01-01 01:01:01'),(344,20241231112624,1,'2020-01-01 01:01:01'),(345,20250102121439,1,'2020-01-01 01:01:01'),(346,20250121094045,1,'2020-01-01 01:01:01'),(347,20250121094500,1,'2020-01-01 01:01:01'),(348,20250121094600,1,'2020-01-01 01:01:01'),(349,20250121094700,1,'2020-01-01 01:01:01'),(350,20250124194347,1,'2020-01-01 01:01:01'),(351,20250127162751,1,'2020-01-01 01:01:01'),(352,20250213104005,1,'2020-01-01 01:01:01'),(353,20250214205657,1,'2020-01-01 01:01:01'),(354,20250217093329,1,'2020-01-01 01:01:01'),(355,20250219090511,1,'2020-01-01 01:01:01'),(356,20250219100000,1,'2020-01-01 01:01:01'),(357,20250219142401,1,'2020-01-01 01:01:01'),(358,20250224184002,1,'2020-01-01 01:01:01'),(359,20250225085436,1,'2020-01-01 01:01:01'),(360,20250226000000,1,'2020-01-01 01:01:01'),(361,20250226153445,1,'2020-01-01 01:01:01'),(362,20250304162702,1,'2020-01-01 01:01:01'),(363,20250306144233,1,'2020-01-01 01:01:01'),(364,20250313163430,1,'2020-01-01 01:01:01'),(365,20250317130944,1,'2020-01-01 01:01:01'),(366,20250318165922,1,'2020-01-01 01:01:01'),(367,20250320132525,1,'2020-01-01 01:01:01'),(368,20250320200000,1,'2020-01-01 01:01:01'),(369,20250326161930,1,'2020-01-01 01:01:01'),(370,20250326161931,1,'2020-01-01 01:01:01'),(371,20250331042354,1,'2020-01-01 01:01:01'),(372,20250331154206,1,'2020-01-01 01:01:01'),(373,20250401155831,1,'2020-01-01 01:01:01'),(374,20250408133233,1,'2020-01-01 01:01:01'),(375,20250410104321,1,'2020-01-01 01:01:01'),(376,20250421085116,1,'2020-01-01 01:01:01'),(377,20250422095806,1,'2020-01-01 01:01:01'),(378,20250424153059,1,'2020-01-01 01:01:01'),(379,20250430103833,1,'2020-01-01 01:01:01'),(380,20250430112622,1,'2020-01-01 01:01:01'),(381,20250501162727,1,'2020-01-01 01:01:01'),(382,20250502154517,1,'2020-01-01 01:01:01'),(383,20250502222222,1,'2020-01-01 01:01:01'),(384,20250507170845,1,'2020-01-01 01:01:01'),(385,20250513162912,1,'2020-01-01 01:01:01'),(386,20250519161614,1,'2020-01-01 01:01:01'),(387,20250519170000,1,'2020-01-01 01:01:01'),(388,20250520153848,1,'2020-01-01 01:01:01'),(389,20250528115932,1,'2020-01-01 01:01:01'),(390,20250529102706,1,'2020-01-01 01:01:01'),(391,20250603105558,1,'2020-01-01 01:01:01'),(392,20250609102714,1,'2020-01-01 01:01:01'),(393,20250609112613,1,'2020-01-01 01:01:01'),(394,20250613103810,1,'2020-01-01 01:01:01'),(395,20250616193950,1,'2020-01-01 01:01:01'),(396,20250624140757,1,'2020-01-01 01:01:01'),(397,20250626130239,1,'2020-01-01 01:01:01'),(398,20250629131032,1,'2020-01-01 01:01:01'),(399,20250701155654,1,'2020-01-01 01:01:01'),(400,20250707095725,1,'2020-01-01 01:01:01'),(401,20250716152435,1,'2020-01-01 01:01:01'),(402,20250718091828,1,'2020-01-01 01:01:01'),(403,20250728122229,1,'2020-01-01 01:01:01'),(404,20250731122715,1,'2020-01-01 01:01:01'),(405,20250731151000,1,'2020-01-01 01:01:01'),(406,20250803000000,1,'2020-01-01 01:01:01'),(407,20250805083116,1,'2020-01-01 01:01:01'),(408,20250807140441,1,'2020-01-01 01:01:01'),(409,20250808000000,1,'2020-01-01 01:01:01'),(410,20250811155036,1,'2020-01-01 01:01:01'),(411,20250813205039,1,'2020-01-01 01:01:01'),(412,20250814123333,1,'2020-01-01 01:01:01'),(413,20250815130115,1,'2020-01-01 01:01:01'),(414,20250816115553,1,'2020-01-01 01:01:01'),(415,20250817154557,1,'2020-01-01 01:01:01'),(416,20250825113751,1,'2020-01-01 01:01:01'),(417,20250827113140,1,'2020-01-01 01:01:01'),(418,20250828120836,1,'2020-01-01 01:01:01'),(419,20250902112642,1,'2020-01-01 01:01:01'),(420,20250904091745,1,'2020-01-01 01:01:01'),(421,20250905090000,1,'2020-01-01 01:01:01'),(422,20250922083056,1,'2020-01-01 01:01:01'),(423,20250923120000,1,'2020-01-01 01:01:01'),(424,20250926123048,1,'2020-01-01 01:01:01'),(425,20251015103505,1,'2020-01-01 01:01:01'),(426,20251015103600,1,'2020-01-01 01:01:01'),(427,20251015103700,1,'2020-01-01 01:01:01'),(428,20251015103800,1,'2020-01-01 01:01:01'),(429,20251015103900,1,'2020-01-01 01:01:01'),(430,20251028140000,1,'2020-01-01 01:01:01'),(431,20251028140100,1,'2020-01-01 01:01:01'),(432,20251028140110,1,'2020-01-01 01:01:01'),(433,20251028140200,1,'2020-01-01 01:01:01'),(434,20251028140300,1,'2020-01-01 01:01:01'),(435,20251028140400,1,'2020-01-01 01:01:01'),(436,20251031154558,1,'2020-01-01 01:01:01'),(437,20251103160848,1,'2020-01-01 01:01:01'),(438,20251104112849,1,'2020-01-01 01:01:01'),(439,20251106000000,1,'2020-01-01 01:01:01'),(440,20251107164629,1,'2020-01-01 01:01:01'),(441,20251107170854,1,'2020-01-01 01:01:01'),(442,20251110172137,1,'2020-01-01 01:01:01'),(443,20251111153133,1,'2020-01-01 01:01:01'),(444,20251117020000,1,'2020-01-01 01:01:01'),(445,20251117020100,1,'2020-01-01 01:01:01'),(446,20251117020200,1,'2020-01-01 01:01:01'),(447,20251121100000,1,'2020-01-01 01:01:01'),(448,20251121124239,1,'2020-01-01 01:01:01'),(449,20251124090450,1,'2020-01-01 01:01:01'),(450,20251124135808,1,'2020-01-01 01:01:01'),(451,20251124140138,1,'2020-01-01 01:01:01'),(452,20251124162948,1,'2020-01-01 01:01:01'),(453,20251127113559,1,'2020-01-01 01:01:01'),(454,20251202162232,1,'2020-01-01 01:01:01'),(455,20251203170808,1,'2020-01-01 01:01:01'),(456,20251207050413,1,'2020-01-01 01:01:01'),(457,20251208215800,1,'2020-01-01 01:01:01'),(458,20251209221730,1,'2020-01-01 01:01:01'),(459,20251209221850,1,'2020-01-01 01:01:01'),(460,20251215163721,1,'2020-01-01 01:01:01'),(461,20251217000000,1,'2020-01-01 01:01:01'),(462,20251217120000,1,'2020-01-01 01:01:01'),(463,20251229000000,1,'2020-01-01 01:01:01'),(464,20251229000010,1,'2020-01-01 01:01:01'),(465,20251229000020,1,'2020-01-01 01:01:01'),(466,20260106000000,1,'2020-01-01 01:01:01'),(467,20260108200708,1,'2020-01-01 01:01:01'),(468,20260108214732,1,'2020-01-01 01:01:01'),(469,20260109231821,1,'2020-01-01 01:01:01'),(470,20260113012054,1,'2020-01-01 01:01:01'),(471,20260124200020,1,'2020-01-01 01:01:01'),(472,20260126150840,1,'2020-01-01 01:01:01'),(473,20260126210724,1,'2020-01-01 01:01:01'),(474,20260202151756,1,'2020-01-01 01:01:01'),(475,20260205184907,1,'2020-01-01 01:01:01'),(476,20260210151544,1,'2020-01-01 01:01:01'),(477,20260210155109,1,'2020-01-01 01:01:01'),(478,20260210181120,1,'2020-01-01 01:01:01'),(479,20260211200153,1,'2020-01-01 01:01:01'),(480,20260217141240,1,'2020-01-01 01:01:01'),(481,20260217200906,1,'2020-01-01 01:01:01'),(482,20260218175704,1,'2020-01-01 01:01:01'),(483,20260314120000,1,'2020-01-01 01:01:01'),(484,20260316120000,1,'2020-01-01 01:01:01'),(485,20260316120001,1,'2020-01-01 01:01:01'),(486,20260316120002,1,'2020-01-01 01:01:01'),(487,20260316120003,1,'2020-01-01 01:01:01'),(488,20260316120004,1,'2020-01-01 01:01:01'),(489,20260316120005,1,'2020-01-01 01:01:01'),(490,20260316120006,1,'2020-01-01 01:01:01'),(491,20260316120007,1,'2020-01-01 01:01:01'),(492,20260316120008,1,'2020-01-01 01:01:01'),(493,20260316120009,1,'2020-01-01 01:01:01'),(494,20260316120010,1,'2020-01-01 01:01:01'),(495,20260317120000,1,'2020-01-01 01:01:01'),(496,20260318184559,1,'2020-01-01 01:01:01'),(497,20260319120000,1,'2020-01-01 01:01:01'),(498,20260323144117,1,'2020-01-01 01:01:01'),(499,20260324161944,1,'2020-01-01 01:01:01'),(500,20260324223334,1,'2020-01-01 01:01:01'),(501,20260326131501,1,'2020-01-01 01:01:01'),(502,20260326210603,1,'2020-01-01 01:01:01'),(503,20260331000000,1,'2020-01-01 01:01:01'),(504,20260401153000,1,'2020-01-01 01:01:01'),(505,20260401153001,1,'2020-01-01 01:01:01'),(506,20260401153503,1,'2020-01-01 01:01:01'),(507,20260403120000,1,'2020-01-01 01:01:01'),(508,20260409153713,1,'2020-01-01 01:01:01'),(509,20260409153714,1,'2020-01-01 01:01:01'),(510,20260409153715,1,'2020-01-01 01:01:01'),(511,20260409153716,1,'2020-01-01 01:01:01'),(512,20260409153717,1,'2020-01-01 01:01:01'),(513,20260409183610,1,'2020-01-01 01:01:01'),(514,20260410173222,1,'2020-01-01 01:01:01'),(515,20260422181702,1,'2020-01-01 01:01:01'),(516,20260423161823,1,'2020-01-01 01:01:01'),(517,20260423161824,1,'2020-01-01 01:01:01'),(518,20260518194422,1,'2020-01-01 01:01:01'),(519,20260522195224,1,'2020-01-01 01:01:01'),(520,20260522195225,1,'2020-01-01 01:01:01'),(521,20260522195226,1,'2020-01-01 01:01:01'),(522,20260522195227,1,'2020-01-01 01:01:01'),(523,20260522195229,1,'2020-01-01 01:01:01'),(524,20260522195230,1,'2020-01-01 01:01:01'),(525,20260522195231,1,'2020-01-01 01:01:01'),(526,20260522195232,1,'2020-01-01 01:01:01'),(527,20260522195233,1,'2020-01-01 01:01:01'),(528,20260522195234,1,'2020-01-01 01:01:01'),(529,20260522195235,1,'2020-01-01 01:01:01'),(530,20260527215817,1,'2020-01-01 01:01:01'),(531,20260527215818,1,'2020-01-01 01:01:01'),(532,20260528201143,1,'2020-01-01 01:01:01'),(533,20260528201150,1,'2020-01-01 01:01:01'),(534,20260528211626,1,'2020-01-01 01:01:01'),(535,20260528213326,1,'2020-01-01 01:01:01'),(536,20260529091823,1,'2020-01-01 01:01:01'),(537,20260529120000,1,'2020-01-01 01:01:01'),(538,20260601200727,1,'2020-01-01 01:01:01'),(539,20260603101320,1,'2020-01-01 01:01:01'),(540,20260603120000,1,'2020-01-01 01:01:01'),(541,20260604221206,1,'2020-01-01 01:01:01'),(542,20260605195941,1,'2020-01-01 01:01:01'),(543,20260606051849,1,'2020-01-01 01:01:01'),(544,20260608160653,1,'2020-01-01 01:01:01'),(545,20260608202705,1,'2020-01-01 01:01:01'),(546,20260608210432,1,'2020-01-01 01:01:01'),(547,20260610172952,1,'2020-01-01 01:01:01'),(548,20260624210253,1,'2020-01-01 01:01:01'),(549,20260624210311,1,'2020-01-01 01:01:01'),(550,20260626120000,1,'2020-01-01 01:01:01'),(551,20260702013055,1,'2020-01-01 01:01:01'),(552,20260702013056,1,'2020-01-01 01:01:01'),(553,20260702013057,1,'2020-01-01 01:01:01'),(554,20260702013058,1,'2020-01-01 01:01:01'),(555,20260702013059,1,'2020-01-01 01:01:01'),(556,20260702013100,1,'2020-01-01 01:01:01'),(557,20260702013101,1,'2020-01-01 01:01:01'),(558,20260702013102,1,'2020-01-01 01:01:01'),(559,20260702164518,1,'2020-01-01 01:01:01'),(560,20260704213830,1,'2020-01-01 01:01:01'),(561,20260706174522,1,'2020-01-01 01:01:01'),(562,20260707071142,1,'2020-01-01 01:01:01'),(563,20260707140752,1,'2020-01-01 01:01:01'),(564,20260708153912,1,'2020-01-01 01:01:01'),(565,20260708185958,1,'2020-01-01 01:01:01'),(566,20260708190008,1,'2020-01-01 01:01:01'),(567,20260708192536,1,'2020-01-01 01:01:01'),(568,20260713115453,1,'2020-01-01 01:01:01'),(569,20260715144547,1,'2020-01-01 01:01:01'); /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `mobile_device_management_solutions` ( diff --git a/server/datastore/mysql/secret_variables.go b/server/datastore/mysql/secret_variables.go index 57f7966d97..7dd3e663c9 100644 --- a/server/datastore/mysql/secret_variables.go +++ b/server/datastore/mysql/secret_variables.go @@ -154,7 +154,7 @@ func (ds *Datastore) GetSecretVariables(ctx context.Context, names []string) ([] func (ds *Datastore) ListSecretVariables(ctx context.Context, opt fleet.ListOptions) ( secretVariables []fleet.SecretVariableIdentifier, meta *fleet.PaginationMetadata, count int, err error, ) { - stmt := `SELECT id, name, updated_at FROM secret_variables WHERE true` + stmt := `SELECT id, name, created_at, updated_at FROM secret_variables WHERE true` // normalize the name for full Unicode support (Unicode equivalence). normMatch := norm.NFC.String(opt.MatchQuery) @@ -392,34 +392,47 @@ func (ds *Datastore) expandEmbeddedSecrets(ctx context.Context, document string) return "", nil, fleet.MissingSecretsError{MissingSecrets: missingSecrets} } - // Detect document format so we can escape the secret value appropriately. + expanded := expandDocumentVars(document, func(s string) (string, bool) { + if !strings.HasPrefix(s, fleet.ServerSecretPrefix) { + return "", false + } + val, ok := secretMap[strings.TrimPrefix(s, fleet.ServerSecretPrefix)] + return val, ok + }) + + return expanded, secrets, nil +} + +// expandDocumentVars runs fleet.MaybeExpand over document, resolving each +// variable name via resolve and escaping the substituted value for the +// document's format (JSON or XML) so an injected value can't break the +// surrounding profile. resolve returns the raw (unescaped) value and whether the +// variable was handled; unhandled variables are left in place. Shared by +// ExpandEmbeddedSecrets ($FLEET_SECRET_) and ExpandCustomHostVitals +// ($FLEET_HOST_VITAL_) so the format escaping has a single implementation. +func expandDocumentVars(document string, resolve func(name string) (string, bool)) string { // XML detection is aggressive because Windows profiles do not begin with 0 { + return errors.New("Couldn't edit profile. Custom host vitals aren't supported in Android configuration profiles.") + } + found := variables.Find(string(rawJSON)) if len(found) == 0 { return nil diff --git a/server/fleet/android_test.go b/server/fleet/android_test.go index 6d91d54d38..5655673128 100644 --- a/server/fleet/android_test.go +++ b/server/fleet/android_test.go @@ -220,6 +220,18 @@ func TestValidateUserProvided_FleetVariables(t *testing.T) { wantErr: true, errSubstr: "Invalid JSON payload", }, + { + name: "custom host vital is not supported on Android", + rawJSON: `{"name": "$FLEET_HOST_VITAL_7"}`, + wantErr: true, + errSubstr: "Custom host vitals aren't supported in Android configuration profiles", + }, + { + name: "custom host vital rejected even alongside a supported variable", + rawJSON: `{"name": "$FLEET_VAR_HOST_UUID $FLEET_HOST_VITAL_7"}`, + wantErr: true, + errSubstr: "Custom host vitals aren't supported in Android configuration profiles", + }, } for _, tt := range tests { diff --git a/server/fleet/api_custom_host_vitals.go b/server/fleet/api_custom_host_vitals.go new file mode 100644 index 0000000000..a3b71e625a --- /dev/null +++ b/server/fleet/api_custom_host_vitals.go @@ -0,0 +1,97 @@ +package fleet + +////////////////////////////////////////////////////////////////////////////////// +// List custom host vitals +////////////////////////////////////////////////////////////////////////////////// + +type ListCustomHostVitalsRequest struct { + ListOptions ListOptions `url:"list_options"` +} + +type ListCustomHostVitalsResponse struct { + CustomHostVitals []CustomHostVital `json:"custom_host_vitals"` + Meta *PaginationMetadata `json:"meta"` + Count int `json:"count"` + + Err error `json:"error,omitempty"` +} + +func (r ListCustomHostVitalsResponse) Error() error { return r.Err } + +////////////////////////////////////////////////////////////////////////////////// +// Create custom host vital +////////////////////////////////////////////////////////////////////////////////// + +type CreateCustomHostVitalRequest struct { + Name string `json:"name"` +} + +type CreateCustomHostVitalResponse struct { + CustomHostVital *CustomHostVital `json:"custom_host_vital,omitempty"` + + Err error `json:"error,omitempty"` +} + +func (r CreateCustomHostVitalResponse) Error() error { return r.Err } + +////////////////////////////////////////////////////////////////////////////////// +// Update (rename) custom host vital +////////////////////////////////////////////////////////////////////////////////// + +type UpdateCustomHostVitalRequest struct { + ID uint `url:"id"` + Name string `json:"name"` +} + +type UpdateCustomHostVitalResponse struct { + CustomHostVital *CustomHostVital `json:"custom_host_vital,omitempty"` + + Err error `json:"error,omitempty"` +} + +func (r UpdateCustomHostVitalResponse) Error() error { return r.Err } + +////////////////////////////////////////////////////////////////////////////////// +// Delete custom host vital +////////////////////////////////////////////////////////////////////////////////// + +type DeleteCustomHostVitalRequest struct { + ID uint `url:"id"` +} + +type DeleteCustomHostVitalResponse struct { + Err error `json:"error,omitempty"` +} + +func (r DeleteCustomHostVitalResponse) Error() error { return r.Err } + +////////////////////////////////////////////////////////////////////////////////// +// Set host custom host vital value +////////////////////////////////////////////////////////////////////////////////// + +type SetHostCustomHostVitalValueRequest struct { + HostID uint `url:"host_id"` + ID uint `url:"id"` + Value string `json:"value"` +} + +type SetHostCustomHostVitalValueResponse struct { + Err error `json:"error,omitempty"` +} + +func (r SetHostCustomHostVitalValueResponse) Error() error { return r.Err } + +////////////////////////////////////////////////////////////////////////////////// +// Upsert custom host vitals (spec) +////////////////////////////////////////////////////////////////////////////////// + +type UpsertCustomHostVitalsRequest struct { + DryRun bool `json:"dry_run"` + CustomHostVitals []CustomHostVital `json:"custom_host_vitals"` +} + +type UpsertCustomHostVitalsResponse struct { + Err error `json:"error,omitempty"` +} + +func (r UpsertCustomHostVitalsResponse) Error() error { return r.Err } diff --git a/server/fleet/apple_mdm.go b/server/fleet/apple_mdm.go index 1b537df571..dd4e809801 100644 --- a/server/fleet/apple_mdm.go +++ b/server/fleet/apple_mdm.go @@ -537,9 +537,12 @@ type AppleDeclarationForReconcile struct { IncludeMode AppleProfileIncludeMode IncludeLabels []AppleProfileLabelRef ExcludeLabels []AppleProfileLabelRef - // HasFleetVariables is true if the declaration references any $FLEET_VAR_*. - // The reconciler sets VariablesUpdatedAt on the host declaration row so the - // host knows to re-deliver when variable values change. + // HasFleetVariables is true if the declaration references any per-host Fleet + // variable that is expanded at delivery time: a $FLEET_VAR_* or a custom host + // vital ($FLEET_HOST_VITAL_*). Both behave identically here — the reconciler + // sets VariablesUpdatedAt on the host declaration row so the host knows to + // re-deliver when a variable value changes — so they share this flag even + // though custom host vitals are a separate variable namespace. // // This does not cover $FLEET_SECRET_* variables and SecretsUpdatedAt as that is handled at upload time // where we extract the secrets and their last update time. diff --git a/server/fleet/custom_host_vitals.go b/server/fleet/custom_host_vitals.go new file mode 100644 index 0000000000..9555bab3e8 --- /dev/null +++ b/server/fleet/custom_host_vitals.go @@ -0,0 +1,204 @@ +package fleet + +import ( + "fmt" + "strconv" + "strings" + "unicode/utf8" +) + +const CustomHostVitalPrefix = "FLEET_HOST_VITAL_" + +const customHostVitalNameMaxNameLen = 255 + +type CustomHostVital struct { + ID uint `json:"id" db:"id"` + Name string `json:"name" db:"name"` + CreatedAt string `json:"created_at" db:"created_at"` + UpdatedAt string `json:"updated_at" db:"updated_at"` +} + +func (h CustomHostVital) AuthzType() string { + return "custom_vital" +} + +// HostCustomHostVital is a single host's value for a custom host vital. +type HostCustomHostVital struct { + CustomHostVitalID uint `json:"custom_host_vital_id" db:"custom_host_vital_id"` + Name string `json:"name" db:"name"` + Value string `json:"value" db:"value"` +} + +// HostCustomHostVitalValue is the authz subject for setting a host's custom host vital value. +type HostCustomHostVitalValue struct { + TeamID *uint `json:"team_id" renameto:"fleet_id"` +} + +func (HostCustomHostVitalValue) AuthzType() string { + return "host_custom_vital" +} + +type MissingCustomHostVitalsError struct { + MissingIDs []uint +} + +func (e MissingCustomHostVitalsError) Error() string { + tokens := make([]string, 0, len(e.MissingIDs)) + for _, id := range e.MissingIDs { + tokens = append(tokens, fmt.Sprintf("\"$%s%d\"", CustomHostVitalPrefix, id)) + } + plural := "" + if len(tokens) > 1 { + plural = "s" + } + return fmt.Sprintf("Couldn't add. Custom host vital%s %s is not defined", plural, strings.Join(tokens, ", ")) +} + +// InvalidCustomHostVitalRefError is returned on upload when a document contains a +// $FLEET_HOST_VITAL_ token whose is not a valid custom host vital ID +// (a positive integer) — e.g. a typo like $FLEET_HOST_VITAL_asset_tag. +type InvalidCustomHostVitalRefError struct { + // Refs are the offending tokens without the leading '$', e.g. "FLEET_HOST_VITAL_asset_tag". + Refs []string +} + +func (e InvalidCustomHostVitalRefError) Error() string { + tokens := make([]string, 0, len(e.Refs)) + for _, r := range e.Refs { + tokens = append(tokens, fmt.Sprintf("\"$%s\"", r)) + } + plural := "" + if len(tokens) > 1 { + plural = "s" + } + return fmt.Sprintf( + "Couldn't add. Invalid custom host vital reference%s %s; the value after $%s must be a custom host vital ID", + plural, strings.Join(tokens, ", "), CustomHostVitalPrefix, + ) +} + +// MissingCustomHostVitalValueError is returned when expanding $FLEET_HOST_VITAL_ +// at delivery time for a host that has no value set for that (existing) vital. +// Distinct from MissingCustomHostVitalsError (upload-time: the id doesn't exist) +// so the delivery failure detail shown to admins names the real cause. +type MissingCustomHostVitalValueError struct { + MissingIDs []uint +} + +func (e MissingCustomHostVitalValueError) Error() string { + tokens := make([]string, 0, len(e.MissingIDs)) + for _, id := range e.MissingIDs { + tokens = append(tokens, fmt.Sprintf("\"$%s%d\"", CustomHostVitalPrefix, id)) + } + plural := "" + if len(tokens) > 1 { + plural = "s" + } + return fmt.Sprintf("Couldn't populate custom host vital%s %s: no value set for this host", plural, strings.Join(tokens, ", ")) +} + +// CustomHostVitalEntity identifies the kind of entity that can reference a custom host vital. +type CustomHostVitalEntity string + +const ( + CustomHostVitalEntityScript CustomHostVitalEntity = "script" + CustomHostVitalEntityAppleProfile CustomHostVitalEntity = "apple_profile" + CustomHostVitalEntityAppleDeclaration CustomHostVitalEntity = "apple_declaration" + CustomHostVitalEntityWindowsProfile CustomHostVitalEntity = "windows_profile" + CustomHostVitalEntitySoftwareInstaller CustomHostVitalEntity = "software_installer" + CustomHostVitalEntitySetupExperienceScript CustomHostVitalEntity = "setup_experience_script" + CustomHostVitalEntityLabel CustomHostVitalEntity = "label" +) + +// Describes an entity that references a custom host vital. +type EntityUsingCustomHostVital struct { + Type CustomHostVitalEntity + // Name is the name of the entity. + Name string + // FleetName is the name of the fleet (team) the entity belongs to. + FleetName string +} + +// CustomHostVitalUsedInfo describes a script/profile/declaration that references a custom host vital. +type CustomHostVitalUsedInfo struct { + CustomHostVitalID uint + CustomHostVitalName string + Entity EntityUsingCustomHostVital +} + +// Message returns the human-readable "X is used by Y" explanation. +func (i CustomHostVitalUsedInfo) Message() string { + noun, action := "configuration profile", "Please delete the configuration profile and try again." + switch i.Entity.Type { + case CustomHostVitalEntityScript: + noun, action = "script", "Please edit or delete the script and try again." + case CustomHostVitalEntitySoftwareInstaller: + noun, action = "software", "Please edit or delete the software and try again." + case CustomHostVitalEntitySetupExperienceScript: + noun, action = "setup experience script", "Please edit or delete the setup experience script and try again." + case CustomHostVitalEntityLabel: + noun, action = "label", "Please edit or delete the label and try again." + } + return fmt.Sprintf( + "Custom host vital %q (used as $%s%d) is used by the %q %s in the %q fleet. %s", + i.CustomHostVitalName, CustomHostVitalPrefix, i.CustomHostVitalID, i.Entity.Name, noun, i.Entity.FleetName, action, + ) +} + +// CustomHostVitalUsedError wraps CustomHostVitalUsedInfo as an error, returned when +// a custom host vital can't be deleted because it is still referenced. +type CustomHostVitalUsedError struct { + CustomHostVitalUsedInfo +} + +func (e *CustomHostVitalUsedError) Error() string { + return e.Message() +} + +func ValidateCustomHostVitalName(name string) error { + if len(name) == 0 { + return NewInvalidArgumentError("name", "custom host vital name cannot be empty") + } + if strings.TrimSpace(name) != name { + return NewInvalidArgumentError("name", "custom host vital name cannot have leading or trailing whitespace") + } + if utf8.RuneCountInString(name) > customHostVitalNameMaxNameLen { + return NewInvalidArgumentError("name", fmt.Sprintf("custom host vital name is too long: %s", name)) + } + return nil +} + +func ContainsCustomHostVitalIDs(text string) []uint { + suffixes := ContainsPrefixVars(text, CustomHostVitalPrefix) + if len(suffixes) == 0 { + return nil + } + seen := make(map[uint]struct{}, len(suffixes)) + ids := make([]uint, 0, len(suffixes)) + for _, s := range suffixes { + id, err := strconv.ParseUint(s, 10, strconv.IntSize) + if err != nil || id == 0 { + continue + } + if _, ok := seen[uint(id)]; ok { + continue + } + seen[uint(id)] = struct{}{} + ids = append(ids, uint(id)) + } + return ids +} + +// ContainsMalformedCustomHostVitalRefs returns the $FLEET_HOST_VITAL_ tokens in +// text whose is not a valid custom host vital ID (a positive integer), e.g. a +// typo like $FLEET_HOST_VITAL_asset_tag. +// Returned tokens omit the leading '$' (e.g. "FLEET_HOST_VITAL_asset_tag"). +func ContainsMalformedCustomHostVitalRefs(text string) []string { + var malformed []string + for _, s := range ContainsPrefixVars(text, CustomHostVitalPrefix) { + if id, err := strconv.ParseUint(s, 10, strconv.IntSize); err != nil || id == 0 { + malformed = append(malformed, CustomHostVitalPrefix+s) + } + } + return malformed +} diff --git a/server/fleet/custom_host_vitals_test.go b/server/fleet/custom_host_vitals_test.go new file mode 100644 index 0000000000..2465692509 --- /dev/null +++ b/server/fleet/custom_host_vitals_test.go @@ -0,0 +1,59 @@ +package fleet + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestContainsCustomHostVitalIDs(t *testing.T) { + t.Run("both token forms and dedupe", func(t *testing.T) { + doc := ` +#!/bin/sh +echo $FLEET_HOST_VITAL_1 +echo words${FLEET_HOST_VITAL_2}words +echo $FLEET_HOST_VITAL_1 again +` + ids := ContainsCustomHostVitalIDs(doc) + require.ElementsMatch(t, []uint{1, 2}, ids) + }) + + t.Run("ignores non-numeric and zero suffixes", func(t *testing.T) { + doc := `$FLEET_HOST_VITAL_ABC ${FLEET_HOST_VITAL_} $FLEET_HOST_VITAL_0 $FLEET_HOST_VITAL_12X $FLEET_HOST_VITAL_7` + ids := ContainsCustomHostVitalIDs(doc) + require.Equal(t, []uint{7}, ids) + }) + + t.Run("no tokens", func(t *testing.T) { + require.Empty(t, ContainsCustomHostVitalIDs("no vitals here $FLEET_SECRET_FOO $FLEET_VAR_HOST_UUID")) + }) + + t.Run("does not match a longer variable that starts with the prefix name", func(t *testing.T) { + // FLEET_VAR_ prefix should not be caught. + require.Empty(t, ContainsCustomHostVitalIDs("$FLEET_VAR_HOST_VITAL_1")) + }) +} + +func TestMissingCustomHostVitalsError(t *testing.T) { + single := MissingCustomHostVitalsError{MissingIDs: []uint{5}} + require.Contains(t, single.Error(), `"$FLEET_HOST_VITAL_5"`) + require.Contains(t, single.Error(), "Custom host vital ") + + multi := MissingCustomHostVitalsError{MissingIDs: []uint{5, 9}} + require.Contains(t, multi.Error(), "Custom host vitals") + require.Contains(t, multi.Error(), `"$FLEET_HOST_VITAL_5"`) + require.Contains(t, multi.Error(), `"$FLEET_HOST_VITAL_9"`) +} + +func TestMissingCustomHostVitalValueError(t *testing.T) { + single := MissingCustomHostVitalValueError{MissingIDs: []uint{5}} + require.Contains(t, single.Error(), `"$FLEET_HOST_VITAL_5"`) + require.Contains(t, single.Error(), "no value set for this host") + // Distinct from the upload-time "is not defined" wording. + require.NotContains(t, single.Error(), "is not defined") + + multi := MissingCustomHostVitalValueError{MissingIDs: []uint{5, 9}} + require.Contains(t, multi.Error(), "custom host vitals") + require.Contains(t, multi.Error(), `"$FLEET_HOST_VITAL_5"`) + require.Contains(t, multi.Error(), `"$FLEET_HOST_VITAL_9"`) +} diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index 2533f2b128..70ebe0772a 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -3259,6 +3259,28 @@ type Datastore interface { // like recovery lock passwords. ExpandHostSecrets(ctx context.Context, document string, enrollmentID string) (string, error) + // ///////////////////////////////////////////////////////////////////////////// + // Custom host vitals + CreateCustomHostVital(ctx context.Context, name string) (CustomHostVital, error) + ListCustomHostVitals(ctx context.Context, opt ListOptions) (customHostVitals []CustomHostVital, meta *PaginationMetadata, count int, err error) + UpdateCustomHostVital(ctx context.Context, id uint, name string) (CustomHostVital, error) + DeleteCustomHostVital(ctx context.Context, id uint) (name string, err error) + SetHostCustomHostVitalValue(ctx context.Context, hostID uint, vitalID uint, value string) error + GetHostCustomHostVitals(ctx context.Context, hostID uint) ([]HostCustomHostVital, error) + GetCustomHostVitals(ctx context.Context, ids []uint) ([]CustomHostVital, error) + // ValidateReferencedCustomHostVitals parses $FLEET_HOST_VITAL_ tokens from + // the given documents and checks that every referenced id exists. Returns a + // MissingCustomHostVitalsError if any referenced id is unknown. + ValidateReferencedCustomHostVitals(ctx context.Context, documents []string) error + + // ExpandCustomHostVitals substitutes $FLEET_HOST_VITAL_ tokens in the + // document with the given host's stored values (format-aware escaping). + // Returns a MissingCustomHostVitalValueError if a referenced vital has no value + // for the host. + ExpandCustomHostVitals(ctx context.Context, hostID uint, document string) (string, error) + + UpsertCustomHostVitals(ctx context.Context, vitals []CustomHostVital) (created []CustomHostVital, deleted []CustomHostVital, err error) + // ///////////////////////////////////////////////////////////////////////////// // Android diff --git a/server/fleet/embedded_variables.go b/server/fleet/embedded_variables.go new file mode 100644 index 0000000000..12c359f5e0 --- /dev/null +++ b/server/fleet/embedded_variables.go @@ -0,0 +1,16 @@ +package fleet + +import "context" + +// ValidateEmbeddedSecretsAndCustomHostVitals validates the database-backed +// variables a script or profile can embed: $FLEET_SECRET_* secrets and +// $FLEET_HOST_VITAL_ custom host vitals. Callers run it on upload so a +// document referencing a non-existent secret or vital is rejected up front. It +// lives in the fleet package (rather than a secrets- or vitals-specific file) +// because it spans both domains. +func ValidateEmbeddedSecretsAndCustomHostVitals(ctx context.Context, ds Datastore, documents []string) error { + if err := ds.ValidateEmbeddedSecrets(ctx, documents); err != nil { + return err + } + return ds.ValidateReferencedCustomHostVitals(ctx, documents) +} diff --git a/server/fleet/hosts.go b/server/fleet/hosts.go index 5e5eda90f1..3ededef63a 100644 --- a/server/fleet/hosts.go +++ b/server/fleet/hosts.go @@ -485,6 +485,7 @@ const ( HostVitalTypeDomestic HostVitalType = iota // Domestic vitals are those that are stored in the host table HostVitalTypeForeign // Foreign vitals are those that are stored in a separate table and joined to the host table HostVitalTypeAdditional // Additional vitals are those that are stored in the host_additional table as a JSON blob + HostVitalTypeCustom // Custom vitals are stored per-host in host_custom_host_vitals, scoped by a custom_host_vital_id ) type HostVital struct { @@ -528,6 +529,15 @@ var hostVitals = map[string]HostVital{ ForeignVitalGroup: ptr.String("idp"), Path: "scim_users.department", }, + // custom_host_vital does not self-identify which vital (unlike the IDP enum + // values); the criterion's custom_host_vital_id selects it and scopes the + // per-host value join built in parseHostVitalCriteria. + "custom_host_vital": { + Name: "Custom host vital", + VitalType: HostVitalTypeCustom, + DataType: "string", + Path: "host_custom_host_vitals.value", + }, } type AndroidHost struct { @@ -1104,6 +1114,8 @@ type HostDetail struct { MaintenanceWindow *HostMaintenanceWindow `json:"maintenance_window,omitempty"` EndUsers []HostEndUser `json:"end_users,omitempty"` + CustomHostVitals []HostCustomHostVital `json:"custom_host_vitals,omitempty"` + LastMDMEnrolledAt *time.Time `json:"last_mdm_enrolled_at"` LastMDMCheckedInAt *time.Time `json:"last_mdm_checked_in_at"` diff --git a/server/fleet/labels.go b/server/fleet/labels.go index 95f0c34fed..7024e20761 100644 --- a/server/fleet/labels.go +++ b/server/fleet/labels.go @@ -31,11 +31,14 @@ const ( ) type HostVitalCriteria struct { - Vital *string `json:"vital,omitempty"` - Value *string `json:"value,omitempty"` - Operator *HostVitalOperator `json:"operator,omitempty"` - And []HostVitalCriteria `json:"and,omitempty"` - Or []HostVitalCriteria `json:"or,omitempty"` + Vital *string `json:"vital,omitempty"` + Value *string `json:"value,omitempty"` + Operator *HostVitalOperator `json:"operator,omitempty"` + // CustomHostVitalID is required when Vital is "custom_host_vital": that name + // alone doesn't identify which custom vital to match, so the id selects it. + CustomHostVitalID *uint `json:"custom_host_vital_id,omitempty"` + And []HostVitalCriteria `json:"and,omitempty"` + Or []HostVitalCriteria `json:"or,omitempty"` } type LabelPayload struct { @@ -497,13 +500,30 @@ func parseHostVitalCriteria(criteria *HostVitalCriteria, foreignVitalsGroups map if !ok { return "", fmt.Errorf("unknown vital %s", *criteria.Vital) } - // If the vital is a foreign vitals group, add it to the list of foreign vitals groups. - if vital.VitalType == HostVitalTypeForeign { + switch vital.VitalType { + case HostVitalTypeForeign: + // If the vital is a foreign vitals group, add it to the list of foreign vitals groups. foreignVitalsGroup, ok := hostForeignVitalGroups[*vital.ForeignVitalGroup] if !ok { return "", fmt.Errorf("unknown foreign vital group %s", *vital.ForeignVitalGroup) } foreignVitalsGroups[&foreignVitalsGroup] = struct{}{} + case HostVitalTypeCustom: + if criteria.CustomHostVitalID == nil { + return "", errors.New("custom_host_vital criteria must have a custom_host_vital_id") + } + // Join only this vital's per-host rows. The id is appended to values + // before the criterion value below because the join is concatenated + // ahead of the WHERE clause in CalculateHostVitalsQuery, so its + // placeholder must bind first. A fresh group per call is fine: only a + // single criterion is supported (And/Or are rejected above), so at most + // one parameterized join exists. + group := HostForeignVitalGroup{ + Name: "custom_host_vital", + Query: "JOIN host_custom_host_vitals ON (hosts.id = host_custom_host_vitals.host_id AND host_custom_host_vitals.custom_host_vital_id = ?)", + } + foreignVitalsGroups[&group] = struct{}{} + *values = append(*values, *criteria.CustomHostVitalID) } *values = append(*values, *criteria.Value) diff --git a/server/fleet/secret_variables.go b/server/fleet/secret_variables.go index 5e501bb1a0..f6e3d26e22 100644 --- a/server/fleet/secret_variables.go +++ b/server/fleet/secret_variables.go @@ -39,5 +39,6 @@ func ValidateSecretVariableName(name string) error { type SecretVariableIdentifier struct { ID uint `json:"id" db:"id"` Name string `json:"name" name:"name"` + CreatedAt string `json:"created_at" db:"created_at"` UpdatedAt string `json:"updated_at" db:"updated_at"` } diff --git a/server/fleet/service.go b/server/fleet/service.go index 252881d30f..816571c842 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -1505,6 +1505,15 @@ type Service interface { // Returns a NotFoundError error if there's no secret variable with such ID. DeleteSecretVariable(ctx context.Context, id uint) error + ListCustomHostVitals(ctx context.Context, opts ListOptions) (customHostVitals []CustomHostVital, meta *PaginationMetadata, count int, err error) + CreateCustomHostVital(ctx context.Context, name string) (*CustomHostVital, error) + UpdateCustomHostVital(ctx context.Context, id uint, name string) (*CustomHostVital, error) + DeleteCustomHostVital(ctx context.Context, id uint) error + SetHostCustomHostVitalValue(ctx context.Context, hostID uint, vitalID uint, value string) error + // UpsertCustomHostVitals declaratively reconciles custom host vital definitions (GitOps): + // names present are upserted, names absent from customHostVitals are deleted. + UpsertCustomHostVitals(ctx context.Context, customHostVitals []CustomHostVital, dryRun bool) error + // ListAPIEndpoints returns all API endpoints ListAPIEndpoints(ctx context.Context) (endpoints []APIEndpoint, err error) diff --git a/server/mdm/apple/profile_processor.go b/server/mdm/apple/profile_processor.go index 2eef129f18..446242c2f2 100644 --- a/server/mdm/apple/profile_processor.go +++ b/server/mdm/apple/profile_processor.go @@ -187,10 +187,11 @@ func preprocessProfileContents( continue } - // Check if Fleet variables are present. + // Check if Fleet variables or custom host vitals are present. contentsStr := string(contents) fleetVars := variables.Find(contentsStr) - if len(fleetVars) == 0 { + hasHostVitals := len(fleet.ContainsCustomHostVitalIDs(contentsStr)) > 0 + if len(fleetVars) == 0 && !hasHostVitals { continue } @@ -651,6 +652,44 @@ func preprocessProfileContents( // This was handled in the above switch statement, so we should never reach this case } } + + // Expand per-host custom host vitals ($FLEET_HOST_VITAL_). This is a + // top-level prefix not handled by the FLEET_VAR_ loop above. On a + // missing/empty value for this host, mark the profile failed with a + // detail rather than shipping a blank substitution. + if !failed && hasHostVitals { + hostForVitals, ok, err := profiles.HydrateHost(ctx, ds, hostLite, onMismatchedHostCount) + if err != nil { + return ctxerr.Wrap(ctx, err, "hydrating host for custom host vitals") + } + if !ok { + // onMismatchedHostCount already marked the profile failed. + failed = true + } else { + hostLite = hostForVitals + expanded, err := ds.ExpandCustomHostVitals(ctx, hostLite.ID, hostContents) + if err != nil { + var missing *fleet.MissingCustomHostVitalValueError + if !errors.As(err, &missing) { + return ctxerr.Wrap(ctx, err, "expanding custom host vitals") + } + if updErr := ds.UpdateOrDeleteHostMDMAppleProfile(ctx, &fleet.HostMDMAppleProfile{ + CommandUUID: target.CmdUUID, + HostUUID: hostUUID, + Status: &fleet.MDMDeliveryFailed, + Detail: missing.Error(), + OperationType: fleet.MDMOperationTypeInstall, + VariablesUpdatedAt: variablesUpdatedAt, + }); updErr != nil { + return ctxerr.Wrap(ctx, updErr, "marking profile failed for missing custom host vital") + } + failed = true + } else { + hostContents = expanded + } + } + } + if !failed { addedTargets[tempProfUUID] = &fleet.CmdTarget{ CmdUUID: tempCmdUUID, diff --git a/server/mdm/microsoft/custom_host_vitals_test.go b/server/mdm/microsoft/custom_host_vitals_test.go new file mode 100644 index 0000000000..21051df72a --- /dev/null +++ b/server/mdm/microsoft/custom_host_vitals_test.go @@ -0,0 +1,65 @@ +package microsoft_mdm + +import ( + "context" + "log/slog" + "testing" + + "github.com/fleetdm/fleet/v4/server/contexts/license" + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/mock" + "github.com/stretchr/testify/require" +) + +func TestPreprocessWindowsProfileContentsCustomHostVitals(t *testing.T) { + ds := new(mock.Store) + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return &fleet.AppConfig{}, nil + } + ds.ListHostsLiteByUUIDsFunc = func(ctx context.Context, filter fleet.TeamFilter, uuids []string) ([]*fleet.Host, error) { + return []*fleet.Host{{ID: 55, UUID: "host-uuid-55"}}, nil + } + + ctx := license.NewContext(t.Context(), &fleet.LicenseInfo{Tier: fleet.TierPremium}) + appConfig, err := ds.AppConfig(ctx) + require.NoError(t, err) + + newDeps := func() ProfilePreprocessDependencies { + return ProfilePreprocessDependencies{ + Context: ctx, + Logger: slog.New(slog.DiscardHandler), + DataStore: ds, + HostIDForUUIDCache: map[string]uint{}, + AppConfig: appConfig, + ManagedCertificatePayloads: &[]*fleet.MDMManagedCertificate{}, + } + } + + profile := `$FLEET_HOST_VITAL_3` + + t.Run("substitutes the host's value with XML escaping", func(t *testing.T) { + ds.ExpandCustomHostVitalsFunc = func(ctx context.Context, hostID uint, doc string) (string, error) { + require.Equal(t, uint(55), hostID) + // simulate escaping of a value containing an XML-special char + return `a & b`, nil + } + result, err := PreprocessWindowsProfileContentsForDeployment(newDeps(), ProfilePreprocessParams{ + HostUUID: "host-uuid-55", ProfileUUID: "prof-1", + }, profile) + require.NoError(t, err) + require.Equal(t, `a & b`, result) + }) + + t.Run("missing/empty value marks the profile failed with detail", func(t *testing.T) { + ds.ExpandCustomHostVitalsFunc = func(ctx context.Context, hostID uint, doc string) (string, error) { + return "", &fleet.MissingCustomHostVitalValueError{MissingIDs: []uint{3}} + } + _, err := PreprocessWindowsProfileContentsForDeployment(newDeps(), ProfilePreprocessParams{ + HostUUID: "host-uuid-55", ProfileUUID: "prof-1", + }, profile) + require.Error(t, err) + var procErr *MicrosoftProfileProcessingError + require.ErrorAs(t, err, &procErr) + require.Contains(t, procErr.Error(), "FLEET_HOST_VITAL_3") + }) +} diff --git a/server/mdm/microsoft/profile_variables.go b/server/mdm/microsoft/profile_variables.go index ec4e4797ac..dd74827203 100644 --- a/server/mdm/microsoft/profile_variables.go +++ b/server/mdm/microsoft/profile_variables.go @@ -2,6 +2,7 @@ package microsoft_mdm import ( "context" + "errors" "fmt" "log/slog" "slices" @@ -78,9 +79,10 @@ type ProfilePreprocessParams struct { // implementation and to the interface if it's required for both verification and deployment. For new dependencies that // vary profile-to-profile, add them to ProfilePreprocessParams. func preprocessWindowsProfileContents(deps ProfilePreprocessDependencies, params ProfilePreprocessParams, profileContents string) (string, error) { - // Check if Fleet variables are present + // Check if Fleet variables or custom host vitals are present. fleetVars := variables.Find(profileContents) - if len(fleetVars) == 0 { + hasHostVitals := len(fleet.ContainsCustomHostVitalIDs(profileContents)) > 0 + if len(fleetVars) == 0 && !hasHostVitals { // No variables to replace, return original content return profileContents, nil } @@ -186,5 +188,26 @@ func preprocessWindowsProfileContents(deps ProfilePreprocessDependencies, params } } + // Expand per-host custom host vitals. On a missing/empty value the datastore + // returns a MissingCustomHostVitalValueError, which we surface as a + // MicrosoftProfileProcessingError so the caller marks the profile failed with + // this detail rather than shipping a blank substitution. + if hasHostVitals { + hostLite, _, err := profiles.HydrateHost(deps.Context, deps.DataStore, fleet.Host{UUID: params.HostUUID}, func(hostCount int) error { + return &MicrosoftProfileProcessingError{message: fmt.Sprintf("Found %d hosts with UUID %s. Custom host vital substitution requires exactly one host.", hostCount, params.HostUUID)} + }) + if err != nil { + return profileContents, err + } + expanded, err := deps.DataStore.ExpandCustomHostVitals(deps.Context, hostLite.ID, result) + if err != nil { + if missing, ok := errors.AsType[*fleet.MissingCustomHostVitalValueError](err); ok { + return profileContents, &MicrosoftProfileProcessingError{message: missing.Error()} + } + return profileContents, err + } + result = expanded + } + return result, nil } diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index 4b14c222e3..31b88e07c4 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -1848,6 +1848,26 @@ type ExpandEmbeddedSecretsAndUpdatedAtFunc func(ctx context.Context, document st type ExpandHostSecretsFunc func(ctx context.Context, document string, enrollmentID string) (string, error) +type CreateCustomHostVitalFunc func(ctx context.Context, name string) (fleet.CustomHostVital, error) + +type ListCustomHostVitalsFunc func(ctx context.Context, opt fleet.ListOptions) (customHostVitals []fleet.CustomHostVital, meta *fleet.PaginationMetadata, count int, err error) + +type UpdateCustomHostVitalFunc func(ctx context.Context, id uint, name string) (fleet.CustomHostVital, error) + +type DeleteCustomHostVitalFunc func(ctx context.Context, id uint) (name string, err error) + +type SetHostCustomHostVitalValueFunc func(ctx context.Context, hostID uint, vitalID uint, value string) error + +type GetHostCustomHostVitalsFunc func(ctx context.Context, hostID uint) ([]fleet.HostCustomHostVital, error) + +type GetCustomHostVitalsFunc func(ctx context.Context, ids []uint) ([]fleet.CustomHostVital, error) + +type ValidateReferencedCustomHostVitalsFunc func(ctx context.Context, documents []string) error + +type ExpandCustomHostVitalsFunc func(ctx context.Context, hostID uint, document string) (string, error) + +type UpsertCustomHostVitalsFunc func(ctx context.Context, vitals []fleet.CustomHostVital) (created []fleet.CustomHostVital, deleted []fleet.CustomHostVital, err error) + type CreateEnterpriseFunc func(ctx context.Context, userID uint) (uint, error) type GetEnterpriseByIDFunc func(ctx context.Context, id uint) (*android.EnterpriseDetails, error) @@ -4935,6 +4955,36 @@ type DataStore struct { ExpandHostSecretsFunc ExpandHostSecretsFunc ExpandHostSecretsFuncInvoked bool + CreateCustomHostVitalFunc CreateCustomHostVitalFunc + CreateCustomHostVitalFuncInvoked bool + + ListCustomHostVitalsFunc ListCustomHostVitalsFunc + ListCustomHostVitalsFuncInvoked bool + + UpdateCustomHostVitalFunc UpdateCustomHostVitalFunc + UpdateCustomHostVitalFuncInvoked bool + + DeleteCustomHostVitalFunc DeleteCustomHostVitalFunc + DeleteCustomHostVitalFuncInvoked bool + + SetHostCustomHostVitalValueFunc SetHostCustomHostVitalValueFunc + SetHostCustomHostVitalValueFuncInvoked bool + + GetHostCustomHostVitalsFunc GetHostCustomHostVitalsFunc + GetHostCustomHostVitalsFuncInvoked bool + + GetCustomHostVitalsFunc GetCustomHostVitalsFunc + GetCustomHostVitalsFuncInvoked bool + + ValidateReferencedCustomHostVitalsFunc ValidateReferencedCustomHostVitalsFunc + ValidateReferencedCustomHostVitalsFuncInvoked bool + + ExpandCustomHostVitalsFunc ExpandCustomHostVitalsFunc + ExpandCustomHostVitalsFuncInvoked bool + + UpsertCustomHostVitalsFunc UpsertCustomHostVitalsFunc + UpsertCustomHostVitalsFuncInvoked bool + CreateEnterpriseFunc CreateEnterpriseFunc CreateEnterpriseFuncInvoked bool @@ -11847,6 +11897,76 @@ func (s *DataStore) ExpandHostSecrets(ctx context.Context, document string, enro return s.ExpandHostSecretsFunc(ctx, document, enrollmentID) } +func (s *DataStore) CreateCustomHostVital(ctx context.Context, name string) (fleet.CustomHostVital, error) { + s.mu.Lock() + s.CreateCustomHostVitalFuncInvoked = true + s.mu.Unlock() + return s.CreateCustomHostVitalFunc(ctx, name) +} + +func (s *DataStore) ListCustomHostVitals(ctx context.Context, opt fleet.ListOptions) (customHostVitals []fleet.CustomHostVital, meta *fleet.PaginationMetadata, count int, err error) { + s.mu.Lock() + s.ListCustomHostVitalsFuncInvoked = true + s.mu.Unlock() + return s.ListCustomHostVitalsFunc(ctx, opt) +} + +func (s *DataStore) UpdateCustomHostVital(ctx context.Context, id uint, name string) (fleet.CustomHostVital, error) { + s.mu.Lock() + s.UpdateCustomHostVitalFuncInvoked = true + s.mu.Unlock() + return s.UpdateCustomHostVitalFunc(ctx, id, name) +} + +func (s *DataStore) DeleteCustomHostVital(ctx context.Context, id uint) (name string, err error) { + s.mu.Lock() + s.DeleteCustomHostVitalFuncInvoked = true + s.mu.Unlock() + return s.DeleteCustomHostVitalFunc(ctx, id) +} + +func (s *DataStore) SetHostCustomHostVitalValue(ctx context.Context, hostID uint, vitalID uint, value string) error { + s.mu.Lock() + s.SetHostCustomHostVitalValueFuncInvoked = true + s.mu.Unlock() + return s.SetHostCustomHostVitalValueFunc(ctx, hostID, vitalID, value) +} + +func (s *DataStore) GetHostCustomHostVitals(ctx context.Context, hostID uint) ([]fleet.HostCustomHostVital, error) { + s.mu.Lock() + s.GetHostCustomHostVitalsFuncInvoked = true + s.mu.Unlock() + return s.GetHostCustomHostVitalsFunc(ctx, hostID) +} + +func (s *DataStore) GetCustomHostVitals(ctx context.Context, ids []uint) ([]fleet.CustomHostVital, error) { + s.mu.Lock() + s.GetCustomHostVitalsFuncInvoked = true + s.mu.Unlock() + return s.GetCustomHostVitalsFunc(ctx, ids) +} + +func (s *DataStore) ValidateReferencedCustomHostVitals(ctx context.Context, documents []string) error { + s.mu.Lock() + s.ValidateReferencedCustomHostVitalsFuncInvoked = true + s.mu.Unlock() + return s.ValidateReferencedCustomHostVitalsFunc(ctx, documents) +} + +func (s *DataStore) ExpandCustomHostVitals(ctx context.Context, hostID uint, document string) (string, error) { + s.mu.Lock() + s.ExpandCustomHostVitalsFuncInvoked = true + s.mu.Unlock() + return s.ExpandCustomHostVitalsFunc(ctx, hostID, document) +} + +func (s *DataStore) UpsertCustomHostVitals(ctx context.Context, vitals []fleet.CustomHostVital) (created []fleet.CustomHostVital, deleted []fleet.CustomHostVital, err error) { + s.mu.Lock() + s.UpsertCustomHostVitalsFuncInvoked = true + s.mu.Unlock() + return s.UpsertCustomHostVitalsFunc(ctx, vitals) +} + func (s *DataStore) CreateEnterprise(ctx context.Context, userID uint) (uint, error) { s.mu.Lock() s.CreateEnterpriseFuncInvoked = true diff --git a/server/mock/service/service_mock.go b/server/mock/service/service_mock.go index b0fa616518..f103c33706 100644 --- a/server/mock/service/service_mock.go +++ b/server/mock/service/service_mock.go @@ -912,6 +912,18 @@ type ListSecretVariablesFunc func(ctx context.Context, opts fleet.ListOptions) ( type DeleteSecretVariableFunc func(ctx context.Context, id uint) error +type ListCustomHostVitalsFunc func(ctx context.Context, opts fleet.ListOptions) (customHostVitals []fleet.CustomHostVital, meta *fleet.PaginationMetadata, count int, err error) + +type CreateCustomHostVitalFunc func(ctx context.Context, name string) (*fleet.CustomHostVital, error) + +type UpdateCustomHostVitalFunc func(ctx context.Context, id uint, name string) (*fleet.CustomHostVital, error) + +type DeleteCustomHostVitalFunc func(ctx context.Context, id uint) error + +type SetHostCustomHostVitalValueFunc func(ctx context.Context, hostID uint, vitalID uint, value string) error + +type UpsertCustomHostVitalsFunc func(ctx context.Context, customHostVitals []fleet.CustomHostVital, dryRun bool) error + type ListAPIEndpointsFunc func(ctx context.Context) (endpoints []fleet.APIEndpoint, err error) type ScimDetailsFunc func(ctx context.Context) (fleet.ScimDetails, error) @@ -2307,6 +2319,24 @@ type Service struct { DeleteSecretVariableFunc DeleteSecretVariableFunc DeleteSecretVariableFuncInvoked bool + ListCustomHostVitalsFunc ListCustomHostVitalsFunc + ListCustomHostVitalsFuncInvoked bool + + CreateCustomHostVitalFunc CreateCustomHostVitalFunc + CreateCustomHostVitalFuncInvoked bool + + UpdateCustomHostVitalFunc UpdateCustomHostVitalFunc + UpdateCustomHostVitalFuncInvoked bool + + DeleteCustomHostVitalFunc DeleteCustomHostVitalFunc + DeleteCustomHostVitalFuncInvoked bool + + SetHostCustomHostVitalValueFunc SetHostCustomHostVitalValueFunc + SetHostCustomHostVitalValueFuncInvoked bool + + UpsertCustomHostVitalsFunc UpsertCustomHostVitalsFunc + UpsertCustomHostVitalsFuncInvoked bool + ListAPIEndpointsFunc ListAPIEndpointsFunc ListAPIEndpointsFuncInvoked bool @@ -5516,6 +5546,48 @@ func (s *Service) DeleteSecretVariable(ctx context.Context, id uint) error { return s.DeleteSecretVariableFunc(ctx, id) } +func (s *Service) ListCustomHostVitals(ctx context.Context, opts fleet.ListOptions) (customHostVitals []fleet.CustomHostVital, meta *fleet.PaginationMetadata, count int, err error) { + s.mu.Lock() + s.ListCustomHostVitalsFuncInvoked = true + s.mu.Unlock() + return s.ListCustomHostVitalsFunc(ctx, opts) +} + +func (s *Service) CreateCustomHostVital(ctx context.Context, name string) (*fleet.CustomHostVital, error) { + s.mu.Lock() + s.CreateCustomHostVitalFuncInvoked = true + s.mu.Unlock() + return s.CreateCustomHostVitalFunc(ctx, name) +} + +func (s *Service) UpdateCustomHostVital(ctx context.Context, id uint, name string) (*fleet.CustomHostVital, error) { + s.mu.Lock() + s.UpdateCustomHostVitalFuncInvoked = true + s.mu.Unlock() + return s.UpdateCustomHostVitalFunc(ctx, id, name) +} + +func (s *Service) DeleteCustomHostVital(ctx context.Context, id uint) error { + s.mu.Lock() + s.DeleteCustomHostVitalFuncInvoked = true + s.mu.Unlock() + return s.DeleteCustomHostVitalFunc(ctx, id) +} + +func (s *Service) SetHostCustomHostVitalValue(ctx context.Context, hostID uint, vitalID uint, value string) error { + s.mu.Lock() + s.SetHostCustomHostVitalValueFuncInvoked = true + s.mu.Unlock() + return s.SetHostCustomHostVitalValueFunc(ctx, hostID, vitalID, value) +} + +func (s *Service) UpsertCustomHostVitals(ctx context.Context, customHostVitals []fleet.CustomHostVital, dryRun bool) error { + s.mu.Lock() + s.UpsertCustomHostVitalsFuncInvoked = true + s.mu.Unlock() + return s.UpsertCustomHostVitalsFunc(ctx, customHostVitals, dryRun) +} + func (s *Service) ListAPIEndpoints(ctx context.Context) (endpoints []fleet.APIEndpoint, err error) { s.mu.Lock() s.ListAPIEndpointsFuncInvoked = true diff --git a/server/service/apple_mdm.go b/server/service/apple_mdm.go index 9f598e6d95..483330eb67 100644 --- a/server/service/apple_mdm.go +++ b/server/service/apple_mdm.go @@ -424,6 +424,10 @@ func (svc *Service) NewMDMAppleConfigProfile(ctx context.Context, teamID uint, d return nil, ctxerr.Wrap(ctx, err, "validating fleet variables") } + if err := svc.ds.ValidateReferencedCustomHostVitals(ctx, []string{string(data)}); err != nil { + return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("profile", err.Error())) + } + cp, err := fleet.NewMDMAppleConfigProfile([]byte(expanded), &teamID) if err != nil { return nil, ctxerr.Wrap(ctx, &fleet.BadRequestError{ @@ -1001,6 +1005,11 @@ func (svc *Service) NewMDMAppleDeclaration(ctx context.Context, teamID uint, dat return nil, ctxerr.Wrap(ctx, err, "validating declaration Fleet variables") } + // Validate custom host vital references (top-level $FLEET_HOST_VITAL_). + if err := svc.ds.ValidateReferencedCustomHostVitals(ctx, []string{string(data)}); err != nil { + return nil, fleet.NewInvalidArgumentError("profile", err.Error()) + } + varNames := make([]fleet.FleetVarName, 0, len(declVars)) for _, v := range declVars { varNames = append(varNames, fleet.FleetVarName(v)) @@ -1280,8 +1289,12 @@ func jsonEscapeString(s string) string { func (svc *MDMAppleDDMService) replaceDeclarationFleetVariables( ctx context.Context, contents string, hostUUID string, ) (string, error) { + // variables.Find only detects $FLEET_VAR_; custom host vitals are a separate + // top-level prefix, so gate on both so a declaration referencing only custom + // host vitals is still expanded. fleetVars := variables.Find(contents) - if len(fleetVars) == 0 { + hasHostVitals := len(fleet.ContainsCustomHostVitalIDs(contents)) > 0 + if len(fleetVars) == 0 && !hasHostVitals { return contents, nil } @@ -1402,6 +1415,23 @@ func (svc *MDMAppleDDMService) replaceDeclarationFleetVariables( contents = variables.Replace(contents, fleetVar, jsonEscapeString(value)) } + // Expand custom host vitals last, after the Fleet-var pass. variables.Replace + // is a blind global string replace, so expanding vitals earlier would let a + // vital value that happens to contain a literal $FLEET_VAR_ be rewritten + // by that pass. Doing it last makes the vital value the terminal substitution. + // On a missing/empty value the caller marks the declaration failed with this + // error's Detail. + if hasHostVitals { + if err := hydrateHost(); err != nil { + return "", err + } + expanded, err := svc.ds.ExpandCustomHostVitals(ctx, hostLite.ID, contents) + if err != nil { + return "", err + } + contents = expanded + } + return contents, nil } @@ -3051,6 +3081,9 @@ func (svc *Service) BatchSetMDMAppleProfiles(ctx context.Context, tmID *uint, tm fleet.NewInvalidArgumentError(fmt.Sprintf("profiles[%d]", i), err.Error()), "missing fleet secrets") } + if err := svc.ds.ValidateReferencedCustomHostVitals(ctx, []string{string(prof)}); err != nil { + return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError(fmt.Sprintf("profiles[%d]", i), err.Error())) + } mdmProf, err := fleet.NewMDMAppleConfigProfile([]byte(expanded), tmID) if err != nil { return ctxerr.Wrap(ctx, diff --git a/server/service/apple_mdm_ddm_test.go b/server/service/apple_mdm_ddm_test.go index fc0df60ebe..2f9a219803 100644 --- a/server/service/apple_mdm_ddm_test.go +++ b/server/service/apple_mdm_ddm_test.go @@ -17,6 +17,43 @@ import ( "github.com/stretchr/testify/require" ) +// Custom host vital values are arbitrary admin/external strings, so one may +// contain a literal $FLEET_VAR_. variables.Replace is a blind global +// string replace, so vitals must be expanded after the Fleet-var pass — else a +// $FLEET_VAR_ token embedded in a vital value would be rewritten by that pass. +func TestReplaceDeclarationFleetVariablesExpandsVitalsLast(t *testing.T) { + ctx := t.Context() + ds := mysqltest.CreateMySQLDS(t) + svc := MDMAppleDDMService{ + ds: ds, + logger: slog.New(slog.NewTextHandler(os.Stdout, nil)), + } + + host, err := ds.NewHost(ctx, &fleet.Host{ + UUID: "vital-order-uuid", + Hostname: "vital-order-host", + HardwareSerial: "SERIAL123", + OsqueryHostID: new("vital-order"), + NodeKey: new("vital-order"), + DetailUpdatedAt: time.Now(), + }) + require.NoError(t, err) + + vital, err := ds.CreateCustomHostVital(ctx, "asset_tag") + require.NoError(t, err) + // The vital's value deliberately embeds a literal $FLEET_VAR_ token. + require.NoError(t, ds.SetHostCustomHostVitalValue(ctx, host.ID, vital.ID, "tag-$FLEET_VAR_HOST_HARDWARE_SERIAL")) + + contents := fmt.Sprintf(`{"vital":"$FLEET_HOST_VITAL_%d","serial":"$FLEET_VAR_HOST_HARDWARE_SERIAL"}`, vital.ID) + out, err := svc.replaceDeclarationFleetVariables(ctx, contents, host.UUID) + require.NoError(t, err) + + // The genuine $FLEET_VAR_HOST_HARDWARE_SERIAL reference expands to the serial, + // but the identical token inside the vital's value survives intact because + // vitals are expanded last (variables.Replace never sees it). + require.JSONEq(t, `{"vital":"tag-$FLEET_VAR_HOST_HARDWARE_SERIAL","serial":"SERIAL123"}`, out) +} + func TestDeclarativeManagement_DeclarationItems(t *testing.T) { ctx := t.Context() ds := mysqltest.CreateMySQLDS(t) diff --git a/server/service/apple_mdm_test.go b/server/service/apple_mdm_test.go index 7ea488bc2b..925121823b 100644 --- a/server/service/apple_mdm_test.go +++ b/server/service/apple_mdm_test.go @@ -1296,6 +1296,9 @@ func TestHostDetailsMDMProfiles(t *testing.T) { ds.ListPoliciesForHostFunc = func(ctx context.Context, host *fleet.Host) ([]*fleet.HostPolicy, error) { return nil, nil } + ds.GetHostCustomHostVitalsFunc = func(ctx context.Context, hostID uint) ([]fleet.HostCustomHostVital, error) { + return nil, nil + } ds.GetHostMDMMacOSSetupFunc = func(ctx context.Context, hostID uint) (*fleet.HostMDMMacOSSetup, error) { return nil, nil } diff --git a/server/service/client.go b/server/service/client.go index 269fd6d7c6..47e9bffcb7 100644 --- a/server/service/client.go +++ b/server/service/client.go @@ -2469,6 +2469,14 @@ func (c *Client) DoGitOps( } } + // Custom host vitals are global-only and fully declarative: an absent + // `custom_host_vitals:` key clears all existing definitions. Runs last in + // this branch, after all local-only validation above, since it fetches + // current server state over the network to compute the diff. + if err := c.doGitOpsCustomHostVitals(incoming, logFn, dryRun); err != nil { + return nil, err + } + } else if !incoming.IsNoTeam() { team = make(map[string]interface{}) team["name"] = *incoming.TeamName @@ -3222,6 +3230,72 @@ func (c *Client) doGitOpsLabels( return c.ApplyLabels(config.Labels, config.TeamID, namesToMove) } +// doGitOpsCustomHostVitals reconciles custom host vital definitions against +// config.CustomHostVitals (an absent key clears all, per parseCustomHostVitals). +// Global-only, so this is a no-op on a team file (config.CustomHostVitals is +// always empty there). +func (c *Client) doGitOpsCustomHostVitals(config *spec.GitOps, logFn func(format string, args ...any), dryRun bool) error { + if config.TeamName != nil { + return nil + } + + desired := config.CustomHostVitals + if !config.CustomHostVitalsPresent { + desired = []fleet.CustomHostVital{} + } + + existing, err := c.listAllCustomHostVitals() + if err != nil { + return err + } + + existingNames := make(map[string]struct{}, len(existing)) + for _, v := range existing { + existingNames[v.Name] = struct{}{} + } + desiredNames := make(map[string]struct{}, len(desired)) + for _, v := range desired { + desiredNames[v.Name] = struct{}{} + } + + var toDelete []string + for _, v := range existing { + if _, ok := desiredNames[v.Name]; !ok { + toDelete = append(toDelete, v.Name) + } + } + var toAdd []string + for _, v := range desired { + if _, ok := existingNames[v.Name]; !ok { + toAdd = append(toAdd, v.Name) + } + } + + if dryRun { + if len(toDelete) > 0 { + logFn("[-] would've deleted %s\n", numberWithPluralization(len(toDelete), "custom host vital", "custom host vitals")) + } + for _, name := range toDelete { + logFn("[-] would've deleted custom host vital '%s'\n", name) + } + if len(toAdd) > 0 { + logFn("[+] would've created %s\n", numberWithPluralization(len(toAdd), "custom host vital", "custom host vitals")) + } + return c.SaveCustomHostVitals(desired, true) + } + + if len(toDelete) > 0 { + logFn("[-] deleting %s\n", numberWithPluralization(len(toDelete), "custom host vital", "custom host vitals")) + } + for _, name := range toDelete { + logFn("[-] deleting custom host vital '%s'\n", name) + } + if len(toAdd) > 0 { + logFn("[+] creating %s\n", numberWithPluralization(len(toAdd), "custom host vital", "custom host vitals")) + } + return c.SaveCustomHostVitals(desired, false) +} + // resolvePolicySoftwareTitleID attempts to resolve the software title ID for a // policy by trying each available identifier in order: URL, App Store ID, hash, // then FMA slug. Returns the resolved title ID and true if found, or 0 and diff --git a/server/service/client_custom_host_vitals.go b/server/service/client_custom_host_vitals.go new file mode 100644 index 0000000000..469b8b18e5 --- /dev/null +++ b/server/service/client_custom_host_vitals.go @@ -0,0 +1,44 @@ +package service + +import ( + "fmt" + + "github.com/fleetdm/fleet/v4/server/fleet" +) + +func (c *Client) SaveCustomHostVitals(customHostVitals []fleet.CustomHostVital, dryRun bool) error { + verb, path := "PUT", "/api/latest/fleet/spec/custom_host_vitals" + params := fleet.UpsertCustomHostVitalsRequest{ + CustomHostVitals: customHostVitals, + DryRun: dryRun, + } + var responseBody fleet.UpsertCustomHostVitalsResponse + return c.authenticatedRequest(params, verb, path, &responseBody) +} + +// ListCustomHostVitals returns a page of custom host vital definitions. +func (c *Client) ListCustomHostVitals(query string) ([]fleet.CustomHostVital, error) { + verb, path := "GET", "/api/latest/fleet/custom_host_vitals" + var responseBody fleet.ListCustomHostVitalsResponse + err := c.authenticatedRequestWithQuery(nil, verb, path, &responseBody, query) + if err != nil { + return nil, err + } + return responseBody.CustomHostVitals, nil +} + +// listAllCustomHostVitals pages through ListCustomHostVitals to return every definition. +func (c *Client) listAllCustomHostVitals() ([]fleet.CustomHostVital, error) { + const perPage = 1000 + var all []fleet.CustomHostVital + for page := 0; ; page++ { + pageVitals, err := c.ListCustomHostVitals(fmt.Sprintf("per_page=%d&page=%d", perPage, page)) + if err != nil { + return nil, err + } + all = append(all, pageVitals...) + if len(pageVitals) < perPage { + return all, nil + } + } +} diff --git a/server/service/custom_host_vitals.go b/server/service/custom_host_vitals.go new file mode 100644 index 0000000000..6abc0ba43a --- /dev/null +++ b/server/service/custom_host_vitals.go @@ -0,0 +1,308 @@ +package service + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/fleetdm/fleet/v4/server/authz" + "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" + "github.com/fleetdm/fleet/v4/server/fleet" + common_mysql "github.com/fleetdm/fleet/v4/server/platform/mysql" + "golang.org/x/text/unicode/norm" +) + +////////////////////////////////////////////////////////////////////////////////// +// List custom host vitals +////////////////////////////////////////////////////////////////////////////////// + +func listCustomHostVitalsEndpoint(ctx context.Context, request any, svc fleet.Service) (fleet.Errorer, error) { + req := request.(*fleet.ListCustomHostVitalsRequest) + vitals, meta, count, err := svc.ListCustomHostVitals(ctx, req.ListOptions) + return fleet.ListCustomHostVitalsResponse{ + CustomHostVitals: vitals, + Meta: meta, + Count: count, + Err: err, + }, nil +} + +func (svc *Service) ListCustomHostVitals( + ctx context.Context, + opts fleet.ListOptions, +) (customHostVitals []fleet.CustomHostVital, meta *fleet.PaginationMetadata, count int, err error) { + if err := svc.authz.Authorize(ctx, &fleet.CustomHostVital{}, fleet.ActionRead); err != nil { + return nil, nil, 0, err + } + + // Always include pagination info. + opts.IncludeMetadata = true + if opts.OrderKey == "" { + opts.OrderKey = "name" + opts.OrderDirection = fleet.OrderAscending + } + + customHostVitals, meta, count, err = svc.ds.ListCustomHostVitals(ctx, opts) + if err != nil { + return nil, nil, 0, ctxerr.Wrap(ctx, err, "list custom host vitals") + } + return customHostVitals, meta, count, nil +} + +////////////////////////////////////////////////////////////////////////////////// +// Create custom host vital +////////////////////////////////////////////////////////////////////////////////// + +func createCustomHostVitalEndpoint(ctx context.Context, request any, svc fleet.Service) (fleet.Errorer, error) { + req := request.(*fleet.CreateCustomHostVitalRequest) + vital, err := svc.CreateCustomHostVital(ctx, req.Name) + if err != nil { + return fleet.CreateCustomHostVitalResponse{Err: err}, nil + } + return fleet.CreateCustomHostVitalResponse{CustomHostVital: vital}, nil +} + +func (svc *Service) CreateCustomHostVital(ctx context.Context, name string) (*fleet.CustomHostVital, error) { + if err := svc.authz.Authorize(ctx, &fleet.CustomHostVital{}, fleet.ActionWrite); err != nil { + return nil, err + } + + if err := fleet.ValidateCustomHostVitalName(name); err != nil { + return nil, ctxerr.Wrap(ctx, err, "validate custom host vital name") + } + + vital, err := svc.ds.CreateCustomHostVital(ctx, name) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "creating custom host vital") + } + + if err := svc.NewActivity( + ctx, + authz.UserFromContext(ctx), + fleet.ActivityTypeCreatedCustomHostVital{ + CustomHostVitalID: vital.ID, + CustomHostVitalName: vital.Name, + }, + ); err != nil { + return nil, ctxerr.Wrap(ctx, err, "create activity for custom host vital creation") + } + + return &vital, nil +} + +////////////////////////////////////////////////////////////////////////////////// +// Update (rename) custom host vital +////////////////////////////////////////////////////////////////////////////////// + +func updateCustomHostVitalEndpoint(ctx context.Context, request any, svc fleet.Service) (fleet.Errorer, error) { + req := request.(*fleet.UpdateCustomHostVitalRequest) + vital, err := svc.UpdateCustomHostVital(ctx, req.ID, req.Name) + if err != nil { + return fleet.UpdateCustomHostVitalResponse{Err: err}, nil + } + return fleet.UpdateCustomHostVitalResponse{CustomHostVital: vital}, nil +} + +func (svc *Service) UpdateCustomHostVital(ctx context.Context, id uint, name string) (*fleet.CustomHostVital, error) { + if err := svc.authz.Authorize(ctx, &fleet.CustomHostVital{}, fleet.ActionWrite); err != nil { + return nil, err + } + + if err := fleet.ValidateCustomHostVitalName(name); err != nil { + return nil, ctxerr.Wrap(ctx, err, "validate custom host vital name") + } + + vital, err := svc.ds.UpdateCustomHostVital(ctx, id, name) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "updating custom host vital") + } + + if err := svc.NewActivity( + ctx, + authz.UserFromContext(ctx), + fleet.ActivityTypeEditedCustomHostVital{ + CustomHostVitalID: vital.ID, + CustomHostVitalName: vital.Name, + }, + ); err != nil { + return nil, ctxerr.Wrap(ctx, err, "create activity for custom host vital edit") + } + + return &vital, nil +} + +////////////////////////////////////////////////////////////////////////////////// +// Delete custom host vital +////////////////////////////////////////////////////////////////////////////////// + +func deleteCustomHostVitalEndpoint(ctx context.Context, request any, svc fleet.Service) (fleet.Errorer, error) { + req := request.(*fleet.DeleteCustomHostVitalRequest) + err := svc.DeleteCustomHostVital(ctx, req.ID) + return fleet.DeleteCustomHostVitalResponse{Err: err}, nil +} + +func (svc *Service) DeleteCustomHostVital(ctx context.Context, id uint) error { + if err := svc.authz.Authorize(ctx, &fleet.CustomHostVital{}, fleet.ActionWrite); err != nil { + return err + } + + name, err := svc.ds.DeleteCustomHostVital(ctx, id) + if err != nil { + if usedErr, ok := errors.AsType[*fleet.CustomHostVitalUsedError](err); ok { + return ctxerr.Wrap(ctx, &fleet.ConflictError{ + Message: fmt.Sprintf("Couldn't delete. %s", usedErr.Error()), + }, "delete custom host vital") + } + return ctxerr.Wrap(ctx, err, "delete custom host vital") + } + + if err := svc.NewActivity( + ctx, + authz.UserFromContext(ctx), + fleet.ActivityTypeDeletedCustomHostVital{ + CustomHostVitalID: id, + CustomHostVitalName: name, + }, + ); err != nil { + return ctxerr.Wrap(ctx, err, "create activity for custom host vital deletion") + } + + return nil +} + +////////////////////////////////////////////////////////////////////////////////// +// Set host custom host vital value +////////////////////////////////////////////////////////////////////////////////// + +func setHostCustomHostVitalValueEndpoint(ctx context.Context, request any, svc fleet.Service) (fleet.Errorer, error) { + req := request.(*fleet.SetHostCustomHostVitalValueRequest) + err := svc.SetHostCustomHostVitalValue(ctx, req.HostID, req.ID, req.Value) + return fleet.SetHostCustomHostVitalValueResponse{Err: err}, nil +} + +func (svc *Service) SetHostCustomHostVitalValue(ctx context.Context, hostID uint, vitalID uint, value string) error { + // Authorize against the host so team-scoped roles are enforced (host-write pattern). + if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil { + return err + } + + host, err := svc.ds.HostLite(ctx, hostID) + if err != nil { + return ctxerr.Wrap(ctx, err, "find host for setting custom host vital value") + } + + if err := svc.authz.Authorize(ctx, &fleet.HostCustomHostVitalValue{TeamID: host.TeamID}, fleet.ActionWrite); err != nil { + return err + } + + vital, err := svc.customHostVitalByID(ctx, vitalID) + if err != nil { + return err + } + + if err := svc.ds.SetHostCustomHostVitalValue(ctx, hostID, vitalID, value); err != nil { + return ctxerr.Wrap(ctx, err, "set host custom host vital value") + } + + if err := svc.NewActivity( + ctx, + authz.UserFromContext(ctx), + fleet.ActivityTypeEditedCustomHostVitalValue{ + HostID: hostID, + HostDisplayName: host.DisplayName(), + CustomHostVitalID: vitalID, + CustomHostVitalName: vital.Name, + }, + ); err != nil { + return ctxerr.Wrap(ctx, err, "create activity for custom host vital value edit") + } + + return nil +} + +func (svc *Service) customHostVitalByID(ctx context.Context, id uint) (*fleet.CustomHostVital, error) { + vitals, err := svc.ds.GetCustomHostVitals(ctx, []uint{id}) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "get custom host vital by id") + } + if len(vitals) == 0 { + return nil, ctxerr.Wrap(ctx, common_mysql.NotFound("CustomHostVital").WithID(id)) + } + return &vitals[0], nil +} + +////////////////////////////////////////////////////////////////////////////////// +// Upsert custom host vitals (spec) +////////////////////////////////////////////////////////////////////////////////// + +func upsertCustomHostVitalsEndpoint(ctx context.Context, request any, svc fleet.Service) (fleet.Errorer, error) { + req := request.(*fleet.UpsertCustomHostVitalsRequest) + err := svc.UpsertCustomHostVitals(ctx, req.CustomHostVitals, req.DryRun) + return fleet.UpsertCustomHostVitalsResponse{Err: err}, nil +} + +func (svc *Service) UpsertCustomHostVitals(ctx context.Context, customHostVitals []fleet.CustomHostVital, dryRun bool) error { + if err := svc.authz.Authorize(ctx, &fleet.CustomHostVital{}, fleet.ActionWrite); err != nil { + return err + } + + // Names are unique in the database under the utf8mb4_unicode_ci collation + // (case-insensitive), so dedupe on that same basis rather than exact string + // equality -- otherwise two names differing only by case would pass this + // check and then fail as a raw DB duplicate-key error at insert time. + seen := make(map[string]string, len(customHostVitals)) // collation key -> original name + for _, vital := range customHostVitals { + if err := fleet.ValidateCustomHostVitalName(vital.Name); err != nil { + return ctxerr.Wrap(ctx, err, "validate custom host vital name") + } + key := norm.NFC.String(strings.ToLower(vital.Name)) + if prev, ok := seen[key]; ok { + return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("custom_host_vitals", + fmt.Sprintf("duplicate custom host vital names: %q and %q must differ by more than letter case", prev, vital.Name))) + } + seen[key] = vital.Name + } + + if dryRun { + return nil + } + + created, deleted, err := svc.ds.UpsertCustomHostVitals(ctx, customHostVitals) + if err != nil { + if usedErr, ok := errors.AsType[*fleet.CustomHostVitalUsedError](err); ok { + return ctxerr.Wrap(ctx, &fleet.ConflictError{ + Message: fmt.Sprintf("Couldn't delete. %s", usedErr.Error()), + }, "upsert custom host vitals") + } + return ctxerr.Wrap(ctx, err, "upsert custom host vitals") + } + + user := authz.UserFromContext(ctx) + for _, vital := range created { + if err := svc.NewActivity( + ctx, + user, + fleet.ActivityTypeCreatedCustomHostVital{ + CustomHostVitalID: vital.ID, + CustomHostVitalName: vital.Name, + }, + ); err != nil { + return ctxerr.Wrap(ctx, err, "create activity for custom host vital creation") + } + } + for _, vital := range deleted { + if err := svc.NewActivity( + ctx, + user, + fleet.ActivityTypeDeletedCustomHostVital{ + CustomHostVitalID: vital.ID, + CustomHostVitalName: vital.Name, + }, + ); err != nil { + return ctxerr.Wrap(ctx, err, "create activity for custom host vital deletion") + } + } + + return nil +} diff --git a/server/service/custom_host_vitals_resolution_test.go b/server/service/custom_host_vitals_resolution_test.go new file mode 100644 index 0000000000..89238788ce --- /dev/null +++ b/server/service/custom_host_vitals_resolution_test.go @@ -0,0 +1,125 @@ +package service + +import ( + "context" + "strconv" + "strings" + "testing" + "time" + + hostctx "github.com/fleetdm/fleet/v4/server/contexts/host" + "github.com/fleetdm/fleet/v4/server/contexts/viewer" + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/mock" + "github.com/stretchr/testify/require" +) + +// fakeExpandCustomHostVitals mimics the datastore's ExpandCustomHostVitals +// behavior (missing/empty value -> MissingCustomHostVitalValueError) for the given +// per-host value map, so service-layer tests don't need a real DB. +func fakeExpandCustomHostVitals(valueByID map[uint]string) func(context.Context, uint, string) (string, error) { + return func(_ context.Context, _ uint, document string) (string, error) { + refIDs := fleet.ContainsCustomHostVitalIDs(document) + if len(refIDs) == 0 { + return document, nil + } + var missing []uint + for _, id := range refIDs { + if v, ok := valueByID[id]; !ok || v == "" { + missing = append(missing, id) + } + } + if len(missing) > 0 { + return "", &fleet.MissingCustomHostVitalValueError{MissingIDs: missing} + } + expanded := fleet.MaybeExpand(document, func(s string, _, _ int) (string, bool) { + if !strings.HasPrefix(s, fleet.CustomHostVitalPrefix) { + return "", false + } + id, err := strconv.ParseUint(strings.TrimPrefix(s, fleet.CustomHostVitalPrefix), 10, 64) + if err != nil { + return "", false + } + v, ok := valueByID[uint(id)] + return v, ok + }) + return expanded, nil + } +} + +func TestGetHostScriptExpandsCustomHostVitals(t *testing.T) { + ds := new(mock.Store) + license := &fleet.LicenseInfo{Tier: fleet.TierPremium} + svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: license, SkipCreateTestUsers: true}) + + host := &fleet.Host{ID: 42, UUID: "host-uuid-42", OrbitNodeKey: new("nk")} + + ds.ExpandEmbeddedSecretsFunc = func(ctx context.Context, doc string) (string, error) { + return doc, nil + } + + t.Run("substitutes the host's value", func(t *testing.T) { + ds.GetHostScriptExecutionResultFunc = func(ctx context.Context, execID string) (*fleet.HostScriptResult, error) { + return &fleet.HostScriptResult{HostID: host.ID, ExecutionID: execID, ScriptContents: "echo $FLEET_HOST_VITAL_7"}, nil + } + ds.ExpandCustomHostVitalsFunc = fakeExpandCustomHostVitals(map[uint]string{7: "engineering"}) + + hctx := hostctx.NewContext(ctx, host) + res, err := svc.GetHostScript(hctx, "exec-1") + require.NoError(t, err) + require.Equal(t, "echo engineering", res.ScriptContents) + }) + + t.Run("empty/missing value fails the script fetch", func(t *testing.T) { + ds.GetHostScriptExecutionResultFunc = func(ctx context.Context, execID string) (*fleet.HostScriptResult, error) { + return &fleet.HostScriptResult{HostID: host.ID, ExecutionID: execID, ScriptContents: "echo $FLEET_HOST_VITAL_9"}, nil + } + // host 42 has no value for vital 9 + ds.ExpandCustomHostVitalsFunc = fakeExpandCustomHostVitals(map[uint]string{7: "engineering"}) + + hctx := hostctx.NewContext(ctx, host) + _, err := svc.GetHostScript(hctx, "exec-2") + require.Error(t, err) + var missing *fleet.MissingCustomHostVitalValueError + require.ErrorAs(t, err, &missing) + require.Equal(t, []uint{9}, missing.MissingIDs) + }) +} + +func TestCreateScriptValidatesCustomHostVitals(t *testing.T) { + ds := new(mock.Store) + license := &fleet.LicenseInfo{Tier: fleet.TierPremium, Expiration: time.Now().Add(24 * time.Hour)} + svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: license, SkipCreateTestUsers: true}) + ctx = viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{GlobalRole: new(fleet.RoleAdmin)}}) + + ds.ValidateEmbeddedSecretsFunc = func(ctx context.Context, documents []string) error { return nil } + + // Simulate the real datastore: unknown ids -> MissingCustomHostVitalsError. + ds.ValidateReferencedCustomHostVitalsFunc = func(ctx context.Context, documents []string) error { + want := map[uint]struct{}{} + for _, d := range documents { + for _, id := range fleet.ContainsCustomHostVitalIDs(d) { + want[id] = struct{}{} + } + } + // only id 1 exists + var missing []uint + for id := range want { + if id != 1 { + missing = append(missing, id) + } + } + if len(missing) > 0 { + return &fleet.MissingCustomHostVitalsError{MissingIDs: missing} + } + return nil + } + + // Unknown id (999) should be rejected. + _, err := svc.NewScript(ctx, nil, "myscript.sh", strings.NewReader("#!/bin/sh\necho $FLEET_HOST_VITAL_999\n")) + require.Error(t, err) + require.Contains(t, err.Error(), "FLEET_HOST_VITAL_999") + + // ValidateReferencedCustomHostVitals must actually have been called. + require.True(t, ds.ValidateReferencedCustomHostVitalsFuncInvoked) +} diff --git a/server/service/custom_host_vitals_test.go b/server/service/custom_host_vitals_test.go new file mode 100644 index 0000000000..241bc28e7e --- /dev/null +++ b/server/service/custom_host_vitals_test.go @@ -0,0 +1,265 @@ +package service + +import ( + "context" + "testing" + + activity_api "github.com/fleetdm/fleet/v4/server/activity/api" + "github.com/fleetdm/fleet/v4/server/contexts/viewer" + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/mock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCustomHostVitalsAuth(t *testing.T) { + t.Parallel() + ds := new(mock.Store) + svc, ctx := newTestService(t, ds, nil, nil) + + ds.CreateCustomHostVitalFunc = func(ctx context.Context, name string) (fleet.CustomHostVital, error) { + return fleet.CustomHostVital{ID: 1, Name: name}, nil + } + ds.UpdateCustomHostVitalFunc = func(ctx context.Context, id uint, name string) (fleet.CustomHostVital, error) { + return fleet.CustomHostVital{ID: id, Name: name}, nil + } + ds.DeleteCustomHostVitalFunc = func(ctx context.Context, id uint) (string, error) { + return "Asset tag", nil + } + ds.ListCustomHostVitalsFunc = func(ctx context.Context, opt fleet.ListOptions) ([]fleet.CustomHostVital, *fleet.PaginationMetadata, int, error) { + return nil, &fleet.PaginationMetadata{}, 0, nil + } + ds.GetCustomHostVitalsFunc = func(ctx context.Context, ids []uint) ([]fleet.CustomHostVital, error) { + return []fleet.CustomHostVital{{ID: 1, Name: "Asset tag"}}, nil + } + ds.SetHostCustomHostVitalValueFunc = func(ctx context.Context, hostID, vitalID uint, value string) error { + return nil + } + ds.HostLiteFunc = func(ctx context.Context, id uint) (*fleet.Host, error) { + return &fleet.Host{ID: id}, nil + } + ds.UpsertCustomHostVitalsFunc = func(ctx context.Context, vitals []fleet.CustomHostVital) ([]fleet.CustomHostVital, []fleet.CustomHostVital, error) { + return nil, nil, nil + } + + globalRoles := []struct { + name string + user *fleet.User + readOK bool + writeOK bool + }{ + {"global admin", &fleet.User{ID: 1, GlobalRole: new(fleet.RoleAdmin)}, true, true}, + {"global maintainer", &fleet.User{ID: 2, GlobalRole: new(fleet.RoleMaintainer)}, true, true}, + {"global gitops", &fleet.User{ID: 3, GlobalRole: new(fleet.RoleGitOps)}, true, true}, + {"global observer", &fleet.User{ID: 4, GlobalRole: new(fleet.RoleObserver)}, true, false}, + {"global observer+", &fleet.User{ID: 5, GlobalRole: new(fleet.RoleObserverPlus)}, true, false}, + {"team admin", &fleet.User{ID: 6, Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleAdmin}}}, true, false}, + {"team maintainer", &fleet.User{ID: 7, Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleMaintainer}}}, true, false}, + {"team gitops", &fleet.User{ID: 8, Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleGitOps}}}, true, false}, + {"team observer", &fleet.User{ID: 9, Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleObserver}}}, true, false}, + } + + for _, tt := range globalRoles { + t.Run(tt.name, func(t *testing.T) { + ctx := viewer.NewContext(ctx, viewer.Viewer{User: tt.user}) + + _, _, _, err := svc.ListCustomHostVitals(ctx, fleet.ListOptions{}) + checkAuthErr(t, !tt.readOK, err) + + _, err = svc.CreateCustomHostVital(ctx, "Asset tag") + checkAuthErr(t, !tt.writeOK, err) + + _, err = svc.UpdateCustomHostVital(ctx, 1, "Asset tag") + checkAuthErr(t, !tt.writeOK, err) + + err = svc.DeleteCustomHostVital(ctx, 1) + checkAuthErr(t, !tt.writeOK, err) + + err = svc.UpsertCustomHostVitals(ctx, []fleet.CustomHostVital{{Name: "Asset tag"}}, false) + checkAuthErr(t, !tt.writeOK, err) + }) + } +} + +func TestListCustomHostVitalsPassesSearchQuery(t *testing.T) { + t.Parallel() + ds := new(mock.Store) + svc, ctx := newTestService(t, ds, nil, nil) + ctx = viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{ID: 1, GlobalRole: new(fleet.RoleAdmin)}}) + + var gotOpts fleet.ListOptions + ds.ListCustomHostVitalsFunc = func(ctx context.Context, opt fleet.ListOptions) ([]fleet.CustomHostVital, *fleet.PaginationMetadata, int, error) { + gotOpts = opt + return nil, &fleet.PaginationMetadata{}, 0, nil + } + + _, _, _, err := svc.ListCustomHostVitals(ctx, fleet.ListOptions{MatchQuery: "asset"}) + require.NoError(t, err) + require.True(t, ds.ListCustomHostVitalsFuncInvoked) + // MatchQuery is forwarded to the datastore (search by name or variable name). + assert.Equal(t, "asset", gotOpts.MatchQuery) +} + +func TestSetHostCustomHostVitalValueAuth(t *testing.T) { + t.Parallel() + ds := new(mock.Store) + svc, ctx := newTestService(t, ds, nil, nil) + + hostTeamID := uint(1) + ds.HostLiteFunc = func(ctx context.Context, id uint) (*fleet.Host, error) { + return &fleet.Host{ID: id, TeamID: &hostTeamID}, nil + } + ds.GetCustomHostVitalsFunc = func(ctx context.Context, ids []uint) ([]fleet.CustomHostVital, error) { + return []fleet.CustomHostVital{{ID: 1, Name: "Asset tag"}}, nil + } + ds.SetHostCustomHostVitalValueFunc = func(ctx context.Context, hostID, vitalID uint, value string) error { + return nil + } + + // Per-host value is a host-scoped write (authz type host_custom_vital): global + // admin/maintainer and admins/maintainers of the host's team can set it; + // observers, gitops (blocked at the host-list gate), and users of another team + // cannot. + testCases := []struct { + name string + user *fleet.User + shouldFail bool + }{ + {"global admin", &fleet.User{ID: 1, GlobalRole: new(fleet.RoleAdmin)}, false}, + {"global maintainer", &fleet.User{ID: 2, GlobalRole: new(fleet.RoleMaintainer)}, false}, + {"global gitops", &fleet.User{ID: 3, GlobalRole: new(fleet.RoleGitOps)}, true}, + {"global observer", &fleet.User{ID: 4, GlobalRole: new(fleet.RoleObserver)}, true}, + {"team admin (host team)", &fleet.User{ID: 5, Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleAdmin}}}, false}, + {"team maintainer (host team)", &fleet.User{ID: 6, Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleMaintainer}}}, false}, + {"team observer (host team)", &fleet.User{ID: 7, Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleObserver}}}, true}, + {"team maintainer (other team)", &fleet.User{ID: 8, Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleMaintainer}}}, true}, + } + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + ctx := viewer.NewContext(ctx, viewer.Viewer{User: tt.user}) + err := svc.SetHostCustomHostVitalValue(ctx, 42, 1, "engineering") + checkAuthErr(t, tt.shouldFail, err) + }) + } +} + +func TestCustomHostVitalNameValidation(t *testing.T) { + t.Parallel() + ds := new(mock.Store) + svc, ctx := newTestService(t, ds, nil, nil) + ctx = viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{ID: 1, GlobalRole: new(fleet.RoleAdmin)}}) + + ds.CreateCustomHostVitalFunc = func(ctx context.Context, name string) (fleet.CustomHostVital, error) { + return fleet.CustomHostVital{ID: 1, Name: name}, nil + } + + invalidNames := []struct { + name string + value string + }{ + {"empty", ""}, + {"leading space", " Asset tag"}, + {"trailing space", "Asset tag "}, + {"leading tab", "\tAsset tag"}, + {"trailing newline", "Asset tag\n"}, + } + for _, tt := range invalidNames { + t.Run("reject "+tt.name, func(t *testing.T) { + ds.CreateCustomHostVitalFuncInvoked = false + _, err := svc.CreateCustomHostVital(ctx, tt.value) + require.Error(t, err) + assert.False(t, ds.CreateCustomHostVitalFuncInvoked) + }) + } + + validNames := []struct { + name string + value string + }{ + {"internal spaces", "Asset tag"}, + {"lowercase", "asset tag"}, + {"mixed case with digits", "Rack 12B Location"}, + } + for _, tt := range validNames { + t.Run("accept "+tt.name, func(t *testing.T) { + vital, err := svc.CreateCustomHostVital(ctx, tt.value) + require.NoError(t, err) + require.NotNil(t, vital) + }) + } +} + +func TestUpsertCustomHostVitals(t *testing.T) { + t.Parallel() + ds := new(mock.Store) + opts := &TestServerOpts{} + svc, ctx := newTestService(t, ds, nil, nil, opts) + ctx = viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{ID: 1, GlobalRole: new(fleet.RoleAdmin)}}) + + t.Run("rejects invalid names without persisting", func(t *testing.T) { + ds.UpsertCustomHostVitalsFunc = func(ctx context.Context, vitals []fleet.CustomHostVital) ([]fleet.CustomHostVital, []fleet.CustomHostVital, error) { + t.Fatal("UpsertCustomHostVitals should not be called for an invalid name") + return nil, nil, nil + } + err := svc.UpsertCustomHostVitals(ctx, []fleet.CustomHostVital{{Name: " bad"}}, false) + require.Error(t, err) + }) + + t.Run("rejects duplicate names within the same payload without persisting", func(t *testing.T) { + ds.UpsertCustomHostVitalsFunc = func(ctx context.Context, vitals []fleet.CustomHostVital) ([]fleet.CustomHostVital, []fleet.CustomHostVital, error) { + t.Fatal("UpsertCustomHostVitals should not be called for a duplicate name") + return nil, nil, nil + } + err := svc.UpsertCustomHostVitals(ctx, []fleet.CustomHostVital{{Name: "Function"}, {Name: "Function"}}, false) + require.Error(t, err) + }) + + t.Run("rejects names that are duplicates under the case-insensitive collation", func(t *testing.T) { + ds.UpsertCustomHostVitalsFunc = func(ctx context.Context, vitals []fleet.CustomHostVital) ([]fleet.CustomHostVital, []fleet.CustomHostVital, error) { + t.Fatal("UpsertCustomHostVitals should not be called for a case-only duplicate name") + return nil, nil, nil + } + err := svc.UpsertCustomHostVitals(ctx, []fleet.CustomHostVital{{Name: "Function"}, {Name: "function"}}, false) + require.Error(t, err) + }) + + t.Run("dry run validates without persisting", func(t *testing.T) { + ds.UpsertCustomHostVitalsFunc = func(ctx context.Context, vitals []fleet.CustomHostVital) ([]fleet.CustomHostVital, []fleet.CustomHostVital, error) { + t.Fatal("UpsertCustomHostVitals should not be called on a dry run") + return nil, nil, nil + } + err := svc.UpsertCustomHostVitals(ctx, []fleet.CustomHostVital{{Name: "Function"}}, true) + require.NoError(t, err) + }) + + t.Run("emits an activity per created and deleted vital", func(t *testing.T) { + ds.UpsertCustomHostVitalsFunc = func(ctx context.Context, vitals []fleet.CustomHostVital) ([]fleet.CustomHostVital, []fleet.CustomHostVital, error) { + require.Equal(t, []fleet.CustomHostVital{{Name: "Function"}}, vitals) + return []fleet.CustomHostVital{{ID: 2, Name: "Function"}}, []fleet.CustomHostVital{{ID: 1, Name: "Department"}}, nil + } + var activities []activity_api.ActivityDetails + opts.ActivityMock.NewActivityFunc = func(_ context.Context, _ *activity_api.User, activity activity_api.ActivityDetails) error { + activities = append(activities, activity) + return nil + } + err := svc.UpsertCustomHostVitals(ctx, []fleet.CustomHostVital{{Name: "Function"}}, false) + require.NoError(t, err) + require.Len(t, activities, 2) + require.IsType(t, fleet.ActivityTypeCreatedCustomHostVital{}, activities[0]) + require.IsType(t, fleet.ActivityTypeDeletedCustomHostVital{}, activities[1]) + }) + + t.Run("surfaces a still-referenced vital as a conflict", func(t *testing.T) { + ds.UpsertCustomHostVitalsFunc = func(ctx context.Context, vitals []fleet.CustomHostVital) ([]fleet.CustomHostVital, []fleet.CustomHostVital, error) { + return nil, nil, &fleet.CustomHostVitalUsedError{CustomHostVitalUsedInfo: fleet.CustomHostVitalUsedInfo{ + CustomHostVitalID: 1, + CustomHostVitalName: "Department", + Entity: fleet.EntityUsingCustomHostVital{Type: fleet.CustomHostVitalEntityScript, Name: "collect.sh", FleetName: "Unassigned"}, + }} + } + err := svc.UpsertCustomHostVitals(ctx, nil, false) + require.Error(t, err) + var conflictErr *fleet.ConflictError + require.ErrorAs(t, err, &conflictErr) + }) +} diff --git a/server/service/devices_endpoint_test.go b/server/service/devices_endpoint_test.go index d15a85083d..d374142397 100644 --- a/server/service/devices_endpoint_test.go +++ b/server/service/devices_endpoint_test.go @@ -96,6 +96,9 @@ func TestGetDeviceHostEndpointScrubbing(t *testing.T) { ds.ConditionalAccessBypassedAtFunc = func(ctx context.Context, hostID uint) (*time.Time, error) { return nil, nil } + ds.GetHostCustomHostVitalsFunc = func(ctx context.Context, hostID uint) ([]fleet.HostCustomHostVital, error) { + return nil, nil + } // Inject host into context ctx = host.NewContext(ctx, h) @@ -227,6 +230,9 @@ func TestGetDeviceHostEndpointNoScrubbingForMacOS(t *testing.T) { ds.ConditionalAccessBypassedAtFunc = func(ctx context.Context, hostID uint) (*time.Time, error) { return nil, nil } + ds.GetHostCustomHostVitalsFunc = func(ctx context.Context, hostID uint) ([]fleet.HostCustomHostVital, error) { + return nil, nil + } // Inject host into context ctx = host.NewContext(ctx, h) @@ -376,6 +382,9 @@ func TestGetDeviceHostEndpointConditionalAccessBypass(t *testing.T) { ds.ConditionalAccessBypassedAtFunc = func(ctx context.Context, hostID uint) (*time.Time, error) { return nil, nil } + ds.GetHostCustomHostVitalsFunc = func(ctx context.Context, hostID uint) ([]fleet.HostCustomHostVital, error) { + return nil, nil + } // Inject host into context ctx = host.NewContext(ctx, h) diff --git a/server/service/handler.go b/server/service/handler.go index 655e506eb9..377e0bfba7 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -606,6 +606,14 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC ue.GET("/api/_version_/fleet/custom_variables", listSecretVariablesEndpoint, fleet.ListSecretVariablesRequest{}) ue.DELETE("/api/_version_/fleet/custom_variables/{id:[0-9]+}", deleteSecretVariableEndpoint, fleet.DeleteSecretVariableRequest{}) + // Custom host vitals + ue.GET("/api/_version_/fleet/custom_host_vitals", listCustomHostVitalsEndpoint, fleet.ListCustomHostVitalsRequest{}) + ue.POST("/api/_version_/fleet/custom_host_vitals", createCustomHostVitalEndpoint, fleet.CreateCustomHostVitalRequest{}) + ue.PATCH("/api/_version_/fleet/custom_host_vitals/{id:[0-9]+}", updateCustomHostVitalEndpoint, fleet.UpdateCustomHostVitalRequest{}) + ue.DELETE("/api/_version_/fleet/custom_host_vitals/{id:[0-9]+}", deleteCustomHostVitalEndpoint, fleet.DeleteCustomHostVitalRequest{}) + ue.PUT("/api/_version_/fleet/hosts/{host_id:[0-9]+}/custom_host_vitals/{id:[0-9]+}", setHostCustomHostVitalValueEndpoint, fleet.SetHostCustomHostVitalValueRequest{}) + ue.PUT("/api/_version_/fleet/spec/custom_host_vitals", upsertCustomHostVitalsEndpoint, fleet.UpsertCustomHostVitalsRequest{}) + // API end-points ue.GET("/api/_version_/fleet/rest_api", listAPIEndpointsEndpoint, listAPIEndpointsRequest{}) diff --git a/server/service/hosts.go b/server/service/hosts.go index 6992517ae0..0715227bbf 100644 --- a/server/service/hosts.go +++ b/server/service/hosts.go @@ -1980,6 +1980,11 @@ func (svc *Service) getHostDetails(ctx context.Context, host *fleet.Host, opts f } conditionalAccessBypassed := conditionalAccessBypassedAt != nil + customHostVitals, err := svc.ds.GetHostCustomHostVitals(ctx, host.ID) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "get custom host vitals for host") + } + return &fleet.HostDetail{ Host: *host, Labels: labels, @@ -1987,6 +1992,7 @@ func (svc *Service) getHostDetails(ctx context.Context, host *fleet.Host, opts f Batteries: &bats, MaintenanceWindow: nextMw, EndUsers: endUsers, + CustomHostVitals: customHostVitals, LastMDMEnrolledAt: mdmLastEnrollment, LastMDMCheckedInAt: mdmLastCheckedIn, MDMEnrollmentHardwareAttested: mdmHardwareAttested, diff --git a/server/service/hosts_test.go b/server/service/hosts_test.go index defef77ccf..ecec822b6a 100644 --- a/server/service/hosts_test.go +++ b/server/service/hosts_test.go @@ -94,12 +94,12 @@ func TestHostDetails(t *testing.T) { ds.ConditionalAccessBypassedAtFunc = func(ctx context.Context, hostID uint) (*time.Time, error) { return nil, nil } + ds.GetHostCustomHostVitalsFunc = func(ctx context.Context, hostID uint) ([]fleet.HostCustomHostVital, error) { + return nil, nil + } ds.IsHostDiskEncryptionKeyArchivedFunc = func(ctx context.Context, hostID uint) (bool, error) { return false, nil } - ds.ConditionalAccessBypassedAtFunc = func(ctx context.Context, hostID uint) (*time.Time, error) { - return nil, nil - } opts := fleet.HostDetailOptions{ IncludeCVEScores: false, @@ -152,6 +152,9 @@ func TestHostDetailsMDMAppleDiskEncryption(t *testing.T) { ds.ConditionalAccessBypassedAtFunc = func(ctx context.Context, hostID uint) (*time.Time, error) { return nil, nil } + ds.GetHostCustomHostVitalsFunc = func(ctx context.Context, hostID uint) ([]fleet.HostCustomHostVital, error) { + return nil, nil + } ds.GetNanoMDMEnrollmentDetailsFunc = func(ctx context.Context, hostUUID string) (*fleet.NanoMDMEnrollmentDetails, error) { return &fleet.NanoMDMEnrollmentDetails{}, nil } @@ -453,6 +456,9 @@ func TestHostDetailsMDMTimestamps(t *testing.T) { ds.ConditionalAccessBypassedAtFunc = func(ctx context.Context, hostID uint) (*time.Time, error) { return nil, nil } + ds.GetHostCustomHostVitalsFunc = func(ctx context.Context, hostID uint) ([]fleet.HostCustomHostVital, error) { + return nil, nil + } ds.GetHostMDMAppleProfilesFunc = func(ctx context.Context, uuid string) ([]fleet.HostMDMAppleProfile, error) { return nil, nil } @@ -572,6 +578,9 @@ func TestHostDetailsOSSettings(t *testing.T) { ds.ConditionalAccessBypassedAtFunc = func(ctx context.Context, hostID uint) (*time.Time, error) { return nil, nil } + ds.GetHostCustomHostVitalsFunc = func(ctx context.Context, hostID uint) ([]fleet.HostCustomHostVital, error) { + return nil, nil + } ds.GetNanoMDMEnrollmentDetailsFunc = func(ctx context.Context, hostUUID string) (*fleet.NanoMDMEnrollmentDetails, error) { return &fleet.NanoMDMEnrollmentDetails{}, nil } @@ -749,6 +758,9 @@ func TestHostDetailsOSSettingsWindowsOnly(t *testing.T) { ds.ConditionalAccessBypassedAtFunc = func(ctx context.Context, hostID uint) (*time.Time, error) { return nil, nil } + ds.GetHostCustomHostVitalsFunc = func(ctx context.Context, hostID uint) ([]fleet.HostCustomHostVital, error) { + return nil, nil + } ds.IsHostDiskEncryptionKeyArchivedFunc = func(ctx context.Context, hostID uint) (bool, error) { return false, nil } @@ -817,6 +829,9 @@ func TestHostDetailsRecoveryLockPasswordStatus(t *testing.T) { ds.ConditionalAccessBypassedAtFunc = func(ctx context.Context, hostID uint) (*time.Time, error) { return nil, nil } + ds.GetHostCustomHostVitalsFunc = func(ctx context.Context, hostID uint) ([]fleet.HostCustomHostVital, error) { + return nil, nil + } ds.IsHostDiskEncryptionKeyArchivedFunc = func(ctx context.Context, hostID uint) (bool, error) { return false, nil } @@ -910,6 +925,9 @@ func TestHostDetailsHostNameStatus(t *testing.T) { ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { return nil, nil } ds.ListHostDeviceMappingFunc = func(ctx context.Context, id uint) ([]*fleet.HostDeviceMapping, error) { return nil, nil } ds.ConditionalAccessBypassedAtFunc = func(ctx context.Context, hostID uint) (*time.Time, error) { return nil, nil } + ds.GetHostCustomHostVitalsFunc = func(ctx context.Context, hostID uint) ([]fleet.HostCustomHostVital, error) { + return nil, nil + } ds.IsHostDiskEncryptionKeyArchivedFunc = func(ctx context.Context, hostID uint) (bool, error) { return false, nil } ds.GetNanoMDMEnrollmentDetailsFunc = func(ctx context.Context, hostUUID string) (*fleet.NanoMDMEnrollmentDetails, error) { return &fleet.NanoMDMEnrollmentDetails{}, nil @@ -1123,6 +1141,9 @@ func TestHostAuth(t *testing.T) { ds.ConditionalAccessBypassedAtFunc = func(ctx context.Context, hostID uint) (*time.Time, error) { return nil, nil } + ds.GetHostCustomHostVitalsFunc = func(ctx context.Context, hostID uint) ([]fleet.HostCustomHostVital, error) { + return nil, nil + } ds.GetCategoriesForSoftwareTitlesFunc = func(ctx context.Context, softwareTitleIDs []uint, team_id *uint) (map[uint][]string, error) { return map[uint][]string{}, nil } @@ -3381,6 +3402,9 @@ func TestHostMDMProfileDetail(t *testing.T) { ds.ConditionalAccessBypassedAtFunc = func(ctx context.Context, hostID uint) (*time.Time, error) { return nil, nil } + ds.GetHostCustomHostVitalsFunc = func(ctx context.Context, hostID uint) ([]fleet.HostCustomHostVital, error) { + return nil, nil + } ds.GetNanoMDMEnrollmentDetailsFunc = func(ctx context.Context, hostUUID string) (*fleet.NanoMDMEnrollmentDetails, error) { return &fleet.NanoMDMEnrollmentDetails{}, nil } @@ -3531,6 +3555,9 @@ func TestHostMDMProfileScopes(t *testing.T) { ds.ConditionalAccessBypassedAtFunc = func(ctx context.Context, hostID uint) (*time.Time, error) { return nil, nil } + ds.GetHostCustomHostVitalsFunc = func(ctx context.Context, hostID uint) ([]fleet.HostCustomHostVital, error) { + return nil, nil + } ds.GetNanoMDMEnrollmentDetailsFunc = func(ctx context.Context, hostUUID string) (*fleet.NanoMDMEnrollmentDetails, error) { return &fleet.NanoMDMEnrollmentDetails{}, nil } @@ -4363,6 +4390,9 @@ func TestGetHostDetailsExcludeSoftwareFlag(t *testing.T) { ds.ConditionalAccessBypassedAtFunc = func(ctx context.Context, hostID uint) (*time.Time, error) { return nil, nil } + ds.GetHostCustomHostVitalsFunc = func(ctx context.Context, hostID uint) ([]fleet.HostCustomHostVital, error) { + return nil, nil + } ds.IsHostDiskEncryptionKeyArchivedFunc = func(ctx context.Context, hostID uint) (bool, error) { return false, nil } diff --git a/server/service/integration_custom_host_vitals_test.go b/server/service/integration_custom_host_vitals_test.go new file mode 100644 index 0000000000..7ba24f064f --- /dev/null +++ b/server/service/integration_custom_host_vitals_test.go @@ -0,0 +1,120 @@ +package service + +import ( + "fmt" + "net/http" + + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestCustomHostVitalsCRUD exercises the full lifecycle through the HTTP stack: +// list (empty) -> create -> list/search -> update -> set a host value -> +// host detail surfaces it -> delete (cascades) -> list (empty), asserting the +// activity emitted at each mutating step. +func (s *integrationTestSuite) TestCustomHostVitalsCRUD() { + t := s.T() + + // Initially empty. + var listResp fleet.ListCustomHostVitalsResponse + s.DoJSON("GET", "/api/latest/fleet/custom_host_vitals", nil, http.StatusOK, &listResp) + require.Empty(t, listResp.CustomHostVitals) + require.Equal(t, 0, listResp.Count) + + // Create. + var createResp fleet.CreateCustomHostVitalResponse + s.DoJSON("POST", "/api/latest/fleet/custom_host_vitals", fleet.CreateCustomHostVitalRequest{Name: "Asset tag"}, http.StatusOK, &createResp) + require.NotNil(t, createResp.CustomHostVital) + require.NotZero(t, createResp.CustomHostVital.ID) + require.Equal(t, "Asset tag", createResp.CustomHostVital.Name) + vitalID := createResp.CustomHostVital.ID + s.lastActivityMatches( + fleet.ActivityTypeCreatedCustomHostVital{}.ActivityName(), + fmt.Sprintf(`{"custom_host_vital_id": %d, "custom_host_vital_name": "Asset tag"}`, vitalID), + 0, + ) + + // List shows the created definition. + listResp = fleet.ListCustomHostVitalsResponse{} + s.DoJSON("GET", "/api/latest/fleet/custom_host_vitals", nil, http.StatusOK, &listResp) + require.Len(t, listResp.CustomHostVitals, 1) + require.Equal(t, 1, listResp.Count) + require.Equal(t, "Asset tag", listResp.CustomHostVitals[0].Name) + + // Duplicate name is rejected with a conflict. + s.Do("POST", "/api/latest/fleet/custom_host_vitals", fleet.CreateCustomHostVitalRequest{Name: "Asset tag"}, http.StatusConflict) + + // Update (rename). + var updateResp fleet.UpdateCustomHostVitalResponse + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/custom_host_vitals/%d", vitalID), fleet.UpdateCustomHostVitalRequest{Name: "Asset ID"}, http.StatusOK, &updateResp) + require.NotNil(t, updateResp.CustomHostVital) + require.Equal(t, vitalID, updateResp.CustomHostVital.ID) + require.Equal(t, "Asset ID", updateResp.CustomHostVital.Name) + s.lastActivityMatches( + fleet.ActivityTypeEditedCustomHostVital{}.ActivityName(), + fmt.Sprintf(`{"custom_host_vital_id": %d, "custom_host_vital_name": "Asset ID"}`, vitalID), + 0, + ) + + // Search matches by name; a non-matching query returns nothing. + listResp = fleet.ListCustomHostVitalsResponse{} + s.DoJSON("GET", "/api/latest/fleet/custom_host_vitals", nil, http.StatusOK, &listResp, "query", "asset") + require.Len(t, listResp.CustomHostVitals, 1) + listResp = fleet.ListCustomHostVitalsResponse{} + s.DoJSON("GET", "/api/latest/fleet/custom_host_vitals", nil, http.StatusOK, &listResp, "query", "nomatch") + require.Empty(t, listResp.CustomHostVitals) + + // Before any value is set, the host detail still surfaces every definition + // with an empty value. + host := s.createHosts(t)[0] + var preSetResp getHostResponse + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID), nil, http.StatusOK, &preSetResp) + require.Len(t, preSetResp.Host.CustomHostVitals, 1) + assert.Equal(t, vitalID, preSetResp.Host.CustomHostVitals[0].CustomHostVitalID) + assert.Equal(t, "Asset ID", preSetResp.Host.CustomHostVitals[0].Name) + assert.Empty(t, preSetResp.Host.CustomHostVitals[0].Value) + + // Set a value for the vital on a host. + s.Do("PUT", fmt.Sprintf("/api/latest/fleet/hosts/%d/custom_host_vitals/%d", host.ID, vitalID), fleet.SetHostCustomHostVitalValueRequest{Value: "engineering"}, http.StatusOK) + s.lastActivityMatches( + fleet.ActivityTypeEditedCustomHostVitalValue{}.ActivityName(), + fmt.Sprintf(`{"host_id": %d, "host_display_name": %q, "custom_host_vital_id": %d, "custom_host_vital_name": "Asset ID"}`, host.ID, host.DisplayName(), vitalID), + 0, + ) + + // Host detail surfaces the per-host value. + var hostResp getHostResponse + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID), nil, http.StatusOK, &hostResp) + require.Len(t, hostResp.Host.CustomHostVitals, 1) + assert.Equal(t, vitalID, hostResp.Host.CustomHostVitals[0].CustomHostVitalID) + assert.Equal(t, "Asset ID", hostResp.Host.CustomHostVitals[0].Name) + assert.Equal(t, "engineering", hostResp.Host.CustomHostVitals[0].Value) + + // Clearing the value (Save empty) is accepted and persists as an empty string. + s.Do("PUT", fmt.Sprintf("/api/latest/fleet/hosts/%d/custom_host_vitals/%d", host.ID, vitalID), fleet.SetHostCustomHostVitalValueRequest{Value: ""}, http.StatusOK) + hostResp = getHostResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID), nil, http.StatusOK, &hostResp) + require.Len(t, hostResp.Host.CustomHostVitals, 1) + assert.Equal(t, vitalID, hostResp.Host.CustomHostVitals[0].CustomHostVitalID) + assert.Empty(t, hostResp.Host.CustomHostVitals[0].Value) + + // Delete the definition; the per-host value cascades away. + s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/custom_host_vitals/%d", vitalID), nil, http.StatusOK) + s.lastActivityMatches( + fleet.ActivityTypeDeletedCustomHostVital{}.ActivityName(), + fmt.Sprintf(`{"custom_host_vital_id": %d, "custom_host_vital_name": "Asset ID"}`, vitalID), + 0, + ) + + // List is empty again. + listResp = fleet.ListCustomHostVitalsResponse{} + s.DoJSON("GET", "/api/latest/fleet/custom_host_vitals", nil, http.StatusOK, &listResp) + require.Empty(t, listResp.CustomHostVitals) + require.Equal(t, 0, listResp.Count) + + // Host detail no longer surfaces the value. + hostResp = getHostResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID), nil, http.StatusOK, &hostResp) + require.Empty(t, hostResp.Host.CustomHostVitals) +} diff --git a/server/service/labels.go b/server/service/labels.go index 8f79c863f8..edd2fc0686 100644 --- a/server/service/labels.go +++ b/server/service/labels.go @@ -80,6 +80,9 @@ func (svc *Service) NewLabel(ctx context.Context, p fleet.LabelPayload) (*fleet. if err != nil { return nil, nil, fleet.NewInvalidArgumentError("criteria", fmt.Sprintf("invalid criteria: %s", err.Error())) } + if err := svc.validateCustomHostVitalCriteria(ctx, label.HostVitalsCriteria); err != nil { + return nil, nil, err + } } else { if p.Query != "" && (len(p.Hosts) > 0 || len(p.HostIDs) > 0) { return nil, nil, fleet.NewInvalidArgumentError("query", `Only one of "criteria", "query" or "hosts/host_ids" can be included in the request.`) @@ -149,6 +152,33 @@ func (svc *Service) NewLabel(ctx context.Context, p fleet.LabelPayload) (*fleet. return label, nil, nil } +// validateCustomHostVitalCriteria verifies that a custom_host_vital criterion +// references a custom host vital that actually exists. Without this a label +// could be created against a stale or made-up id, which would silently match +// zero hosts (the membership join finds no rows) instead of erroring. +func (svc *Service) validateCustomHostVitalCriteria(ctx context.Context, raw *json.RawMessage) error { + if raw == nil { + return nil + } + var criteria fleet.HostVitalCriteria + if err := json.Unmarshal(*raw, &criteria); err != nil { + return fleet.NewInvalidArgumentError("criteria", fmt.Sprintf("invalid criteria: %s", err.Error())) + } + if criteria.CustomHostVitalID == nil { + return nil + } + id := *criteria.CustomHostVitalID + + existing, err := svc.ds.GetCustomHostVitals(ctx, []uint{id}) + if err != nil { + return ctxerr.Wrap(ctx, err, "validate custom host vital criteria") + } + if len(existing) == 0 { + return fleet.NewInvalidArgumentError("criteria", fmt.Sprintf("custom host vital %d does not exist", id)) + } + return nil +} + // authorizeWriteLabelOnHosts verifies that the caller is authorized to write // labels (the write_host_label action) to every host in hostIDs. It returns a // permission error if the caller lacks write access to any of them, which @@ -681,6 +711,18 @@ func (svc *Service) ApplyLabelSpecs(ctx context.Context, specs []*fleet.LabelSpe if err := fleet.ValidateLabelMembershipFields(spec); err != nil { return err.WithStatus(http.StatusUnprocessableEntity) } + // Validate host vitals criteria structurally (unknown vital, missing + // custom_host_vital_id, etc.) and that any referenced custom vital + // exists, mirroring the checks in NewLabel so a bad spec fails at apply + // rather than silently matching no hosts at cron evaluation time. + if spec.LabelMembershipType == fleet.LabelMembershipTypeHostVitals { + if _, _, err := (&fleet.Label{HostVitalsCriteria: spec.HostVitalsCriteria}).CalculateHostVitalsQuery(); err != nil { + return fleet.NewInvalidArgumentError("criteria", fmt.Sprintf("invalid criteria: %s", err.Error())).WithStatus(http.StatusUnprocessableEntity) + } + if err := svc.validateCustomHostVitalCriteria(ctx, spec.HostVitalsCriteria); err != nil { + return err + } + } if spec.LabelType == fleet.LabelTypeBuiltIn { // We allow specs to contain built-in labels as long as they are not being modified. // This allows the user to do the following workflow without manually removing built-in labels: diff --git a/server/service/labels_test.go b/server/service/labels_test.go index e2372a0efd..9319a69d89 100644 --- a/server/service/labels_test.go +++ b/server/service/labels_test.go @@ -530,6 +530,46 @@ func TestApplyLabelSpecsWithBuiltInLabels(t *testing.T) { assert.ErrorIs(t, err, assert.AnError) } +func TestApplyLabelSpecsCustomHostVitalCriteria(t *testing.T) { + t.Parallel() + ds := new(mock.Store) + svc, ctx := newTestService(t, ds, nil, nil) + ctx = viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{ID: 1, GlobalRole: new(fleet.RoleAdmin)}}) + + specWithCriteria := func(criteria fleet.HostVitalCriteria) *fleet.LabelSpec { + raw, err := json.Marshal(&criteria) + require.NoError(t, err) + return &fleet.LabelSpec{ + Name: "custom-vital-spec", + LabelType: fleet.LabelTypeRegular, + LabelMembershipType: fleet.LabelMembershipTypeHostVitals, + HostVitalsCriteria: new(json.RawMessage(raw)), + } + } + + // A custom_host_vital criterion without an id fails structural validation + // before any datastore call. + err := svc.ApplyLabelSpecs(ctx, []*fleet.LabelSpec{specWithCriteria(fleet.HostVitalCriteria{ + Vital: new("custom_host_vital"), + Value: new("Engineering"), + })}, nil, nil) + require.Error(t, err) + require.Contains(t, err.Error(), "custom_host_vital_id") + + // A criterion referencing a non-existent custom vital is rejected. + ds.GetCustomHostVitalsFunc = func(ctx context.Context, ids []uint) ([]fleet.CustomHostVital, error) { + return nil, nil + } + err = svc.ApplyLabelSpecs(ctx, []*fleet.LabelSpec{specWithCriteria(fleet.HostVitalCriteria{ + Vital: new("custom_host_vital"), + Value: new("Engineering"), + CustomHostVitalID: new(uint(999)), + })}, nil, nil) + require.Error(t, err) + require.Contains(t, err.Error(), "does not exist") + require.True(t, ds.GetCustomHostVitalsFuncInvoked) +} + func TestLabelsWithReplica(t *testing.T) { opts := &testing_utils.DatastoreTestOptions{DummyReplica: true} ds := mysqltest.CreateMySQLDSWithOptions(t, opts) @@ -1212,6 +1252,60 @@ func TestNewHostVitalsLabel(t *testing.T) { assert.Equal(t, "SELECT %s FROM %s JOIN host_scim_user ON (hosts.id = host_scim_user.host_id) JOIN scim_users ON (host_scim_user.scim_user_id = scim_users.id) LEFT JOIN scim_user_group ON (host_scim_user.scim_user_id = scim_user_group.scim_user_id) LEFT JOIN scim_groups ON (scim_user_group.group_id = scim_groups.id) WHERE scim_groups.display_name = ? GROUP BY hosts.id", query) assert.Equal(t, `["admin"]`, string(queryValuesJson)) }) + + t.Run("create custom host vital label", func(t *testing.T) { + ds.GetCustomHostVitalsFunc = func(ctx context.Context, ids []uint) ([]fleet.CustomHostVital, error) { + return []fleet.CustomHostVital{{ID: 7, Name: "Department"}}, nil + } + ds.GetCustomHostVitalsFuncInvoked = false + + lbl, _, err := svc.NewLabel(ctx, fleet.LabelPayload{ + Name: "custom-vital-label", + Criteria: &fleet.HostVitalCriteria{ + Vital: new("custom_host_vital"), + Value: new("Engineering"), + CustomHostVitalID: new(uint(7)), + }, + }) + require.NoError(t, err) + assert.True(t, ds.GetCustomHostVitalsFuncInvoked) + assert.Equal(t, fleet.LabelMembershipTypeHostVitals, lbl.LabelMembershipType) + + query, queryValues, err := lbl.CalculateHostVitalsQuery() + require.NoError(t, err) + queryValuesJson, err := json.Marshal(queryValues) + require.NoError(t, err) + assert.Equal(t, "SELECT %s FROM %s JOIN host_custom_host_vitals ON (hosts.id = host_custom_host_vitals.host_id AND host_custom_host_vitals.custom_host_vital_id = ?) WHERE host_custom_host_vitals.value = ? GROUP BY hosts.id", query) + assert.JSONEq(t, `[7,"Engineering"]`, string(queryValuesJson)) + }) + + t.Run("custom host vital label missing id is rejected", func(t *testing.T) { + _, _, err := svc.NewLabel(ctx, fleet.LabelPayload{ + Name: "custom-vital-no-id", + Criteria: &fleet.HostVitalCriteria{ + Vital: new("custom_host_vital"), + Value: new("Engineering"), + }, + }) + require.Error(t, err) + require.Contains(t, err.Error(), "custom_host_vital_id") + }) + + t.Run("custom host vital label with unknown id is rejected", func(t *testing.T) { + ds.GetCustomHostVitalsFunc = func(ctx context.Context, ids []uint) ([]fleet.CustomHostVital, error) { + return nil, nil + } + _, _, err := svc.NewLabel(ctx, fleet.LabelPayload{ + Name: "custom-vital-bad-id", + Criteria: &fleet.HostVitalCriteria{ + Vital: new("custom_host_vital"), + Value: new("Engineering"), + CustomHostVitalID: new(uint(999)), + }, + }) + require.Error(t, err) + require.Contains(t, err.Error(), "does not exist") + }) } func TestNewLabelFieldValidation(t *testing.T) { diff --git a/server/service/mdm.go b/server/service/mdm.go index 3138419301..0c45bf213b 100644 --- a/server/service/mdm.go +++ b/server/service/mdm.go @@ -2249,6 +2249,22 @@ func (svc *Service) BatchSetMDMProfiles( return ctxerr.Wrap(ctx, err, "validating profiles") } + // Only Apple and Windows profiles expand $FLEET_HOST_VITAL_ tokens at delivery. + // Android has no expansion path and is rejected outright in getAndroidProfiles + // (MDMAndroidConfigProfile.ValidateUserProvided) below; skip it here so an Android + // profile referencing a vital gets that clear "not supported" error rather than a + // misleading "missing from database" one from this existence check. + customHostVitalDocs := make([]string, 0, len(profiles)) + for _, p := range profiles { + if mdm.GetRawProfilePlatform(p.Contents) == "android" { + continue + } + customHostVitalDocs = append(customHostVitalDocs, string(p.Contents)) + } + if err := svc.ds.ValidateReferencedCustomHostVitals(ctx, customHostVitalDocs); err != nil { + return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("profiles", err.Error())) + } + appleProfiles, appleDecls, err := getAppleProfiles(ctx, tmID, appCfg, profilesWithSecrets, labelMap, svc.config.MDM) if err != nil { return ctxerr.Wrap(ctx, err, "validating macOS profiles") diff --git a/server/service/microsoft_mdm.go b/server/service/microsoft_mdm.go index 322484eef7..5149fcc08b 100644 --- a/server/service/microsoft_mdm.go +++ b/server/service/microsoft_mdm.go @@ -3550,6 +3550,13 @@ func ReconcileWindowsProfilesForEnrollingHost(ctx context.Context, ds fleet.Data return executeWindowsProfileReconcileBatch(ctx, ds, logger, appConfig, toInstall, toRemove, desiredByHost) } +// windowsProfileNeedsPerHostProcessing reports whether a Windows profile must be +// processed per-host at delivery time — i.e. it references any FLEET_VAR_ variable +// or any $FLEET_HOST_VITAL_ custom host vital. +func windowsProfileNeedsPerHostProcessing(syncML []byte) bool { + return variables.ContainsBytes(syncML) || len(fleet.ContainsCustomHostVitalIDs(string(syncML))) > 0 +} + // ReconcileWindowsProfiles applies configuration profiles to Windows MDM hosts. // // It walks every enrolled Windows host via a host_uuid cursor (persisted in Redis through the mysqlredis wrapper), loading a @@ -3958,7 +3965,7 @@ func executeWindowsProfileReconcileBatch( continue } p, ok := profileContents[profUUID] - if !ok || variables.ContainsBytes(p.SyncML) { + if !ok || windowsProfileNeedsPerHostProcessing(p.SyncML) { continue // variable profiles get per-host commands, can't pre-build } command, err := buildCommandFromProfileBytes(p.SyncML, target.cmdUUID) @@ -4007,7 +4014,7 @@ func executeWindowsProfileReconcileBatch( continue } - if !variables.ContainsBytes(p.SyncML) { + if !windowsProfileNeedsPerHostProcessing(p.SyncML) { // No Fleet variables, send the same command to all hosts payloads, ok := batchProfileCmdsMap[target.cmdUUID] if !ok { diff --git a/server/service/orbit.go b/server/service/orbit.go index a9f46d0c25..f0f7e9dbdf 100644 --- a/server/service/orbit.go +++ b/server/service/orbit.go @@ -1117,6 +1117,11 @@ func (svc *Service) GetHostScript(ctx context.Context, execID string) (*fleet.Ho return nil, ctxerr.Wrap(ctx, err, fmt.Sprintf("expand embedded secrets for host %d and script %s", host.ID, execID)) } + script.ScriptContents, err = svc.ds.ExpandCustomHostVitals(ctx, host.ID, script.ScriptContents) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, fmt.Sprintf("expand custom host vitals for host %d and script %s", host.ID, execID)) + } + return script, nil } diff --git a/server/service/scripts.go b/server/service/scripts.go index c8778dc3b3..d5d46757ad 100644 --- a/server/service/scripts.go +++ b/server/service/scripts.go @@ -128,7 +128,7 @@ func (svc *Service) RunHostScript(ctx context.Context, request *fleet.HostScript } if request.ScriptContents != "" { - if err := svc.ds.ValidateEmbeddedSecrets(ctx, []string{request.ScriptContents}); err != nil { + if err := fleet.ValidateEmbeddedSecretsAndCustomHostVitals(ctx, svc.ds, []string{request.ScriptContents}); err != nil { svc.authz.SkipAuthorization(ctx) return nil, fleet.NewInvalidArgumentError("script", err.Error()) } @@ -410,7 +410,7 @@ func (svc *Service) NewScript(ctx context.Context, teamID *uint, name string, r ScriptContents: file.Dos2UnixNewlines(string(b)), } - if err := svc.ds.ValidateEmbeddedSecrets(ctx, []string{script.ScriptContents}); err != nil { + if err := fleet.ValidateEmbeddedSecretsAndCustomHostVitals(ctx, svc.ds, []string{script.ScriptContents}); err != nil { return nil, fleet.NewInvalidArgumentError("script", err.Error()) } @@ -613,7 +613,7 @@ func (svc *Service) UpdateScript(ctx context.Context, scriptID uint, r io.Reader scriptContents := file.Dos2UnixNewlines(string(b)) - if err := svc.ds.ValidateEmbeddedSecrets(ctx, []string{scriptContents}); err != nil { + if err := fleet.ValidateEmbeddedSecretsAndCustomHostVitals(ctx, svc.ds, []string{scriptContents}); err != nil { return nil, fleet.NewInvalidArgumentError("script", err.Error()) } @@ -766,7 +766,7 @@ func (svc *Service) BatchSetScripts(ctx context.Context, maybeTmID *uint, maybeT return nil, nil } - if err := svc.ds.ValidateEmbeddedSecrets(ctx, scriptContents); err != nil { + if err := fleet.ValidateEmbeddedSecretsAndCustomHostVitals(ctx, svc.ds, scriptContents); err != nil { return nil, fleet.NewInvalidArgumentError("script", err.Error()) } diff --git a/server/service/testing_utils_test.go b/server/service/testing_utils_test.go index 40e50863cb..864bdacc0b 100644 --- a/server/service/testing_utils_test.go +++ b/server/service/testing_utils_test.go @@ -90,6 +90,16 @@ func newTestService(t *testing.T, ds fleet.Datastore, rs fleet.QueryResultStore, } func newTestServiceWithConfig(t *testing.T, ds fleet.Datastore, fleetConfig config.FleetConfig, rs fleet.QueryResultStore, lq fleet.LiveQueryStore, opts ...*TestServerOpts) (fleet.Service, context.Context) { + // Custom host vital reference validation is wired into all script/profile + // upload paths. Provide a permissive default so tests that don't reference + // $FLEET_HOST_VITAL_ don't need to stub it (the real datastore no-ops + // when the document has no such tokens). Tests that assert on it can override. + if mockDS, ok := ds.(*fleet_mock.Store); ok { + if mockDS.ValidateReferencedCustomHostVitalsFunc == nil { + mockDS.ValidateReferencedCustomHostVitalsFunc = func(ctx context.Context, documents []string) error { return nil } + } + } + lic := &fleet.LicenseInfo{Tier: fleet.TierFree} logger := slog.New(slog.DiscardHandler) writer, err := logging.NewFilesystemLogWriter(t.Context(), fleetConfig.Filesystem.StatusLogFile, logger, fleetConfig.Filesystem.EnableLogRotation, diff --git a/server/service/windows_mdm_profiles.go b/server/service/windows_mdm_profiles.go index f7242be56f..11a3a7f6f8 100644 --- a/server/service/windows_mdm_profiles.go +++ b/server/service/windows_mdm_profiles.go @@ -92,7 +92,7 @@ func (svc *Service) NewMDMWindowsConfigProfile(ctx context.Context, teamID uint, } cp.LabelsExcludeAny = excludeLabels - if err := svc.ds.ValidateEmbeddedSecrets(ctx, []string{string(cp.SyncML)}); err != nil { + if err := fleet.ValidateEmbeddedSecretsAndCustomHostVitals(ctx, svc.ds, []string{string(cp.SyncML)}); err != nil { return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("profile", err.Error())) } From e6eb37988ff33d77820246b33fcd85a3224dedf3 Mon Sep 17 00:00:00 2001 From: Eric Date: Wed, 15 Jul 2026 17:36:56 -0500 Subject: [PATCH 4/4] Website: update testimonials and logo carousel (#49313) Changes: - Removed "Former" from job titles in testimonials - Brought back the Uber logo on two testimonials and the logo carousel component. --- handbook/company/testimonials.yml | 12 +++++++----- .../assets/images/logos/logo-uber-65x32@2x.png | Bin 0 -> 789 bytes .../images/social-proof-logo-uber-71x24@2x.png | Bin 0 -> 829 bytes .../js/components/logo-carousel.component.js | 12 ++++++++---- 4 files changed, 15 insertions(+), 9 deletions(-) create mode 100644 website/assets/images/logos/logo-uber-65x32@2x.png create mode 100644 website/assets/images/social-proof-logo-uber-71x24@2x.png diff --git a/handbook/company/testimonials.yml b/handbook/company/testimonials.yml index aee1575275..bb1e93ec88 100644 --- a/handbook/company/testimonials.yml +++ b/handbook/company/testimonials.yml @@ -27,7 +27,8 @@ quoteAuthorName: Luis Madrigal quoteAuthorProfileImageFilename: testimonial-author-luis-madrigal-100x100@2x.png quoteLinkUrl: https://www.linkedin.com/in/luismadrigal/ - quoteAuthorJobTitle: Former Engineering Leader, Uber + quoteImageFilename: social-proof-logo-uber-71x24@2x.png + quoteAuthorJobTitle: Engineering Leader, Uber productCategories: [Device management, Observability, Software management] - quote: Yes Sir. Great tools for the everyday open-source geeks 💯 quoteAuthorName: Alvaro Gutierrez @@ -60,7 +61,7 @@ quoteLinkUrl: https://www.linkedin.com/in/danielgrzelak/ quoteAuthorName: Dan Grzelak quoteAuthorProfileImageFilename: testimonial-author-daniel-grzelak-48x48@2x.png - quoteAuthorJobTitle: Former Security Chief of Staff + quoteAuthorJobTitle: Security Chief of Staff productCategories: [Observability, Software management, Device management] - quote: We can build it exactly the way we want it. Which is just not possible on other platforms. quoteAuthorName: Austin Anderson @@ -72,8 +73,9 @@ - quote: Exciting. This is a team that listens to feedback. quoteLinkUrl: https://www.linkedin.com/in/eriknicolasgomez/ quoteAuthorName: Erik Gomez + quoteImageFilename: social-proof-logo-uber-71x24@2x.png quoteAuthorProfileImageFilename: testimonial-author-erik-gomez-48x48@2x.png - quoteAuthorJobTitle: Former Staff Client Platform Engineer + quoteAuthorJobTitle: Staff Client Platform Engineer productCategories: [Observability, Device management, Software management] - quote: Context is king for device data, and Fleet provides a way to surface that information to our other teams and partners. quoteAuthorName: Nick Fohs @@ -87,14 +89,14 @@ quoteLinkUrl: https://www.linkedin.com/in/nwaisman/ quoteAuthorName: Nico Waisman quoteAuthorProfileImageFilename: testimonial-author-nico-waisman-48x48@2x.png - quoteAuthorJobTitle: Former CISO of Lyft + quoteAuthorJobTitle: CISO of Lyft productCategories: [Observability, Software management] - quote: Having the freedom to take full advantage of the product is one of the reasons why I always support open-source products with a commercially-backed company, like Fleet. quoteImageFilename: social-proof-logo-lyft-47x32@2x.png quoteLinkUrl: https://www.linkedin.com/posts/nwaisman_movingtofleet-activity-7156319785981509632-bk_W quoteAuthorName: nico waisman # Note: this name is lowercased here so we can display only one Nico Waisman quote on the testimonials page (which does not filter quotes by product category) (The name will be capitalized via CSS) quoteAuthorProfileImageFilename: testimonial-author-nico-waisman-48x48@2x.png - quoteAuthorJobTitle: Former CISO of Lyft + quoteAuthorJobTitle: CISO of Lyft productCategories: [Device management] # « explanation: https://github.com/fleetdm/fleet/blob/f412b1f02fb6d6f36bdc0776252fff72fc0fc2ea/website/views/pages/device-management.ejs#L32 - quote: I had to answer some really complex questions for a compliance audit, and I was able to do it in about 15 minutes by munging some data together via a few queries into a csv. It took me longer to remember how to use `xsv` than to actually put together the report. If you aren't using osquery in your environment, you should be. quoteAuthorName: Charles Zaffery diff --git a/website/assets/images/logos/logo-uber-65x32@2x.png b/website/assets/images/logos/logo-uber-65x32@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..368c7b3430cb616297d09a33395ec8af839210e6 GIT binary patch literal 789 zcmV+w1M2*VP)kfMu^ql}NDjE|y=kE4%|uZNGHi;$v=kD`l@qKuEDiI1a;kD`yU zz^niO013$Fi=ocVuwcn00MnUL_t(&-tCvswxb{nMTY>1 z20RUpy=F`GU<#Dmrb|B1_G7C2?(}mXKO}kWLT<11IO=N))9q@IH zAq2t-Id>r#%u@E$0+1G1PY8x`9|E3RprkKZ)pSIF+=swI?-P>7ur!|F6X!WbVD%#4 z5{qF=H56)&*#l&E&+2Q$oDtAY&bbZ*S{lm+?SEt+8aW$yM4+Fpb3tx^I-fANDJdR2 z&|o@lN*{vUf^lE5&5uDmdRF2#I3CYV1kOCYeRw`koN-mjvAqw$Yc_5(UYH`4PBFsL zjo=hptec`XWRspy-UK7KUIb8mSnF^5%aSSx#0aJrA*sHl*0=eC=Kn{q(!@jfZ1)G5 zy9nU}g1CQu7Xt5Csq`hn){nrYNgp$T;J=v#vX@e4A#p^WIbZIGR69A^4E zlzn$DnDe=?+(6(6DddoEo{rUpHxPIpbI>YFgzT>G2+mpVA=r!BYYBh6Dd&Ap6yED+`n8f@F{){PsHnqc~2a z7JU}@Wl6!R+?0}5Zs=8bc-jR5%ZjuV)yvv=(E@Q)NKf`ro(m$)f~cz?h#%0utc`E0 zQD90Hffp^}V-e6#h1s~5%Hm6=#3%n83Ie~BfL<*jJd}cqZ9MKphlYlRhW@F)q34H= Txp_Wi00000NkvXXu0mjfftF!8 literal 0 HcmV?d00001 diff --git a/website/assets/images/social-proof-logo-uber-71x24@2x.png b/website/assets/images/social-proof-logo-uber-71x24@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..7b3232bec378d3a3d6415567e75487c85f199df6 GIT binary patch literal 829 zcmeAS@N?(olHy`uVBq!ia0vp^eL!r$!3-onJlKZ6LR^6~sbDdy+ex4vfs!D< zU7shROn%*93yS(u2`hTxa8_<{^P};FKSBm{QwenR{z767vz6Y z_7@bH{vuNq_bwJ&c{usbg-+*G-Q}*%Sv`Lum-lsBnXQ${`7~QX^hLvM zD^0z7{@&_6{mk>?c2@qHyISKy>5GG@b6d_HS>KlySZwk{DalE_aA(E41+F(Qmgv3o zlic`bVRLOkth)d57@M=r=U=#ee0jm~&8~AzM@~MsG!-plVA?5Da^mi#mtID;=4)4! zFG*gv@Njr(LD7u!dk?&IIk;hA-|nTsS2J#%4LhDL*<9Lin|H2f@25Pa+r`Y9N5Z8o zrsls)v3+&%o0!SNxqg2pzP`R~yR=xx(Hc*6ZL6yx>O50ix0LP)mi!;xzUJvm)45k= zPL}vDPt5deTIT#LC@#_Mq3yq3zpZHtoSv^2KD~RJG@m%LZ@QdYXi({*%Ml-o+mb)r zwwipAoj+7vJbC8ZdX-DxOJ(-HT|D7};McF7`Zp!yqSYUzX;x3LDw0n+r+A@I*G_Qv z+Tcw!X%(-mo*dos>iYVyl*LbUWlN&}-rK~tl6n4u9^R&mEtQpbPv*Q#=oI*EpK8b- z>t1-AyJ$+UkTr{Rk&}C^ve(3!$xbID+1J?E?wb3oMO>Ht&uY$93vHSd)y`~P)jZR@ zv5&X%D5R=rZExoz5}SX2G{ch-R}SAXvbubnhqIyUTWlx?j0>lLia z4=7Df|1HrHu(a97AisGI-@~JO8*eL`i!XoqqGev)cmK6TmD6%|{N4U?<@xN>$xp)4 l-}Bg9cj2Ef`F~IKNA7vr)3u}*9O?#T0#8>zmvv4FO#q@daV`J= literal 0 HcmV?d00001 diff --git a/website/assets/js/components/logo-carousel.component.js b/website/assets/js/components/logo-carousel.component.js index c7555349d4..fd59377660 100644 --- a/website/assets/js/components/logo-carousel.component.js +++ b/website/assets/js/components/logo-carousel.component.js @@ -54,7 +54,8 @@ parasails.registerComponent('logoCarousel', { Cursor logo Grafana labs logo - + + Uber logo Proton logo Mozilla logo Nutanix logo @@ -96,7 +97,8 @@ parasails.registerComponent('logoCarousel', { Cursor logo Grafana labs logo - + + Uber logo Proton logo Mozilla logo Nutanix logo @@ -148,7 +150,8 @@ parasails.registerComponent('logoCarousel', { Lastpass logo Smarter Technologies logo Treeline logo - + + Uber logo Proton logo Mozilla logo Nutanix logo @@ -186,7 +189,8 @@ parasails.registerComponent('logoCarousel', { Lastpass logo Smarter Technologies logo Treeline logo - + + Uber logo Proton logo Mozilla logo Nutanix logo