diff --git a/cmd/fleetctl/fleetctl/generate_gitops.go b/cmd/fleetctl/fleetctl/generate_gitops.go index dd69a1ebfc..0527268c83 100644 --- a/cmd/fleetctl/fleetctl/generate_gitops.go +++ b/cmd/fleetctl/fleetctl/generate_gitops.go @@ -1279,10 +1279,13 @@ func (cmd *GenerateGitopsCommand) generateLabels() ([]map[string]interface{}, er if label.Platform != "" { labelSpec[jsonFieldName(t, "Platform")] = label.Platform } - if label.LabelMembershipType == fleet.LabelMembershipTypeDynamic { - labelSpec[jsonFieldName(t, "Query")] = label.Query - } else { + switch label.LabelMembershipType { + case fleet.LabelMembershipTypeManual: labelSpec[jsonFieldName(t, "Hosts")] = label.Hosts + case fleet.LabelMembershipTypeDynamic: + labelSpec[jsonFieldName(t, "Query")] = label.Query + case fleet.LabelMembershipTypeHostVitals: + labelSpec[jsonFieldName(t, "HostVitalsCriteria")] = label.HostVitalsCriteria } result = append(result, labelSpec) diff --git a/cmd/fleetctl/fleetctl/generate_gitops_test.go b/cmd/fleetctl/fleetctl/generate_gitops_test.go index fb67798859..9c2da89d07 100644 --- a/cmd/fleetctl/fleetctl/generate_gitops_test.go +++ b/cmd/fleetctl/fleetctl/generate_gitops_test.go @@ -361,6 +361,11 @@ func (MockClient) GetLabels() ([]*fleet.LabelSpec, error) { Description: "Label B description", LabelMembershipType: fleet.LabelMembershipTypeManual, Hosts: []string{"host1", "host2"}, + }, { + Name: "Label C", + Description: "Label C description", + LabelMembershipType: fleet.LabelMembershipTypeHostVitals, + HostVitalsCriteria: ptr.RawMessage(json.RawMessage(`{"vital": "end_user_idp_group", "value": "some-group"}`)), }}, nil } diff --git a/cmd/fleetctl/fleetctl/testdata/generateGitops/expectedLabels.yaml b/cmd/fleetctl/fleetctl/testdata/generateGitops/expectedLabels.yaml index 34a75cc6e1..e054141ec4 100644 --- a/cmd/fleetctl/fleetctl/testdata/generateGitops/expectedLabels.yaml +++ b/cmd/fleetctl/fleetctl/testdata/generateGitops/expectedLabels.yaml @@ -9,3 +9,9 @@ hosts: - host1 - host2 +- name: Label C + description: Label C description + label_membership_type: host_vitals + criteria: + vital: end_user_idp_group + value: some-group \ No newline at end of file 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 d06b393b34..375d3e942b 100644 --- a/cmd/fleetctl/fleetctl/testdata/generateGitops/test_dir_free/default.yml +++ b/cmd/fleetctl/fleetctl/testdata/generateGitops/test_dir_free/default.yml @@ -39,6 +39,12 @@ labels: - host2 label_membership_type: manual name: Label B +- criteria: + value: some-group + vital: end_user_idp_group + description: Label C description + label_membership_type: host_vitals + name: Label C org_settings: features: additional_queries: 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 61a3ae589d..47d4aa43b6 100644 --- a/cmd/fleetctl/fleetctl/testdata/generateGitops/test_dir_premium/default.yml +++ b/cmd/fleetctl/fleetctl/testdata/generateGitops/test_dir_premium/default.yml @@ -24,6 +24,12 @@ labels: - host2 label_membership_type: manual name: Label B +- criteria: + value: some-group + vital: end_user_idp_group + description: Label C description + label_membership_type: host_vitals + name: Label C org_settings: features: additional_queries: diff --git a/pkg/spec/gitops.go b/pkg/spec/gitops.go index 3565139e86..d5acf3e77a 100644 --- a/pkg/spec/gitops.go +++ b/pkg/spec/gitops.go @@ -675,13 +675,28 @@ func parseLabels(top map[string]json.RawMessage, result *GitOps, baseDir string, if l.Name == "" { multiError = multierror.Append(multiError, errors.New("name is required for each label")) } - if l.Query == "" && len(l.Hosts) == 0 { - multiError = multierror.Append(multiError, errors.New("a SQL query or hosts list is required for each label")) + if l.Query == "" && len(l.Hosts) == 0 && l.HostVitalsCriteria == nil { + multiError = multierror.Append(multiError, errors.New("a SQL query, hosts list or host vitals criteria is required for each label")) } // Don't use non-ASCII if !isASCII(l.Name) { multiError = multierror.Append(multiError, fmt.Errorf("label name must be in ASCII: %s", l.Name)) } + // Check that host vitals criteria is valid + if l.HostVitalsCriteria != nil { + criteriaJson, err := json.Marshal(l.HostVitalsCriteria) + if err != nil { + multiError = multierror.Append(multiError, fmt.Errorf("failed to marshal host vitals criteria for label %s: %v", l.Name, err)) + continue + } + label := fleet.Label{ + Name: l.Name, + HostVitalsCriteria: ptr.RawMessage(criteriaJson), + } + if _, _, err := label.CalculateHostVitalsQuery(); err != nil { + multiError = multierror.Append(multiError, fmt.Errorf("invalid host vitals criteria for label %s: %v", l.Name, err)) + } + } } duplicates := getDuplicateNames( result.Labels, func(l *fleet.LabelSpec) string { diff --git a/server/datastore/mysql/labels.go b/server/datastore/mysql/labels.go index c3c107dc2c..4b816241ed 100644 --- a/server/datastore/mysql/labels.go +++ b/server/datastore/mysql/labels.go @@ -33,15 +33,17 @@ func (ds *Datastore) ApplyLabelSpecsWithAuthor(ctx context.Context, specs []*fle platform, label_type, label_membership_type, + criteria, author_id - ) VALUES ( ?, ?, ?, ?, ?, ?, ? ) + ) VALUES ( ?, ?, ?, ?, ?, ?, ?, ? ) ON DUPLICATE KEY UPDATE name = VALUES(name), description = VALUES(description), query = VALUES(query), platform = VALUES(platform), label_type = VALUES(label_type), - label_membership_type = VALUES(label_membership_type) + label_membership_type = VALUES(label_membership_type), + criteria = VALUES(criteria) ` prepTx, ok := tx.(sqlx.PreparerContext) @@ -58,7 +60,7 @@ func (ds *Datastore) ApplyLabelSpecsWithAuthor(ctx context.Context, specs []*fle if s.Name == "" { return ctxerr.New(ctx, "label name must not be empty") } - _, err := stmt.ExecContext(ctx, s.Name, s.Description, s.Query, s.Platform, s.LabelType, s.LabelMembershipType, authorID) + _, err := stmt.ExecContext(ctx, s.Name, s.Description, s.Query, s.Platform, s.LabelType, s.LabelMembershipType, s.HostVitalsCriteria, authorID) if err != nil { return ctxerr.Wrap(ctx, err, "exec ApplyLabelSpecs insert") } @@ -182,6 +184,58 @@ VALUES ` + strings.Join(placeholders, ", ") return ds.labelDB(ctx, labelID, teamFilter, ds.writer(ctx)) } +// Update label membership for a host vitals label. +func (ds *Datastore) UpdateLabelMembershipByHostCriteria(ctx context.Context, hvl fleet.HostVitalsLabel) (*fleet.Label, error) { + // Get the label data. + label := hvl.GetLabel() + + // If the label isn't a host vitals label, bail out. + if label.LabelMembershipType != fleet.LabelMembershipTypeHostVitals { + return nil, ctxerr.New(ctx, "label is not a host vitals label") + } + + // Get the query and value params for the host vitals label. + query, queryVals, err := hvl.CalculateHostVitalsQuery() + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "calculating host vitals query") + } + if query == "" { + return nil, ctxerr.New(ctx, "label query is empty after calculating host vitals query") + } + + err = ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { + labelSelect := fmt.Sprintf("%d as label_id, hosts.id as host_id", label.ID) + labelQuery := fmt.Sprintf(query, labelSelect, "hosts") + // Insert new label membership based on the label query. + sql := fmt.Sprintf(`INSERT INTO label_membership (label_id, host_id) SELECT candidate.label_id, candidate.host_id FROM (%s) as candidate ON DUPLICATE KEY UPDATE host_id = label_membership.host_id`, labelQuery) + _, err := tx.ExecContext(ctx, sql, queryVals...) + if err != nil { + return ctxerr.Wrap(ctx, err, "execute membership INSERT") + } + + // Remove any existing label membership for the label that is not in the new query. + sql = fmt.Sprintf(`DELETE FROM label_membership WHERE label_id = %d AND NOT EXISTS (SELECT 1 FROM (%s) as candidate WHERE candidate.host_id = label_membership.host_id)`, label.ID, labelQuery) + _, err = tx.ExecContext(ctx, sql, queryVals...) + if err != nil { + return ctxerr.Wrap(ctx, err, "execute membership DELETE") + } + + // Get the new number of members. + sql = `SELECT COUNT(*) FROM label_membership WHERE label_id = ?` + var count int + if err := sqlx.GetContext(ctx, tx, &count, sql, label.ID); err != nil { + return ctxerr.Wrap(ctx, err, "get label membership count") + } + label.HostCount = count + return nil + }) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "UpdateLabelMembershipByHostCriteria transaction") + } + + return label, err +} + func batchHostIds(hostIds []uint) [][]uint { // same functionality as `batchHostnames`, but for host IDs const batchSize = 50000 // Large, but well under the undocumented limit @@ -197,7 +251,7 @@ func batchHostIds(hostIds []uint) [][]uint { func (ds *Datastore) GetLabelSpecs(ctx context.Context) ([]*fleet.LabelSpec, error) { var specs []*fleet.LabelSpec // Get basic specs - query := "SELECT id, name, description, query, platform, label_type, label_membership_type FROM labels" + query := "SELECT id, name, description, query, platform, label_type, label_membership_type, criteria FROM labels" if err := sqlx.SelectContext(ctx, ds.reader(ctx), &specs, query); err != nil { return nil, ctxerr.Wrap(ctx, err, "get labels") } @@ -268,11 +322,12 @@ func (ds *Datastore) NewLabel(ctx context.Context, label *fleet.Label, opts ...f name, description, query, + criteria, platform, label_type, label_membership_type, author_id - ) VALUES ( ?, ?, ?, ?, ?, ?, ?) + ) VALUES ( ?, ?, ?, ?, ?, ?, ?, ? ) ` result, err := ds.writer(ctx).ExecContext( ctx, @@ -280,6 +335,7 @@ func (ds *Datastore) NewLabel(ctx context.Context, label *fleet.Label, opts ...f label.Name, label.Description, label.Query, + label.HostVitalsCriteria, label.Platform, label.LabelType, label.LabelMembershipType, diff --git a/server/datastore/mysql/labels_test.go b/server/datastore/mysql/labels_test.go index 9ec7f8ce33..e61b628bbc 100644 --- a/server/datastore/mysql/labels_test.go +++ b/server/datastore/mysql/labels_test.go @@ -2,6 +2,7 @@ package mysql import ( "context" + "encoding/json" "fmt" "strconv" "strings" @@ -96,6 +97,7 @@ func TestLabels(t *testing.T) { {"ListHostsInLabelOSSettings", testLabelsListHostsInLabelOSSettings}, {"AddDeleteLabelsToFromHost", testAddDeleteLabelsToFromHost}, {"ApplyLabelSpecSerialUUID", testApplyLabelSpecsForSerialUUID}, + {"UpdateLabelMembershipByHostCriteria", testUpdateLabelMembershipByHostCriteria}, } // call TruncateTables first to remove migration-created labels TruncateTables(t, ds) @@ -2009,3 +2011,121 @@ func testApplyLabelSpecsForSerialUUID(t *testing.T, ds *Datastore) { require.Equal(t, host2.ID, hosts[1].ID) require.Equal(t, host3.ID, hosts[2].ID) } + +type TestHostVitalsLabel struct { + fleet.Label +} + +func (t *TestHostVitalsLabel) CalculateHostVitalsQuery() (string, []interface{}, error) { + return "SELECT %s FROM %s JOIN host_users ON (host_users.host_id = hosts.id) WHERE host_users.username = ?", []interface{}{"user1"}, nil +} + +func (t *TestHostVitalsLabel) GetLabel() *fleet.Label { + return &t.Label +} + +func testUpdateLabelMembershipByHostCriteria(t *testing.T, ds *Datastore) { + ctx := context.Background() + + hosts := make([]*fleet.Host, 4) + for i := 1; i <= 4; i++ { + host, err := ds.NewHost(ctx, &fleet.Host{ + OsqueryHostID: ptr.String(fmt.Sprintf("%d", i)), + NodeKey: ptr.String(fmt.Sprintf("%d", i)), + UUID: fmt.Sprintf("uuid%d", i), + Hostname: fmt.Sprintf("host%d.local", i), + HardwareSerial: fmt.Sprintf("hwd%d", i), + Platform: "darwin", + }) + require.NoError(t, err) + hosts[i-1] = host + } + // Add users to the hosts + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + _, err := q.ExecContext(ctx, ` + INSERT INTO host_users (host_id, uid, username) VALUES + (?, ?, ?), + (?, ?, ?), + (?, ?, ?), + (?, ?, ?), + (?, ?, ?)`, + hosts[0].ID, 1, "user1", + hosts[1].ID, 2, "user2", + hosts[2].ID, 1, "user1", + hosts[2].ID, 3, "user3", + hosts[3].ID, 3, "user3") + return err + }) + + criteria, err := json.Marshal(&fleet.HostVitalCriteria{ + Vital: ptr.String("username"), + Value: ptr.String("user1"), + }) + require.NoError(t, err) + + var id uint + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + result, err := q.ExecContext(context.Background(), + "INSERT INTO labels (name, description, platform, label_type, label_membership_type) VALUES (?, ?, ?, ?, ?)", + "test host vitals label", "test", "", fleet.LabelTypeRegular, fleet.LabelMembershipTypeHostVitals) + if err != nil { + return err + } + id64, err := result.LastInsertId() + if err != nil { + return err + } + id = uint(id64) // nolint:gosec + return nil + }) + + label := &TestHostVitalsLabel{ + Label: fleet.Label{ + ID: id, + Name: "Test Host Vitals Label", + LabelType: fleet.LabelTypeRegular, + LabelMembershipType: fleet.LabelMembershipTypeHostVitals, + HostVitalsCriteria: ptr.RawMessage(criteria), + }, + } + + filter := fleet.TeamFilter{User: test.UserAdmin} + + updatedLabel, err := ds.UpdateLabelMembershipByHostCriteria(ctx, label) + require.NoError(t, err) + require.Equal(t, 2, updatedLabel.HostCount) + + // Check that the label has the correct hosts + hostsInLabel, err := ds.ListHostsInLabel(ctx, filter, label.ID, fleet.HostListOptions{}) + require.NoError(t, err) + require.Len(t, hostsInLabel, 2) // Only hosts 1 and 3 should match the criteria (user1) + require.ElementsMatch(t, []uint{hosts[0].ID, hosts[2].ID}, []uint{hostsInLabel[0].ID, hostsInLabel[1].ID}) + + // Update host users. + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + _, err := q.ExecContext(ctx, ` + INSERT INTO host_users (host_id, uid, username) VALUES + (?, ?, ?), + (?, ?, ?), + (?, ?, ?) ON DUPLICATE KEY UPDATE username = VALUES(username), uid = VALUES(uid)`, + hosts[0].ID, 2, "user2", + hosts[1].ID, 1, "user1", + hosts[3].ID, 1, "user1") + return err + }) + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + _, err := q.ExecContext(ctx, ` + DELETE FROM host_users WHERE host_id = ? AND uid = ?`, + hosts[0].ID, 1) // Remove user1 from host 1 + return err + }) + updatedLabel, err = ds.UpdateLabelMembershipByHostCriteria(ctx, label) + require.NoError(t, err) + require.Equal(t, 3, updatedLabel.HostCount) + + // Check that the label has the correct hosts + hostsInLabel, err = ds.ListHostsInLabel(ctx, filter, label.ID, fleet.HostListOptions{}) + require.NoError(t, err) + require.Len(t, hostsInLabel, 3) // Only hosts 2, 3 and 4 should match the criteria (user1) + require.ElementsMatch(t, []uint{hosts[1].ID, hosts[2].ID, hosts[3].ID}, []uint{hostsInLabel[0].ID, hostsInLabel[1].ID, hostsInLabel[2].ID}) +} diff --git a/server/datastore/mysql/migrations/tables/20250629131032_AddHostVitalsLabelType.go b/server/datastore/mysql/migrations/tables/20250629131032_AddHostVitalsLabelType.go new file mode 100644 index 0000000000..8da368003b --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20250629131032_AddHostVitalsLabelType.go @@ -0,0 +1,25 @@ +package tables + +import ( + "database/sql" + "fmt" +) + +func init() { + MigrationClient.AddMigration(Up_20250629131032, Down_20250629131032) +} + +func Up_20250629131032(tx *sql.Tx) error { + _, err := tx.Exec( + "ALTER TABLE `labels` " + + "ADD COLUMN `criteria` json DEFAULT NULL; ", + ) + if err != nil { + return fmt.Errorf("failed to add criteria column to labels table: %w", err) + } + return nil +} + +func Down_20250629131032(tx *sql.Tx) error { + return nil +} diff --git a/server/datastore/mysql/schema.sql b/server/datastore/mysql/schema.sql index 9a24e9b91c..537fdd5a6c 100644 --- a/server/datastore/mysql/schema.sql +++ b/server/datastore/mysql/schema.sql @@ -1005,6 +1005,7 @@ CREATE TABLE `labels` ( `label_type` int unsigned NOT NULL DEFAULT '1', `label_membership_type` int unsigned NOT NULL DEFAULT '0', `author_id` int unsigned DEFAULT NULL, + `criteria` json DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `idx_label_unique_name` (`name`), KEY `author_id` (`author_id`), @@ -1012,7 +1013,7 @@ CREATE TABLE `labels` ( CONSTRAINT `labels_ibfk_1` FOREIGN KEY (`author_id`) REFERENCES `users` (`id`) ON DELETE SET NULL ) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; -INSERT INTO `labels` VALUES (1,'2024-04-03 00:00:00','2024-04-03 00:00:00','macOS 14+ (Sonoma+)','macOS hosts with version 14 and above','select 1 from os_version where platform = \'darwin\' and major >= 14;','darwin',1,0,NULL),(2,'2024-06-28 00:00:00','2024-06-28 00:00:00','iOS','All iOS hosts','','ios',1,1,NULL),(3,'2024-06-28 00:00:00','2024-06-28 00:00:00','iPadOS','All iPadOS hosts','','ipados',1,1,NULL),(4,'2024-09-27 00:00:00','2024-09-27 00:00:00','Fedora Linux','All Fedora hosts','select 1 from os_version where name = \'Fedora Linux\';','rhel',1,0,NULL),(5,'2025-02-25 00:00:00','2025-02-25 00:00:00','Android','All Android hosts','','android',1,1,NULL); +INSERT INTO `labels` VALUES (1,'2024-04-03 00:00:00','2024-04-03 00:00:00','macOS 14+ (Sonoma+)','macOS hosts with version 14 and above','select 1 from os_version where platform = \'darwin\' and major >= 14;','darwin',1,0,NULL,NULL),(2,'2024-06-28 00:00:00','2024-06-28 00:00:00','iOS','All iOS hosts','','ios',1,1,NULL,NULL),(3,'2024-06-28 00:00:00','2024-06-28 00:00:00','iPadOS','All iPadOS hosts','','ipados',1,1,NULL,NULL),(4,'2024-09-27 00:00:00','2024-09-27 00:00:00','Fedora Linux','All Fedora hosts','select 1 from os_version where name = \'Fedora Linux\';','rhel',1,0,NULL,NULL),(5,'2025-02-25 00:00:00','2025-02-25 00:00:00','Android','All Android hosts','','android',1,1,NULL,NULL); /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `legacy_host_mdm_enroll_refs` ( @@ -1377,9 +1378,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=398 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB AUTO_INCREMENT=399 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'); +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'); /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `mobile_device_management_solutions` ( diff --git a/server/fleet/hosts.go b/server/fleet/hosts.go index 25b66cb777..e6358cf5e4 100644 --- a/server/fleet/hosts.go +++ b/server/fleet/hosts.go @@ -387,6 +387,54 @@ type Host struct { Policies *[]*HostPolicy `json:"policies,omitempty" csv:"-"` } +type HostForeignVitalGroup struct { + Name string + Query string +} + +type HostVitalType int + +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 +) + +type HostVital struct { + Name string // Display name of the vital + VitalType HostVitalType + DataType string // Data type of the vital, e.g. "string", "int", "bool" + ForeignVitalGroup *string // For foreign vitals, the group they belong to + Path string // Path to the vital in the SQL query, for use in generating the WHERE clause +} + +var hostForeignVitalGroups = map[string]HostForeignVitalGroup{ + "idp": { + Name: "Identity Provider", + Query: `RIGHT 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) JOIN scim_user_group ON (host_scim_user.scim_user_id = scim_user_group.scim_user_id) JOIN scim_groups ON (scim_user_group.group_id = scim_groups.id)`, + }, +} + +var hostVitals = map[string]HostVital{ + "end_user_idp_group": { + Name: "IDP Group", + VitalType: HostVitalTypeForeign, + // A user can be in multiple groups, but we use a join table to specify them, + // so we can represent "group" as a string rather than an array and use AND/OR + // criteria to filter hosts by group membership. + DataType: "string", + ForeignVitalGroup: ptr.String("idp"), + Path: "scim_groups.display_name", + }, + "end_user_idp_department": { + Name: "IDP Department", + VitalType: HostVitalTypeForeign, + DataType: "string", + ForeignVitalGroup: ptr.String("idp"), + Path: "scim_users.department", + }, +} + type AndroidHost struct { *Host *android.Device diff --git a/server/fleet/labels.go b/server/fleet/labels.go index 42838317d4..30b76af7a6 100644 --- a/server/fleet/labels.go +++ b/server/fleet/labels.go @@ -1,6 +1,8 @@ package fleet import ( + "encoding/json" + "errors" "fmt" "strings" "time" @@ -18,6 +20,24 @@ type ModifyLabelPayload struct { HostIDs []uint `json:"host_ids"` } +type HostVitalOperator string + +const ( + HostVitalOperatorEqual HostVitalOperator = "=" + HostVitalOperatorNotEqual HostVitalOperator = "!=" + HostVitalOperatorGreater HostVitalOperator = ">" + HostVitalOperatorLess HostVitalOperator = "<" + HostVitalOperatorLike HostVitalOperator = "LIKE" +) + +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"` +} + type LabelPayload struct { Name string `json:"name"` // Query is the SQL query that defines the label. This defines a dynamic @@ -33,6 +53,8 @@ type LabelPayload struct { // host. Must be empty for a dynamic label. Hosts []string `json:"hosts"` HostIDs []uint `json:"host_ids"` + // Criteria is the set of criteria that defines a host vitals label. + Criteria *HostVitalCriteria `json:"criteria,omitempty"` } // LabelType is used to catagorize the kind of label @@ -78,6 +100,9 @@ const ( LabelMembershipTypeDynamic LabelMembershipType = iota // LabelTypeManual indicates that the label is populated manually. LabelMembershipTypeManual + // LabelMembershipTypeHostVitals indicates that the label is populated + // dynamically based on host vitals data. + LabelMembershipTypeHostVitals ) func (t LabelMembershipType) MarshalJSON() ([]byte, error) { @@ -86,6 +111,8 @@ func (t LabelMembershipType) MarshalJSON() ([]byte, error) { return []byte(`"dynamic"`), nil case LabelMembershipTypeManual: return []byte(`"manual"`), nil + case LabelMembershipTypeHostVitals: + return []byte(`"host_vitals"`), nil default: return nil, fmt.Errorf("invalid LabelMembershipType: %d", t) } @@ -97,12 +124,21 @@ func (t *LabelMembershipType) UnmarshalJSON(b []byte) error { *t = LabelMembershipTypeDynamic case `"manual"`: *t = LabelMembershipTypeManual + case `"host_vitals"`: + *t = LabelMembershipTypeHostVitals default: return fmt.Errorf("invalid LabelMembershipType: %s", string(b)) } return nil } +// Create a separate interface for host vitals labels to allow for +// different query generation logic in tests. +type HostVitalsLabel interface { + CalculateHostVitalsQuery() (query string, values []any, err error) + GetLabel() *Label +} + type Label struct { UpdateCreateTimestamps ID uint `json:"id"` @@ -110,12 +146,18 @@ type Label struct { Name string `json:"name"` Description string `json:"description"` Query string `json:"query"` + HostVitalsCriteria *json.RawMessage `json:"criteria,omitempty" db:"criteria"` Platform string `json:"platform"` LabelType LabelType `json:"label_type" db:"label_type"` LabelMembershipType LabelMembershipType `json:"label_membership_type" db:"label_membership_type"` HostCount int `json:"host_count,omitempty" db:"host_count"` } +// Implement the HostVitalsLabel interface. +func (l *Label) GetLabel() *Label { + return l +} + type LabelSummary struct { ID uint `json:"id"` Name string `json:"name"` @@ -148,6 +190,7 @@ type LabelSpec struct { LabelType LabelType `json:"label_type,omitempty" db:"label_type"` LabelMembershipType LabelMembershipType `json:"label_membership_type" db:"label_membership_type"` Hosts []string `json:"hosts"` + HostVitalsCriteria *json.RawMessage `json:"criteria,omitempty" db:"criteria"` } const ( @@ -258,3 +301,80 @@ func (l *LabelIdentsWithScope) Equal(other *LabelIdentsWithScope) bool { return true } + +// Translate label host vitals crteria into a query. +// TODO -- add caching support for this query? +func (l *Label) CalculateHostVitalsQuery() (query string, values []any, err error) { + var criteria *HostVitalCriteria + if l.HostVitalsCriteria == nil { + return "", nil, errors.New("label has no host vitals criteria") + } + // Unmarshal the criteria from JSON. + if err := json.Unmarshal(*l.HostVitalsCriteria, &criteria); err != nil { + return "", nil, fmt.Errorf("unmarshalling host vitals criteria: %w", err) + } + + // We'll use a set to gather the foreign vitals groups we need to join on, + // so that we can avoid duplicates. + foreignVitalsGroups := make(map[*HostForeignVitalGroup]struct{}) + // Hold values to be substituted in the paramerized query. + values = make([]any, 0) + // Recursively parse the criteria to build the WHERE clause. + whereClause, err := parseHostVitalCriteria(criteria, foreignVitalsGroups, &values) + if err != nil { + return "", nil, fmt.Errorf("parsing host vitals criteria: %w", err) + } + // If there are foreign vitals groups, concatenate all their joins. + joins := make([]string, 0, len(foreignVitalsGroups)) + if len(foreignVitalsGroups) > 0 { + for group := range foreignVitalsGroups { + joins = append(joins, group.Query) + } + } + + // Leave SELECT and FROM to be filled in later for flexibility. + query = "SELECT %s FROM %s " + strings.Join(joins, " ") + " WHERE " + whereClause + " GROUP BY hosts.id" + return +} + +// Translates a HostVitalCriteria into part of a SQL WHERE clause +// TODO: add support for And/Or criteria +func parseHostVitalCriteria(criteria *HostVitalCriteria, foreignVitalsGroups map[*HostForeignVitalGroup]struct{}, values *[]any) (string, error) { + // We don't support anything other than vital/value right now. + if criteria.And != nil || criteria.Or != nil { + return "", errors.New("And/Or criteria not supported in host vitals labels yet") + } + if criteria.Vital == nil { + return "", errors.New("vital criteria must have a vital") + } + if criteria.Value == nil { + return "", fmt.Errorf("vital %s must have a value", *criteria.Vital) + } + // Look up the vital in the map. + vital, ok := hostVitals[*criteria.Vital] + 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 { + foreignVitalsGroup, ok := hostForeignVitalGroups[*vital.ForeignVitalGroup] + if !ok { + return "", fmt.Errorf("unknown foreign vital group %s", *vital.ForeignVitalGroup) + } + foreignVitalsGroups[&foreignVitalsGroup] = struct{}{} + } + *values = append(*values, *criteria.Value) + + operator := criteria.Operator + if operator == nil { + // Default to equality if no operator is specified. + op := HostVitalOperatorEqual + operator = &op + } + // TODO - handle different vital data types and operator types. + // For now, we only support equality checks. + if *operator != HostVitalOperatorEqual { + return "", fmt.Errorf("operator %s not supported for vital %s", *operator, *criteria.Vital) + } + return fmt.Sprintf("%s = ?", vital.Path), nil +} diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index 765078c3ce..cd0fbe0bc8 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -4249,373 +4249,492 @@ func (s *integrationTestSuite) TestLabels() { manualHosts := hosts[:3] lbl2Hosts := hosts[3:] - // list labels, has the built-in ones - builtinsMap := fleet.ReservedLabelNames() - var listResp listLabelsResponse - s.DoJSON("GET", "/api/latest/fleet/labels", nil, http.StatusOK, &listResp) - assert.True(t, len(listResp.Labels) > 0) - var builtinLbl fleet.Label - for _, lbl := range listResp.Labels { - _, ok := builtinsMap[lbl.Name] - assert.True(t, ok) - assert.Equal(t, fleet.LabelTypeBuiltIn, lbl.LabelType) - builtinLbl = lbl.Label - } - builtInsCount := len(listResp.Labels) - require.Equal(t, builtInsCount, len(builtinsMap)) + t.Run("Manual and Dynamic Labels", func(t *testing.T) { + // list labels, has the built-in ones + builtinsMap := fleet.ReservedLabelNames() + var listResp listLabelsResponse + s.DoJSON("GET", "/api/latest/fleet/labels", nil, http.StatusOK, &listResp) + assert.True(t, len(listResp.Labels) > 0) + var builtinLbl fleet.Label + for _, lbl := range listResp.Labels { + _, ok := builtinsMap[lbl.Name] + assert.True(t, ok) + assert.Equal(t, fleet.LabelTypeBuiltIn, lbl.LabelType) + builtinLbl = lbl.Label + } + builtInsCount := len(listResp.Labels) + require.Equal(t, builtInsCount, len(builtinsMap)) - // labels summary has the built-in ones - var summaryResp getLabelsSummaryResponse - s.DoJSON("GET", "/api/latest/fleet/labels/summary", nil, http.StatusOK, &summaryResp) - assert.Len(t, summaryResp.Labels, builtInsCount) - for _, lbl := range summaryResp.Labels { - _, ok := builtinsMap[lbl.Name] - assert.True(t, ok) - assert.Equal(t, fleet.LabelTypeBuiltIn, lbl.LabelType) - } + // labels summary has the built-in ones + var summaryResp getLabelsSummaryResponse + s.DoJSON("GET", "/api/latest/fleet/labels/summary", nil, http.StatusOK, &summaryResp) + assert.Len(t, summaryResp.Labels, builtInsCount) + for _, lbl := range summaryResp.Labels { + _, ok := builtinsMap[lbl.Name] + assert.True(t, ok) + assert.Equal(t, fleet.LabelTypeBuiltIn, lbl.LabelType) + } - // create a label without name, an error - var createResp createLabelResponse - s.DoJSON("POST", "/api/latest/fleet/labels", &fleet.LabelPayload{Query: "select 1"}, http.StatusUnprocessableEntity, &createResp) + // create a label without name, an error + var createResp createLabelResponse + s.DoJSON("POST", "/api/latest/fleet/labels", &fleet.LabelPayload{Query: "select 1"}, http.StatusUnprocessableEntity, &createResp) - // create a label with both a query and hosts, error - res := s.Do("POST", "/api/latest/fleet/labels", &fleet.LabelPayload{Name: t.Name(), Query: "select 1", Hosts: []string{manualHosts[0].UUID}}, http.StatusUnprocessableEntity) - errMsg := extractServerErrorText(res.Body) - require.Contains(t, errMsg, `Only one of either "query" or "hosts/host_ids" can be included in the request.`) + // create a label with both a query and hosts, error + res := s.Do("POST", "/api/latest/fleet/labels", &fleet.LabelPayload{Name: t.Name(), Query: "select 1", Hosts: []string{manualHosts[0].UUID}}, http.StatusUnprocessableEntity) + errMsg := extractServerErrorText(res.Body) + require.Contains(t, errMsg, `Only one of "criteria", "query" or "hosts/host_ids" can be included in the request.`) - // create invalid label, conflicts with builtin name - for n := range builtinsMap { - s.DoJSON("POST", "/api/latest/fleet/labels", &fleet.LabelPayload{Name: n, Query: "select 1"}, http.StatusUnprocessableEntity, &createResp) - } + // create invalid label, conflicts with builtin name + for n := range builtinsMap { + s.DoJSON("POST", "/api/latest/fleet/labels", &fleet.LabelPayload{Name: n, Query: "select 1"}, http.StatusUnprocessableEntity, &createResp) + } - // create a valid dynamic label - s.DoJSON("POST", "/api/latest/fleet/labels", &fleet.LabelPayload{Name: t.Name(), Query: "select 1"}, http.StatusOK, &createResp) - assert.NotZero(t, createResp.Label.ID) - assert.Equal(t, t.Name(), createResp.Label.Name) - assert.Empty(t, createResp.Label.HostIDs) - lbl1 := createResp.Label.Label + // create a valid dynamic label + s.DoJSON("POST", "/api/latest/fleet/labels", &fleet.LabelPayload{Name: t.Name(), Query: "select 1"}, http.StatusOK, &createResp) + assert.NotZero(t, createResp.Label.ID) + assert.Equal(t, t.Name(), createResp.Label.Name) + assert.Empty(t, createResp.Label.HostIDs) + lbl1 := createResp.Label.Label - // try to create a manual label with the same name - s.DoJSON("POST", "/api/latest/fleet/labels", &fleet.LabelPayload{Name: lbl1.Name, Hosts: []string{manualHosts[0].UUID}}, http.StatusConflict, &createResp) - // try to create a dynamic label with the same name - s.DoJSON("POST", "/api/latest/fleet/labels", &fleet.LabelPayload{Name: lbl1.Name, Query: "select 2"}, http.StatusConflict, &createResp) + // try to create a manual label with the same name + s.DoJSON("POST", "/api/latest/fleet/labels", &fleet.LabelPayload{Name: lbl1.Name, Hosts: []string{manualHosts[0].UUID}}, http.StatusConflict, &createResp) + // try to create a dynamic label with the same name + s.DoJSON("POST", "/api/latest/fleet/labels", &fleet.LabelPayload{Name: lbl1.Name, Query: "select 2"}, http.StatusConflict, &createResp) - // get the label - var getResp getLabelResponse - s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d", lbl1.ID), nil, http.StatusOK, &getResp) - assert.Equal(t, lbl1.ID, getResp.Label.ID) - assert.Empty(t, getResp.Label.HostIDs) + // get the label + var getResp getLabelResponse + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d", lbl1.ID), nil, http.StatusOK, &getResp) + assert.Equal(t, lbl1.ID, getResp.Label.ID) + assert.Empty(t, getResp.Label.HostIDs) - // get a non-existing label - s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d", lbl1.ID+1), nil, http.StatusNotFound, &getResp) + // get a non-existing label + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d", lbl1.ID+1), nil, http.StatusNotFound, &getResp) - // create a valid manual label - createResp = createLabelResponse{} - s.DoJSON("POST", "/api/latest/fleet/labels", &fleet.LabelPayload{Name: t.Name() + "manual", Hosts: []string{manualHosts[0].UUID, manualHosts[1].Hostname, *manualHosts[2].NodeKey}}, http.StatusOK, &createResp) - assert.NotZero(t, createResp.Label.ID) - assert.Equal(t, t.Name()+"manual", createResp.Label.Name) - assert.ElementsMatch(t, []uint{manualHosts[0].ID, manualHosts[1].ID, manualHosts[2].ID}, createResp.Label.HostIDs) - manualLbl1 := createResp.Label.Label + // create a valid manual label + createResp = createLabelResponse{} + s.DoJSON("POST", "/api/latest/fleet/labels", &fleet.LabelPayload{Name: t.Name() + "manual", Hosts: []string{manualHosts[0].UUID, manualHosts[1].Hostname, *manualHosts[2].NodeKey}}, http.StatusOK, &createResp) + assert.NotZero(t, createResp.Label.ID) + assert.Equal(t, t.Name()+"manual", createResp.Label.Name) + assert.ElementsMatch(t, []uint{manualHosts[0].ID, manualHosts[1].ID, manualHosts[2].ID}, createResp.Label.HostIDs) + manualLbl1 := createResp.Label.Label - // get the label - getResp = getLabelResponse{} - s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d", manualLbl1.ID), nil, http.StatusOK, &getResp) - assert.Equal(t, manualLbl1.ID, getResp.Label.ID) - assert.Equal(t, fleet.LabelTypeRegular, getResp.Label.LabelType) - assert.Equal(t, fleet.LabelMembershipTypeManual, getResp.Label.LabelMembershipType) - assert.ElementsMatch(t, []uint{manualHosts[0].ID, manualHosts[1].ID, manualHosts[2].ID}, getResp.Label.HostIDs) - assert.EqualValues(t, 3, getResp.Label.HostCount) + // get the label + getResp = getLabelResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d", manualLbl1.ID), nil, http.StatusOK, &getResp) + assert.Equal(t, manualLbl1.ID, getResp.Label.ID) + assert.Equal(t, fleet.LabelTypeRegular, getResp.Label.LabelType) + assert.Equal(t, fleet.LabelMembershipTypeManual, getResp.Label.LabelMembershipType) + assert.ElementsMatch(t, []uint{manualHosts[0].ID, manualHosts[1].ID, manualHosts[2].ID}, getResp.Label.HostIDs) + assert.EqualValues(t, 3, getResp.Label.HostCount) - // create a valid empty manual label - createResp = createLabelResponse{} - s.DoJSON("POST", "/api/latest/fleet/labels", &fleet.LabelPayload{Name: strings.ReplaceAll(t.Name(), "/", "_") + "manual2"}, http.StatusOK, &createResp) - assert.NotZero(t, createResp.Label.ID) - assert.Equal(t, strings.ReplaceAll(t.Name(), "/", "_")+"manual2", createResp.Label.Name) - assert.Empty(t, createResp.Label.HostIDs) - manualLbl2 := createResp.Label.Label + // create a valid empty manual label + createResp = createLabelResponse{} + s.DoJSON("POST", "/api/latest/fleet/labels", &fleet.LabelPayload{Name: strings.ReplaceAll(t.Name(), "/", "_") + "manual2"}, http.StatusOK, &createResp) + assert.NotZero(t, createResp.Label.ID) + assert.Equal(t, strings.ReplaceAll(t.Name(), "/", "_")+"manual2", createResp.Label.Name) + assert.Empty(t, createResp.Label.HostIDs) + manualLbl2 := createResp.Label.Label - // try to create a manual label with the same name - s.DoJSON("POST", "/api/latest/fleet/labels", &fleet.LabelPayload{Name: manualLbl2.Name, Hosts: []string{manualHosts[0].UUID}}, http.StatusConflict, &createResp) - // try to create a dynamic label with the same name - s.DoJSON("POST", "/api/latest/fleet/labels", &fleet.LabelPayload{Name: manualLbl2.Name, Query: "select 2"}, http.StatusConflict, &createResp) + // try to create a manual label with the same name + s.DoJSON("POST", "/api/latest/fleet/labels", &fleet.LabelPayload{Name: manualLbl2.Name, Hosts: []string{manualHosts[0].UUID}}, http.StatusConflict, &createResp) + // try to create a dynamic label with the same name + s.DoJSON("POST", "/api/latest/fleet/labels", &fleet.LabelPayload{Name: manualLbl2.Name, Query: "select 2"}, http.StatusConflict, &createResp) - // get the label - getResp = getLabelResponse{} - s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d", manualLbl2.ID), nil, http.StatusOK, &getResp) - assert.Equal(t, manualLbl2.ID, getResp.Label.ID) - assert.Equal(t, fleet.LabelTypeRegular, getResp.Label.LabelType) - assert.Equal(t, fleet.LabelMembershipTypeManual, getResp.Label.LabelMembershipType) - assert.Empty(t, getResp.Label.HostIDs) - assert.EqualValues(t, 0, getResp.Label.HostCount) + // get the label + getResp = getLabelResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d", manualLbl2.ID), nil, http.StatusOK, &getResp) + assert.Equal(t, manualLbl2.ID, getResp.Label.ID) + assert.Equal(t, fleet.LabelTypeRegular, getResp.Label.LabelType) + assert.Equal(t, fleet.LabelMembershipTypeManual, getResp.Label.LabelMembershipType) + assert.Empty(t, getResp.Label.HostIDs) + assert.EqualValues(t, 0, getResp.Label.HostCount) - // get a non-existing label - s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d", 9999), nil, http.StatusNotFound, &getResp) + // get a non-existing label + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d", 9999), nil, http.StatusNotFound, &getResp) - // modify dynamic label lbl1 - var modResp modifyLabelResponse - s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/labels/%d", lbl1.ID), &fleet.ModifyLabelPayload{Name: ptr.String(t.Name() + "zzz")}, http.StatusOK, &modResp) - assert.Equal(t, lbl1.ID, modResp.Label.ID) - assert.Empty(t, modResp.Label.HostIDs) - assert.NotEqual(t, lbl1.Name, modResp.Label.Name) + // modify dynamic label lbl1 + var modResp modifyLabelResponse + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/labels/%d", lbl1.ID), &fleet.ModifyLabelPayload{Name: ptr.String(t.Name() + "zzz")}, http.StatusOK, &modResp) + assert.Equal(t, lbl1.ID, modResp.Label.ID) + assert.Empty(t, modResp.Label.HostIDs) + assert.NotEqual(t, lbl1.Name, modResp.Label.Name) - // attempt to modify a label to a reserved name - for n := range builtinsMap { - s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/labels/%d", lbl1.ID), &fleet.ModifyLabelPayload{Name: ptr.String(n)}, http.StatusUnprocessableEntity, &modResp) - } + // attempt to modify a label to a reserved name + for n := range builtinsMap { + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/labels/%d", lbl1.ID), &fleet.ModifyLabelPayload{Name: ptr.String(n)}, http.StatusUnprocessableEntity, &modResp) + } - // modify a non-existing label - s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/labels/%d", 9999), &fleet.ModifyLabelPayload{Name: ptr.String("zzz")}, http.StatusNotFound, &modResp) - // modify a built-in label - res = s.Do("PATCH", fmt.Sprintf("/api/latest/fleet/labels/%d", builtinLbl.ID), &fleet.ModifyLabelPayload{Name: ptr.String("zzz")}, http.StatusUnprocessableEntity) - errMsg = extractServerErrorText(res.Body) - require.Contains(t, errMsg, "cannot modify built-in label") + // modify a non-existing label + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/labels/%d", 9999), &fleet.ModifyLabelPayload{Name: ptr.String("zzz")}, http.StatusNotFound, &modResp) + // modify a built-in label + res = s.Do("PATCH", fmt.Sprintf("/api/latest/fleet/labels/%d", builtinLbl.ID), &fleet.ModifyLabelPayload{Name: ptr.String("zzz")}, http.StatusUnprocessableEntity) + errMsg = extractServerErrorText(res.Body) + require.Contains(t, errMsg, "cannot modify built-in label") - // modify manual label 1 without modifying its hosts - modResp = modifyLabelResponse{} - newName := "modified_manual_label1" - s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/labels/%d", manualLbl1.ID), &fleet.ModifyLabelPayload{Name: &newName}, http.StatusOK, - &modResp) - assert.Equal(t, manualLbl1.ID, modResp.Label.ID) - assert.Equal(t, fleet.LabelTypeRegular, modResp.Label.LabelType) - assert.Equal(t, fleet.LabelMembershipTypeManual, modResp.Label.LabelMembershipType) - assert.ElementsMatch(t, []uint{manualHosts[0].ID, manualHosts[1].ID, manualHosts[2].ID}, modResp.Label.HostIDs) - assert.EqualValues(t, 3, modResp.Label.HostCount) - assert.Equal(t, newName, modResp.Label.Name) + // modify manual label 1 without modifying its hosts + modResp = modifyLabelResponse{} + newName := "modified_manual_label1" + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/labels/%d", manualLbl1.ID), &fleet.ModifyLabelPayload{Name: &newName}, http.StatusOK, + &modResp) + assert.Equal(t, manualLbl1.ID, modResp.Label.ID) + assert.Equal(t, fleet.LabelTypeRegular, modResp.Label.LabelType) + assert.Equal(t, fleet.LabelMembershipTypeManual, modResp.Label.LabelMembershipType) + assert.ElementsMatch(t, []uint{manualHosts[0].ID, manualHosts[1].ID, manualHosts[2].ID}, modResp.Label.HostIDs) + assert.EqualValues(t, 3, modResp.Label.HostCount) + assert.Equal(t, newName, modResp.Label.Name) - // add a host with the same name as another host to manual label 2, confirm only one host is added - sameName, err := s.ds.NewHost(context.Background(), &fleet.Host{ - HardwareSerial: "ABCDE", - Hostname: manualHosts[0].Hostname, - Platform: "darwin", - }) - require.NoError(t, err) - - modResp = modifyLabelResponse{} - s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/labels/%d", manualLbl2.ID), - &fleet.ModifyLabelPayload{Hosts: []string{sameName.HardwareSerial}}, http.StatusOK, &modResp) - assert.Len(t, modResp.Label.HostIDs, 1) - assert.NotEqual(t, manualHosts[0].ID, modResp.Label.HostIDs[0]) - assert.Equal(t, manualLbl2.ID, modResp.Label.ID) - assert.Equal(t, fleet.LabelTypeRegular, modResp.Label.LabelType) - assert.Equal(t, fleet.LabelMembershipTypeManual, modResp.Label.LabelMembershipType) - assert.ElementsMatch(t, []uint{sameName.ID}, modResp.Label.HostIDs) - assert.EqualValues(t, 1, modResp.Label.HostCount) - - // modify manual label 2 adding some hosts - modResp = modifyLabelResponse{} - newName = "modified_manual_label2" - s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/labels/%d", manualLbl2.ID), - &fleet.ModifyLabelPayload{Name: &newName, Hosts: []string{manualHosts[0].UUID}}, http.StatusOK, &modResp) - assert.Equal(t, manualLbl2.ID, modResp.Label.ID) - assert.Equal(t, fleet.LabelTypeRegular, modResp.Label.LabelType) - assert.Equal(t, fleet.LabelMembershipTypeManual, modResp.Label.LabelMembershipType) - assert.ElementsMatch(t, []uint{manualHosts[0].ID}, modResp.Label.HostIDs) - assert.EqualValues(t, 1, modResp.Label.HostCount) - assert.Equal(t, newName, modResp.Label.Name) - manualLbl2.Name = newName - - // modify manual label 2 adding some hosts by ID - modResp = modifyLabelResponse{} - newName = "modified_manual_label2" - s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/labels/%d", manualLbl2.ID), - &fleet.ModifyLabelPayload{Name: &newName, HostIDs: []uint{manualHosts[1].ID, manualHosts[2].ID}}, http.StatusOK, &modResp) - assert.Equal(t, manualLbl2.ID, modResp.Label.ID) - assert.Equal(t, fleet.LabelTypeRegular, modResp.Label.LabelType) - assert.Equal(t, fleet.LabelMembershipTypeManual, modResp.Label.LabelMembershipType) - assert.ElementsMatch(t, []uint{manualHosts[1].ID, manualHosts[2].ID}, modResp.Label.HostIDs) - assert.EqualValues(t, 2, modResp.Label.HostCount) - assert.Equal(t, newName, modResp.Label.Name) - manualLbl2.Name = newName - - // modify manual label 2 clearing its hosts - modResp = modifyLabelResponse{} - s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/labels/%d", manualLbl2.ID), &fleet.ModifyLabelPayload{Hosts: []string{}, Description: ptr.String("desc")}, http.StatusOK, &modResp) - assert.Equal(t, manualLbl2.ID, modResp.Label.ID) - assert.Equal(t, "desc", modResp.Label.Description) - assert.Empty(t, modResp.Label.HostIDs) - assert.EqualValues(t, 0, modResp.Label.HostCount) - - // list labels - dynamicLabels := []fleet.Label{lbl1} - manualLabels := []fleet.Label{manualLbl1, manualLbl2} - s.DoJSON("GET", "/api/latest/fleet/labels", nil, http.StatusOK, &listResp, "per_page", strconv.Itoa(100)) - assert.Len(t, listResp.Labels, builtInsCount+len(dynamicLabels)+len(manualLabels)) - - // labels summary - s.DoJSON("GET", "/api/latest/fleet/labels/summary", nil, http.StatusOK, &summaryResp) - assert.Len(t, summaryResp.Labels, builtInsCount+len(dynamicLabels)+len(manualLabels)) - - // next page is empty - s.DoJSON("GET", "/api/latest/fleet/labels", nil, http.StatusOK, &listResp, "per_page", "100", "page", "1") - assert.Len(t, listResp.Labels, 0) - - // list labels with invalid query params - s.DoJSON("GET", "/api/latest/fleet/labels", nil, http.StatusBadRequest, &listResp, "per_page", strconv.Itoa(builtInsCount+1), "order_key", "id", "after", "1") - s.DoJSON("GET", "/api/latest/fleet/labels", nil, http.StatusBadRequest, &listResp, "per_page", strconv.Itoa(builtInsCount+1), "query", "no match query for this endpoint") - - // create another dynamic label - s.DoJSON("POST", "/api/latest/fleet/labels", &fleet.LabelPayload{Name: strings.ReplaceAll(t.Name(), "/", "_"), Query: "select 1"}, http.StatusOK, &createResp) - assert.NotZero(t, createResp.Label.ID) - lbl2 := createResp.Label.Label - dynamicLabels = append(dynamicLabels, lbl2) - require.Len(t, dynamicLabels, 2) // to make linter happy (dynamicLabels is not used past this point) - - // add lbl2 hosts to that label - for _, h := range lbl2Hosts { - err := s.ds.RecordLabelQueryExecutions(context.Background(), h, map[uint]*bool{lbl2.ID: ptr.Bool(true)}, time.Now(), false) + // add a host with the same name as another host to manual label 2, confirm only one host is added + sameName, err := s.ds.NewHost(context.Background(), &fleet.Host{ + HardwareSerial: "ABCDE", + Hostname: manualHosts[0].Hostname, + Platform: "darwin", + }) require.NoError(t, err) - } - // list hosts in dynamic label lbl2 - var listHostsResp listHostsResponse - s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d/hosts", lbl2.ID), nil, http.StatusOK, &listHostsResp) - assert.Len(t, listHostsResp.Hosts, len(lbl2Hosts)) + modResp = modifyLabelResponse{} + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/labels/%d", manualLbl2.ID), + &fleet.ModifyLabelPayload{Hosts: []string{sameName.HardwareSerial}}, http.StatusOK, &modResp) + assert.Len(t, modResp.Label.HostIDs, 1) + assert.NotEqual(t, manualHosts[0].ID, modResp.Label.HostIDs[0]) + assert.Equal(t, manualLbl2.ID, modResp.Label.ID) + assert.Equal(t, fleet.LabelTypeRegular, modResp.Label.LabelType) + assert.Equal(t, fleet.LabelMembershipTypeManual, modResp.Label.LabelMembershipType) + assert.ElementsMatch(t, []uint{sameName.ID}, modResp.Label.HostIDs) + assert.EqualValues(t, 1, modResp.Label.HostCount) - s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d/hosts", lbl2.ID), nil, http.StatusOK, &listHostsResp, "order_key", "id", "after", fmt.Sprintf("%d", lbl2Hosts[0].ID)) - assert.Len(t, listHostsResp.Hosts, 2) - assert.Equal(t, lbl2Hosts[1].ID, listHostsResp.Hosts[0].ID) - assert.Equal(t, lbl2Hosts[2].ID, listHostsResp.Hosts[1].ID) + // modify manual label 2 adding some hosts + modResp = modifyLabelResponse{} + newName = "modified_manual_label2" + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/labels/%d", manualLbl2.ID), + &fleet.ModifyLabelPayload{Name: &newName, Hosts: []string{manualHosts[0].UUID}}, http.StatusOK, &modResp) + assert.Equal(t, manualLbl2.ID, modResp.Label.ID) + assert.Equal(t, fleet.LabelTypeRegular, modResp.Label.LabelType) + assert.Equal(t, fleet.LabelMembershipTypeManual, modResp.Label.LabelMembershipType) + assert.ElementsMatch(t, []uint{manualHosts[0].ID}, modResp.Label.HostIDs) + assert.EqualValues(t, 1, modResp.Label.HostCount) + assert.Equal(t, newName, modResp.Label.Name) + manualLbl2.Name = newName - // list hosts in manual label 1 - listHostsResp = listHostsResponse{} - s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d/hosts", manualLbl1.ID), nil, http.StatusOK, &listHostsResp, "order_key", "id") - assert.Len(t, listHostsResp.Hosts, manualLbl1.HostCount) - assert.Equal(t, manualHosts[0].ID, listHostsResp.Hosts[0].ID) - assert.Equal(t, manualHosts[1].ID, listHostsResp.Hosts[1].ID) - assert.Equal(t, manualHosts[2].ID, listHostsResp.Hosts[2].ID) + // modify manual label 2 adding some hosts by ID + modResp = modifyLabelResponse{} + newName = "modified_manual_label2" + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/labels/%d", manualLbl2.ID), + &fleet.ModifyLabelPayload{Name: &newName, HostIDs: []uint{manualHosts[1].ID, manualHosts[2].ID}}, http.StatusOK, &modResp) + assert.Equal(t, manualLbl2.ID, modResp.Label.ID) + assert.Equal(t, fleet.LabelTypeRegular, modResp.Label.LabelType) + assert.Equal(t, fleet.LabelMembershipTypeManual, modResp.Label.LabelMembershipType) + assert.ElementsMatch(t, []uint{manualHosts[1].ID, manualHosts[2].ID}, modResp.Label.HostIDs) + assert.EqualValues(t, 2, modResp.Label.HostCount) + assert.Equal(t, newName, modResp.Label.Name) + manualLbl2.Name = newName - // list hosts in manual label 2 - listHostsResp = listHostsResponse{} - s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d/hosts", manualLbl2.ID), nil, http.StatusOK, &listHostsResp, "order_key", "id") - assert.Len(t, listHostsResp.Hosts, 0) + // modify manual label 2 clearing its hosts + modResp = modifyLabelResponse{} + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/labels/%d", manualLbl2.ID), &fleet.ModifyLabelPayload{Hosts: []string{}, Description: ptr.String("desc")}, http.StatusOK, &modResp) + assert.Equal(t, manualLbl2.ID, modResp.Label.ID) + assert.Equal(t, "desc", modResp.Label.Description) + assert.Empty(t, modResp.Label.HostIDs) + assert.EqualValues(t, 0, modResp.Label.HostCount) - // list hosts in dynamic label 2 searching by display_name - s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d/hosts", lbl2.ID), nil, http.StatusOK, &listHostsResp, "order_key", "display_name", "order_direction", "desc") - assert.Len(t, listHostsResp.Hosts, len(lbl2Hosts)) - // first in the list is the last one, as the names are ordered with the index - // of creation, and vice-versa - assert.Equal(t, lbl2Hosts[len(lbl2Hosts)-1].ID, listHostsResp.Hosts[0].ID) - assert.Equal(t, lbl2Hosts[0].ID, listHostsResp.Hosts[len(lbl2Hosts)-1].ID) + // list labels + dynamicLabels := []fleet.Label{lbl1} + manualLabels := []fleet.Label{manualLbl1, manualLbl2} + s.DoJSON("GET", "/api/latest/fleet/labels", nil, http.StatusOK, &listResp, "per_page", strconv.Itoa(100)) + assert.Len(t, listResp.Labels, builtInsCount+len(dynamicLabels)+len(manualLabels)) - mysql.ExecAdhocSQL(t, s.ds, func(db sqlx.ExtContext) error { - _, err := db.ExecContext( - context.Background(), - `INSERT INTO host_emails (host_id, email, source) VALUES (?, ?, ?)`, - lbl2Hosts[0].ID, "a@b.c", "src1") + // labels summary + s.DoJSON("GET", "/api/latest/fleet/labels/summary", nil, http.StatusOK, &summaryResp) + assert.Len(t, summaryResp.Labels, builtInsCount+len(dynamicLabels)+len(manualLabels)) - return err + // next page is empty + s.DoJSON("GET", "/api/latest/fleet/labels", nil, http.StatusOK, &listResp, "per_page", "100", "page", "1") + assert.Len(t, listResp.Labels, 0) + + // list labels with invalid query params + s.DoJSON("GET", "/api/latest/fleet/labels", nil, http.StatusBadRequest, &listResp, "per_page", strconv.Itoa(builtInsCount+1), "order_key", "id", "after", "1") + s.DoJSON("GET", "/api/latest/fleet/labels", nil, http.StatusBadRequest, &listResp, "per_page", strconv.Itoa(builtInsCount+1), "query", "no match query for this endpoint") + + // create another dynamic label + s.DoJSON("POST", "/api/latest/fleet/labels", &fleet.LabelPayload{Name: strings.ReplaceAll(t.Name(), "/", "_"), Query: "select 1"}, http.StatusOK, &createResp) + assert.NotZero(t, createResp.Label.ID) + lbl2 := createResp.Label.Label + dynamicLabels = append(dynamicLabels, lbl2) + require.Len(t, dynamicLabels, 2) // to make linter happy (dynamicLabels is not used past this point) + + // add lbl2 hosts to that label + for _, h := range lbl2Hosts { + err := s.ds.RecordLabelQueryExecutions(context.Background(), h, map[uint]*bool{lbl2.ID: ptr.Bool(true)}, time.Now(), false) + require.NoError(t, err) + } + + // list hosts in dynamic label lbl2 + var listHostsResp listHostsResponse + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d/hosts", lbl2.ID), nil, http.StatusOK, &listHostsResp) + assert.Len(t, listHostsResp.Hosts, len(lbl2Hosts)) + + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d/hosts", lbl2.ID), nil, http.StatusOK, &listHostsResp, "order_key", "id", "after", fmt.Sprintf("%d", lbl2Hosts[0].ID)) + assert.Len(t, listHostsResp.Hosts, 2) + assert.Equal(t, lbl2Hosts[1].ID, listHostsResp.Hosts[0].ID) + assert.Equal(t, lbl2Hosts[2].ID, listHostsResp.Hosts[1].ID) + + // list hosts in manual label 1 + listHostsResp = listHostsResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d/hosts", manualLbl1.ID), nil, http.StatusOK, &listHostsResp, "order_key", "id") + assert.Len(t, listHostsResp.Hosts, manualLbl1.HostCount) + assert.Equal(t, manualHosts[0].ID, listHostsResp.Hosts[0].ID) + assert.Equal(t, manualHosts[1].ID, listHostsResp.Hosts[1].ID) + assert.Equal(t, manualHosts[2].ID, listHostsResp.Hosts[2].ID) + + // list hosts in manual label 2 + listHostsResp = listHostsResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d/hosts", manualLbl2.ID), nil, http.StatusOK, &listHostsResp, "order_key", "id") + assert.Len(t, listHostsResp.Hosts, 0) + + // list hosts in dynamic label 2 searching by display_name + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d/hosts", lbl2.ID), nil, http.StatusOK, &listHostsResp, "order_key", "display_name", "order_direction", "desc") + assert.Len(t, listHostsResp.Hosts, len(lbl2Hosts)) + // first in the list is the last one, as the names are ordered with the index + // of creation, and vice-versa + assert.Equal(t, lbl2Hosts[len(lbl2Hosts)-1].ID, listHostsResp.Hosts[0].ID) + assert.Equal(t, lbl2Hosts[0].ID, listHostsResp.Hosts[len(lbl2Hosts)-1].ID) + + mysql.ExecAdhocSQL(t, s.ds, func(db sqlx.ExtContext) error { + _, err := db.ExecContext( + context.Background(), + `INSERT INTO host_emails (host_id, email, source) VALUES (?, ?, ?)`, + lbl2Hosts[0].ID, "a@b.c", "src1") + + return err + }) + + // list hosts in label searching by email address + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d/hosts", lbl2.ID), nil, http.StatusOK, &listHostsResp, "query", "a@b.c") + assert.Len(t, listHostsResp.Hosts, 1) + assert.Equal(t, lbl2Hosts[0].ID, listHostsResp.Hosts[0].ID) + + // list hosts in label searching by email address with leading/trailing whitespace + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d/hosts", lbl2.ID), nil, http.StatusOK, &listHostsResp, "query", " a@b.c ") + assert.Len(t, listHostsResp.Hosts, 1) + assert.Equal(t, lbl2Hosts[0].ID, listHostsResp.Hosts[0].ID) + + // count hosts in label order by display_name + var countResp countHostsResponse + s.DoJSON("GET", "/api/latest/fleet/hosts/count", nil, http.StatusOK, &countResp, "label_id", fmt.Sprint(lbl2.ID), "order_key", "display_name", "order_direction", "desc") + assert.Equal(t, len(lbl2Hosts), countResp.Count) + + // lists hosts in label without hosts + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d/hosts", lbl1.ID), nil, http.StatusOK, &listHostsResp) + assert.Len(t, listHostsResp.Hosts, 0) + + // count hosts in label + s.DoJSON("GET", "/api/latest/fleet/hosts/count", nil, http.StatusOK, &countResp, "label_id", fmt.Sprint(lbl1.ID)) + assert.Equal(t, 0, countResp.Count) + + // lists hosts in invalid label + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d/hosts", lbl2.ID+1), nil, http.StatusOK, &listHostsResp) + assert.Len(t, listHostsResp.Hosts, 0) + + // set MDM information on a host + require.NoError(t, s.ds.SetOrUpdateMDMData(context.Background(), lbl2Hosts[0].ID, false, true, "https://simplemdm.com", false, fleet.WellKnownMDMSimpleMDM, "")) + var mdmID uint + mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + return sqlx.GetContext(context.Background(), q, &mdmID, + `SELECT id FROM mobile_device_management_solutions WHERE name = ? AND server_url = ?`, fleet.WellKnownMDMSimpleMDM, "https://simplemdm.com") + }) + // generate aggregated stats + require.NoError(t, s.ds.GenerateAggregatedMunkiAndMDM(context.Background())) + + // list host in label by mdm_id + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d/hosts", lbl2.ID), nil, http.StatusOK, &listHostsResp, "mdm_id", fmt.Sprint(mdmID)) + require.Len(t, listHostsResp.Hosts, 1) + assert.Nil(t, listHostsResp.Software) + assert.Nil(t, listHostsResp.MunkiIssue) + require.NotNil(t, listHostsResp.MDMSolution) + assert.Equal(t, mdmID, listHostsResp.MDMSolution.ID) + assert.Equal(t, fleet.WellKnownMDMSimpleMDM, listHostsResp.MDMSolution.Name) + assert.Equal(t, "https://simplemdm.com", listHostsResp.MDMSolution.ServerURL) + + // delete a label by id + var delIDResp deleteLabelByIDResponse + s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/labels/id/%d", lbl1.ID), nil, http.StatusOK, &delIDResp) + + // delete a non-existing label by id + s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/labels/id/%d", lbl2.ID+1), nil, http.StatusNotFound, &delIDResp) + + // delete a label by name + var delResp deleteLabelResponse + s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/labels/%s", url.PathEscape(lbl2.Name)), nil, http.StatusOK, &delResp) + + // delete a non-existing label by name + s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/labels/%s", url.PathEscape(lbl2.Name)), nil, http.StatusNotFound, &delResp) + + // delete a manual label by id + s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/labels/id/%d", manualLbl1.ID), nil, http.StatusOK, &delIDResp) + + // delete a manual label by name + s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/labels/%s", url.PathEscape(manualLbl2.Name)), nil, http.StatusOK, &delResp) + + // list labels, only the built-ins remain + s.DoJSON("GET", "/api/latest/fleet/labels", nil, http.StatusOK, &listResp, "per_page", strconv.Itoa(builtInsCount+1)) + assert.Len(t, listResp.Labels, builtInsCount) + idsByName := make(map[string]uint, len(listResp.Labels)) + for _, lbl := range listResp.Labels { + _, ok := builtinsMap[lbl.Name] + assert.True(t, ok) + assert.Equal(t, fleet.LabelTypeBuiltIn, lbl.LabelType) + idsByName[lbl.Name] = lbl.ID + } + + // labels summary, only the built-ins remains + s.DoJSON("GET", "/api/latest/fleet/labels/summary", nil, http.StatusOK, &summaryResp) + assert.Len(t, summaryResp.Labels, builtInsCount) + for _, lbl := range summaryResp.Labels { + _, ok := builtinsMap[lbl.Name] + assert.True(t, ok) + assert.Equal(t, fleet.LabelTypeBuiltIn, lbl.LabelType) + assert.Equal(t, idsByName[lbl.Name], lbl.ID) + } + + // host summary matches built-ins count + var hostSummaryResp getHostSummaryResponse + s.DoJSON("GET", "/api/latest/fleet/host_summary", nil, http.StatusOK, &hostSummaryResp) + assert.Len(t, hostSummaryResp.BuiltinLabels, builtInsCount) + for _, lbl := range hostSummaryResp.BuiltinLabels { + _, ok := builtinsMap[lbl.Name] + assert.True(t, ok) + assert.Equal(t, fleet.LabelTypeBuiltIn, lbl.LabelType) + assert.Equal(t, idsByName[lbl.Name], lbl.ID) + } + + require.Len(t, idsByName, len(builtinsMap)) + for name := range builtinsMap { + id, ok := idsByName[name] + require.True(t, ok) + + // attempt to delete by name + s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/labels/%s", url.PathEscape(name)), nil, http.StatusUnprocessableEntity, &delResp) + + // attempt to delete by id + s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/labels/id/%d", id), nil, http.StatusUnprocessableEntity, &delIDResp) + } }) - // list hosts in label searching by email address - s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d/hosts", lbl2.ID), nil, http.StatusOK, &listHostsResp, "query", "a@b.c") - assert.Len(t, listHostsResp.Hosts, 1) - assert.Equal(t, lbl2Hosts[0].ID, listHostsResp.Hosts[0].ID) + t.Run("IdP Labels", func(t *testing.T) { + // Add some SCIM users + mysql.ExecAdhocSQL( + t, s.ds, func(db sqlx.ExtContext) error { + _, err := db.ExecContext( + context.Background(), + "INSERT INTO scim_users (id, user_name) VALUES (?, ?), (?, ?), (?, ?), (?, ?)", + 1, + "no_groups", + 2, + "one_group", + 3, + "all_the_groups", + 4, + "wrong_groups", + ) + return err + }, + ) + // Add some SCIM groups + mysql.ExecAdhocSQL( + t, s.ds, func(db sqlx.ExtContext) error { + _, err := db.ExecContext( + context.Background(), + "INSERT INTO scim_groups (id, display_name) VALUES (?, ?), (?, ?), (?, ?)", + 1, + "group_good", + 2, + "group_bad", + 3, + "group_great", + ) + return err + }, + ) + // Add some SCIM group memberships + mysql.ExecAdhocSQL( + t, s.ds, func(db sqlx.ExtContext) error { + _, err := db.ExecContext( + context.Background(), + "INSERT INTO scim_user_group (scim_user_id, group_id) VALUES (?, ?), (?, ?), (?, ?), (?, ?), (?, ?)", + 2, 1, // "one_group" -> "group_good" + 3, 1, // "all_the_groups" -> "group_good" + 3, 2, // "all_the_groups" -> "group_bad" + 3, 3, // "all_the_groups" -> "group_great" + 4, 2, // "wrong_groups" -> "group_bad" + ) + return err + }, + ) + // Add some host->scim user mappings + mysql.ExecAdhocSQL( + t, s.ds, func(db sqlx.ExtContext) error { + _, err := db.ExecContext( + context.Background(), + "INSERT INTO host_scim_user (host_id, scim_user_id) VALUES (?, ?), (?, ?), (?, ?), (?, ?), (?, ?)", + hosts[0].ID, 1, // host 1 shouldn't be returned because its scim user has no groups + hosts[1].ID, 2, // host 2 should be returned because its scim user has the "group_good" group + hosts[2].ID, 3, // host 3 should be returned because it has a scim user with the "group_good" group + hosts[3].ID, 2, // host 4 should be returned because it has a scim user with the "group_good" group + hosts[4].ID, 4, // host 5 shouldn't be returned because its scim user only has the "group_bad" group + // host 6 shouldn't be returned because it has no scim user + ) + return err + }, + ) - // list hosts in label searching by email address with leading/trailing whitespace - s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d/hosts", lbl2.ID), nil, http.StatusOK, &listHostsResp, "query", " a@b.c ") - assert.Len(t, listHostsResp.Hosts, 1) - assert.Equal(t, lbl2Hosts[0].ID, listHostsResp.Hosts[0].ID) + t.Run("IdP Group Label", func(t *testing.T) { + // Create a label for an IdP group + criteria := &fleet.HostVitalCriteria{ + Vital: ptr.String("end_user_idp_group"), + Value: ptr.String("group_good"), + } - // count hosts in label order by display_name - var countResp countHostsResponse - s.DoJSON("GET", "/api/latest/fleet/hosts/count", nil, http.StatusOK, &countResp, "label_id", fmt.Sprint(lbl2.ID), "order_key", "display_name", "order_direction", "desc") - assert.Equal(t, len(lbl2Hosts), countResp.Count) + labelParams := createLabelRequest{ + fleet.LabelPayload{ + Name: "Test IdP Group Label", + Criteria: criteria, + }, + } - // lists hosts in label without hosts - s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d/hosts", lbl1.ID), nil, http.StatusOK, &listHostsResp) - assert.Len(t, listHostsResp.Hosts, 0) + labelResp := createLabelResponse{} + s.DoJSON("POST", "/api/latest/fleet/labels", labelParams, http.StatusOK, &labelResp) + require.NotNil(t, labelResp.Label) - // count hosts in label - s.DoJSON("GET", "/api/latest/fleet/hosts/count", nil, http.StatusOK, &countResp, "label_id", fmt.Sprint(lbl1.ID)) - assert.Equal(t, 0, countResp.Count) + filter := fleet.TeamFilter{User: test.UserAdmin} + label, _, err := s.ds.Label(context.Background(), labelResp.Label.Label.ID, filter) + require.NoError(t, err) - // lists hosts in invalid label - s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d/hosts", lbl2.ID+1), nil, http.StatusOK, &listHostsResp) - assert.Len(t, listHostsResp.Hosts, 0) + // Verify that the query and values are correct. + // Test parsing the criteria + query, queryValues, err := label.CalculateHostVitalsQuery() + require.NoError(t, err) + queryValuesJson, err := json.Marshal(queryValues) + require.NoError(t, err) - // set MDM information on a host - require.NoError(t, s.ds.SetOrUpdateMDMData(context.Background(), lbl2Hosts[0].ID, false, true, "https://simplemdm.com", false, fleet.WellKnownMDMSimpleMDM, "")) - var mdmID uint - mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { - return sqlx.GetContext(context.Background(), q, &mdmID, - `SELECT id FROM mobile_device_management_solutions WHERE name = ? AND server_url = ?`, fleet.WellKnownMDMSimpleMDM, "https://simplemdm.com") + assert.Equal(t, "SELECT %s FROM %s RIGHT 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) JOIN scim_user_group ON (host_scim_user.scim_user_id = scim_user_group.scim_user_id) 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, `["group_good"]`, string(queryValuesJson)) + + // Update label membership. + _, err = s.ds.UpdateLabelMembershipByHostCriteria(context.Background(), label) + require.NoError(t, err) + + // Verify that the label has the correct hosts. + // Check that the label has the correct hosts + hostsInLabel, err := s.ds.ListHostsInLabel(context.Background(), filter, label.ID, fleet.HostListOptions{}) + require.NoError(t, err) + require.Len(t, hostsInLabel, 3) + require.ElementsMatch(t, []uint{hosts[1].ID, hosts[2].ID, hosts[3].ID}, []uint{hostsInLabel[0].ID, hostsInLabel[1].ID, hostsInLabel[2].ID}) + + // Check that the label has the correct host count + label, _, err = s.ds.Label(context.Background(), labelResp.Label.Label.ID, filter) + require.NoError(t, err) + assert.Equal(t, 3, label.HostCount) + }) }) - // generate aggregated stats - require.NoError(t, s.ds.GenerateAggregatedMunkiAndMDM(context.Background())) - - // list host in label by mdm_id - s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d/hosts", lbl2.ID), nil, http.StatusOK, &listHostsResp, "mdm_id", fmt.Sprint(mdmID)) - require.Len(t, listHostsResp.Hosts, 1) - assert.Nil(t, listHostsResp.Software) - assert.Nil(t, listHostsResp.MunkiIssue) - require.NotNil(t, listHostsResp.MDMSolution) - assert.Equal(t, mdmID, listHostsResp.MDMSolution.ID) - assert.Equal(t, fleet.WellKnownMDMSimpleMDM, listHostsResp.MDMSolution.Name) - assert.Equal(t, "https://simplemdm.com", listHostsResp.MDMSolution.ServerURL) - - // delete a label by id - var delIDResp deleteLabelByIDResponse - s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/labels/id/%d", lbl1.ID), nil, http.StatusOK, &delIDResp) - - // delete a non-existing label by id - s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/labels/id/%d", lbl2.ID+1), nil, http.StatusNotFound, &delIDResp) - - // delete a label by name - var delResp deleteLabelResponse - s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/labels/%s", url.PathEscape(lbl2.Name)), nil, http.StatusOK, &delResp) - - // delete a non-existing label by name - s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/labels/%s", url.PathEscape(lbl2.Name)), nil, http.StatusNotFound, &delResp) - - // delete a manual label by id - s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/labels/id/%d", manualLbl1.ID), nil, http.StatusOK, &delIDResp) - - // delete a manual label by name - s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/labels/%s", url.PathEscape(manualLbl2.Name)), nil, http.StatusOK, &delResp) - - // list labels, only the built-ins remain - s.DoJSON("GET", "/api/latest/fleet/labels", nil, http.StatusOK, &listResp, "per_page", strconv.Itoa(builtInsCount+1)) - assert.Len(t, listResp.Labels, builtInsCount) - idsByName := make(map[string]uint, len(listResp.Labels)) - for _, lbl := range listResp.Labels { - _, ok := builtinsMap[lbl.Name] - assert.True(t, ok) - assert.Equal(t, fleet.LabelTypeBuiltIn, lbl.LabelType) - idsByName[lbl.Name] = lbl.ID - } - - // labels summary, only the built-ins remains - s.DoJSON("GET", "/api/latest/fleet/labels/summary", nil, http.StatusOK, &summaryResp) - assert.Len(t, summaryResp.Labels, builtInsCount) - for _, lbl := range summaryResp.Labels { - _, ok := builtinsMap[lbl.Name] - assert.True(t, ok) - assert.Equal(t, fleet.LabelTypeBuiltIn, lbl.LabelType) - assert.Equal(t, idsByName[lbl.Name], lbl.ID) - } - - // host summary matches built-ins count - var hostSummaryResp getHostSummaryResponse - s.DoJSON("GET", "/api/latest/fleet/host_summary", nil, http.StatusOK, &hostSummaryResp) - assert.Len(t, hostSummaryResp.BuiltinLabels, builtInsCount) - for _, lbl := range hostSummaryResp.BuiltinLabels { - _, ok := builtinsMap[lbl.Name] - assert.True(t, ok) - assert.Equal(t, fleet.LabelTypeBuiltIn, lbl.LabelType) - assert.Equal(t, idsByName[lbl.Name], lbl.ID) - } - - require.Len(t, idsByName, len(builtinsMap)) - for name := range builtinsMap { - id, ok := idsByName[name] - require.True(t, ok) - - // attempt to delete by name - s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/labels/%s", url.PathEscape(name)), nil, http.StatusUnprocessableEntity, &delResp) - - // attempt to delete by id - s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/labels/id/%d", id), nil, http.StatusUnprocessableEntity, &delIDResp) - } } // Sanity test to make sure fleet/labels//hosts and fleet/hosts return the same thing. diff --git a/server/service/labels.go b/server/service/labels.go index 81d3140676..1a761f5ef1 100644 --- a/server/service/labels.go +++ b/server/service/labels.go @@ -2,6 +2,7 @@ package service import ( "context" + "encoding/json" "fmt" "net/http" @@ -71,12 +72,29 @@ func (svc *Service) NewLabel(ctx context.Context, p fleet.LabelPayload) (*fleet. } label.Name = p.Name - if p.Query != "" && (len(p.Hosts) > 0 || len(p.HostIDs) > 0) { - return nil, nil, fleet.NewInvalidArgumentError("query", `Only one of either "query" or "hosts/host_ids" can be included in the request.`) - } - label.Query = p.Query - if p.Query == "" { - label.LabelMembershipType = fleet.LabelMembershipTypeManual + if p.Criteria != nil { + if p.Query != "" || (len(p.Hosts) > 0 || len(p.HostIDs) > 0) { + return nil, nil, fleet.NewInvalidArgumentError("criteria", `Only one of "criteria", "query" or "hosts/host_ids" can be included in the request.`) + } + label.LabelMembershipType = fleet.LabelMembershipTypeHostVitals + labelCriteriaJson, err := json.Marshal(p.Criteria) + if err != nil { + return nil, nil, fleet.NewInvalidArgumentError("criteria", fmt.Sprintf("invalid criteria: %s", err.Error())) + } + label.HostVitalsCriteria = ptr.RawMessage(json.RawMessage(labelCriteriaJson)) + // Attempt to calculate a query from the criteria. + _, _, err = label.CalculateHostVitalsQuery() + if err != nil { + return nil, nil, fleet.NewInvalidArgumentError("criteria", fmt.Sprintf("invalid criteria: %s", err.Error())) + } + } 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.`) + } + label.Query = p.Query + if p.Query == "" { + label.LabelMembershipType = fleet.LabelMembershipTypeManual + } } label.Platform = p.Platform @@ -532,6 +550,12 @@ func (svc *Service) ApplyLabelSpecs(ctx context.Context, specs []*fleet.LabelSpe ctxerr.Errorf(ctx, "label %s is declared as manual but contains no `hosts key`", spec.Name), http.StatusUnprocessableEntity, ) } + if spec.LabelMembershipType == fleet.LabelMembershipTypeHostVitals && spec.HostVitalsCriteria == nil { + // Criteria is required for host vitals labels. + return fleet.NewUserMessageError( + ctxerr.Errorf(ctx, "label %s is declared as host vitals but contains no `criteria` key", spec.Name), http.StatusUnprocessableEntity, + ) + } 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 0cea3448f2..f51c3bb09d 100644 --- a/server/service/labels_test.go +++ b/server/service/labels_test.go @@ -2,6 +2,7 @@ package service import ( "context" + "encoding/json" "testing" "time" @@ -515,3 +516,34 @@ func TestModifyManualLabel(t *testing.T) { require.NoError(t, err) }) } + +func TestNewHostVitalsLabel(t *testing.T) { + ds := new(mock.Store) + svc, ctx := newTestService(t, ds, nil, nil) + ctx = viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}}) + + ds.NewLabelFunc = func(ctx context.Context, lbl *fleet.Label, opts ...fleet.OptionalArg) (*fleet.Label, error) { + return lbl, nil + } + + t.Run("create host vitals label", func(t *testing.T) { + lbl, _, err := svc.NewLabel(ctx, fleet.LabelPayload{ + Name: "foo", + Criteria: &fleet.HostVitalCriteria{ + Vital: ptr.String("end_user_idp_group"), + Value: ptr.String("admin"), + }, + }) + require.NoError(t, err) + assert.Equal(t, fleet.LabelTypeRegular, lbl.LabelType) + assert.Equal(t, fleet.LabelMembershipTypeHostVitals, lbl.LabelMembershipType) + + // Test parsing the criteria + 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 RIGHT 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) JOIN scim_user_group ON (host_scim_user.scim_user_id = scim_user_group.scim_user_id) 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)) + }) +}