From 74016d2e2b8bd70bb11f632671e626b1e786c188 Mon Sep 17 00:00:00 2001 From: Sarah Gillespie <73313222+gillespi314@users.noreply.github.com> Date: Thu, 13 Feb 2025 11:03:17 -0600 Subject: [PATCH 01/14] Add database table and type definitions for host certificates feature (#26281) --- ...20250211141712_AddHostCertificatesTable.go | 47 ++++++ ...211141712_AddHostCertificatesTable_test.go | 20 +++ server/datastore/mysql/schema.sql | 34 +++- server/fleet/host_certificates.go | 145 ++++++++++++++++++ 4 files changed, 244 insertions(+), 2 deletions(-) create mode 100644 server/datastore/mysql/migrations/tables/20250211141712_AddHostCertificatesTable.go create mode 100644 server/datastore/mysql/migrations/tables/20250211141712_AddHostCertificatesTable_test.go create mode 100644 server/fleet/host_certificates.go diff --git a/server/datastore/mysql/migrations/tables/20250211141712_AddHostCertificatesTable.go b/server/datastore/mysql/migrations/tables/20250211141712_AddHostCertificatesTable.go new file mode 100644 index 0000000000..1340bf4682 --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20250211141712_AddHostCertificatesTable.go @@ -0,0 +1,47 @@ +package tables + +import ( + "database/sql" +) + +func init() { + MigrationClient.AddMigration(Up_20250211141712, Down_20250211141712) +} + +func Up_20250211141712(tx *sql.Tx) error { + _, err := tx.Exec(` +CREATE TABLE host_certificates ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + host_id INT UNSIGNED NOT NULL, + not_valid_after DATETIME(6) NOT NULL, + not_valid_before DATETIME(6) NOT NULL, + certificate_authority TINYINT(1) NOT NULL, + common_name VARCHAR(255) NOT NULL, + key_algorithm VARCHAR(255) NOT NULL, + key_strength INT NOT NULL, + key_usage VARCHAR(255) NOT NULL, + serial VARCHAR(255) NOT NULL, + signing_algorithm VARCHAR(255) NOT NULL, + subject_country VARCHAR(2) NOT NULL, + subject_org VARCHAR(255) NOT NULL, + subject_org_unit VARCHAR(255) NOT NULL, + subject_common_name VARCHAR(255) NOT NULL, + issuer_country VARCHAR(2) NOT NULL, + issuer_org VARCHAR(255) NOT NULL, + issuer_org_unit VARCHAR(255) NOT NULL, + issuer_common_name VARCHAR(255) NOT NULL, + sha1_sum BINARY(20) NOT NULL, + created_at DATETIME(6) NOT NULL DEFAULT NOW(6), + deleted_at DATETIME(6) NULL DEFAULT NULL, + + PRIMARY KEY (id), + INDEX idx_host_certs_hid_cn (host_id, common_name), + INDEX idx_host_certs_hid_sha1 (host_id, sha1_sum) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci;`) + + return err +} + +func Down_20250211141712(tx *sql.Tx) error { + return nil +} diff --git a/server/datastore/mysql/migrations/tables/20250211141712_AddHostCertificatesTable_test.go b/server/datastore/mysql/migrations/tables/20250211141712_AddHostCertificatesTable_test.go new file mode 100644 index 0000000000..07147442ad --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20250211141712_AddHostCertificatesTable_test.go @@ -0,0 +1,20 @@ +package tables + +import "testing" + +func TestUp_20250211141712(t *testing.T) { + db := applyUpToPrev(t) + + // + // Insert data to test the migration + // + // ... + + // Apply current migration. + applyNext(t, db) + + // + // Check data, insert new entries, e.g. to verify migration is safe. + // + // ... +} diff --git a/server/datastore/mysql/schema.sql b/server/datastore/mysql/schema.sql index 9469c3fb2f..cf1817bde8 100644 --- a/server/datastore/mysql/schema.sql +++ b/server/datastore/mysql/schema.sql @@ -272,6 +272,36 @@ CREATE TABLE `host_calendar_events` ( /*!40101 SET character_set_client = @saved_cs_client */; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `host_certificates` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `host_id` int unsigned NOT NULL, + `not_valid_after` datetime(6) NOT NULL, + `not_valid_before` datetime(6) NOT NULL, + `certificate_authority` tinyint(1) NOT NULL, + `common_name` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `key_algorithm` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `key_strength` int NOT NULL, + `key_usage` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `serial` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `signing_algorithm` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `subject_country` varchar(2) COLLATE utf8mb4_unicode_ci NOT NULL, + `subject_org` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `subject_org_unit` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `subject_common_name` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `issuer_country` varchar(2) COLLATE utf8mb4_unicode_ci NOT NULL, + `issuer_org` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `issuer_org_unit` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `issuer_common_name` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `sha1_sum` binary(20) NOT NULL, + `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + `deleted_at` datetime(6) DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `idx_host_certs_hid_cn` (`host_id`,`common_name`), + KEY `idx_host_certs_hid_sha1` (`host_id`,`sha1_sum`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `host_dep_assignments` ( `host_id` int unsigned NOT NULL, `added_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -1128,9 +1158,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=352 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB AUTO_INCREMENT=353 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'); +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,20250211141712,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/host_certificates.go b/server/fleet/host_certificates.go new file mode 100644 index 0000000000..391fa05816 --- /dev/null +++ b/server/fleet/host_certificates.go @@ -0,0 +1,145 @@ +package fleet + +import ( + "crypto/sha1" // nolint:gosec // used for compatibility with existing osquery certificates table schema + "crypto/x509" + "encoding/base64" + "errors" + "time" +) + +// HostCertificateRecord is the database model for a host certificate. +type HostCertificateRecord struct { + ID uint `json:"-" db:"id"` + HostID uint `json:"-" db:"host_id"` + + // SHA1Sum is a SHA-1 hash of the DER encoded certificate. + SHA1Sum []byte `json:"-" db:"sha1_sum"` + + // CreatedAt is the time the certificate was recorded by Fleet (i.e. certificate initially + // reported to Fleet). + CreatedAt time.Time `json:"-" db:"created_at"` + // DeletedAt is the time the certificate was soft deleted by Fleet (i.e. previously reported to + // Fleet certificate is subsequently not reported). + DeletedAt *time.Time `json:"-" db:"deleted_at"` + + // The following fields are extracted from the certificate. + + NotValidAfter time.Time `json:"-" db:"not_valid_after"` + NotValidBefore time.Time `json:"-" db:"not_valid_before"` + CertificateAuthority bool `json:"-" db:"certificate_authority"` + CommonName string `json:"-" db:"common_name"` + KeyAlgorithm string `json:"-" db:"key_algorithm"` + KeyStrength int `json:"-" db:"key_strength"` + KeyUsage string `json:"-" db:"key_usage"` + Serial string `json:"-" db:"serial"` + SigningAlgorithm string `json:"-" db:"signing_algorithm"` + SubjectCountry string `json:"-" db:"subject_country"` + SubjectOrganization string `json:"-" db:"subject_org"` + SubjectOrganizationalUnit string `json:"-" db:"subject_org_unit"` + SubjectCommonName string `json:"-" db:"subject_common_name"` + IssuerCountry string `json:"-" db:"issuer_country"` + IssuerOrganization string `json:"-" db:"issuer_org"` + IssuerOrganizationalUnit string `json:"-" db:"issuer_org_unit"` + IssuerCommonName string `json:"-" db:"issuer_common_name"` +} + +type HostCertificateNameDetails struct { + CommonName string `json:"common_name"` + Country string `json:"country"` + Organization string `json:"organization"` + OrganizationalUnit string `json:"organizational_unit"` +} + +// MDMAppleCertificateListResponse is the plist model for a certificate list response. +// https://developer.apple.com/documentation/devicemanagement/certificatelistresponse +type MDMAppleCertificateListResponse struct { + CertificateList []MDMAppleCertificateListItem `plist:"CertificateList"` + CommandUUID string `plist:"CommandUUID"` + EnrollmentID string `plist:"EnrollmentID"` + EnrollmentUserID string `plist:"EnrollmentUserID"` + ErrorChain []MDMAppleErrorChainItem `plist:"ErrorChain"` + NotOnConsole bool `plist:"NotOnConsole"` + Status string `plist:"Status"` + UDID string `plist:"UDID"` + UserID string `plist:"UserID"` + UserLongName string `plist:"UserLongName"` + UserShortName string `plist:"UserShortName"` +} + +// MDMAppleCertificateListItem is the plist model for a certificate. +// https://developer.apple.com/documentation/devicemanagement/certificatelistresponse/certificatelistitem +type MDMAppleCertificateListItem struct { + CommonName string `plist:"CommonName"` + // Data is the DER encoded certificate. + Data b64Data `plist:"Data"` + IsIdentity bool `plist:"IsIdentity"` +} + +func (c *MDMAppleCertificateListItem) Parse() (*HostCertificateRecord, error) { + hash := sha1.Sum(c.Data) // nolint:gosec + + parsed, err := x509.ParseCertificate(c.Data) + if err != nil { + return nil, err + } + + return &HostCertificateRecord{ + SHA1Sum: hash[:], + NotValidBefore: parsed.NotBefore, + NotValidAfter: parsed.NotAfter, + CertificateAuthority: parsed.IsCA, + // TODO: we need to define methodology for determining common name analogous to osquery, + // which seems to preferentially use Subject.CommonName for this value: + // https://github.com/osquery/osquery/blob/16bb01508eeca6d663b6d4f7e15034306be0fc3d/osquery/tables/system/posix/openssl_utils.cpp#L253 + CommonName: parsed.Subject.CommonName, + KeyAlgorithm: parsed.PublicKeyAlgorithm.String(), + // TODO: we need to define methodology for determining key strength analogous to osquery, + // which describes this value as "Key size used for RSA/DSA, or curve name": + // https://github.com/osquery/osquery/blob/16bb01508eeca6d663b6d4f7e15034306be0fc3d/osquery/tables/system/posix/openssl_utils.cpp#L337 + KeyStrength: 0, + // TODO: we need to define methodology for determining key usage analogous to osquery, which + // describes this as "Certificate key usage and extended key usage": + // https://github.com/osquery/osquery/blob/16bb01508eeca6d663b6d4f7e15034306be0fc3d/osquery/tables/system/posix/openssl_utils.cpp#L166 + KeyUsage: "", + Serial: parsed.SerialNumber.String(), + SigningAlgorithm: parsed.SignatureAlgorithm.String(), + SubjectCommonName: parsed.Subject.CommonName, + SubjectCountry: parsed.Subject.Country[0], // TODO: confirm methodology + SubjectOrganization: parsed.Subject.Organization[0], // TODO: confirm methodology + SubjectOrganizationalUnit: parsed.Subject.OrganizationalUnit[0], // TODO: confirm methodology + IssuerCommonName: parsed.Issuer.CommonName, + IssuerCountry: parsed.Issuer.Country[0], // TODO: confirm methodology + IssuerOrganization: parsed.Issuer.Organization[0], // TODO: confirm methodology + IssuerOrganizationalUnit: parsed.Issuer.OrganizationalUnit[0], // TODO: confirm methodology + }, nil +} + +// MdmAppleErrorChainItem is the plist model for an error chain item. +// https://developer.apple.com/documentation/devicemanagement/certificatelistresponse/errorchainitem +type MDMAppleErrorChainItem struct { + ErrorCode int `plist:"ErrorCode"` + ErrorDomain string `plist:"ErrorDomain"` + LocalizedDescription string `plist:"LocalizedDescription"` + USEnglishDescription string `plist:"USEnglishDescription"` +} + +// b64Data is a byte slice that can be base64 encoded. +type b64Data []byte + +// String returns the base64-encoded string form of b +func (b b64Data) String() string { + return base64.StdEncoding.EncodeToString(b) +} + +// ExtractDetailsFromOsqueryDistinguishedName parses a distinguished name and returns the country, +// organization, and organizational unit. It assumes provided string follows the formatting used by +// osquery `certificates` table[1], which appears to follow the style used by openSSL for `-subj` +// values). Key-value pairs are assumed to be separated by forward slashes, for example: +// "/C=US/O=Fleet Device Management Inc./OU=Fleet Device Management Inc./CN=FleetDM". +// +// See https://osquery.io/schema/5.15.0/#certificates +func ExtractDetailsFromOsqueryDistinguishedName(str string) (*HostCertificateNameDetails, error) { + // TODO + return nil, errors.New("not implemented") +} From 7b6e2120030c97cc6d96747ef73bb512e7e4127e Mon Sep 17 00:00:00 2001 From: Sarah Gillespie <73313222+gillespi314@users.noreply.github.com> Date: Tue, 18 Feb 2025 17:49:02 -0600 Subject: [PATCH 02/14] Add datastore methods for host certificates feature (#26416) --- cmd/fleetctl/gitops_test.go | 65 ++++-- cmd/fleetctl/testing_utils.go | 4 +- ee/server/service/mdm_external_test.go | 6 +- server/datastore/mysql/host_certificates.go | 197 ++++++++++++++++++ .../datastore/mysql/host_certificates_test.go | 120 +++++++++++ ...20250211141712_AddHostCertificatesTable.go | 2 +- ...211141712_AddHostCertificatesTable_test.go | 20 -- server/datastore/mysql/schema.sql | 2 +- server/datastore/mysql/testing_utils.go | 36 +--- server/fleet/datastore.go | 3 + server/fleet/host_certificates.go | 98 +++++---- server/mdm/testing_utils/testing_utils.go | 29 +++ server/mock/datastore_mock.go | 24 +++ server/service/apple_mdm_test.go | 8 +- 14 files changed, 491 insertions(+), 123 deletions(-) create mode 100644 server/datastore/mysql/host_certificates.go create mode 100644 server/datastore/mysql/host_certificates_test.go delete mode 100644 server/datastore/mysql/migrations/tables/20250211141712_AddHostCertificatesTable_test.go create mode 100644 server/mdm/testing_utils/testing_utils.go diff --git a/cmd/fleetctl/gitops_test.go b/cmd/fleetctl/gitops_test.go index ddf70e364e..0e3bd5d90d 100644 --- a/cmd/fleetctl/gitops_test.go +++ b/cmd/fleetctl/gitops_test.go @@ -21,6 +21,7 @@ import ( apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple" "github.com/fleetdm/fleet/v4/server/mdm/apple/vpp" "github.com/fleetdm/fleet/v4/server/mdm/nanodep/tokenpki" + "github.com/fleetdm/fleet/v4/server/mdm/testing_utils" "github.com/fleetdm/fleet/v4/server/mock" mdmmock "github.com/fleetdm/fleet/v4/server/mock/mdm" "github.com/fleetdm/fleet/v4/server/ptr" @@ -1623,14 +1624,18 @@ software: require.NoError(t, err) // Dry run, global defines software, should fail. - _, err = runAppNoChecks([]string{"gitops", "-f", globalFileWithSoftware.Name(), "-f", teamFileBasic.Name(), "-f", + _, err = runAppNoChecks([]string{ + "gitops", "-f", globalFileWithSoftware.Name(), "-f", teamFileBasic.Name(), "-f", noTeamFileBasic.Name(), - "--dry-run"}) + "--dry-run", + }) require.Error(t, err) assert.ErrorContains(t, err, "'software' cannot be set on global file") // Real run, global defines software, should fail. - _, err = runAppNoChecks([]string{"gitops", "-f", globalFileWithSoftware.Name(), "-f", teamFileBasic.Name(), "-f", - noTeamFileBasic.Name()}) + _, err = runAppNoChecks([]string{ + "gitops", "-f", globalFileWithSoftware.Name(), "-f", teamFileBasic.Name(), "-f", + noTeamFileBasic.Name(), + }) require.Error(t, err) assert.ErrorContains(t, err, "'software' cannot be set on global file") }) @@ -1653,13 +1658,17 @@ software: require.NoError(t, err) // Dry run, both global and no-team.yml define controls. - _, err = runAppNoChecks([]string{"gitops", "-f", globalFileWithControls.Name(), "-f", teamFileBasic.Name(), "-f", - noTeamFileWithControls.Name(), "--dry-run"}) + _, err = runAppNoChecks([]string{ + "gitops", "-f", globalFileWithControls.Name(), "-f", teamFileBasic.Name(), "-f", + noTeamFileWithControls.Name(), "--dry-run", + }) require.Error(t, err) assert.True(t, strings.Contains(err.Error(), "'controls' cannot be set on both global config and on no-team.yml")) // Real run, both global and no-team.yml define controls. - _, err = runAppNoChecks([]string{"gitops", "-f", globalFileWithControls.Name(), "-f", teamFileBasic.Name(), "-f", - noTeamFileWithControls.Name()}) + _, err = runAppNoChecks([]string{ + "gitops", "-f", globalFileWithControls.Name(), "-f", teamFileBasic.Name(), "-f", + noTeamFileWithControls.Name(), + }) require.Error(t, err) assert.True(t, strings.Contains(err.Error(), "'controls' cannot be set on both global config and on no-team.yml")) }) @@ -1682,13 +1691,17 @@ software: require.NoError(t, err) // Dry run, both global and no-team.yml defines policy with calendar events enabled. - _, err = runAppNoChecks([]string{"gitops", "-f", globalFileWithControls.Name(), "-f", teamFileBasic.Name(), "-f", - noTeamFilePathPoliciesCalendar.Name(), "--dry-run"}) + _, err = runAppNoChecks([]string{ + "gitops", "-f", globalFileWithControls.Name(), "-f", teamFileBasic.Name(), "-f", + noTeamFilePathPoliciesCalendar.Name(), "--dry-run", + }) require.Error(t, err) assert.True(t, strings.Contains(err.Error(), "calendar events are not supported on \"No team\" policies: \"Foobar\""), err.Error()) // Real run, both global and no-team.yml define controls. - _, err = runAppNoChecks([]string{"gitops", "-f", globalFileWithControls.Name(), "-f", teamFileBasic.Name(), "-f", - noTeamFilePathPoliciesCalendar.Name()}) + _, err = runAppNoChecks([]string{ + "gitops", "-f", globalFileWithControls.Name(), "-f", teamFileBasic.Name(), "-f", + noTeamFilePathPoliciesCalendar.Name(), + }) require.Error(t, err) assert.True(t, strings.Contains(err.Error(), "calendar events are not supported on \"No team\" policies: \"Foobar\""), err.Error()) }) @@ -1707,13 +1720,17 @@ software: require.NoError(t, err) // Dry run, controls should be defined somewhere, either in no-team.yml or global. - _, err = runAppNoChecks([]string{"gitops", "-f", globalFileWithoutControlsAndSoftwareKeys.Name(), "-f", teamFileBasic.Name(), "-f", - noTeamFileWithoutControls.Name(), "--dry-run"}) + _, err = runAppNoChecks([]string{ + "gitops", "-f", globalFileWithoutControlsAndSoftwareKeys.Name(), "-f", teamFileBasic.Name(), "-f", + noTeamFileWithoutControls.Name(), "--dry-run", + }) require.Error(t, err) assert.True(t, strings.Contains(err.Error(), "'controls' must be set on global config or no-team.yml")) // Real run - _, err = runAppNoChecks([]string{"gitops", "-f", globalFileWithoutControlsAndSoftwareKeys.Name(), "-f", teamFileBasic.Name(), "-f", - noTeamFileWithoutControls.Name()}) + _, err = runAppNoChecks([]string{ + "gitops", "-f", globalFileWithoutControlsAndSoftwareKeys.Name(), "-f", teamFileBasic.Name(), "-f", + noTeamFileWithoutControls.Name(), + }) require.Error(t, err) assert.True(t, strings.Contains(err.Error(), "'controls' must be set on global config or no-team.yml")) }) @@ -1725,15 +1742,19 @@ software: // Dry run, global file without controls and software keys. _ = runAppForTest(t, - []string{"gitops", "-f", globalFileWithoutControlsAndSoftwareKeys.Name(), "-f", teamFileBasic.Name(), "-f", + []string{ + "gitops", "-f", globalFileWithoutControlsAndSoftwareKeys.Name(), "-f", teamFileBasic.Name(), "-f", noTeamFileBasic.Name(), - "--dry-run"}) + "--dry-run", + }) assert.Equal(t, fleet.AppConfig{}, *savedAppConfig, "AppConfig should be empty") // Real run, global file without controls and software keys. _ = runAppForTest(t, - []string{"gitops", "-f", globalFileWithoutControlsAndSoftwareKeys.Name(), "-f", teamFileBasic.Name(), "-f", - noTeamFileBasic.Name()}) + []string{ + "gitops", "-f", globalFileWithoutControlsAndSoftwareKeys.Name(), "-f", teamFileBasic.Name(), "-f", + noTeamFileBasic.Name(), + }) assert.Equal(t, orgName, savedAppConfig.OrgInfo.OrgName) assert.Equal(t, fleetServerURL, savedAppConfig.ServerSettings.ServerURL) assert.Len(t, enrolledSecrets, 1) @@ -1741,7 +1762,6 @@ software: assert.Equal(t, teamName, savedTeam.Name) require.Len(t, enrolledTeamSecrets, 1) assert.Equal(t, secret, enrolledTeamSecrets[0].Secret) - }) t.Run("basic global and no-team.yml", func(t *testing.T) { @@ -1907,7 +1927,7 @@ func TestGitOpsFullGlobalAndTeam(t *testing.T) { return 0, nil } - apnsCert, apnsKey, err := mysql.GenerateTestCertBytes() + apnsCert, apnsKey, err := mysql.GenerateTestCertBytes(testing_utils.NewTestMDMAppleCertTemplate()) require.NoError(t, err) crt, key, err := apple_mdm.NewSCEPCACertKey() require.NoError(t, err) @@ -2017,7 +2037,6 @@ software: assert.Equal(t, filepath.Base(cspFile.Name()), filepath.Base((*savedAppConfigPtr).MDM.WindowsSettings.CustomSettings.Value[0].Path)) assert.True(t, ds.BatchSetScriptsFuncInvoked) }) - } func TestGitOpsTeamSofwareInstallers(t *testing.T) { diff --git a/cmd/fleetctl/testing_utils.go b/cmd/fleetctl/testing_utils.go index 19bb8bdaef..a4d750608e 100644 --- a/cmd/fleetctl/testing_utils.go +++ b/cmd/fleetctl/testing_utils.go @@ -24,6 +24,8 @@ import ( "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" "github.com/urfave/cli/v2" + + mdmtesting "github.com/fleetdm/fleet/v4/server/mdm/testing_utils" ) type withDS struct { @@ -122,7 +124,7 @@ func runServerWithMockedDS(t *testing.T, opts ...*service.TestServerOpts) (*http ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { return &fleet.AppConfig{}, nil } - apnsCert, apnsKey, err := mysql.GenerateTestCertBytes() + apnsCert, apnsKey, err := mysql.GenerateTestCertBytes(mdmtesting.NewTestMDMAppleCertTemplate()) require.NoError(t, err) certPEM, keyPEM, tokenBytes, err := mysql.GenerateTestABMAssets(t) require.NoError(t, err) diff --git a/ee/server/service/mdm_external_test.go b/ee/server/service/mdm_external_test.go index 71272b08af..88031c9e03 100644 --- a/ee/server/service/mdm_external_test.go +++ b/ee/server/service/mdm_external_test.go @@ -22,6 +22,7 @@ import ( "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig" nanodep_client "github.com/fleetdm/fleet/v4/server/mdm/nanodep/client" + mdmtesting "github.com/fleetdm/fleet/v4/server/mdm/testing_utils" "github.com/fleetdm/fleet/v4/server/mock" nanodep_mock "github.com/fleetdm/fleet/v4/server/mock/nanodep" "github.com/fleetdm/fleet/v4/server/ptr" @@ -234,12 +235,13 @@ func TestGetOrCreatePreassignTeam(t *testing.T) { ) (updates fleet.MDMProfilesUpdates, err error) { return fleet.MDMProfilesUpdates{}, nil } - apnsCert, apnsKey, err := mysql.GenerateTestCertBytes() + apnsCert, apnsKey, err := mysql.GenerateTestCertBytes(mdmtesting.NewTestMDMAppleCertTemplate()) require.NoError(t, err) certPEM, keyPEM, tokenBytes, err := mysql.GenerateTestABMAssets(t) require.NoError(t, err) ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName, - _ sqlx.QueryerContext) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { + _ sqlx.QueryerContext, + ) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { return map[fleet.MDMAssetName]fleet.MDMConfigAsset{ fleet.MDMAssetABMCert: {Name: fleet.MDMAssetABMCert, Value: certPEM}, fleet.MDMAssetABMKey: {Name: fleet.MDMAssetABMKey, Value: keyPEM}, diff --git a/server/datastore/mysql/host_certificates.go b/server/datastore/mysql/host_certificates.go new file mode 100644 index 0000000000..19dada7419 --- /dev/null +++ b/server/datastore/mysql/host_certificates.go @@ -0,0 +1,197 @@ +package mysql + +import ( + "context" + "encoding/hex" + "fmt" + "strings" + + "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/go-kit/kit/log/level" + "github.com/jmoiron/sqlx" +) + +func (ds *Datastore) ListHostCertificates(ctx context.Context, hostID uint, opts fleet.ListOptions) ([]*fleet.HostCertificateRecord, *fleet.PaginationMetadata, error) { + return listHostCertsDB(ctx, ds.reader(ctx), hostID, opts) +} + +func (ds *Datastore) UpdateHostCertificates(ctx context.Context, hostID uint, certs []*fleet.HostCertificateRecord) error { + incomingBySHA1 := make(map[string]*fleet.HostCertificateRecord, len(certs)) + for _, cert := range certs { + if cert.HostID != hostID { + // caller should ensure this does not happen + level.Debug(ds.logger).Log("msg", fmt.Sprintf("host ID does not match provided certificate: %d %d", hostID, cert.HostID)) + } + if _, ok := incomingBySHA1[strings.ToUpper(hex.EncodeToString(cert.SHA1Sum))]; ok { + // TODO: sha1 is broken so this could be a sign of a problem, how should we handle? + level.Info(ds.logger).Log("msg", "host has multiple certificates with the same SHA1, only the first will be recorded", "host_id", hostID, "sha1", string(cert.SHA1Sum)) + continue + } + incomingBySHA1[strings.ToUpper(hex.EncodeToString(cert.SHA1Sum))] = cert + } + + // get existing certs for this host; we'll use the reader because we expect certs to change + // infrequently and they will be eventually consistent + existingCerts, _, err := listHostCertsDB(ctx, ds.reader(ctx), hostID, fleet.ListOptions{}) // requesting unpaginated results with default limit of 1 million + if err != nil { + return fmt.Errorf("list host certs for update: %w", err) + } + existingBySHA1 := make(map[string]*fleet.HostCertificateRecord, len(existingCerts)) + for _, ec := range existingCerts { + existingBySHA1[strings.ToUpper(hex.EncodeToString(ec.SHA1Sum))] = ec + } + + toInsert := make([]*fleet.HostCertificateRecord, 0, len(incomingBySHA1)) + // toUpdate := make([]*fleet.HostCertificateRecord, 0, len(incomingBySHA1)) + for sha1, incoming := range incomingBySHA1 { + if _, ok := existingBySHA1[sha1]; ok { + // TODO: should we always update existing records? skipping updates reduces db load but + // osquery is using sha1 so we consider subtleties + level.Debug(ds.logger).Log("msg", fmt.Sprintf("existing certificate: %s", sha1), "host_id", hostID) + } else { + toInsert = append(toInsert, incoming) + } + } + + toDelete := make([]uint, 0, len(existingBySHA1)) + for sha1, existing := range existingBySHA1 { + if _, ok := incomingBySHA1[sha1]; !ok { + toDelete = append(toDelete, existing.ID) + } + } + + return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { + if err := insertHostCertsDB(ctx, tx, toInsert); err != nil { + return ctxerr.Wrap(ctx, err, "insert host certs") + } + if err := softDeleteHostCertsDB(ctx, tx, hostID, toDelete); err != nil { + return ctxerr.Wrap(ctx, err, "soft delete host certs") + } + return nil + }) +} + +func listHostCertsDB(ctx context.Context, tx sqlx.QueryerContext, hostID uint, opts fleet.ListOptions) ([]*fleet.HostCertificateRecord, *fleet.PaginationMetadata, error) { + // TODO: move this to the service layer and do validation of the order key? + if opts.OrderKey == "" { + // default sort by common name ascending + opts.OrderKey = "common_name" + opts.OrderDirection = fleet.OrderAscending + } + + stmt := ` +SELECT + id, + sha1_sum, + host_id, + created_at, + deleted_at, + not_valid_before, + not_valid_after, + certificate_authority, + common_name, + key_algorithm, + key_strength, + key_usage, + serial, + signing_algorithm, + subject_country, + subject_org, + subject_org_unit, + subject_common_name, + issuer_country, + issuer_org, + issuer_org_unit, + issuer_common_name +FROM + host_certificates +WHERE + host_id = ? + AND deleted_at IS NULL` + + args := []interface{}{hostID} + stmtPaged, args := appendListOptionsWithCursorToSQL(stmt, args, &opts) + + var certs []*fleet.HostCertificateRecord + if err := sqlx.SelectContext(ctx, tx, &certs, stmtPaged, args...); err != nil { + return nil, nil, err + } + + var metaData *fleet.PaginationMetadata + if opts.IncludeMetadata { + metaData = &fleet.PaginationMetadata{HasPreviousResults: opts.Page > 0} + if len(certs) > int(opts.PerPage) { //nolint:gosec // dismiss G115 + metaData.HasNextResults = true + certs = certs[:len(certs)-1] + } + } + + return certs, metaData, nil +} + +func insertHostCertsDB(ctx context.Context, tx sqlx.ExtContext, certs []*fleet.HostCertificateRecord) error { + if len(certs) == 0 { + return nil + } + + stmt := ` +INSERT INTO host_certificates ( + host_id, + sha1_sum, + not_valid_before, + not_valid_after, + certificate_authority, + common_name, + key_algorithm, + key_strength, + key_usage, + serial, + signing_algorithm, + subject_country, + subject_org, + subject_org_unit, + subject_common_name, + issuer_country, + issuer_org, + issuer_org_unit, + issuer_common_name +) VALUES %s` + + placeholders := make([]string, 0, len(certs)) + args := make([]interface{}, 0, len(certs)*19) + for _, cert := range certs { + placeholders = append(placeholders, "(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)") + args = append(args, + cert.HostID, cert.SHA1Sum, cert.NotValidBefore, cert.NotValidAfter, cert.CertificateAuthority, cert.CommonName, + cert.KeyAlgorithm, cert.KeyStrength, cert.KeyUsage, cert.Serial, cert.SigningAlgorithm, + cert.SubjectCountry, cert.SubjectOrganization, cert.SubjectOrganizationalUnit, cert.SubjectCommonName, + cert.IssuerCountry, cert.IssuerOrganization, cert.IssuerOrganizationalUnit, cert.IssuerCommonName) + } + + stmt = fmt.Sprintf(stmt, strings.Trim(strings.Join(placeholders, ","), ",")) + + if _, err := tx.ExecContext(ctx, stmt, args...); err != nil { + return err + } + + return nil +} + +func softDeleteHostCertsDB(ctx context.Context, tx sqlx.ExtContext, hostID uint, toDelete []uint) error { + if len(toDelete) == 0 { + return nil + } + + stmt := `UPDATE host_certificates SET deleted_at = NOW(6) WHERE host_id = ? AND id IN (?)` + stmt, args, err := sqlx.In(stmt, hostID, toDelete) + if err != nil { + return err + } + + if _, err := tx.ExecContext(ctx, stmt, args...); err != nil { + return err + } + + return nil +} diff --git a/server/datastore/mysql/host_certificates_test.go b/server/datastore/mysql/host_certificates_test.go new file mode 100644 index 0000000000..587bbf3a29 --- /dev/null +++ b/server/datastore/mysql/host_certificates_test.go @@ -0,0 +1,120 @@ +package mysql + +import ( + "context" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "testing" + "time" + + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/stretchr/testify/require" +) + +func TestHostCertificates(t *testing.T) { + ds := CreateMySQLDS(t) + + cases := []struct { + name string + fn func(t *testing.T, ds *Datastore) + }{ + {"UpdateAndList", testUpdateAndListHostCertificates}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + defer TruncateTables(t, ds) + c.fn(t, ds) + }) + } +} + +func testUpdateAndListHostCertificates(t *testing.T, ds *Datastore) { + expected1 := x509.Certificate{ + Subject: pkix.Name{ + Country: []string{"US"}, + CommonName: "test.example.com", + Organization: []string{"Org 1"}, + + OrganizationalUnit: []string{"Engineering"}, + }, + Issuer: pkix.Name{ + Country: []string{"US"}, + CommonName: "issuer.test.example.com", + Organization: []string{"Issuer 1"}, + }, + SerialNumber: big.NewInt(1337), + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + + SignatureAlgorithm: x509.SHA256WithRSA, + NotBefore: time.Now().Add(-time.Hour).Truncate(time.Second).UTC(), + NotAfter: time.Now().Add(24 * time.Hour).Truncate(time.Second).UTC(), + BasicConstraintsValid: true, + } + + expected2 := x509.Certificate{ + Subject: pkix.Name{ + Country: []string{"US"}, + CommonName: "another.test.example.com", + Organization: []string{"Org 2"}, + OrganizationalUnit: []string{"Engineering"}, + }, + SerialNumber: big.NewInt(1337), + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + + SignatureAlgorithm: x509.SHA256WithRSA, + NotBefore: time.Now().Add(-2 * time.Hour).Truncate(time.Second).UTC(), + NotAfter: time.Now().Add(48 * time.Hour).Truncate(time.Second).UTC(), + BasicConstraintsValid: true, + } + + payload := []*fleet.HostCertificateRecord{ + generateTestHostCertificateRecord(t, 1, &expected1), + generateTestHostCertificateRecord(t, 1, &expected2), + } + + require.NoError(t, ds.UpdateHostCertificates(context.Background(), 1, payload)) + + // verify that we saved the records correctly + certs, _, err := ds.ListHostCertificates(context.Background(), 1, fleet.ListOptions{}) + require.NoError(t, err) + require.Len(t, certs, 2) + // default ordering is by common name ascending + require.Equal(t, expected2.Subject.CommonName, certs[0].CommonName) + require.Equal(t, expected2.Subject.CommonName, certs[0].SubjectCommonName) + require.Equal(t, expected1.Subject.CommonName, certs[1].CommonName) + require.Equal(t, expected1.Subject.CommonName, certs[1].SubjectCommonName) + + // order by not_valid_after descending + certs2, _, err := ds.ListHostCertificates(context.Background(), 1, fleet.ListOptions{OrderKey: "not_valid_after", OrderDirection: fleet.OrderAscending}) + require.NoError(t, err) + require.Len(t, certs2, 2) + require.Equal(t, expected1.Subject.CommonName, certs2[0].CommonName) + require.Equal(t, expected1.Subject.CommonName, certs2[0].SubjectCommonName) + require.Equal(t, expected2.Subject.CommonName, certs2[1].CommonName) + require.Equal(t, expected2.Subject.CommonName, certs2[1].SubjectCommonName) + + // simulate removal of a certificate + require.NoError(t, ds.UpdateHostCertificates(context.Background(), 1, []*fleet.HostCertificateRecord{payload[1]})) + certs3, _, err := ds.ListHostCertificates(context.Background(), 1, fleet.ListOptions{}) + require.NoError(t, err) + require.Len(t, certs3, 1) + require.Equal(t, expected2.Subject.CommonName, certs3[0].CommonName) + require.Equal(t, expected2.Subject.CommonName, certs3[0].SubjectCommonName) +} + +func generateTestHostCertificateRecord(t *testing.T, hostID uint, template *x509.Certificate) *fleet.HostCertificateRecord { + b, _, err := GenerateTestCertBytes(template) + require.NoError(t, err) + + block, _ := pem.Decode(b) + + parsed, err := x509.ParseCertificate(block.Bytes) + require.NoError(t, err) + require.NotNil(t, parsed) + + return fleet.NewHostCertificateRecord(hostID, parsed) +} diff --git a/server/datastore/mysql/migrations/tables/20250211141712_AddHostCertificatesTable.go b/server/datastore/mysql/migrations/tables/20250211141712_AddHostCertificatesTable.go index 1340bf4682..a374ebdb6c 100644 --- a/server/datastore/mysql/migrations/tables/20250211141712_AddHostCertificatesTable.go +++ b/server/datastore/mysql/migrations/tables/20250211141712_AddHostCertificatesTable.go @@ -36,7 +36,7 @@ CREATE TABLE host_certificates ( PRIMARY KEY (id), INDEX idx_host_certs_hid_cn (host_id, common_name), - INDEX idx_host_certs_hid_sha1 (host_id, sha1_sum) + INDEX idx_host_certs_not_valid_after (host_id, not_valid_after) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci;`) return err diff --git a/server/datastore/mysql/migrations/tables/20250211141712_AddHostCertificatesTable_test.go b/server/datastore/mysql/migrations/tables/20250211141712_AddHostCertificatesTable_test.go deleted file mode 100644 index 07147442ad..0000000000 --- a/server/datastore/mysql/migrations/tables/20250211141712_AddHostCertificatesTable_test.go +++ /dev/null @@ -1,20 +0,0 @@ -package tables - -import "testing" - -func TestUp_20250211141712(t *testing.T) { - db := applyUpToPrev(t) - - // - // Insert data to test the migration - // - // ... - - // Apply current migration. - applyNext(t, db) - - // - // Check data, insert new entries, e.g. to verify migration is safe. - // - // ... -} diff --git a/server/datastore/mysql/schema.sql b/server/datastore/mysql/schema.sql index 78509bef0d..1a741e8511 100644 --- a/server/datastore/mysql/schema.sql +++ b/server/datastore/mysql/schema.sql @@ -308,7 +308,7 @@ CREATE TABLE `host_certificates` ( `deleted_at` datetime(6) DEFAULT NULL, PRIMARY KEY (`id`), KEY `idx_host_certs_hid_cn` (`host_id`,`common_name`), - KEY `idx_host_certs_hid_sha1` (`host_id`,`sha1_sum`) + KEY `idx_host_certs_not_valid_after` (`host_id`,`not_valid_after`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; /*!40101 SET @saved_cs_client = @@character_set_client */; diff --git a/server/datastore/mysql/testing_utils.go b/server/datastore/mysql/testing_utils.go index eeee8710bb..3a49667074 100644 --- a/server/datastore/mysql/testing_utils.go +++ b/server/datastore/mysql/testing_utils.go @@ -5,15 +5,13 @@ import ( "crypto/rand" "crypto/rsa" "crypto/x509" - "crypto/x509/pkix" "database/sql" - "encoding/asn1" "encoding/base64" "encoding/json" "encoding/pem" + "errors" "fmt" "io" - "math/big" "os" "os/exec" "path" @@ -30,6 +28,7 @@ import ( "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" "github.com/fleetdm/fleet/v4/server/fleet" nanodep_client "github.com/fleetdm/fleet/v4/server/mdm/nanodep/client" + mdmtesting "github.com/fleetdm/fleet/v4/server/mdm/testing_utils" "github.com/go-kit/log" "github.com/google/uuid" "github.com/jmoiron/sqlx" @@ -808,7 +807,7 @@ func CreateAndSetABMToken(t testing.TB, ds *Datastore, orgName string) *fleet.AB } func SetTestABMAssets(t testing.TB, ds *Datastore, orgName string) *fleet.ABMToken { - apnsCert, apnsKey, err := GenerateTestCertBytes() + apnsCert, apnsKey, err := GenerateTestCertBytes(mdmtesting.NewTestMDMAppleCertTemplate()) require.NoError(t, err) certPEM, keyPEM, tokenBytes, err := GenerateTestABMAssets(t) @@ -839,7 +838,7 @@ func SetTestABMAssets(t testing.TB, ds *Datastore, orgName string) *fleet.ABMTok } func GenerateTestABMAssets(t testing.TB) ([]byte, []byte, []byte, error) { - certPEM, keyPEM, err := GenerateTestCertBytes() + certPEM, keyPEM, err := GenerateTestCertBytes(mdmtesting.NewTestMDMAppleCertTemplate()) require.NoError(t, err) testBMToken := &nanodep_client.OAuth1Tokens{ @@ -878,32 +877,17 @@ func GenerateTestABMAssets(t testing.TB) ([]byte, []byte, []byte, error) { return certPEM, keyPEM, []byte(tokenBytes), nil } -// TODO: move to mdmcrypto? -func GenerateTestCertBytes() ([]byte, []byte, error) { +func GenerateTestCertBytes(template *x509.Certificate) ([]byte, []byte, error) { + if template == nil { + return nil, nil, errors.New("template is nil") + } + priv, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { return nil, nil, err } - template := x509.Certificate{ - SerialNumber: big.NewInt(1), - Subject: pkix.Name{ - Organization: []string{"Test Org"}, - ExtraNames: []pkix.AttributeTypeAndValue{ - { - Type: asn1.ObjectIdentifier{0, 9, 2342, 19200300, 100, 1, 1}, - Value: "com.apple.mgmt.Example", - }, - }, - }, - NotBefore: time.Now(), - NotAfter: time.Now().Add(365 * 24 * time.Hour), - KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, - ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, - BasicConstraintsValid: true, - } - - certBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv) + certBytes, err := x509.CreateCertificate(rand.Reader, template, template, &priv.PublicKey, priv) if err != nil { return nil, nil, err } diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index 6477923e31..0fc3a58d5f 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -364,6 +364,9 @@ type Datastore interface { // IsHostConnectedToFleetMDM verifies if the host has an active Fleet MDM enrollment with this server IsHostConnectedToFleetMDM(ctx context.Context, host *Host) (bool, error) + ListHostCertificates(ctx context.Context, hostID uint, opts ListOptions) ([]*HostCertificateRecord, *PaginationMetadata, error) + UpdateHostCertificates(ctx context.Context, hostID uint, certs []*HostCertificateRecord) error + // AreHostsConnectedToFleetMDM checks each host MDM enrollment with // this server and returns a map indexed by the host uuid and a boolean // indicating if the enrollment is active. diff --git a/server/fleet/host_certificates.go b/server/fleet/host_certificates.go index 391fa05816..97b26c38c5 100644 --- a/server/fleet/host_certificates.go +++ b/server/fleet/host_certificates.go @@ -3,8 +3,8 @@ package fleet import ( "crypto/sha1" // nolint:gosec // used for compatibility with existing osquery certificates table schema "crypto/x509" - "encoding/base64" "errors" + "fmt" "time" ) @@ -44,6 +44,44 @@ type HostCertificateRecord struct { IssuerCommonName string `json:"-" db:"issuer_common_name"` } +func NewHostCertificateRecord( + hostID uint, + cert *x509.Certificate, +) *HostCertificateRecord { + hash := sha1.Sum(cert.Raw) // nolint:gosec + + return &HostCertificateRecord{ + HostID: hostID, + SHA1Sum: hash[:], // nolint:gosec + NotValidAfter: cert.NotAfter, + NotValidBefore: cert.NotBefore, + CertificateAuthority: cert.IsCA, + // TODO: we need to define methodology for determining common name analogous to osquery, + // which seems to preferentially use Subject.CommonName for this value: + // https://github.com/osquery/osquery/blob/16bb01508eeca6d663b6d4f7e15034306be0fc3d/osquery/tables/system/posix/openssl_utils.cpp#L253 + CommonName: cert.Subject.CommonName, + KeyAlgorithm: cert.PublicKeyAlgorithm.String(), + // TODO: we need to define methodology for determining key strength analogous to osquery, + // which describes this value as "Key size used for RSA/DSA, or curve name": + // https://github.com/osquery/osquery/blob/16bb01508eeca6d663b6d4f7e15034306be0fc3d/osquery/tables/system/posix/openssl_utils.cpp#L337 + KeyStrength: 0, // TODO: add key strength here + // TODO: we need to define methodology for determining key usage analogous to osquery, which + // describes this as "Certificate key usage and extended key usage": + // https://github.com/osquery/osquery/blob/16bb01508eeca6d663b6d4f7e15034306be0fc3d/osquery/tables/system/posix/openssl_utils.cpp#L166 + KeyUsage: "", + Serial: cert.SerialNumber.String(), + SigningAlgorithm: cert.SignatureAlgorithm.String(), + SubjectCommonName: cert.Subject.CommonName, + SubjectCountry: firstOrEmpty(cert.Subject.Country), // TODO: confirm methodology + SubjectOrganization: firstOrEmpty(cert.Subject.Organization), // TODO: confirm methodology + SubjectOrganizationalUnit: firstOrEmpty(cert.Subject.OrganizationalUnit), // TODO: confirm methodology + IssuerCommonName: cert.Issuer.CommonName, + IssuerCountry: firstOrEmpty(cert.Issuer.Country), // TODO: confirm methodology + IssuerOrganization: firstOrEmpty(cert.Issuer.Organization), // TODO: confirm methodology + IssuerOrganizationalUnit: firstOrEmpty(cert.Issuer.OrganizationalUnit), // TODO: confirm methodology + } +} + type HostCertificateNameDetails struct { CommonName string `json:"common_name"` Country string `json:"country"` @@ -71,48 +109,17 @@ type MDMAppleCertificateListResponse struct { // https://developer.apple.com/documentation/devicemanagement/certificatelistresponse/certificatelistitem type MDMAppleCertificateListItem struct { CommonName string `plist:"CommonName"` - // Data is the DER encoded certificate. - Data b64Data `plist:"Data"` - IsIdentity bool `plist:"IsIdentity"` + // Data is the DER-encoded certificate. + Data []byte `plist:"Data"` + IsIdentity bool `plist:"IsIdentity"` } func (c *MDMAppleCertificateListItem) Parse() (*HostCertificateRecord, error) { - hash := sha1.Sum(c.Data) // nolint:gosec - - parsed, err := x509.ParseCertificate(c.Data) + cert, err := x509.ParseCertificate(c.Data) if err != nil { - return nil, err + return nil, fmt.Errorf("parsing certificate list item: %w", err) } - - return &HostCertificateRecord{ - SHA1Sum: hash[:], - NotValidBefore: parsed.NotBefore, - NotValidAfter: parsed.NotAfter, - CertificateAuthority: parsed.IsCA, - // TODO: we need to define methodology for determining common name analogous to osquery, - // which seems to preferentially use Subject.CommonName for this value: - // https://github.com/osquery/osquery/blob/16bb01508eeca6d663b6d4f7e15034306be0fc3d/osquery/tables/system/posix/openssl_utils.cpp#L253 - CommonName: parsed.Subject.CommonName, - KeyAlgorithm: parsed.PublicKeyAlgorithm.String(), - // TODO: we need to define methodology for determining key strength analogous to osquery, - // which describes this value as "Key size used for RSA/DSA, or curve name": - // https://github.com/osquery/osquery/blob/16bb01508eeca6d663b6d4f7e15034306be0fc3d/osquery/tables/system/posix/openssl_utils.cpp#L337 - KeyStrength: 0, - // TODO: we need to define methodology for determining key usage analogous to osquery, which - // describes this as "Certificate key usage and extended key usage": - // https://github.com/osquery/osquery/blob/16bb01508eeca6d663b6d4f7e15034306be0fc3d/osquery/tables/system/posix/openssl_utils.cpp#L166 - KeyUsage: "", - Serial: parsed.SerialNumber.String(), - SigningAlgorithm: parsed.SignatureAlgorithm.String(), - SubjectCommonName: parsed.Subject.CommonName, - SubjectCountry: parsed.Subject.Country[0], // TODO: confirm methodology - SubjectOrganization: parsed.Subject.Organization[0], // TODO: confirm methodology - SubjectOrganizationalUnit: parsed.Subject.OrganizationalUnit[0], // TODO: confirm methodology - IssuerCommonName: parsed.Issuer.CommonName, - IssuerCountry: parsed.Issuer.Country[0], // TODO: confirm methodology - IssuerOrganization: parsed.Issuer.Organization[0], // TODO: confirm methodology - IssuerOrganizationalUnit: parsed.Issuer.OrganizationalUnit[0], // TODO: confirm methodology - }, nil + return NewHostCertificateRecord(0, cert), nil } // MdmAppleErrorChainItem is the plist model for an error chain item. @@ -124,14 +131,6 @@ type MDMAppleErrorChainItem struct { USEnglishDescription string `plist:"USEnglishDescription"` } -// b64Data is a byte slice that can be base64 encoded. -type b64Data []byte - -// String returns the base64-encoded string form of b -func (b b64Data) String() string { - return base64.StdEncoding.EncodeToString(b) -} - // ExtractDetailsFromOsqueryDistinguishedName parses a distinguished name and returns the country, // organization, and organizational unit. It assumes provided string follows the formatting used by // osquery `certificates` table[1], which appears to follow the style used by openSSL for `-subj` @@ -143,3 +142,10 @@ func ExtractDetailsFromOsqueryDistinguishedName(str string) (*HostCertificateNam // TODO return nil, errors.New("not implemented") } + +func firstOrEmpty(s []string) string { + if len(s) > 0 { + return s[0] + } + return "" +} diff --git a/server/mdm/testing_utils/testing_utils.go b/server/mdm/testing_utils/testing_utils.go new file mode 100644 index 0000000000..e3b3f76b54 --- /dev/null +++ b/server/mdm/testing_utils/testing_utils.go @@ -0,0 +1,29 @@ +package testing_utils + +import ( + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "math/big" + "time" +) + +func NewTestMDMAppleCertTemplate() *x509.Certificate { + return &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{ + Organization: []string{"Test Org"}, + ExtraNames: []pkix.AttributeTypeAndValue{ + { + Type: asn1.ObjectIdentifier{0, 9, 2342, 19200300, 100, 1, 1}, + Value: "com.apple.mgmt.Example", + }, + }, + }, + NotBefore: time.Now(), + NotAfter: time.Now().Add(365 * 24 * time.Hour), + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + } +} diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index 6d81dc3cdd..377778f6be 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -273,6 +273,10 @@ type CleanupHostMDMAppleProfilesFunc func(ctx context.Context) error type IsHostConnectedToFleetMDMFunc func(ctx context.Context, host *fleet.Host) (bool, error) +type ListHostCertificatesFunc func(ctx context.Context, hostID uint, opts fleet.ListOptions) ([]*fleet.HostCertificateRecord, *fleet.PaginationMetadata, error) + +type UpdateHostCertificatesFunc func(ctx context.Context, hostID uint, certs []*fleet.HostCertificateRecord) error + type AreHostsConnectedToFleetMDMFunc func(ctx context.Context, hosts []*fleet.Host) (map[string]bool, error) type AggregatedMunkiVersionFunc func(ctx context.Context, teamID *uint) ([]fleet.AggregatedMunkiVersion, time.Time, error) @@ -1602,6 +1606,12 @@ type DataStore struct { IsHostConnectedToFleetMDMFunc IsHostConnectedToFleetMDMFunc IsHostConnectedToFleetMDMFuncInvoked bool + ListHostCertificatesFunc ListHostCertificatesFunc + ListHostCertificatesFuncInvoked bool + + UpdateHostCertificatesFunc UpdateHostCertificatesFunc + UpdateHostCertificatesFuncInvoked bool + AreHostsConnectedToFleetMDMFunc AreHostsConnectedToFleetMDMFunc AreHostsConnectedToFleetMDMFuncInvoked bool @@ -3912,6 +3922,20 @@ func (s *DataStore) IsHostConnectedToFleetMDM(ctx context.Context, host *fleet.H return s.IsHostConnectedToFleetMDMFunc(ctx, host) } +func (s *DataStore) ListHostCertificates(ctx context.Context, hostID uint, opts fleet.ListOptions) ([]*fleet.HostCertificateRecord, *fleet.PaginationMetadata, error) { + s.mu.Lock() + s.ListHostCertificatesFuncInvoked = true + s.mu.Unlock() + return s.ListHostCertificatesFunc(ctx, hostID, opts) +} + +func (s *DataStore) UpdateHostCertificates(ctx context.Context, hostID uint, certs []*fleet.HostCertificateRecord) error { + s.mu.Lock() + s.UpdateHostCertificatesFuncInvoked = true + s.mu.Unlock() + return s.UpdateHostCertificatesFunc(ctx, hostID, certs) +} + func (s *DataStore) AreHostsConnectedToFleetMDM(ctx context.Context, hosts []*fleet.Host) (map[string]bool, error) { s.mu.Lock() s.AreHostsConnectedToFleetMDMFuncInvoked = true diff --git a/server/service/apple_mdm_test.go b/server/service/apple_mdm_test.go index 3f23aab409..65d222a252 100644 --- a/server/service/apple_mdm_test.go +++ b/server/service/apple_mdm_test.go @@ -55,6 +55,8 @@ import ( "github.com/smallstep/pkcs7" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + mdmtesting "github.com/fleetdm/fleet/v4/server/mdm/testing_utils" ) type nopProfileMatcher struct{} @@ -218,7 +220,7 @@ func setupAppleMDMService(t *testing.T, license *fleet.LicenseInfo) (fleet.Servi ds.ExpandEmbeddedSecretsAndUpdatedAtFunc = func(ctx context.Context, document string) (string, *time.Time, error) { return document, nil, nil } - apnsCert, apnsKey, err := mysql.GenerateTestCertBytes() + apnsCert, apnsKey, err := mysql.GenerateTestCertBytes(mdmtesting.NewTestMDMAppleCertTemplate()) require.NoError(t, err) crt, key, err := apple_mdm.NewSCEPCACertKey() require.NoError(t, err) @@ -3618,7 +3620,7 @@ func setupTest(t *testing.T) (context.Context, kitlog.Logger, *mock.Store, *conf AppleSCEPCert: "./testdata/server.pem", AppleSCEPKey: "./testdata/server.key", } - apnsCert, apnsKey, err := mysql.GenerateTestCertBytes() + apnsCert, apnsKey, err := mysql.GenerateTestCertBytes(mdmtesting.NewTestMDMAppleCertTemplate()) require.NoError(t, err) ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { appCfg := &fleet.AppConfig{} @@ -3826,7 +3828,7 @@ func TestRenewSCEPCertificatesBranches(t *testing.T) { } appleStorage.RetrievePushCertFunc = func(ctx context.Context, topic string) (*tls.Certificate, string, error) { - apnsCert, apnsKey, err := mysql.GenerateTestCertBytes() + apnsCert, apnsKey, err := mysql.GenerateTestCertBytes(mdmtesting.NewTestMDMAppleCertTemplate()) require.NoError(t, err) cert, err := tls.X509KeyPair(apnsCert, apnsKey) return &cert, "", err From 351f40230a60c10909f1dd40fc549fa3181f5b80 Mon Sep 17 00:00:00 2001 From: Sarah Gillespie <73313222+gillespi314@users.noreply.github.com> Date: Wed, 19 Feb 2025 14:44:01 -0600 Subject: [PATCH 03/14] Add osquery ingestion for host certificates feature (#26426) --- .../Contributing/Understanding-host-vitals.md | 17 +++ server/datastore/mysql/host_certificates.go | 12 +-- server/fleet/host_certificates.go | 36 ++++++- server/fleet/host_certificates_test.go | 100 ++++++++++++++++++ server/service/osquery_utils/queries.go | 68 ++++++++++++ server/service/osquery_utils/queries_test.go | 57 +++++++++- 6 files changed, 280 insertions(+), 10 deletions(-) create mode 100644 server/fleet/host_certificates_test.go diff --git a/docs/Contributing/Understanding-host-vitals.md b/docs/Contributing/Understanding-host-vitals.md index f4b6a1d362..42531f793d 100644 --- a/docs/Contributing/Understanding-host-vitals.md +++ b/docs/Contributing/Understanding-host-vitals.md @@ -17,6 +17,23 @@ SELECT 1 FROM osquery_registry WHERE active = true AND registry = 'table' AND na SELECT serial_number, cycle_count, designed_capacity, max_capacity FROM battery ``` +## certificates_darwin + +- Platforms: darwin + +- Query: +```sql +SELECT + ca, common_name, subject, issuer, + key_algorithm, key_strength, key_usage, signing_algorithm, + not_valid_after, not_valid_before, + serial, sha1 + FROM + certificates + WHERE + path = '/Library/Keychains/System.keychain'; +``` + ## chromeos_profile_user_info - Platforms: chrome diff --git a/server/datastore/mysql/host_certificates.go b/server/datastore/mysql/host_certificates.go index 19dada7419..c4ab2d41b6 100644 --- a/server/datastore/mysql/host_certificates.go +++ b/server/datastore/mysql/host_certificates.go @@ -35,7 +35,7 @@ func (ds *Datastore) UpdateHostCertificates(ctx context.Context, hostID uint, ce // infrequently and they will be eventually consistent existingCerts, _, err := listHostCertsDB(ctx, ds.reader(ctx), hostID, fleet.ListOptions{}) // requesting unpaginated results with default limit of 1 million if err != nil { - return fmt.Errorf("list host certs for update: %w", err) + return ctxerr.Wrap(ctx, err, "list host certificates for update") } existingBySHA1 := make(map[string]*fleet.HostCertificateRecord, len(existingCerts)) for _, ec := range existingCerts { @@ -115,7 +115,7 @@ WHERE var certs []*fleet.HostCertificateRecord if err := sqlx.SelectContext(ctx, tx, &certs, stmtPaged, args...); err != nil { - return nil, nil, err + return nil, nil, ctxerr.Wrap(ctx, err, "selecting host certificates") } var metaData *fleet.PaginationMetadata @@ -169,10 +169,10 @@ INSERT INTO host_certificates ( cert.IssuerCountry, cert.IssuerOrganization, cert.IssuerOrganizationalUnit, cert.IssuerCommonName) } - stmt = fmt.Sprintf(stmt, strings.Trim(strings.Join(placeholders, ","), ",")) + stmt = fmt.Sprintf(stmt, strings.Join(placeholders, ",")) if _, err := tx.ExecContext(ctx, stmt, args...); err != nil { - return err + return ctxerr.Wrap(ctx, err, "inserting host certificates") } return nil @@ -186,11 +186,11 @@ func softDeleteHostCertsDB(ctx context.Context, tx sqlx.ExtContext, hostID uint, stmt := `UPDATE host_certificates SET deleted_at = NOW(6) WHERE host_id = ? AND id IN (?)` stmt, args, err := sqlx.In(stmt, hostID, toDelete) if err != nil { - return err + return ctxerr.Wrap(ctx, err, "building soft delete query") } if _, err := tx.ExecContext(ctx, stmt, args...); err != nil { - return err + return ctxerr.Wrap(ctx, err, "soft deleting host certificates") } return nil diff --git a/server/fleet/host_certificates.go b/server/fleet/host_certificates.go index 97b26c38c5..898fb1e10b 100644 --- a/server/fleet/host_certificates.go +++ b/server/fleet/host_certificates.go @@ -5,6 +5,7 @@ import ( "crypto/x509" "errors" "fmt" + "strings" "time" ) @@ -139,8 +140,39 @@ type MDMAppleErrorChainItem struct { // // See https://osquery.io/schema/5.15.0/#certificates func ExtractDetailsFromOsqueryDistinguishedName(str string) (*HostCertificateNameDetails, error) { - // TODO - return nil, errors.New("not implemented") + str = strings.TrimSpace(str) + str = strings.Trim(str, "/") + + if !strings.Contains(str, "/") { + return nil, errors.New("invalid format, wrong separator") + } + + parts := strings.Split(str, "/") + + var details HostCertificateNameDetails + for _, part := range parts { + kv := strings.Split(part, "=") + if len(kv) != 2 { + return nil, errors.New("invalid distinguished name, wrong key value pair format") + } + + if len(kv[1]) == 0 { + return nil, errors.New("invalid distinguished name, missing value") + } + + switch strings.ToUpper(kv[0]) { + case "C": + details.Country = strings.Trim(kv[1], " ") + case "O": + details.Organization = strings.Trim(kv[1], " ") + case "OU": + details.OrganizationalUnit = strings.Trim(kv[1], " ") + case "CN": + details.CommonName = strings.Trim(kv[1], " ") + } + } + + return &details, nil } func firstOrEmpty(s []string) string { diff --git a/server/fleet/host_certificates_test.go b/server/fleet/host_certificates_test.go new file mode 100644 index 0000000000..e6c6219e14 --- /dev/null +++ b/server/fleet/host_certificates_test.go @@ -0,0 +1,100 @@ +package fleet + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestExtractHostCertificateNameDetails(t *testing.T) { + expected := HostCertificateNameDetails{ + Country: "US", + Organization: "Fleet Device Management Inc.", + OrganizationalUnit: "Fleet Device Management Inc.", + CommonName: "FleetDM", + } + + cases := []struct { + name string + input string + expected *HostCertificateNameDetails + err bool + }{ + { + name: "valid", + input: "/C=US/O=Fleet Device Management Inc./OU=Fleet Device Management Inc./CN=FleetDM", + expected: &expected, + }, + { + name: "valid with different order", + input: "/O=Fleet Device Management Inc./OU=Fleet Device Management Inc./CN=FleetDM/C=US", + expected: &expected, + }, + { + name: "valid with missing key", + input: "/C=US/O=Fleet Device Management Inc./CN=FleetDM ", + expected: &HostCertificateNameDetails{ + Country: "US", + Organization: "Fleet Device Management Inc.", + OrganizationalUnit: "", + CommonName: "FleetDM", + }, + }, + { + name: "valid with additional keyr", + input: "/C=US/O=Fleet Device Management Inc./OU=Fleet Device Management Inc./CN=FleetDM/L=SomeCity", + expected: &expected, + }, + { + name: "invalid format with extra slash", + input: "/C=US/O=Fleet Device Management Inc./OU=Fleet Device Management Inc./CN=FleetDM/invalid", + err: true, + }, + { + name: "invalid format with wrong separator", + input: "C=US,O=Fleet Device Management Inc.,OU=Fleet Device Management Inc.,CN=FleetDM", + err: true, + }, + { + name: "invalid format with extra equal", + input: "/C=US=/O=Fleet Device Management Inc./OU=Fleet Device Management Inc./CN=FleetDM", + err: true, + }, + { + name: "invalid format with malformed key values", + input: "/C=US/O/OU=Fleet Device Management Inc./=/CN=FleetDM", + err: true, + }, + { + name: "empty", + input: "", + err: true, + }, + { + name: "missing value", + input: "/C=US/O=Fleet Device Management Inc./OU=Fleet Device Management Inc./CN=", + err: true, + }, + { + name: "missing first slash", + input: "C=US/O=Fleet Device Management Inc./OU=Fleet Device Management Inc./CN=FleetDM", + expected: &expected, + }, + { + name: "trailing slash", + input: "/C=US/O=Fleet Device Management Inc./OU=Fleet Device Management Inc./CN=FleetDM/", + expected: &expected, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + actual, err := ExtractDetailsFromOsqueryDistinguishedName(tc.input) + if tc.err { + require.Error(t, err) + } else { + require.NoError(t, err) + require.Equal(t, tc.expected, actual) + } + }) + } +} diff --git a/server/service/osquery_utils/queries.go b/server/service/osquery_utils/queries.go index 0e07446036..8306776665 100644 --- a/server/service/osquery_utils/queries.go +++ b/server/service/osquery_utils/queries.go @@ -693,6 +693,20 @@ var extraDetailQueries = map[string]DetailQuery{ Platforms: []string{"windows"}, DirectIngestFunc: directIngestDiskEncryption, }, + "certificates_darwin": { + Query: ` + SELECT + ca, common_name, subject, issuer, + key_algorithm, key_strength, key_usage, signing_algorithm, + not_valid_after, not_valid_before, + serial, sha1 + FROM + certificates + WHERE + path = '/Library/Keychains/System.keychain';`, + Platforms: []string{"darwin"}, + DirectIngestFunc: directIngestHostCertificates, + }, } // mdmQueries are used by the Fleet server to compliment certain MDM @@ -2376,3 +2390,57 @@ func directIngestWindowsProfiles( } return microsoft_mdm.VerifyHostMDMProfiles(ctx, logger, ds, host, rawResponse) } + +func directIngestHostCertificates( + ctx context.Context, + logger log.Logger, + host *fleet.Host, + ds fleet.Datastore, + rows []map[string]string, +) error { + if len(rows) == 0 { + // if there are no results, it probably may indicate a problem so we log it + level.Debug(logger).Log("component", "service", "method", "directIngestHostCertificates", "msg", "no rows returned", "host_id", host.ID) + return nil + } + + certs := make([]*fleet.HostCertificateRecord, 0, len(rows)) + for _, row := range rows { + csum, err := hex.DecodeString(row["sha1"]) + if err != nil { + return ctxerr.Wrap(ctx, err, "directIngestHostCertificates: decoding sha1") + } + subject, err := fleet.ExtractDetailsFromOsqueryDistinguishedName(row["subject"]) + if err != nil { + return ctxerr.Wrap(ctx, err, "directIngestHostCertificates: extracting subject details") + } + issuer, err := fleet.ExtractDetailsFromOsqueryDistinguishedName(row["issuer"]) + if err != nil { + return ctxerr.Wrap(ctx, err, "directIngestHostCertificates: extracting issuer details") + } + + certs = append(certs, &fleet.HostCertificateRecord{ + HostID: host.ID, + SHA1Sum: csum, + NotValidAfter: time.Unix(cast.ToInt64(row["not_valid_after"]), 0).UTC(), + NotValidBefore: time.Unix(cast.ToInt64(row["not_valid_before"]), 0).UTC(), + CertificateAuthority: cast.ToBool(row["ca"]), + CommonName: row["common_name"], + KeyAlgorithm: row["key_algorithm"], + KeyStrength: cast.ToInt(row["key_strength"]), + KeyUsage: row["key_usage"], + Serial: row["serial"], + SigningAlgorithm: row["signing_algorithm"], + SubjectCountry: subject.Country, + SubjectOrganizationalUnit: subject.OrganizationalUnit, + SubjectOrganization: subject.Organization, + SubjectCommonName: subject.CommonName, + IssuerCountry: issuer.Country, + IssuerOrganizationalUnit: issuer.OrganizationalUnit, + IssuerOrganization: issuer.Organization, + IssuerCommonName: issuer.CommonName, + }) + } + + return ds.UpdateHostCertificates(ctx, host.ID, certs) +} diff --git a/server/service/osquery_utils/queries_test.go b/server/service/osquery_utils/queries_test.go index 8292832fd6..a2486fdcfe 100644 --- a/server/service/osquery_utils/queries_test.go +++ b/server/service/osquery_utils/queries_test.go @@ -290,13 +290,14 @@ func TestGetDetailQueries(t *testing.T) { "disk_encryption_linux", "disk_encryption_windows", "chromeos_profile_user_info", + "certificates_darwin", } require.Len(t, queriesNoConfig, len(baseQueries)) sortedKeysCompare(t, queriesNoConfig, baseQueries) queriesWithoutWinOSVuln := GetDetailQueries(context.Background(), config.FleetConfig{Vulnerabilities: config.VulnerabilitiesConfig{DisableWinOSVulnerabilities: true}}, nil, nil) - require.Len(t, queriesWithoutWinOSVuln, 25) + require.Len(t, queriesWithoutWinOSVuln, 26) queriesWithUsers := GetDetailQueries(context.Background(), config.FleetConfig{App: config.AppConfig{EnableScheduledQueryStats: true}}, nil, &fleet.Features{EnableHostUsers: true}) qs := baseQueries @@ -1530,7 +1531,8 @@ func TestDirectIngestDiskEncryptionKeyDarwin(t *testing.T) { } ds.SetOrUpdateHostDiskEncryptionKeyFunc = func(ctx context.Context, incomingHost *fleet.Host, encryptedBase64Key, clientError string, - decryptable *bool) error { + decryptable *bool, + ) error { if base64.StdEncoding.EncodeToString([]byte(wantKey)) != encryptedBase64Key { return errors.New("key mismatch") } @@ -2219,6 +2221,57 @@ func TestIngestNetworkInterface(t *testing.T) { }) } +func TestDirectIngestHostCertificates(t *testing.T) { + ds := new(mock.Store) + ctx := context.Background() + logger := log.NewNopLogger() + host := &fleet.Host{ID: 1} + + row1 := map[string]string{ + "ca": "0", + "common_name": "Cert 1 Common Name", + "issuer": "/C=US/O=Issuer 1 Inc./CN=Issuer 1 Common Name", + "subject": "/C=US/O=Subject 1 Inc./OU=Subject 1 Org Unit/CN=Subject 1 Common Name", + "key_algorithm": "rsaEncryption", + "key_strength": "2048", + "key_usage": "Data Encipherment, Key Encipherment, Digital Signature", + "serial": "123abc", + "signing_algorithm": "sha256WithRSAEncryption", + "not_valid_after": "1822755797", + "not_valid_before": "1770228826", + "sha1": "9c1e9c00d8120c1a9d96274d2a17c38ffa30fd31", + } + + ds.UpdateHostCertificatesFunc = func(ctx context.Context, hostID uint, certs []*fleet.HostCertificateRecord) error { + require.Equal(t, host.ID, hostID) + require.Len(t, certs, 1) + require.Equal(t, "9c1e9c00d8120c1a9d96274d2a17c38ffa30fd31", hex.EncodeToString(certs[0].SHA1Sum)) + require.Equal(t, "Cert 1 Common Name", certs[0].CommonName) + require.Equal(t, "Subject 1 Common Name", certs[0].SubjectCommonName) + require.Equal(t, "Subject 1 Inc.", certs[0].SubjectOrganization) + require.Equal(t, "Subject 1 Org Unit", certs[0].SubjectOrganizationalUnit) + require.Equal(t, "US", certs[0].SubjectCountry) + require.Equal(t, "Issuer 1 Common Name", certs[0].IssuerCommonName) + require.Equal(t, "Issuer 1 Inc.", certs[0].IssuerOrganization) + require.Empty(t, certs[0].IssuerOrganizationalUnit) + require.Equal(t, "US", certs[0].IssuerCountry) + require.Equal(t, "rsaEncryption", certs[0].KeyAlgorithm) + require.Equal(t, 2048, certs[0].KeyStrength) + require.Equal(t, "Data Encipherment, Key Encipherment, Digital Signature", certs[0].KeyUsage) + require.Equal(t, "123abc", certs[0].Serial) + require.Equal(t, "sha256WithRSAEncryption", certs[0].SigningAlgorithm) + require.Equal(t, int64(1822755797), certs[0].NotValidAfter.Unix()) + require.Equal(t, int64(1770228826), certs[0].NotValidBefore.Unix()) + require.False(t, certs[0].CertificateAuthority) + + return nil + } + + err := directIngestHostCertificates(ctx, logger, host, ds, []map[string]string{row1}) + require.NoError(t, err) + require.True(t, ds.UpdateHostCertificatesFuncInvoked) +} + func TestGenerateSQLForAllExists(t *testing.T) { // Combine two queries query1 := "SELECT 1 WHERE foo = bar" From adf09f099d098f4236e5f056fbf3a1ce4c8d99fd Mon Sep 17 00:00:00 2001 From: Sarah Gillespie <73313222+gillespi314@users.noreply.github.com> Date: Thu, 20 Feb 2025 16:49:41 -0600 Subject: [PATCH 04/14] Add MDM CertificateList command for host certificates feature (#26479) --- pkg/mdm/mdmtest/apple.go | 23 +++++ server/fleet/host_certificates.go | 7 +- server/fleet/mdm.go | 1 + server/mdm/apple/apple_mdm.go | 19 +++- server/mdm/apple/commander.go | 34 +++++- server/service/apple_mdm.go | 137 +++++++++++++++++-------- server/service/hosts.go | 16 ++- server/service/integration_mdm_test.go | 25 ++++- 8 files changed, 212 insertions(+), 50 deletions(-) diff --git a/pkg/mdm/mdmtest/apple.go b/pkg/mdm/mdmtest/apple.go index e78aea43a1..edeaed85f8 100644 --- a/pkg/mdm/mdmtest/apple.go +++ b/pkg/mdm/mdmtest/apple.go @@ -22,6 +22,7 @@ import ( "time" "github.com/fleetdm/fleet/v4/pkg/fleethttp" + "github.com/fleetdm/fleet/v4/server/datastore/mysql" "github.com/fleetdm/fleet/v4/server/fleet" apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple" "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm" @@ -739,6 +740,28 @@ func (c *TestAppleMDMClient) AcknowledgeInstalledApplicationList(udid, cmdUUID s return c.sendAndDecodeCommandResponse(payload) } +func (c *TestAppleMDMClient) AcknowledgeCertificateList(udid, cmdUUID string, certTemplates []*x509.Certificate) (*mdm.Command, error) { + var certList []fleet.MDMAppleCertificateListItem + for _, cert := range certTemplates { + b, _, err := mysql.GenerateTestCertBytes(cert) + if err != nil { + return nil, err + } + certList = append(certList, fleet.MDMAppleCertificateListItem{ + CommonName: cert.Subject.CommonName, + Data: b, + }) + } + cmd := map[string]any{ + "CommandUUID": cmdUUID, + "UDID": udid, + "Status": "Acknowledged", + "CertificateList": certList, + } + + return c.sendAndDecodeCommandResponse(cmd) +} + func (c *TestAppleMDMClient) GetBootstrapToken() ([]byte, error) { payload := map[string]any{ "MessageType": "GetBootstrapToken", diff --git a/server/fleet/host_certificates.go b/server/fleet/host_certificates.go index 898fb1e10b..f7d8ceac3a 100644 --- a/server/fleet/host_certificates.go +++ b/server/fleet/host_certificates.go @@ -4,7 +4,6 @@ import ( "crypto/sha1" // nolint:gosec // used for compatibility with existing osquery certificates table schema "crypto/x509" "errors" - "fmt" "strings" "time" ) @@ -115,12 +114,12 @@ type MDMAppleCertificateListItem struct { IsIdentity bool `plist:"IsIdentity"` } -func (c *MDMAppleCertificateListItem) Parse() (*HostCertificateRecord, error) { +func (c *MDMAppleCertificateListItem) Parse(hostID uint) (*HostCertificateRecord, error) { cert, err := x509.ParseCertificate(c.Data) if err != nil { - return nil, fmt.Errorf("parsing certificate list item: %w", err) + return nil, err } - return NewHostCertificateRecord(0, cert), nil + return NewHostCertificateRecord(hostID, cert), nil } // MdmAppleErrorChainItem is the plist model for an error chain item. diff --git a/server/fleet/mdm.go b/server/fleet/mdm.go index d4f3cfe6d6..54608e5268 100644 --- a/server/fleet/mdm.go +++ b/server/fleet/mdm.go @@ -800,6 +800,7 @@ const ( RefetchBaseCommandUUIDPrefix = "REFETCH-" RefetchDeviceCommandUUIDPrefix = RefetchBaseCommandUUIDPrefix + "DEVICE-" RefetchAppsCommandUUIDPrefix = RefetchBaseCommandUUIDPrefix + "APPS-" + RefetchCertsCommandUUIDPrefix = RefetchBaseCommandUUIDPrefix + "CERTS-" ) // VPPTokenInfo is the representation of the VPP token that we send out via API. diff --git a/server/mdm/apple/apple_mdm.go b/server/mdm/apple/apple_mdm.go index 5ea71f3aa2..d05a4ddafa 100644 --- a/server/mdm/apple/apple_mdm.go +++ b/server/mdm/apple/apple_mdm.go @@ -1211,7 +1211,7 @@ func IOSiPadOSRefetch(ctx context.Context, ds fleet.Datastore, commander *MDMApp logger.Log("msg", "sending commands to refetch", "count", len(devices), "lookup-duration", time.Since(start)) commandUUID := uuid.NewString() - hostMDMCommands := make([]fleet.HostMDMCommand, 0, 2*len(devices)) + hostMDMCommands := make([]fleet.HostMDMCommand, 0, 3*len(devices)) installedAppsUUIDs := make([]string, 0, len(devices)) for _, device := range devices { if !slices.Contains(device.CommandsAlreadySent, fleet.RefetchAppsCommandUUIDPrefix) { @@ -1229,6 +1229,23 @@ func IOSiPadOSRefetch(ctx context.Context, ds fleet.Datastore, commander *MDMApp } } + certsListUUIDs := make([]string, 0, len(devices)) + for _, device := range devices { + if !slices.Contains(device.CommandsAlreadySent, fleet.RefetchCertsCommandUUIDPrefix) { + certsListUUIDs = append(certsListUUIDs, device.UUID) + hostMDMCommands = append(hostMDMCommands, fleet.HostMDMCommand{ + HostID: device.HostID, + CommandType: fleet.RefetchCertsCommandUUIDPrefix, + }) + } + } + if len(certsListUUIDs) > 0 { + err = commander.CertificateList(ctx, certsListUUIDs, fleet.RefetchCertsCommandUUIDPrefix+commandUUID) + if err != nil { + return ctxerr.Wrap(ctx, err, "send CertificateList commands to ios and ipados devices") + } + } + // DeviceInformation is last because the refetch response clears the refetch_requested flag deviceInfoUUIDs := make([]string, 0, len(devices)) for _, device := range devices { diff --git a/server/mdm/apple/commander.go b/server/mdm/apple/commander.go index 82ff022bd0..b4c606aa84 100644 --- a/server/mdm/apple/commander.go +++ b/server/mdm/apple/commander.go @@ -336,6 +336,34 @@ func (svc *MDMAppleCommander) InstalledApplicationList(ctx context.Context, host return svc.EnqueueCommand(ctx, hostUUIDs, raw) } +// CertificateList sends the homonym [command][1] to the device to get a list of installed +// certificates on the device. +// +// Note that user-enrolled devices ignore the [ManagedOnly][2] value set below and will always +// include only managed certificates. This is a limitation imposed by Apple. +// +// [1]: https://developer.apple.com/documentation/devicemanagement/certificatelistcommand +// [2]: https://developer.apple.com/documentation/devicemanagement/certificatelistcommand/command-data.dictionary +func (svc *MDMAppleCommander) CertificateList(ctx context.Context, hostUUIDs []string, cmdUUID string) error { + raw := fmt.Sprintf(` + + + + CommandUUID + %s + Command + + MangedOnly + + RequestType + CertificateList + + +`, cmdUUID) + + return svc.EnqueueCommand(ctx, hostUUIDs, raw) +} + // EnqueueCommand takes care of enqueuing the commands and sending push // notifications to the devices. // @@ -352,7 +380,8 @@ func (svc *MDMAppleCommander) EnqueueCommand(ctx context.Context, hostUUIDs []st } func (svc *MDMAppleCommander) enqueueAndNotify(ctx context.Context, hostUUIDs []string, cmd *mdm.Command, - subtype mdm.CommandSubtype) error { + subtype mdm.CommandSubtype, +) error { if _, err := svc.storage.EnqueueCommand(ctx, hostUUIDs, &mdm.CommandWithSubtype{Command: *cmd, Subtype: subtype}); err != nil { return ctxerr.Wrap(ctx, err, "enqueuing command") @@ -367,7 +396,8 @@ func (svc *MDMAppleCommander) enqueueAndNotify(ctx context.Context, hostUUIDs [] // EnqueueCommandInstallProfileWithSecrets is a special case of EnqueueCommand that does not expand secret variables. // Secret variables are expanded when the command is sent to the device, and secrets are never stored in the database unencrypted. func (svc *MDMAppleCommander) EnqueueCommandInstallProfileWithSecrets(ctx context.Context, hostUUIDs []string, - rawCommand mobileconfig.Mobileconfig, commandUUID string) error { + rawCommand mobileconfig.Mobileconfig, commandUUID string, +) error { cmd := &mdm.Command{ CommandUUID: commandUUID, Raw: []byte(rawCommand), diff --git a/server/service/apple_mdm.go b/server/service/apple_mdm.go index 2d830e6008..6bbe2eefb9 100644 --- a/server/service/apple_mdm.go +++ b/server/service/apple_mdm.go @@ -3030,44 +3030,102 @@ func (svc *MDMAppleCheckinAndCommandService) handleRefetch(r *mdm.Request, cmdRe return nil, ctxerr.Wrap(ctx, err, "failed to get host by identifier") } - if strings.HasPrefix(cmdResult.CommandUUID, fleet.RefetchAppsCommandUUIDPrefix) { - // We remove pending command first in case there is an error processing the results, so that we don't prevent another refetch. - err = svc.ds.RemoveHostMDMCommand(ctx, fleet.HostMDMCommand{ - HostID: host.ID, - CommandType: fleet.RefetchAppsCommandUUIDPrefix, - }) - if err != nil { - return nil, ctxerr.Wrap(ctx, err, "remove refetch apps command") - } + switch { + case strings.HasPrefix(cmdResult.CommandUUID, fleet.RefetchAppsCommandUUIDPrefix): + return svc.handleRefetchAppsResults(ctx, host, cmdResult) - if host.Platform != "ios" && host.Platform != "ipados" { - return nil, ctxerr.New(ctx, "refetch apps command sent to non-iOS/non-iPadOS host") - } - source := "ios_apps" - if host.Platform == "ipados" { - source = "ipados_apps" - } + case strings.HasPrefix(cmdResult.CommandUUID, fleet.RefetchCertsCommandUUIDPrefix): + return svc.handleRefetchCertsResults(ctx, host, cmdResult) - response := cmdResult.Raw - software, err := unmarshalAppList(ctx, response, source) - if err != nil { - return nil, err - } - _, err = svc.ds.UpdateHostSoftware(ctx, host.ID, software) - if err != nil { - return nil, ctxerr.Wrap(ctx, err, "update host software") - } + case strings.HasPrefix(cmdResult.CommandUUID, fleet.RefetchDeviceCommandUUIDPrefix): + return svc.handleRefetchDeviceResults(ctx, host, cmdResult) - return nil, nil + default: + // This should never happen, but just in case we'll return an error. + return nil, ctxerr.New(ctx, fmt.Sprintf("unknown refetch command type %s", cmdResult.CommandUUID)) + } +} + +func (svc *MDMAppleCheckinAndCommandService) handleRefetchAppsResults(ctx context.Context, host *fleet.Host, cmdResult *mdm.CommandResults) (*mdm.Command, error) { + if !strings.HasPrefix(cmdResult.CommandUUID, fleet.RefetchAppsCommandUUIDPrefix) { + // Caller should have checked this, but just in case we'll return an error. + return nil, ctxerr.New(ctx, fmt.Sprintf("expected REFETCH-APPS- prefix but got %s", cmdResult.CommandUUID)) } - // Otherwise, the command has prefix fleet.RefetchDeviceCommandUUIDPrefix, which is a refetch device command. // We remove pending command first in case there is an error processing the results, so that we don't prevent another refetch. - err = svc.ds.RemoveHostMDMCommand(ctx, fleet.HostMDMCommand{ + if err := svc.ds.RemoveHostMDMCommand(ctx, fleet.HostMDMCommand{ + HostID: host.ID, + CommandType: fleet.RefetchAppsCommandUUIDPrefix, + }); err != nil { + return nil, ctxerr.Wrap(ctx, err, "remove refetch apps command") + } + + if host.Platform != "ios" && host.Platform != "ipados" { + return nil, ctxerr.New(ctx, "refetch apps command sent to non-iOS/non-iPadOS host") + } + source := "ios_apps" + if host.Platform == "ipados" { + source = "ipados_apps" + } + + response := cmdResult.Raw + software, err := unmarshalAppList(ctx, response, source) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "unmarshal app list") + } + _, err = svc.ds.UpdateHostSoftware(ctx, host.ID, software) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "update host software") + } + + return nil, nil +} + +func (svc *MDMAppleCheckinAndCommandService) handleRefetchCertsResults(ctx context.Context, host *fleet.Host, cmdResult *mdm.CommandResults) (*mdm.Command, error) { + if !strings.HasPrefix(cmdResult.CommandUUID, fleet.RefetchCertsCommandUUIDPrefix) { + // Caller should have checked this, but just in case we'll return an error. + return nil, ctxerr.New(ctx, fmt.Sprintf("expected REFETCH-CERTS- prefix but got %s", cmdResult.CommandUUID)) + } + + // We remove pending command first in case there is an error processing the results, so that we don't prevent another refetch. + if err := svc.ds.RemoveHostMDMCommand(ctx, fleet.HostMDMCommand{ + HostID: host.ID, + CommandType: fleet.RefetchCertsCommandUUIDPrefix, + }); err != nil { + return nil, ctxerr.Wrap(ctx, err, "refetch certs: remove refetch command") + } + + var listResp fleet.MDMAppleCertificateListResponse + if err := plist.Unmarshal(cmdResult.Raw, &listResp); err != nil { + return nil, ctxerr.Wrap(ctx, err, "refetch certs: unmarshal certificate list command result") + } + payload := make([]*fleet.HostCertificateRecord, 0, len(listResp.CertificateList)) + for _, cert := range listResp.CertificateList { + parsed, err := cert.Parse(host.ID) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "refetch certs: parse certificate") + } + payload = append(payload, parsed) + } + + if err := svc.ds.UpdateHostCertificates(ctx, host.ID, payload); err != nil { + return nil, ctxerr.Wrap(ctx, err, "refetch certs: update host certificates") + } + + return nil, nil +} + +func (svc *MDMAppleCheckinAndCommandService) handleRefetchDeviceResults(ctx context.Context, host *fleet.Host, cmdResult *mdm.CommandResults) (*mdm.Command, error) { + if !strings.HasPrefix(cmdResult.CommandUUID, fleet.RefetchDeviceCommandUUIDPrefix) { + // Caller should have checked this, but just in case we'll return an error. + return nil, ctxerr.New(ctx, fmt.Sprintf("expected REFETCH-DEVICE- prefix but got %s", cmdResult.CommandUUID)) + } + + // We remove pending command first in case there is an error processing the results, so that we don't prevent another refetch. + if err := svc.ds.RemoveHostMDMCommand(ctx, fleet.HostMDMCommand{ HostID: host.ID, CommandType: fleet.RefetchDeviceCommandUUIDPrefix, - }) - if err != nil { + }); err != nil { return nil, ctxerr.Wrap(ctx, err, "remove refetch device command") } @@ -3075,7 +3133,7 @@ func (svc *MDMAppleCheckinAndCommandService) handleRefetch(r *mdm.Request, cmdRe QueryResponses map[string]interface{} `plist:"QueryResponses"` } if err := plist.Unmarshal(cmdResult.Raw, &deviceInformationResponse); err != nil { - return nil, ctxerr.Wrap(r.Context, err, "failed to unmarshal device information command result") + return nil, ctxerr.Wrap(ctx, err, "failed to unmarshal device information command result") } deviceName := deviceInformationResponse.QueryResponses["DeviceName"].(string) deviceCapacity := deviceInformationResponse.QueryResponses["DeviceCapacity"].(float64) @@ -3103,26 +3161,25 @@ func (svc *MDMAppleCheckinAndCommandService) handleRefetch(r *mdm.Request, cmdRe host.HardwareModel = productName host.DetailUpdatedAt = time.Now() host.RefetchRequested = false - if err := svc.ds.UpdateHost(r.Context, host); err != nil { - return nil, ctxerr.Wrap(r.Context, err, "failed to update host") + if err := svc.ds.UpdateHost(ctx, host); err != nil { + return nil, ctxerr.Wrap(ctx, err, "failed to update host") } - if err := svc.ds.SetOrUpdateHostDisksSpace(r.Context, host.ID, availableDeviceCapacity, 100*availableDeviceCapacity/deviceCapacity, + if err := svc.ds.SetOrUpdateHostDisksSpace(ctx, host.ID, availableDeviceCapacity, 100*availableDeviceCapacity/deviceCapacity, deviceCapacity); err != nil { - return nil, ctxerr.Wrap(r.Context, err, "failed to update host storage") + return nil, ctxerr.Wrap(ctx, err, "failed to update host storage") } - if err := svc.ds.UpdateHostOperatingSystem(r.Context, host.ID, fleet.OperatingSystem{ + if err := svc.ds.UpdateHostOperatingSystem(ctx, host.ID, fleet.OperatingSystem{ Name: osVersionPrefix, Version: osVersion, Platform: platform, }); err != nil { - return nil, ctxerr.Wrap(r.Context, err, "failed to update host operating system") + return nil, ctxerr.Wrap(ctx, err, "failed to update host operating system") } if host.MDM.EnrollmentStatus != nil && *host.MDM.EnrollmentStatus == "Pending" { // Since the device has been refetched, we can assume it's enrolled. - err = svc.ds.UpdateMDMData(ctx, host.ID, true) - if err != nil { - return nil, ctxerr.Wrap(r.Context, err, "failed to update MDM data") + if err := svc.ds.UpdateMDMData(ctx, host.ID, true); err != nil { + return nil, ctxerr.Wrap(ctx, err, "failed to update MDM data") } } return nil, nil diff --git a/server/service/hosts.go b/server/service/hosts.go index 3cfe056d45..8c726d5354 100644 --- a/server/service/hosts.go +++ b/server/service/hosts.go @@ -1065,15 +1065,18 @@ func (svc *Service) RefetchHost(ctx context.Context, id uint) error { } doAppRefetch := true doDeviceInfoRefetch := true + doCertsRefetch := true for _, cmd := range commands { switch cmd.CommandType { case fleet.RefetchDeviceCommandUUIDPrefix: doDeviceInfoRefetch = false case fleet.RefetchAppsCommandUUIDPrefix: doAppRefetch = false + case fleet.RefetchCertsCommandUUIDPrefix: + doCertsRefetch = false } } - if !doAppRefetch && !doDeviceInfoRefetch { + if !doAppRefetch && !doDeviceInfoRefetch && !doCertsRefetch { // Nothing to do. return nil } @@ -1081,7 +1084,7 @@ func (svc *Service) RefetchHost(ctx context.Context, id uint) error { if err != nil { return err } - hostMDMCommands := make([]fleet.HostMDMCommand, 0, 2) + hostMDMCommands := make([]fleet.HostMDMCommand, 0, 3) cmdUUID := uuid.NewString() if doAppRefetch { err = svc.mdmAppleCommander.InstalledApplicationList(ctx, []string{host.UUID}, fleet.RefetchAppsCommandUUIDPrefix+cmdUUID) @@ -1093,6 +1096,15 @@ func (svc *Service) RefetchHost(ctx context.Context, id uint) error { CommandType: fleet.RefetchAppsCommandUUIDPrefix, }) } + if doCertsRefetch { + if err := svc.mdmAppleCommander.CertificateList(ctx, []string{host.UUID}, fleet.RefetchCertsCommandUUIDPrefix+cmdUUID); err != nil { + return ctxerr.Wrap(ctx, err, "refetch certs with MDM") + } + hostMDMCommands = append(hostMDMCommands, fleet.HostMDMCommand{ + HostID: host.ID, + CommandType: fleet.RefetchCertsCommandUUIDPrefix, + }) + } if doDeviceInfoRefetch { // DeviceInformation is last because the refetch response clears the refetch_requested flag err = svc.mdmAppleCommander.DeviceInformation(ctx, []string{host.UUID}, fleet.RefetchDeviceCommandUUIDPrefix+cmdUUID) diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go index 44242c5465..4bd33a6995 100644 --- a/server/service/integration_mdm_test.go +++ b/server/service/integration_mdm_test.go @@ -59,6 +59,7 @@ import ( "github.com/fleetdm/fleet/v4/server/mdm/scep/depot" filedepot "github.com/fleetdm/fleet/v4/server/mdm/scep/depot/file" scepserver "github.com/fleetdm/fleet/v4/server/mdm/scep/server" + mdmtesting "github.com/fleetdm/fleet/v4/server/mdm/testing_utils" "github.com/fleetdm/fleet/v4/server/ptr" "github.com/fleetdm/fleet/v4/server/service/mock" "github.com/fleetdm/fleet/v4/server/service/osquery_utils" @@ -10677,6 +10678,8 @@ func (s *integrationMDMTestSuite) TestEnrollAfterDEPSyncIOSIPadOS() { func (s *integrationMDMTestSuite) TestRefetchIOSIPadOS() { t := s.T() + testCerts := []*x509.Certificate{mdmtesting.NewTestMDMAppleCertTemplate()} + // Try to refetch host that is not MDM enrolled serialNumber := mdmtest.RandSerialNumber() fleetHost, err := s.ds.NewHost(context.Background(), &fleet.Host{ @@ -10697,7 +10700,7 @@ func (s *integrationMDMTestSuite) TestRefetchIOSIPadOS() { // Refetch host _ = s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/refetch", host.ID), nil, http.StatusOK) - const commandsSentPerRefetch = 2 + const commandsSentPerRefetch = 3 commandsSent := commandsSentPerRefetch var hostResp getHostResponse @@ -10710,6 +10713,7 @@ func (s *integrationMDMTestSuite) TestRefetchIOSIPadOS() { require.Len(t, commands, commandsSent) assert.ElementsMatch(t, []fleet.HostMDMCommand{ {HostID: host.ID, CommandType: fleet.RefetchAppsCommandUUIDPrefix}, + {HostID: host.ID, CommandType: fleet.RefetchCertsCommandUUIDPrefix}, {HostID: host.ID, CommandType: fleet.RefetchDeviceCommandUUIDPrefix}, }, commands) @@ -10736,6 +10740,9 @@ func (s *integrationMDMTestSuite) TestRefetchIOSIPadOS() { cmd, err = mdmClient.AcknowledgeInstalledApplicationList(mdmClient.UUID, cmd.CommandUUID, []fleet.Software{expectedSoftware[0].Software}) require.NoError(t, err) + require.Equal(t, "CertificateList", cmd.Command.RequestType) + cmd, err = mdmClient.AcknowledgeCertificateList(mdmClient.UUID, cmd.CommandUUID, testCerts) + require.NoError(t, err) require.Equal(t, "DeviceInformation", cmd.Command.RequestType) _, err = mdmClient.AcknowledgeDeviceInformation(mdmClient.UUID, cmd.CommandUUID, deviceName, "iPhone SE") require.NoError(t, err) @@ -10755,6 +10762,8 @@ func (s *integrationMDMTestSuite) TestRefetchIOSIPadOS() { } assert.ElementsMatch(t, expectedSoftware, hostResp.Host.Software) + // TODO: add test for GET /hosts/:id/certificates endpoint, should match up with testCerts + // Install the same app for iPadOS hostIPad, mdmClientIPad := s.createAppleMobileHostThenEnrollMDM("ipados") @@ -10786,6 +10795,9 @@ func (s *integrationMDMTestSuite) TestRefetchIOSIPadOS() { cmd, err = mdmClientIPad.AcknowledgeInstalledApplicationList(mdmClientIPad.UUID, cmd.CommandUUID, []fleet.Software{expectedSoftware[0].Software}) require.NoError(t, err) + require.Equal(t, "CertificateList", cmd.Command.RequestType) + cmd, err = mdmClientIPad.AcknowledgeCertificateList(mdmClientIPad.UUID, cmd.CommandUUID, testCerts) + require.NoError(t, err) require.Equal(t, "DeviceInformation", cmd.Command.RequestType) cmd, err = mdmClientIPad.AcknowledgeDeviceInformation(mdmClientIPad.UUID, cmd.CommandUUID, deviceNameIPad, "iPad 10") require.NoError(t, err) @@ -10802,6 +10814,8 @@ func (s *integrationMDMTestSuite) TestRefetchIOSIPadOS() { } assert.ElementsMatch(t, expectedSoftware, hostResp.Host.Software) + // TODO: add test for GET /hosts/:id/certificates endpoint, should match up with testCerts + hostsCountTs := time.Now().UTC() require.NoError(t, s.ds.SyncHostsSoftware(context.Background(), hostsCountTs)) ctx := context.Background() @@ -10864,6 +10878,9 @@ func (s *integrationMDMTestSuite) TestRefetchIOSIPadOS() { require.Equal(t, "InstalledApplicationList", cmd.Command.RequestType) cmd, err = mdmClient.AcknowledgeInstalledApplicationList(mdmClient.UUID, cmd.CommandUUID, []fleet.Software{}) require.NoError(t, err) + require.Equal(t, "CertificateList", cmd.Command.RequestType) + cmd, err = mdmClient.AcknowledgeCertificateList(mdmClient.UUID, cmd.CommandUUID, []*x509.Certificate{}) + require.NoError(t, err) require.Equal(t, "DeviceInformation", cmd.Command.RequestType) const deviceNameRenamed = "My new iPhone" cmd, err = mdmClient.AcknowledgeDeviceInformation(mdmClient.UUID, cmd.CommandUUID, deviceNameRenamed, "iPhone SE") @@ -10877,6 +10894,8 @@ func (s *integrationMDMTestSuite) TestRefetchIOSIPadOS() { assert.Equal(t, deviceNameRenamed, hostResp.Host.ComputerName) assert.Empty(t, hostResp.Host.Software) + // TODO: add test for GET /hosts/:id/certificates endpoint, should be empty + // Mark host as unenrolled and refetch. require.NoError(t, s.ds.UpdateMDMData(ctx, host.ID, false)) hostResp = getHostResponse{} @@ -10923,6 +10942,8 @@ func (s *integrationMDMTestSuite) TestRefetchIOSIPadOS() { require.Equal(t, "InstalledApplicationList", cmd.Command.RequestType) cmd, err = mdmClient.AcknowledgeInstalledApplicationList(mdmClient.UUID, cmd.CommandUUID, []fleet.Software{}) require.NoError(t, err) + require.Equal(t, "CertificateList", cmd.Command.RequestType) + cmd, err = mdmClient.AcknowledgeCertificateList(mdmClient.UUID, cmd.CommandUUID, []*x509.Certificate{}) require.Equal(t, "DeviceInformation", cmd.Command.RequestType) cmd, err = mdmClient.AcknowledgeDeviceInformation(mdmClient.UUID, cmd.CommandUUID, deviceNameRenamed, "iPhone SE") require.NoError(t, err) @@ -10935,6 +10956,8 @@ func (s *integrationMDMTestSuite) TestRefetchIOSIPadOS() { require.NotNil(t, hostResp.Host.MDM.EnrollmentStatus) assert.Equal(t, "On (automatic)", *hostResp.Host.MDM.EnrollmentStatus) + // TODO: add test for GET /hosts/:id/certificates endpoint, should be empty + // list commands should return all the commands we sent var listCmdResp listMDMAppleCommandsResponse s.DoJSON("GET", "/api/latest/fleet/mdm/apple/commands", nil, http.StatusOK, &listCmdResp) From 76e16f0ec2854187de681884319ff5a44a7c5545 Mon Sep 17 00:00:00 2001 From: Gabriel Hernandez Date: Fri, 21 Feb 2025 11:52:41 +0000 Subject: [PATCH 05/14] UI for certificate details on host details and my device pages (#26380) For #25464 Implements the UI for viewing certification details on the host details and my device pages. This includes: **Certs card and table on host details and my device page** ![image](https://github.com/user-attachments/assets/e685e55f-d982-43a7-91ff-e6d033b758f5) **Cert details modal** ![image](https://github.com/user-attachments/assets/4a1d7421-85b8-4b3e-a010-ea752e7c6bdf) - includes some work around normalising the details section card header styles on these pages. - includes extending DataSet component to add a `horizontal` orientation when rendering the data > NOTE: We still need to integrate with the API endpoints when they are ready. - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. - [ ] Added/updated automated tests - [x] Manual QA for all new/changed functionality --- changes/issue-25464-ui-host-certs | 1 + frontend/__mocks__/certificatesMock.ts | 47 ++++++ .../components/DataSet/DataSet.stories.tsx | 8 +- frontend/components/DataSet/DataSet.tsx | 17 +- frontend/components/DataSet/_styles.scss | 5 + frontend/interfaces/certificates.ts | 24 +++ .../details/DeviceUserPage/DeviceUserPage.tsx | 46 +++++- .../hosts/details/DeviceUserPage/_styles.scss | 6 + .../HostDetailsPage/HostDetailsPage.tsx | 45 +++++- .../details/HostDetailsPage/_styles.scss | 26 +++- .../pages/hosts/details/cards/About/About.tsx | 4 +- .../hosts/details/cards/About/_styles.scss | 5 + .../hosts/details/cards/Activity/_styles.scss | 2 +- .../cards/Certificates/Certificates.tsx | 43 ++++++ .../CertificatesTable/CertificatesTable.tsx | 56 +++++++ .../CertificatesTableConfig.tsx | 67 ++++++++ .../CertificatesTable/_styles.scss | 3 + .../Certificates/CertificatesTable/index.ts | 1 + .../details/cards/Certificates/_styles.scss | 7 + .../hosts/details/cards/Certificates/index.ts | 1 + .../hosts/details/cards/Labels/Labels.tsx | 4 +- .../hosts/details/cards/Labels/__styles.scss | 7 + .../pages/hosts/details/cards/Users/Users.tsx | 4 +- .../hosts/details/cards/Users/_styles.scss | 5 + .../CertificateDetailsModal.tsx | 145 ++++++++++++++++++ .../CertificateDetailsModal/_styles.scss | 32 ++++ .../modals/CertificateDetailsModal/index.ts | 1 + frontend/services/entities/device_user.ts | 23 +++ frontend/services/entities/hosts.ts | 34 ++++ frontend/utilities/endpoints.ts | 5 + frontend/utilities/helpers.tsx | 7 + 31 files changed, 663 insertions(+), 18 deletions(-) create mode 100644 changes/issue-25464-ui-host-certs create mode 100644 frontend/__mocks__/certificatesMock.ts create mode 100644 frontend/interfaces/certificates.ts create mode 100644 frontend/pages/hosts/details/cards/Certificates/Certificates.tsx create mode 100644 frontend/pages/hosts/details/cards/Certificates/CertificatesTable/CertificatesTable.tsx create mode 100644 frontend/pages/hosts/details/cards/Certificates/CertificatesTable/CertificatesTableConfig.tsx create mode 100644 frontend/pages/hosts/details/cards/Certificates/CertificatesTable/_styles.scss create mode 100644 frontend/pages/hosts/details/cards/Certificates/CertificatesTable/index.ts create mode 100644 frontend/pages/hosts/details/cards/Certificates/_styles.scss create mode 100644 frontend/pages/hosts/details/cards/Certificates/index.ts create mode 100644 frontend/pages/hosts/details/cards/Labels/__styles.scss create mode 100644 frontend/pages/hosts/details/modals/CertificateDetailsModal/CertificateDetailsModal.tsx create mode 100644 frontend/pages/hosts/details/modals/CertificateDetailsModal/_styles.scss create mode 100644 frontend/pages/hosts/details/modals/CertificateDetailsModal/index.ts diff --git a/changes/issue-25464-ui-host-certs b/changes/issue-25464-ui-host-certs new file mode 100644 index 0000000000..200d8c6dad --- /dev/null +++ b/changes/issue-25464-ui-host-certs @@ -0,0 +1 @@ +- add UI for viewing certificate details on the host details and my device pages diff --git a/frontend/__mocks__/certificatesMock.ts b/frontend/__mocks__/certificatesMock.ts new file mode 100644 index 0000000000..49e0678030 --- /dev/null +++ b/frontend/__mocks__/certificatesMock.ts @@ -0,0 +1,47 @@ +import { IHostCertificate } from "interfaces/certificates"; +import { IGetHostCertificatesResponse } from "services/entities/hosts"; + +const DEFAULT_HOST_CERTIFICATE_MOCK: IHostCertificate = { + id: 1, + not_valid_after: "2021-08-19T02:02:17Z", + not_valid_before: "2021-08-19T02:02:17Z", + certificate_authority: true, + common_name: "Test Cert", + key_algorithm: "rsaEncryption", + key_strength: 2048, + key_usage: "CRL Sign, Key Cert Sign", + serial: 1, + signing_algorithm: "sha256WithRSAEncryption", + subject: { + country: "US", + organization: "Test Inc.", + organizational_unit: "Test Inc.", + common_name: "Test Biz", + }, + issuer: { + country: "US", + organization: "Test Inc.", + organizational_unit: "Test Inc.", + common_name: "Test Biz", + }, +}; + +export const createMockHostCertificate = ( + overrides?: Partial +): IHostCertificate => { + return { ...DEFAULT_HOST_CERTIFICATE_MOCK, ...overrides }; +}; + +const DEFAULT_HOST_CERTIFICATES_RESPONSE_MOCK: IGetHostCertificatesResponse = { + certificates: [createMockHostCertificate()], + meta: { + has_next_results: false, + has_previous_results: false, + }, +}; + +export const createMockGetHostCertificatesResponse = ( + overrides?: Partial +): IGetHostCertificatesResponse => { + return { ...DEFAULT_HOST_CERTIFICATES_RESPONSE_MOCK, ...overrides }; +}; diff --git a/frontend/components/DataSet/DataSet.stories.tsx b/frontend/components/DataSet/DataSet.stories.tsx index 99670a9143..40dcb565bb 100644 --- a/frontend/components/DataSet/DataSet.stories.tsx +++ b/frontend/components/DataSet/DataSet.stories.tsx @@ -6,7 +6,7 @@ const meta: Meta = { title: "Components/DataSet", component: DataSet, args: { - title: "Data set", + title: "Data set title", value: "This is the value", }, }; @@ -16,3 +16,9 @@ export default meta; type Story = StoryObj; export const Basic: Story = {}; + +export const HorizontalOrientation: Story = { + args: { + orientation: "horizontal", + }, +}; diff --git a/frontend/components/DataSet/DataSet.tsx b/frontend/components/DataSet/DataSet.tsx index 9e1b138178..2982c41ff3 100644 --- a/frontend/components/DataSet/DataSet.tsx +++ b/frontend/components/DataSet/DataSet.tsx @@ -6,15 +6,26 @@ const baseClass = "data-set"; interface IDataSetProps { title: React.ReactNode; value: React.ReactNode; + orientation?: "horizontal" | "vertical"; className?: string; } -const DataSet = ({ title, value, className }: IDataSetProps) => { - const classNames = classnames(baseClass, className); +const DataSet = ({ + title, + value, + orientation = "vertical", + className, +}: IDataSetProps) => { + const classNames = classnames(baseClass, className, { + [`${baseClass}__horizontal`]: orientation === "horizontal", + }); return (
-
{title}
+
+ {title} + {orientation === "horizontal" && ":"} +
{value}
); diff --git a/frontend/components/DataSet/_styles.scss b/frontend/components/DataSet/_styles.scss index 5712d3ab67..6f42a6c71e 100644 --- a/frontend/components/DataSet/_styles.scss +++ b/frontend/components/DataSet/_styles.scss @@ -1,6 +1,11 @@ .data-set { font-size: $x-small; + &__horizontal { + display: flex; + gap: $pad-small; + } + // ff only @-moz-document url-prefix() { display: flex; diff --git a/frontend/interfaces/certificates.ts b/frontend/interfaces/certificates.ts new file mode 100644 index 0000000000..c2ec9e7b55 --- /dev/null +++ b/frontend/interfaces/certificates.ts @@ -0,0 +1,24 @@ +export interface IHostCertificate { + id: number; + not_valid_after: string; + not_valid_before: string; + certificate_authority: boolean; + common_name: string; + key_algorithm: string; + key_strength: number; + key_usage: string; + serial: number; + signing_algorithm: string; + subject: { + country: string; + organization: string; + organizational_unit: string; + common_name: string; + }; + issuer: { + country: string; + organization: string; + organizational_unit: string; + common_name: string; + }; +} diff --git a/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx b/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx index ac43bb2b30..eeb74c0728 100644 --- a/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx +++ b/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx @@ -17,6 +17,7 @@ import { import { IHostPolicy } from "interfaces/policy"; import { IDeviceGlobalConfig } from "interfaces/config"; import { IHostSoftware } from "interfaces/software"; +import { IHostCertificate } from "interfaces/certificates"; import DeviceUserError from "components/DeviceUserError"; // @ts-ignore @@ -30,6 +31,7 @@ import FlashMessage from "components/FlashMessage"; import { normalizeEmptyValues } from "utilities/helpers"; import PATHS from "router/paths"; import { + DEFAULT_USE_QUERY_OPTIONS, DOCUMENT_TITLE_SUFFIX, HOST_ABOUT_DATA, HOST_SUMMARY_DATA, @@ -55,6 +57,8 @@ import { parseHostSoftwareQueryParams } from "../cards/Software/HostSoftware"; import SelfService from "../cards/Software/SelfService"; import SoftwareDetailsModal from "../cards/Software/SoftwareDetailsModal"; import DeviceUserBanners from "./components/DeviceUserBanners"; +import CertificateDetailsModal from "../modals/CertificateDetailsModal"; +import CertificatesCard from "../cards/Certificates"; const baseClass = "device-user"; @@ -117,6 +121,10 @@ const DeviceUserPage = ({ selectedSoftwareDetails, setSelectedSoftwareDetails, ] = useState(null); + const [ + selectedCertificate, + setSelectedCertificate, + ] = useState(null); const { data: deviceMapping, refetch: refetchDeviceMapping } = useQuery( ["deviceMapping", deviceAuthToken], @@ -145,6 +153,19 @@ const DeviceUserPage = ({ } ); + const { + data: deviceCertificates, + isLoading: isLoadingDeviceCertificates, + isError: isErrorDeviceCertificates, + } = useQuery( + ["hostCertificates", deviceAuthToken], + () => deviceUserAPI.getDeviceCertificates(deviceAuthToken), + { + ...DEFAULT_USE_QUERY_OPTIONS, + enabled: !!deviceUserAPI, + } + ); + const refetchExtensions = () => { deviceMapping !== null && refetchDeviceMapping(); }; @@ -327,6 +348,10 @@ const DeviceUserPage = ({ } }; + const onSelectCertificate = (certificate: IHostCertificate) => { + setSelectedCertificate(certificate); + }; + const renderDeviceUserPage = () => { const failingPoliciesCount = host?.issues?.failing_policies_count || 0; @@ -352,6 +377,10 @@ const DeviceUserPage = ({ const isSoftwareEnabled = !!globalConfig?.features ?.enable_software_inventory; + const isDarwinHost = host?.platform === "darwin"; + const isIosOrIpadosHost = + host?.platform === "ios" || host?.platform === "ipados"; + return (
{!host || isLoadingHost ? ( @@ -412,12 +441,21 @@ const DeviceUserPage = ({ )} - + + {(isIosOrIpadosHost || isDarwinHost) && + deviceCertificates?.certificates.length && ( + + )} {isPremiumTier && isSoftwareEnabled && hasSelfService && ( @@ -518,6 +556,12 @@ const DeviceUserPage = ({ hideInstallDetails /> )} + {selectedCertificate && ( + setSelectedCertificate(null)} + /> + )}
); }; diff --git a/frontend/pages/hosts/details/DeviceUserPage/_styles.scss b/frontend/pages/hosts/details/DeviceUserPage/_styles.scss index ffd6d65f3d..522f9f8d0b 100644 --- a/frontend/pages/hosts/details/DeviceUserPage/_styles.scss +++ b/frontend/pages/hosts/details/DeviceUserPage/_styles.scss @@ -27,6 +27,12 @@ transform: scale(0.5); } + &__details-panel { + display: grid; + grid-template-columns: 1fr; + gap: $pad-large; + } + &__error { display: flex; flex-direction: column; diff --git a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx index d96b3f5e60..7afb70d77c 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx +++ b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx @@ -32,6 +32,7 @@ import { IQueryStats } from "interfaces/query_stats"; import { IHostSoftware } from "interfaces/software"; import { ITeam } from "interfaces/team"; import { IHostUpcomingActivity } from "interfaces/activity"; +import { IHostCertificate } from "interfaces/certificates"; import { normalizeEmptyValues, wrapFleetHelper } from "utilities/helpers"; import permissions from "utilities/permissions"; @@ -40,6 +41,7 @@ import { HOST_SUMMARY_DATA, HOST_ABOUT_DATA, HOST_OSQUERY_DATA, + DEFAULT_USE_QUERY_OPTIONS, } from "utilities/constants"; import { isIPadOrIPhone } from "interfaces/platform"; @@ -72,10 +74,12 @@ import PoliciesCard from "../cards/Policies"; import QueriesCard from "../cards/Queries"; import PacksCard from "../cards/Packs"; import PolicyDetailsModal from "../cards/Policies/HostPoliciesTable/PolicyDetailsModal"; -import UnenrollMdmModal from "./modals/UnenrollMdmModal"; +import CertificatesCard from "../cards/Certificates"; + import TransferHostModal from "../../components/TransferHostModal"; import DeleteHostModal from "../../components/DeleteHostModal"; +import UnenrollMdmModal from "./modals/UnenrollMdmModal"; import DiskEncryptionKeyModal from "./modals/DiskEncryptionKeyModal"; import HostActionsDropdown from "./HostActionsDropdown/HostActionsDropdown"; import OSSettingsModal from "../OSSettingsModal"; @@ -94,6 +98,7 @@ import SoftwareDetailsModal from "../cards/Software/SoftwareDetailsModal"; import { parseHostSoftwareQueryParams } from "../cards/Software/HostSoftware"; import { getErrorMessage } from "./helpers"; import CancelActivityModal from "./modals/CancelActivityModal"; +import CertificateDetailsModal from "../modals/CertificateDetailsModal"; const baseClass = "host-details"; @@ -163,6 +168,7 @@ const HostDetailsPage = ({ const [showLockHostModal, setShowLockHostModal] = useState(false); const [showUnlockHostModal, setShowUnlockHostModal] = useState(false); const [showWipeModal, setShowWipeModal] = useState(false); + // Used in activities to show run script details modal const [scriptExecutionId, setScriptExecutiontId] = useState(""); const [selectedPolicy, setSelectedPolicy] = useState( @@ -200,6 +206,10 @@ const HostDetailsPage = ({ selectedCancelActivity, setSelectedCancelActivity, ] = useState(null); + const [ + selectedCertificate, + setSelectedCertificate, + ] = useState(null); // activity states const [activeActivityTab, setActiveActivityTab] = useState< @@ -447,6 +457,19 @@ const HostDetailsPage = ({ } ); + const { + data: hostCertificates, + isLoading: isLoadingHostCertificates, + isError: isErrorHostCertificates, + } = useQuery( + ["hostCertificates", host_id], + () => hostAPI.getHostCertificates(hostIdFromURL), + { + ...DEFAULT_USE_QUERY_OPTIONS, + enabled: !!hostIdFromURL, + } + ); + const featuresConfig = host?.team_id ? teams?.find((t) => t.id === host.team_id)?.features : config?.features; @@ -705,6 +728,10 @@ const HostDetailsPage = ({ setSelectedCancelActivity(activity); }; + const onSelectCertificate = (certificate: IHostCertificate) => { + setSelectedCertificate(certificate); + }; + const renderActionDropdown = () => { if (!host) { return null; @@ -800,11 +827,13 @@ const HostDetailsPage = ({ name: host?.mdm.macos_setup?.bootstrap_package_name, }; + const isDarwinHost = host.platform === "darwin"; const isIosOrIpadosHost = host.platform === "ios" || host.platform === "ipados"; const detailsPanelClass = classNames(`${baseClass}__details-panel`, { [`${baseClass}__details-panel--ios-grid`]: isIosOrIpadosHost, + [`${baseClass}__details-panel--macos-grid`]: isDarwinHost, }); return ( @@ -904,6 +933,14 @@ const HostDetailsPage = ({ hostUsersEnabled={featuresConfig?.enable_host_users} /> )} + {(isIosOrIpadosHost || isDarwinHost) && + hostCertificates?.certificates.length && ( + + )} setSelectedCancelActivity(null)} /> )} + {selectedCertificate && ( + setSelectedCertificate(null)} + /> + )} ); diff --git a/frontend/pages/hosts/details/HostDetailsPage/_styles.scss b/frontend/pages/hosts/details/HostDetailsPage/_styles.scss index 25f6e705f6..73afbc8e65 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/_styles.scss +++ b/frontend/pages/hosts/details/HostDetailsPage/_styles.scss @@ -13,6 +13,7 @@ } @media screen and (min-width: $break-md) { + // default grid to show for non macos, ios, or ipados hosts. &__details-panel.react-tabs__tab-panel--selected { // Must be selected to show grid grid-template-columns: 1fr 1fr; @@ -21,18 +22,29 @@ "activity agent-options" "activity labels" "users users"; - grid-auto-flow: column; } - // No agent options card for i(Pad)OS, so extend Labels card vertically + // No agent options card for i(Pad)OS, so extend Labels card vertically. + // We also add the certs card to the grid layout on mac hosts &__details-panel--ios-grid.react-tabs__tab-panel--selected { - grid-template-columns: 1fr 1fr; grid-template-areas: "about about" - "activity labels"; - grid-auto-flow: column; + "activity labels" + "certs certs"; } + + // We add the certs card to the grid layout on mac hosts + &__details-panel--macos-grid.react-tabs__tab-panel--selected { + grid-template-areas: + "about about" + "activity agent-options" + "activity labels" + "users users" + "certs certs"; + } + + .about-card { grid-area: about; } @@ -52,6 +64,10 @@ .users-card { grid-area: users; } + + .certificates-card { + grid-area: certs; + } } .about-card, diff --git a/frontend/pages/hosts/details/cards/About/About.tsx b/frontend/pages/hosts/details/cards/About/About.tsx index 67a54f9777..f7c995e63c 100644 --- a/frontend/pages/hosts/details/cards/About/About.tsx +++ b/frontend/pages/hosts/details/cards/About/About.tsx @@ -197,10 +197,10 @@ const About = ({ -

About

+

About

void; +} + +const CertificatesCard = ({ + data, + hostPlatform, + isMyDevicePage = false, + onSelectCertificate, +}: ICertificatesProps) => { + return ( + +

Certificates

+ +
+ ); +}; + +export default CertificatesCard; diff --git a/frontend/pages/hosts/details/cards/Certificates/CertificatesTable/CertificatesTable.tsx b/frontend/pages/hosts/details/cards/Certificates/CertificatesTable/CertificatesTable.tsx new file mode 100644 index 0000000000..e6bb565b2a --- /dev/null +++ b/frontend/pages/hosts/details/cards/Certificates/CertificatesTable/CertificatesTable.tsx @@ -0,0 +1,56 @@ +import React from "react"; +import { Row } from "react-table"; + +import { IHostCertificate } from "interfaces/certificates"; + +import TableContainer from "components/TableContainer"; +import CustomLink from "components/CustomLink"; + +import generateTableConfig from "./CertificatesTableConfig"; + +const baseClass = "certificates-table"; + +interface ICertificatesTableProps { + data: IHostCertificate[]; + showHelpText: boolean; + onSelectCertificate: (certificate: IHostCertificate) => void; +} + +const CertificatesTable = ({ + data, + showHelpText, + onSelectCertificate, +}: ICertificatesTableProps) => { + const tableConfig = generateTableConfig(); + + const onClickTableRow = (row: Row) => { + onSelectCertificate(row.original); + }; + + const helpText = showHelpText ? ( +

+ Showing certificates in the system keychain. To get all certificates, you + can query the certificates table.{" "} + +

+ ) : null; + + return ( + > + className={baseClass} + columnConfigs={tableConfig} + data={data} + emptyComponent={() => null} + isAllPagesSelected={false} + showMarkAllPages={false} + isLoading={false} + onClickRow={onClickTableRow} + renderTableHelpText={() => helpText} + /> + ); +}; +export default CertificatesTable; diff --git a/frontend/pages/hosts/details/cards/Certificates/CertificatesTable/CertificatesTableConfig.tsx b/frontend/pages/hosts/details/cards/Certificates/CertificatesTable/CertificatesTableConfig.tsx new file mode 100644 index 0000000000..5022d3a565 --- /dev/null +++ b/frontend/pages/hosts/details/cards/Certificates/CertificatesTable/CertificatesTableConfig.tsx @@ -0,0 +1,67 @@ +import React from "react"; +import { Column } from "react-table"; + +import { IHostCertificate } from "interfaces/certificates"; +import { monthDayYearFormat } from "utilities/date_format"; +import { hasExpired, willExpireWithinXDays } from "utilities/helpers"; + +import HeaderCell from "components/TableContainer/DataTable/HeaderCell/HeaderCell"; +import TextCell from "components/TableContainer/DataTable/TextCell"; +import ViewAllHostsLink from "components/ViewAllHostsLink"; +import StatusIndicator from "components/StatusIndicator"; +import { IIndicatorValue } from "components/StatusIndicator/StatusIndicator"; + +type IHostCertificatesTableConfig = Column; + +const generateTableConfig = (): IHostCertificatesTableConfig[] => { + return [ + { + accessor: "common_name", + Header: (cellProps) => ( + + ), + Cell: (cellProps) => , + }, + { + accessor: "not_valid_after", + Header: (cellProps) => ( + + ), + Cell: (cellProps) => { + let status: IIndicatorValue = "success"; + if (hasExpired(cellProps.value)) { + status = "error"; + } else if (willExpireWithinXDays(cellProps.value, 30)) { + status = "warning"; + } + return ( + + ); + }, + }, + { + Header: "", + id: "view-all-hosts", + disableSortBy: true, + Cell: () => { + return ( + + ); + }, + }, + ]; +}; + +export default generateTableConfig; diff --git a/frontend/pages/hosts/details/cards/Certificates/CertificatesTable/_styles.scss b/frontend/pages/hosts/details/cards/Certificates/CertificatesTable/_styles.scss new file mode 100644 index 0000000000..53c1829bac --- /dev/null +++ b/frontend/pages/hosts/details/cards/Certificates/CertificatesTable/_styles.scss @@ -0,0 +1,3 @@ +.certificates-table { + +} diff --git a/frontend/pages/hosts/details/cards/Certificates/CertificatesTable/index.ts b/frontend/pages/hosts/details/cards/Certificates/CertificatesTable/index.ts new file mode 100644 index 0000000000..a4c90cd5e7 --- /dev/null +++ b/frontend/pages/hosts/details/cards/Certificates/CertificatesTable/index.ts @@ -0,0 +1 @@ +export { default } from "./CertificatesTable"; diff --git a/frontend/pages/hosts/details/cards/Certificates/_styles.scss b/frontend/pages/hosts/details/cards/Certificates/_styles.scss new file mode 100644 index 0000000000..87c29e23d6 --- /dev/null +++ b/frontend/pages/hosts/details/cards/Certificates/_styles.scss @@ -0,0 +1,7 @@ +.certificates-card { + h2 { + font-size: $medium; + margin: 0 0 $pad-large; + } + +} diff --git a/frontend/pages/hosts/details/cards/Certificates/index.ts b/frontend/pages/hosts/details/cards/Certificates/index.ts new file mode 100644 index 0000000000..c1f0962622 --- /dev/null +++ b/frontend/pages/hosts/details/cards/Certificates/index.ts @@ -0,0 +1 @@ +export { default } from "./Certificates"; diff --git a/frontend/pages/hosts/details/cards/Labels/Labels.tsx b/frontend/pages/hosts/details/cards/Labels/Labels.tsx index 5504d9a668..efb33dfd19 100644 --- a/frontend/pages/hosts/details/cards/Labels/Labels.tsx +++ b/frontend/pages/hosts/details/cards/Labels/Labels.tsx @@ -37,10 +37,10 @@ const Labels = ({ onLabelClick, labels }: ILabelsProps): JSX.Element => { -

Labels

+

Labels

{labels.length === 0 ? (

No labels are associated with this host. diff --git a/frontend/pages/hosts/details/cards/Labels/__styles.scss b/frontend/pages/hosts/details/cards/Labels/__styles.scss new file mode 100644 index 0000000000..f70ba0f9a9 --- /dev/null +++ b/frontend/pages/hosts/details/cards/Labels/__styles.scss @@ -0,0 +1,7 @@ +.labels-card { + + h2 { + font-size: $medium; + margin: 0 0 $pad-large; + } +} diff --git a/frontend/pages/hosts/details/cards/Users/Users.tsx b/frontend/pages/hosts/details/cards/Users/Users.tsx index 3879e940ee..6fe749eef7 100644 --- a/frontend/pages/hosts/details/cards/Users/Users.tsx +++ b/frontend/pages/hosts/details/cards/Users/Users.tsx @@ -63,11 +63,11 @@ const Users = ({ <> -

Users

+

Users

{users?.length ? ( void; +} + +const CertificateDetailsModal = ({ + certificate, + onExit, +}: ICertificateDetailsModalProps) => { + return ( + + <> +
+
+

Subject Name

+
+ + + + +
+
+
+

Issuer name

+
+ + + + +
+
+
+

Validity period

+
+ + +
+
+
+

Key info

+
+ + + + +
+
+ +
+

Basic constraints

+
+ +
+
+
+

Signature

+
+ +
+
+
+
+ +
+ +
+ ); +}; + +export default CertificateDetailsModal; diff --git a/frontend/pages/hosts/details/modals/CertificateDetailsModal/_styles.scss b/frontend/pages/hosts/details/modals/CertificateDetailsModal/_styles.scss new file mode 100644 index 0000000000..a522b0d39f --- /dev/null +++ b/frontend/pages/hosts/details/modals/CertificateDetailsModal/_styles.scss @@ -0,0 +1,32 @@ +.certificate-details-modal { + + &__content { + display: flex; + flex-direction: column; + gap: $pad-xlarge; + } + + h3 { + margin: 0; + font-size: $small; + } + + &__section { + display: flex; + gap: $pad-small; + flex-direction: column; + padding-top: $pad-xlarge; + border-top: 1px solid $ui-fleet-black-10; + + &:first-child { + padding-top: 0; + border-top: none; + } + + dl { + display: flex; + flex-direction: column; + gap: $pad-xsmall; + } + } +} diff --git a/frontend/pages/hosts/details/modals/CertificateDetailsModal/index.ts b/frontend/pages/hosts/details/modals/CertificateDetailsModal/index.ts new file mode 100644 index 0000000000..7323ec4a28 --- /dev/null +++ b/frontend/pages/hosts/details/modals/CertificateDetailsModal/index.ts @@ -0,0 +1 @@ +export { default } from "./CertificateDetailsModal"; diff --git a/frontend/services/entities/device_user.ts b/frontend/services/entities/device_user.ts index 0ec397fd8a..57fcb66306 100644 --- a/frontend/services/entities/device_user.ts +++ b/frontend/services/entities/device_user.ts @@ -1,8 +1,11 @@ import { IDeviceUserResponse } from "interfaces/host"; import { IDeviceSoftware } from "interfaces/software"; +import { IHostCertificate } from "interfaces/certificates"; import sendRequest from "services"; import endpoints from "utilities/endpoints"; import { buildQueryStringFromParams } from "utilities/url"; +import { createMockGetHostCertificatesResponse } from "__mocks__/certificatesMock"; + import { IHostSoftwareQueryParams } from "./hosts"; export type ILoadHostDetailsExtension = "device_mapping" | "macadmins"; @@ -27,6 +30,14 @@ interface IGetDeviceDetailsRequest { exclude_software?: boolean; } +export interface IGetDeviceCertificatesResponse { + certificates: IHostCertificate[]; + meta: { + has_next_results: boolean; + has_previous_results: boolean; + }; +} + export default { loadHostDetails: ({ token, @@ -74,4 +85,16 @@ export default { return sendRequest("POST", path); }, + + getDeviceCertificates: ( + deviceToken: string + ): Promise => { + const { DEVICE_CERTIFICATES } = endpoints; + const path = DEVICE_CERTIFICATES(deviceToken); + + // return sendRequest("GET", path); + return new Promise((resolve) => { + resolve(createMockGetHostCertificatesResponse()); + }); + }, }; diff --git a/frontend/services/entities/hosts.ts b/frontend/services/entities/hosts.ts index bf8aff8720..56b484ee8f 100644 --- a/frontend/services/entities/hosts.ts +++ b/frontend/services/entities/hosts.ts @@ -23,6 +23,11 @@ import { } from "interfaces/mdm"; import { IMunkiIssuesAggregate } from "interfaces/macadmins"; import { PlatformValueOptions, PolicyResponse } from "utilities/constants"; +import { IHostCertificate } from "interfaces/certificates"; +import { + createMockGetHostCertificatesResponse, + createMockHostCertificate, +} from "__mocks__/certificatesMock"; export interface ISortOption { key: string; @@ -171,6 +176,14 @@ export interface IHostSoftwareQueryKey extends IHostSoftwareQueryParams { softwareUpdatedAt?: string; } +export interface IGetHostCertificatesResponse { + certificates: IHostCertificate[]; + meta: { + has_next_results: boolean; + has_previous_results: boolean; + }; +} + export type ILoadHostDetailsExtension = "device_mapping" | "macadmins"; const LABEL_PREFIX = "labels/"; @@ -579,6 +592,7 @@ export default { HOST_SOFTWARE_PACKAGE_INSTALL(hostId, softwareId) ); }, + uninstallHostSoftwarePackage: (hostId: number, softwareId: number) => { const { HOST_SOFTWARE_PACKAGE_UNINSTALL } = endpoints; return sendRequest( @@ -586,4 +600,24 @@ export default { HOST_SOFTWARE_PACKAGE_UNINSTALL(hostId, softwareId) ); }, + + getHostCertificates: ( + hostId: number + ): Promise => { + const { HOST_CERTIFICATES } = endpoints; + + // return sendRequest("GET", HOST_CERTIFICATES(hostId)); + return new Promise((resolve) => { + resolve( + createMockGetHostCertificatesResponse({ + certificates: [ + createMockHostCertificate({ + common_name: "Test 2", + not_valid_after: "2025-05-01T00:00:00.000Z", + }), + ], + }) + ); + }); + }, }; diff --git a/frontend/utilities/endpoints.ts b/frontend/utilities/endpoints.ts index ea08ed7a57..8452e85498 100644 --- a/frontend/utilities/endpoints.ts +++ b/frontend/utilities/endpoints.ts @@ -40,6 +40,9 @@ export default { DEVICE_TRIGGER_LINUX_DISK_ENCRYPTION_KEY_ESCROW: (token: string): string => { return `/${API_VERSION}/fleet/device/${token}/mdm/linux/trigger_escrow`; }, + DEVICE_CERTIFICATES: (token: string): string => { + return `/${API_VERSION}/fleet/device/${token}/certificates`; + }, // Host endpoints HOST_SUMMARY: `/${API_VERSION}/fleet/host_summary`, @@ -61,6 +64,8 @@ export default { `/${API_VERSION}/fleet/hosts/${hostId}/software/${softwareId}/install`, HOST_SOFTWARE_PACKAGE_UNINSTALL: (hostId: number, softwareId: number) => `/${API_VERSION}/fleet/hosts/${hostId}/software/${softwareId}/uninstall`, + HOST_CERTIFICATES: (id: number) => + `/${API_VERSION}/fleet/hosts/${id}/certificates`, INVITES: `/${API_VERSION}/fleet/invites`, INVITE_VERIFY: (token: string) => `/${API_VERSION}/fleet/invites/${token}`, diff --git a/frontend/utilities/helpers.tsx b/frontend/utilities/helpers.tsx index 1e68d7322a..dc85d38b47 100644 --- a/frontend/utilities/helpers.tsx +++ b/frontend/utilities/helpers.tsx @@ -639,6 +639,13 @@ export const hasLicenseExpired = (expiration: string): boolean => { return isAfter(new Date(), new Date(expiration)); }; +// just a rename of hasLicenseExpired so that it can be used in other contexts. +// TODO: change hasLicenseExpired instances to hasExpired +/** + * determines if a date has expired. This will check against the current date and time. + */ +export const hasExpired = hasLicenseExpired; + /** * determines if a date will expire within "x" number of days. If the date has * has already expired, this function will return false. From ea35a2d77d9bf6177bc17dfece1c9731e41a93e9 Mon Sep 17 00:00:00 2001 From: Sarah Gillespie <73313222+gillespi314@users.noreply.github.com> Date: Fri, 21 Feb 2025 15:57:09 -0600 Subject: [PATCH 06/14] Cleanup `host_certificates` table on host deletion (#26524) --- changes/23235-host-certificates | 1 + server/datastore/mysql/host_certificates.go | 9 ++++++--- server/datastore/mysql/hosts.go | 1 + server/datastore/mysql/hosts_test.go | 8 ++++++++ 4 files changed, 16 insertions(+), 3 deletions(-) create mode 100644 changes/23235-host-certificates diff --git a/changes/23235-host-certificates b/changes/23235-host-certificates new file mode 100644 index 0000000000..e63315609f --- /dev/null +++ b/changes/23235-host-certificates @@ -0,0 +1 @@ +- Added new features to include certificates in host vitals for macOS, iOS, and iPadOS. diff --git a/server/datastore/mysql/host_certificates.go b/server/datastore/mysql/host_certificates.go index c4ab2d41b6..6bd7fe91bb 100644 --- a/server/datastore/mysql/host_certificates.go +++ b/server/datastore/mysql/host_certificates.go @@ -21,11 +21,11 @@ func (ds *Datastore) UpdateHostCertificates(ctx context.Context, hostID uint, ce for _, cert := range certs { if cert.HostID != hostID { // caller should ensure this does not happen - level.Debug(ds.logger).Log("msg", fmt.Sprintf("host ID does not match provided certificate: %d %d", hostID, cert.HostID)) + level.Debug(ds.logger).Log("msg", fmt.Sprintf("host certificates: host ID does not match provided certificate: %d %d", hostID, cert.HostID)) } if _, ok := incomingBySHA1[strings.ToUpper(hex.EncodeToString(cert.SHA1Sum))]; ok { // TODO: sha1 is broken so this could be a sign of a problem, how should we handle? - level.Info(ds.logger).Log("msg", "host has multiple certificates with the same SHA1, only the first will be recorded", "host_id", hostID, "sha1", string(cert.SHA1Sum)) + level.Info(ds.logger).Log("msg", "host certificates: host has multiple certificates with the same SHA1, only the first will be recorded", "host_id", hostID, "sha1", string(cert.SHA1Sum)) continue } incomingBySHA1[strings.ToUpper(hex.EncodeToString(cert.SHA1Sum))] = cert @@ -48,7 +48,7 @@ func (ds *Datastore) UpdateHostCertificates(ctx context.Context, hostID uint, ce if _, ok := existingBySHA1[sha1]; ok { // TODO: should we always update existing records? skipping updates reduces db load but // osquery is using sha1 so we consider subtleties - level.Debug(ds.logger).Log("msg", fmt.Sprintf("existing certificate: %s", sha1), "host_id", hostID) + level.Debug(ds.logger).Log("msg", fmt.Sprintf("host certificates: already exists: %s", sha1), "host_id", hostID) // TODO: silence this log after initial rollout period } else { toInsert = append(toInsert, incoming) } @@ -179,6 +179,9 @@ INSERT INTO host_certificates ( } func softDeleteHostCertsDB(ctx context.Context, tx sqlx.ExtContext, hostID uint, toDelete []uint) error { + // TODO: consider whether we should hard delete certs after a certain period of time if we are seeing + // the table grow too large with soft deleted records + if len(toDelete) == 0 { return nil } diff --git a/server/datastore/mysql/hosts.go b/server/datastore/mysql/hosts.go index 1dbd9d622a..c0051f0c0d 100644 --- a/server/datastore/mysql/hosts.go +++ b/server/datastore/mysql/hosts.go @@ -549,6 +549,7 @@ var hostRefs = []string{ "host_mdm_actions", "host_calendar_events", "upcoming_activities", + "host_certificates", } // NOTE: The following tables are explicity excluded from hostRefs list and accordingly are not diff --git a/server/datastore/mysql/hosts_test.go b/server/datastore/mysql/hosts_test.go index edb0fa529b..af10e4af83 100644 --- a/server/datastore/mysql/hosts_test.go +++ b/server/datastore/mysql/hosts_test.go @@ -2,6 +2,7 @@ package mysql import ( "context" + "crypto/sha1" "crypto/sha256" "database/sql" "encoding/json" @@ -7057,6 +7058,13 @@ func testHostsDeleteHosts(t *testing.T, ds *Datastore) { require.NoError(t, err) require.True(t, added) + // Add a host certificate + require.NoError(t, ds.UpdateHostCertificates(ctx, host.ID, []*fleet.HostCertificateRecord{{ + HostID: host.ID, + CommonName: "foo", + SHA1Sum: sha1.New().Sum([]byte("foo")), + }})) + // Check there's an entry for the host in all the associated tables. for _, hostRef := range hostRefs { var ok bool From a141830f76484ecceda121f2eb10e620fbfe1b8e Mon Sep 17 00:00:00 2001 From: Martin Angers Date: Mon, 24 Feb 2025 12:52:39 -0500 Subject: [PATCH 07/14] CHV: implement paginated list certificates endpoints (#26554) --- .../25460-add-list-host-certificates-endpoint | 1 + server/datastore/mysql/host_certificates.go | 14 +- server/fleet/host_certificates.go | 82 ++++++++--- server/fleet/service.go | 3 + server/service/devices.go | 39 ++++++ server/service/handler.go | 4 + server/service/hosts.go | 67 +++++++++ server/service/hosts_test.go | 8 ++ server/service/integration_core_test.go | 131 ++++++++++++++++++ server/service/testing_client.go | 4 +- 10 files changed, 323 insertions(+), 30 deletions(-) create mode 100644 changes/25460-add-list-host-certificates-endpoint diff --git a/changes/25460-add-list-host-certificates-endpoint b/changes/25460-add-list-host-certificates-endpoint new file mode 100644 index 0000000000..7663b3f98e --- /dev/null +++ b/changes/25460-add-list-host-certificates-endpoint @@ -0,0 +1 @@ +* Added the list host certificates (and list device's certificates) endpoints. diff --git a/server/datastore/mysql/host_certificates.go b/server/datastore/mysql/host_certificates.go index 6bd7fe91bb..7858305314 100644 --- a/server/datastore/mysql/host_certificates.go +++ b/server/datastore/mysql/host_certificates.go @@ -73,15 +73,8 @@ func (ds *Datastore) UpdateHostCertificates(ctx context.Context, hostID uint, ce } func listHostCertsDB(ctx context.Context, tx sqlx.QueryerContext, hostID uint, opts fleet.ListOptions) ([]*fleet.HostCertificateRecord, *fleet.PaginationMetadata, error) { - // TODO: move this to the service layer and do validation of the order key? - if opts.OrderKey == "" { - // default sort by common name ascending - opts.OrderKey = "common_name" - opts.OrderDirection = fleet.OrderAscending - } - stmt := ` -SELECT +SELECT id, sha1_sum, host_id, @@ -105,8 +98,8 @@ SELECT issuer_org_unit, issuer_common_name FROM - host_certificates -WHERE + host_certificates +WHERE host_id = ? AND deleted_at IS NULL` @@ -126,7 +119,6 @@ WHERE certs = certs[:len(certs)-1] } } - return certs, metaData, nil } diff --git a/server/fleet/host_certificates.go b/server/fleet/host_certificates.go index f7d8ceac3a..0d7a27975d 100644 --- a/server/fleet/host_certificates.go +++ b/server/fleet/host_certificates.go @@ -24,24 +24,24 @@ type HostCertificateRecord struct { DeletedAt *time.Time `json:"-" db:"deleted_at"` // The following fields are extracted from the certificate. + NotValidAfter time.Time `json:"-" db:"not_valid_after"` + NotValidBefore time.Time `json:"-" db:"not_valid_before"` + CertificateAuthority bool `json:"-" db:"certificate_authority"` + CommonName string `json:"-" db:"common_name"` + KeyAlgorithm string `json:"-" db:"key_algorithm"` + KeyStrength int `json:"-" db:"key_strength"` + KeyUsage string `json:"-" db:"key_usage"` + Serial string `json:"-" db:"serial"` + SigningAlgorithm string `json:"-" db:"signing_algorithm"` - NotValidAfter time.Time `json:"-" db:"not_valid_after"` - NotValidBefore time.Time `json:"-" db:"not_valid_before"` - CertificateAuthority bool `json:"-" db:"certificate_authority"` - CommonName string `json:"-" db:"common_name"` - KeyAlgorithm string `json:"-" db:"key_algorithm"` - KeyStrength int `json:"-" db:"key_strength"` - KeyUsage string `json:"-" db:"key_usage"` - Serial string `json:"-" db:"serial"` - SigningAlgorithm string `json:"-" db:"signing_algorithm"` - SubjectCountry string `json:"-" db:"subject_country"` - SubjectOrganization string `json:"-" db:"subject_org"` - SubjectOrganizationalUnit string `json:"-" db:"subject_org_unit"` - SubjectCommonName string `json:"-" db:"subject_common_name"` - IssuerCountry string `json:"-" db:"issuer_country"` - IssuerOrganization string `json:"-" db:"issuer_org"` - IssuerOrganizationalUnit string `json:"-" db:"issuer_org_unit"` - IssuerCommonName string `json:"-" db:"issuer_common_name"` + SubjectCountry string `json:"-" db:"subject_country"` + SubjectOrganization string `json:"-" db:"subject_org"` + SubjectOrganizationalUnit string `json:"-" db:"subject_org_unit"` + SubjectCommonName string `json:"-" db:"subject_common_name"` + IssuerCountry string `json:"-" db:"issuer_country"` + IssuerOrganization string `json:"-" db:"issuer_org"` + IssuerOrganizationalUnit string `json:"-" db:"issuer_org_unit"` + IssuerCommonName string `json:"-" db:"issuer_common_name"` } func NewHostCertificateRecord( @@ -82,6 +82,54 @@ func NewHostCertificateRecord( } } +// ToPayload fills a HostCertificatePayload with the fields of a +// HostCertificateRecord. The HostCertificatePayload is used in API responses. +func (r *HostCertificateRecord) ToPayload() *HostCertificatePayload { + subject := &HostCertificateNameDetails{ + CommonName: r.SubjectCommonName, + Country: r.SubjectCountry, + Organization: r.SubjectOrganization, + OrganizationalUnit: r.SubjectOrganizationalUnit, + } + issuer := &HostCertificateNameDetails{ + CommonName: r.IssuerCommonName, + Country: r.IssuerCountry, + Organization: r.IssuerOrganization, + OrganizationalUnit: r.IssuerOrganizationalUnit, + } + return &HostCertificatePayload{ + ID: r.ID, + NotValidAfter: r.NotValidAfter, + NotValidBefore: r.NotValidBefore, + CertificateAuthority: r.CertificateAuthority, + CommonName: r.CommonName, + KeyAlgorithm: r.KeyAlgorithm, + KeyStrength: r.KeyStrength, + KeyUsage: r.KeyUsage, + Serial: r.Serial, + SigningAlgorithm: r.SigningAlgorithm, + Subject: subject, + Issuer: issuer, + } +} + +// HostCertificatePayload is the JSON model for API endpoints that return host certificates. +type HostCertificatePayload struct { + ID uint `json:"id"` + NotValidAfter time.Time `json:"not_valid_after"` + NotValidBefore time.Time `json:"not_valid_before"` + CertificateAuthority bool `json:"certificate_authority"` + CommonName string `json:"common_name"` + KeyAlgorithm string `json:"key_algorithm"` + KeyStrength int `json:"key_strength"` + KeyUsage string `json:"key_usage"` + Serial string `json:"serial"` + SigningAlgorithm string `json:"signing_algorithm"` + + Subject *HostCertificateNameDetails `json:"subject,omitempty"` + Issuer *HostCertificateNameDetails `json:"issuer,omitempty"` +} + type HostCertificateNameDetails struct { CommonName string `json:"common_name"` Country string `json:"country"` diff --git a/server/fleet/service.go b/server/fleet/service.go index 4f524d2a69..1e753ad232 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -435,6 +435,9 @@ type Service interface { // the specified host. ListHostSoftware(ctx context.Context, hostID uint, opts HostSoftwareTitleListOptions) ([]*HostSoftwareWithInstaller, *PaginationMetadata, error) + // ListHostCertificates lists the certificates installed on the specified host. + ListHostCertificates(ctx context.Context, hostID uint, opts ListOptions) ([]*HostCertificatePayload, *PaginationMetadata, error) + // ///////////////////////////////////////////////////////////////////////////// // AppConfigService provides methods for configuring the Fleet application diff --git a/server/service/devices.go b/server/service/devices.go index 990dc6718f..f8b927ea2a 100644 --- a/server/service/devices.go +++ b/server/service/devices.go @@ -685,3 +685,42 @@ func getDeviceSoftwareEndpoint(ctx context.Context, request interface{}, svc fle } return getDeviceSoftwareResponse{Software: res, Meta: meta, Count: int(meta.TotalResults)}, nil //nolint:gosec // dismiss G115 } + +//////////////////////////////////////////////////////////////////////////////// +// List Current Device's Certificates +//////////////////////////////////////////////////////////////////////////////// + +type listDeviceCertificatesRequest struct { + Token string `url:"token"` + fleet.ListOptions +} + +func (r *listDeviceCertificatesRequest) deviceAuthToken() string { + return r.Token +} + +type listDeviceCertificatesResponse struct { + Certificates []*fleet.HostCertificatePayload `json:"certificates"` + Meta *fleet.PaginationMetadata `json:"meta,omitempty"` + Err error `json:"error,omitempty"` +} + +func (r listDeviceCertificatesResponse) Error() error { return r.Err } + +func listDeviceCertificatesEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) { + host, ok := hostctx.FromContext(ctx) + if !ok { + err := ctxerr.Wrap(ctx, fleet.NewAuthRequiredError("internal error: missing host from request context")) + return listDevicePoliciesResponse{Err: err}, nil + } + + req := request.(*listDeviceCertificatesRequest) + res, meta, err := svc.ListHostCertificates(ctx, host.ID, req.ListOptions) + if err != nil { + return listDeviceCertificatesResponse{Err: err}, nil + } + if res == nil { + res = []*fleet.HostCertificatePayload{} + } + return listDeviceCertificatesResponse{Certificates: res, Meta: meta}, nil +} diff --git a/server/service/handler.go b/server/service/handler.go index 3b5970bb3b..7b1f0d7669 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -409,6 +409,7 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC ue.POST("/api/_version_/fleet/hosts/{id:[0-9]+}/labels", addLabelsToHostEndpoint, addLabelsToHostRequest{}) ue.DELETE("/api/_version_/fleet/hosts/{id:[0-9]+}/labels", removeLabelsFromHostEndpoint, removeLabelsFromHostRequest{}) ue.GET("/api/_version_/fleet/hosts/{id:[0-9]+}/software", getHostSoftwareEndpoint, getHostSoftwareRequest{}) + ue.GET("/api/_version_/fleet/hosts/{id:[0-9]+}/certificates", listHostCertificatesEndpoint, listHostCertificatesRequest{}) ue.GET("/api/_version_/fleet/hosts/summary/mdm", getHostMDMSummary, getHostMDMSummaryRequest{}) ue.GET("/api/_version_/fleet/hosts/{id:[0-9]+}/mdm", getHostMDM, getHostMDMRequest{}) @@ -810,6 +811,9 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC de.WithCustomMiddleware( errorLimiter.Limit("install_self_service", desktopQuota), ).POST("/api/_version_/fleet/device/{token}/software/install/{software_title_id}", submitSelfServiceSoftwareInstall, fleetSelfServiceSoftwareInstallRequest{}) + de.WithCustomMiddleware( + errorLimiter.Limit("get_device_certificates", desktopQuota), + ).GET("/api/_version_/fleet/device/{token}/certificates", listDeviceCertificatesEndpoint, listDeviceCertificatesRequest{}) // mdm-related endpoints available via device authentication demdm := de.WithCustomMiddleware(mdmConfiguredMiddleware.VerifyAppleMDM()) diff --git a/server/service/hosts.go b/server/service/hosts.go index 8c726d5354..08d522e2ad 100644 --- a/server/service/hosts.go +++ b/server/service/hosts.go @@ -2715,3 +2715,70 @@ func (svc *Service) ListHostSoftware(ctx context.Context, hostID uint, opts flee software, meta, err := svc.ds.ListHostSoftware(ctx, host, opts) return software, meta, ctxerr.Wrap(ctx, err, "list host software") } + +//////////////////////////////////////////////////////////////////////////////// +// Host Certificates +//////////////////////////////////////////////////////////////////////////////// + +type listHostCertificatesRequest struct { + ID uint `url:"id"` + fleet.ListOptions +} + +type listHostCertificatesResponse struct { + Certificates []*fleet.HostCertificatePayload `json:"certificates"` + Meta *fleet.PaginationMetadata `json:"meta,omitempty"` + Err error `json:"error,omitempty"` +} + +func (r listHostCertificatesResponse) Error() error { return r.Err } + +func listHostCertificatesEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) { + req := request.(*listHostCertificatesRequest) + res, meta, err := svc.ListHostCertificates(ctx, req.ID, req.ListOptions) + if err != nil { + return listHostCertificatesResponse{Err: err}, nil + } + if res == nil { + res = []*fleet.HostCertificatePayload{} + } + return listHostCertificatesResponse{Certificates: res, Meta: meta}, nil +} + +var listHostCertificatesSortCols = map[string]bool{ + "common_name": true, + "not_valid_after": true, +} + +func (svc *Service) ListHostCertificates(ctx context.Context, hostID uint, opts fleet.ListOptions) ([]*fleet.HostCertificatePayload, *fleet.PaginationMetadata, error) { + if !svc.authz.IsAuthenticatedWith(ctx, authzctx.AuthnDeviceToken) { + host, err := svc.ds.HostLite(ctx, hostID) + if err != nil { + svc.authz.SkipAuthorization(ctx) + return nil, nil, ctxerr.Wrap(ctx, err, "failed to load host") + } + if err := svc.authz.Authorize(ctx, host, fleet.ActionRead); err != nil { + return nil, nil, err + } + } + + // query/after not supported, always include pagination info + opts.MatchQuery = "" + opts.After = "" + opts.IncludeMetadata = true + // default sort order is common name ascending + if opts.OrderKey == "" || !listHostCertificatesSortCols[opts.OrderKey] { + opts.OrderKey = "common_name" + } + + certs, meta, err := svc.ds.ListHostCertificates(ctx, hostID, opts) + if err != nil { + return nil, nil, err + } + + payload := make([]*fleet.HostCertificatePayload, 0, len(certs)) + for _, cert := range certs { + payload = append(payload, cert.ToPayload()) + } + return payload, meta, nil +} diff --git a/server/service/hosts_test.go b/server/service/hosts_test.go index d115b4af21..377f624aa7 100644 --- a/server/service/hosts_test.go +++ b/server/service/hosts_test.go @@ -665,6 +665,9 @@ func TestHostAuth(t *testing.T) { ds.IsHostConnectedToFleetMDMFunc = func(ctx context.Context, host *fleet.Host) (bool, error) { return true, nil } + ds.ListHostCertificatesFunc = func(ctx context.Context, hostID uint, opts fleet.ListOptions) ([]*fleet.HostCertificateRecord, *fleet.PaginationMetadata, error) { + return nil, nil, nil + } testCases := []struct { name string @@ -812,6 +815,11 @@ func TestHostAuth(t *testing.T) { _, _, err = svc.ListHostSoftware(ctx, 2, fleet.HostSoftwareTitleListOptions{}) checkAuthErr(t, tt.shouldFailGlobalRead, err) + + _, _, err = svc.ListHostCertificates(ctx, 1, fleet.ListOptions{}) + checkAuthErr(t, tt.shouldFailTeamRead, err) + _, _, err = svc.ListHostCertificates(ctx, 2, fleet.ListOptions{}) + checkAuthErr(t, tt.shouldFailGlobalRead, err) }) } diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index ff3b4c4bbf..ab258cb181 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -3,6 +3,7 @@ package service import ( "bytes" "context" + "crypto/sha1" // nolint: gosec "database/sql" "encoding/csv" "encoding/json" @@ -12974,3 +12975,133 @@ func (s *integrationTestSuite) TestSecretVariables() { require.Len(t, secrets, 1) assert.Equal(t, "value", secrets[0].Value) } + +func (s *integrationTestSuite) TestHostCertificates() { + t := s.T() + ctx := context.Background() + + token := "good_token" + host := createOrbitEnrolledHost(t, "linux", "host1", s.ds) + createDeviceTokenForHost(t, s.ds, host.ID, token) + + // no certificate at the moment + var certResp listHostCertificatesResponse + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/certificates", host.ID), nil, http.StatusOK, &certResp) + require.Empty(t, certResp.Certificates) + + certResp = listHostCertificatesResponse{} + res := s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/certificates", nil, http.StatusOK) + err := json.NewDecoder(res.Body).Decode(&certResp) + require.NoError(t, err) + require.Empty(t, certResp.Certificates) + + // create some certs for that host + certNames := []string{"a", "b", "c", "d", "e"} + now := time.Now() + // sorting by not_valid_after should get us "d", "c", "e", "a", "b" + notValidAfterTimes := []time.Time{ + now.Add(time.Minute), now.Add(time.Hour), + now.Add(time.Second), now.Add(time.Millisecond), + now.Add(2 * time.Second)} + certs := make([]*fleet.HostCertificateRecord, 0, len(certNames)) + for i, name := range certNames { + certs = append(certs, &fleet.HostCertificateRecord{ + HostID: host.ID, + CommonName: name, + SHA1Sum: sha1.New().Sum([]byte(name)), // nolint: gosec + SubjectCountry: "s" + name, + IssuerCountry: "i" + name, + NotValidAfter: notValidAfterTimes[i], + }) + } + require.NoError(t, s.ds.UpdateHostCertificates(ctx, host.ID, certs)) + + // list all certs + certResp = listHostCertificatesResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/certificates", host.ID), nil, http.StatusOK, &certResp) + require.Len(t, certResp.Certificates, len(certNames)) + for i, cert := range certResp.Certificates { + want := certNames[i] + require.Equal(t, want, cert.CommonName) + require.NotNil(t, cert.Subject) + require.Equal(t, "s"+want, cert.Subject.Country) + require.NotNil(t, cert.Issuer) + require.Equal(t, "i"+want, cert.Issuer.Country) + } + + certResp = listHostCertificatesResponse{} + res = s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/certificates", nil, http.StatusOK) + err = json.NewDecoder(res.Body).Decode(&certResp) + require.NoError(t, err) + require.Len(t, certResp.Certificates, len(certNames)) + for i, cert := range certResp.Certificates { + want := certNames[i] + require.Equal(t, want, cert.CommonName) + require.NotNil(t, cert.Subject) + require.Equal(t, "s"+want, cert.Subject.Country) + require.NotNil(t, cert.Issuer) + require.Equal(t, "i"+want, cert.Issuer.Country) + } + + // non-existing host + certResp = listHostCertificatesResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/certificates", host.ID+1000), nil, http.StatusNotFound, &certResp) + // for the device endpoint, the token is the authentication so if it doesn't + // exist, the endpoint is unauthorized. + certResp = listHostCertificatesResponse{} + s.DoRawNoAuth("GET", "/api/latest/fleet/device/NO-SUCH-TOKEN/certificates", nil, http.StatusUnauthorized) + + pluckCertNames := func(certs []*fleet.HostCertificatePayload) []string { + names := make([]string, 0, len(certs)) + for _, cert := range certs { + names = append(names, cert.CommonName) + } + return names + } + + // invalid sort column silently defaults to "common_name" + certResp = listHostCertificatesResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/certificates", host.ID), nil, http.StatusOK, &certResp, "order_key", "no-such-column") + require.Len(t, certResp.Certificates, len(certNames)) + require.Equal(t, certNames, pluckCertNames(certResp.Certificates)) + + certResp = listHostCertificatesResponse{} + res = s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/certificates", nil, http.StatusOK, "order_key", "no-such-column") + err = json.NewDecoder(res.Body).Decode(&certResp) + require.NoError(t, err) + require.Len(t, certResp.Certificates, len(certNames)) + require.Equal(t, certNames, pluckCertNames(certResp.Certificates)) + + // test the pagination options + cases := []struct { + queryParams []string + wantNames []string + wantMeta fleet.PaginationMetadata + }{ + {queryParams: []string{"page", "0", "per_page", "2"}, wantNames: []string{"a", "b"}, wantMeta: fleet.PaginationMetadata{HasNextResults: true}}, + {queryParams: []string{"page", "1", "per_page", "2"}, wantNames: []string{"c", "d"}, wantMeta: fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: true}}, + {queryParams: []string{"page", "2", "per_page", "2"}, wantNames: []string{"e"}, wantMeta: fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true}}, + {queryParams: []string{"page", "3", "per_page", "2"}, wantNames: []string{}, wantMeta: fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true}}, + {queryParams: []string{"page", "0", "per_page", "4", "order_direction", "desc"}, wantNames: []string{"e", "d", "c", "b"}, wantMeta: fleet.PaginationMetadata{HasNextResults: true}}, + {queryParams: []string{"page", "1", "per_page", "4", "order_direction", "desc"}, wantNames: []string{"a"}, wantMeta: fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true}}, + {queryParams: []string{"page", "0", "per_page", "3", "order_key", "not_valid_after"}, wantNames: []string{"d", "c", "e"}, wantMeta: fleet.PaginationMetadata{HasNextResults: true}}, + {queryParams: []string{"page", "1", "per_page", "3", "order_key", "not_valid_after"}, wantNames: []string{"a", "b"}, wantMeta: fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true}}, + } + for _, c := range cases { + t.Run(strings.Join(c.queryParams, "_"), func(t *testing.T) { + certResp = listHostCertificatesResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/certificates", host.ID), nil, http.StatusOK, &certResp, c.queryParams...) + require.Len(t, certResp.Certificates, len(c.wantNames)) + require.Equal(t, c.wantNames, pluckCertNames(certResp.Certificates)) + require.Equal(t, c.wantMeta, *certResp.Meta) + + certResp = listHostCertificatesResponse{} + res = s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/certificates", nil, http.StatusOK, c.queryParams...) + err = json.NewDecoder(res.Body).Decode(&certResp) + require.NoError(t, err) + require.Len(t, certResp.Certificates, len(c.wantNames)) + require.Equal(t, c.wantNames, pluckCertNames(certResp.Certificates)) + require.Equal(t, c.wantMeta, *certResp.Meta) + }) + } +} diff --git a/server/service/testing_client.go b/server/service/testing_client.go index 4d5d01dcaa..378fa8e46a 100644 --- a/server/service/testing_client.go +++ b/server/service/testing_client.go @@ -293,8 +293,8 @@ func (ts *withServer) DoRaw(verb string, path string, rawBytes []byte, expectedS }, queryParams...) } -func (ts *withServer) DoRawNoAuth(verb string, path string, rawBytes []byte, expectedStatusCode int) *http.Response { - return ts.DoRawWithHeaders(verb, path, rawBytes, expectedStatusCode, nil) +func (ts *withServer) DoRawNoAuth(verb string, path string, rawBytes []byte, expectedStatusCode int, queryParams ...string) *http.Response { + return ts.DoRawWithHeaders(verb, path, rawBytes, expectedStatusCode, nil, queryParams...) } func (ts *withServer) DoJSON(verb, path string, params interface{}, expectedStatusCode int, v interface{}, queryParams ...string) { From 38f51ebc4e19d6f22fcb70aef6bcc704f2878225 Mon Sep 17 00:00:00 2001 From: Martin Angers Date: Mon, 24 Feb 2025 14:30:22 -0500 Subject: [PATCH 08/14] Fix indent --- frontend/pages/hosts/details/cards/About/_styles.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/pages/hosts/details/cards/About/_styles.scss b/frontend/pages/hosts/details/cards/About/_styles.scss index 10f08c6e3d..88d5f7b0e9 100644 --- a/frontend/pages/hosts/details/cards/About/_styles.scss +++ b/frontend/pages/hosts/details/cards/About/_styles.scss @@ -2,7 +2,7 @@ h2 { font-size: $medium; margin: 0 0 $pad-large; - } + } .truncated-tooltip { .about-card__device-mapping__source { From f5cf6a3c6f849f225faeded81055c8b704234644 Mon Sep 17 00:00:00 2001 From: Martin Angers Date: Tue, 25 Feb 2025 10:42:17 -0500 Subject: [PATCH 09/14] Add validation extension to the make decoder flow, validate order_key (#26567) --- server/service/devices.go | 7 +++++++ server/service/hosts.go | 19 +++++++++++++------ server/service/integration_core_test.go | 13 ++++--------- .../endpoint_utils/endpoint_utils.go | 11 +++++++++++ 4 files changed, 35 insertions(+), 15 deletions(-) diff --git a/server/service/devices.go b/server/service/devices.go index 258d04320e..2c084b8e34 100644 --- a/server/service/devices.go +++ b/server/service/devices.go @@ -695,6 +695,13 @@ type listDeviceCertificatesRequest struct { fleet.ListOptions } +func (r *listDeviceCertificatesRequest) ValidateRequest() error { + if r.ListOptions.OrderKey != "" && !listHostCertificatesSortCols[r.ListOptions.OrderKey] { + return badRequest("invalid order key") + } + return nil +} + func (r *listDeviceCertificatesRequest) deviceAuthToken() string { return r.Token } diff --git a/server/service/hosts.go b/server/service/hosts.go index 53384b834e..760b31ce9a 100644 --- a/server/service/hosts.go +++ b/server/service/hosts.go @@ -2725,6 +2725,18 @@ type listHostCertificatesRequest struct { fleet.ListOptions } +var listHostCertificatesSortCols = map[string]bool{ + "common_name": true, + "not_valid_after": true, +} + +func (r *listHostCertificatesRequest) ValidateRequest() error { + if r.ListOptions.OrderKey != "" && !listHostCertificatesSortCols[r.ListOptions.OrderKey] { + return badRequest("invalid order key") + } + return nil +} + type listHostCertificatesResponse struct { Certificates []*fleet.HostCertificatePayload `json:"certificates"` Meta *fleet.PaginationMetadata `json:"meta,omitempty"` @@ -2745,11 +2757,6 @@ func listHostCertificatesEndpoint(ctx context.Context, request interface{}, svc return listHostCertificatesResponse{Certificates: res, Meta: meta}, nil } -var listHostCertificatesSortCols = map[string]bool{ - "common_name": true, - "not_valid_after": true, -} - func (svc *Service) ListHostCertificates(ctx context.Context, hostID uint, opts fleet.ListOptions) ([]*fleet.HostCertificatePayload, *fleet.PaginationMetadata, error) { if !svc.authz.IsAuthenticatedWith(ctx, authzctx.AuthnDeviceToken) { host, err := svc.ds.HostLite(ctx, hostID) @@ -2767,7 +2774,7 @@ func (svc *Service) ListHostCertificates(ctx context.Context, hostID uint, opts opts.After = "" opts.IncludeMetadata = true // default sort order is common name ascending - if opts.OrderKey == "" || !listHostCertificatesSortCols[opts.OrderKey] { + if opts.OrderKey == "" { opts.OrderKey = "common_name" } diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index dcc2610ab0..9d6476ea7e 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -13068,18 +13068,13 @@ func (s *integrationTestSuite) TestHostCertificates() { return names } - // invalid sort column silently defaults to "common_name" + // fails if order_key is invalid certResp = listHostCertificatesResponse{} - s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/certificates", host.ID), nil, http.StatusOK, &certResp, "order_key", "no-such-column") - require.Len(t, certResp.Certificates, len(certNames)) - require.Equal(t, certNames, pluckCertNames(certResp.Certificates)) + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/certificates", host.ID), nil, http.StatusBadRequest, &certResp, "order_key", "no-such-column") certResp = listHostCertificatesResponse{} - res = s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/certificates", nil, http.StatusOK, "order_key", "no-such-column") - err = json.NewDecoder(res.Body).Decode(&certResp) - require.NoError(t, err) - require.Len(t, certResp.Certificates, len(certNames)) - require.Equal(t, certNames, pluckCertNames(certResp.Certificates)) + res = s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/certificates", nil, http.StatusBadRequest, "order_key", "no-such-column") + require.Contains(t, extractServerErrorText(res.Body), "invalid order key") // test the pagination options cases := []struct { diff --git a/server/service/middleware/endpoint_utils/endpoint_utils.go b/server/service/middleware/endpoint_utils/endpoint_utils.go index 9c85aa175d..82bcf98d89 100644 --- a/server/service/middleware/endpoint_utils/endpoint_utils.go +++ b/server/service/middleware/endpoint_utils/endpoint_utils.go @@ -301,6 +301,12 @@ type requestDecoder interface { DecodeRequest(ctx context.Context, r *http.Request) (interface{}, error) } +// A value that implements requestValidator is called after having the values +// decoded into it to apply further validations. +type requestValidator interface { + ValidateRequest() error +} + // MakeDecoder creates a decoder for the type for the struct passed on. If the // struct has at least 1 json tag it'll unmarshall the body. If the struct has // a `url` tag with value list_options it'll gather fleet.ListOptions from the @@ -439,6 +445,11 @@ func MakeDecoder( } } + if rv, ok := v.Interface().(requestValidator); ok { + if err := rv.ValidateRequest(); err != nil { + return nil, err + } + } return v.Interface(), nil } } From f49a45f26d96e50d472726d1ad5a4599c7220761 Mon Sep 17 00:00:00 2001 From: Gabriel Hernandez Date: Wed, 26 Feb 2025 15:32:53 +0000 Subject: [PATCH 10/14] Feat UI finish cert api integration (#26598) For #25464 UI work to integrate the host certificates UI with the finished API endpoints - [x] Manual QA must be performed in the three main OSs, macOS, Windows and Linux. --- frontend/__mocks__/certificatesMock.ts | 2 +- frontend/interfaces/certificates.ts | 2 +- .../DeviceUserPage/DeviceUserPage.tests.tsx | 63 +++- .../details/DeviceUserPage/DeviceUserPage.tsx | 59 ++-- .../HostDetailsPage/HostDetailsPage.tsx | 36 ++- .../CertificatesTable/CertificatesTable.tsx | 3 + .../CertificateDetailsModal.tsx | 286 +++++++++++------- frontend/services/entities/device_user.ts | 16 +- frontend/services/entities/hosts.ts | 22 +- frontend/test/handlers/device-handler.ts | 15 + 10 files changed, 340 insertions(+), 164 deletions(-) diff --git a/frontend/__mocks__/certificatesMock.ts b/frontend/__mocks__/certificatesMock.ts index 49e0678030..9e6fa0f722 100644 --- a/frontend/__mocks__/certificatesMock.ts +++ b/frontend/__mocks__/certificatesMock.ts @@ -10,7 +10,7 @@ const DEFAULT_HOST_CERTIFICATE_MOCK: IHostCertificate = { key_algorithm: "rsaEncryption", key_strength: 2048, key_usage: "CRL Sign, Key Cert Sign", - serial: 1, + serial: "123", signing_algorithm: "sha256WithRSAEncryption", subject: { country: "US", diff --git a/frontend/interfaces/certificates.ts b/frontend/interfaces/certificates.ts index c2ec9e7b55..55e76fbe21 100644 --- a/frontend/interfaces/certificates.ts +++ b/frontend/interfaces/certificates.ts @@ -7,7 +7,7 @@ export interface IHostCertificate { key_algorithm: string; key_strength: number; key_usage: string; - serial: number; + serial: string; signing_algorithm: string; subject: { country: string; diff --git a/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tests.tsx b/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tests.tsx index 29d42e4c15..fd8be10b38 100644 --- a/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tests.tsx +++ b/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tests.tsx @@ -5,7 +5,11 @@ import { IDeviceUserResponse, IHostDevice } from "interfaces/host"; import createMockHost from "__mocks__/hostMock"; import mockServer from "test/mock-server"; import { createCustomRenderer } from "test/test-utils"; -import { customDeviceHandler } from "test/handlers/device-handler"; +import { + customDeviceHandler, + defaultDeviceCertificatesHandler, + defaultDeviceHandler, +} from "test/handlers/device-handler"; import DeviceUserPage from "./DeviceUserPage"; const mockRouter = { @@ -34,12 +38,14 @@ const mockLocation = { describe("Device User Page", () => { it("hides the software tab if the device has no software", async () => { + mockServer.use(defaultDeviceHandler); + mockServer.use(defaultDeviceCertificatesHandler); + const render = createCustomRenderer({ withBackendMock: true, }); - // TODO: fix return type from render - const { user } = render( + render( { await screen.findByText("About"); expect(screen.queryByText(/Software/)).not.toBeInTheDocument(); + }); - // TODO: Fix this to the new copy - // expect(screen.getByText("No software detected")).toBeInTheDocument(); + it("hides the certificates card if the device has no certificates", async () => { + mockServer.use(defaultDeviceHandler); + mockServer.use(defaultDeviceCertificatesHandler); + + const render = createCustomRenderer({ + withBackendMock: true, + }); + + render( + + ); + + // waiting for the device data to render + await screen.findByText("About"); + + expect(screen.queryByText(/Certificates/)).not.toBeInTheDocument(); + }); + + it("hides the certificates card if the device is not an apple device (mac, iphone, ipad)", async () => { + const host = createMockHost() as IHostDevice; + host.mdm.enrollment_status = "On (manual)"; + host.platform = "windows"; + host.dep_assigned_to_fleet = false; + + mockServer.use(customDeviceHandler({ host })); + mockServer.use(defaultDeviceCertificatesHandler); + + const render = createCustomRenderer({ + withBackendMock: true, + }); + + render( + + ); + + // waiting for the device data to render + await screen.findByText("About"); + + expect(screen.queryByText(/Certificates/)).not.toBeInTheDocument(); }); describe("MDM enrollment", () => { const setupTest = async (overrides: Partial) => { mockServer.use(customDeviceHandler(overrides)); + mockServer.use(defaultDeviceCertificatesHandler); const render = createCustomRenderer({ withBackendMock: true, diff --git a/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx b/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx index eeb74c0728..d90ceebd29 100644 --- a/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx +++ b/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx @@ -18,6 +18,7 @@ import { IHostPolicy } from "interfaces/policy"; import { IDeviceGlobalConfig } from "interfaces/config"; import { IHostSoftware } from "interfaces/software"; import { IHostCertificate } from "interfaces/certificates"; +import { isAppleDevice } from "interfaces/platform"; import DeviceUserError from "components/DeviceUserError"; // @ts-ignore @@ -74,6 +75,9 @@ const FREE_TAB_PATHS = [ PATHS.DEVICE_USER_DETAILS_SOFTWARE, ] as const; +const DEFAULT_CERTIFICATES_PAGE_SIZE = 500; +const DEFAULT_CERTIFICATES_PAGE = 0; + interface IDeviceUserPageProps { location: { pathname: string; @@ -153,19 +157,6 @@ const DeviceUserPage = ({ } ); - const { - data: deviceCertificates, - isLoading: isLoadingDeviceCertificates, - isError: isErrorDeviceCertificates, - } = useQuery( - ["hostCertificates", deviceAuthToken], - () => deviceUserAPI.getDeviceCertificates(deviceAuthToken), - { - ...DEFAULT_USE_QUERY_OPTIONS, - enabled: !!deviceUserAPI, - } - ); - const refetchExtensions = () => { deviceMapping !== null && refetchDeviceMapping(); }; @@ -262,6 +253,25 @@ const DeviceUserPage = ({ self_service: hasSelfService = false, } = dupResponse || {}; const isPremiumTier = license?.tier === "premium"; + const isAppleHost = host && isAppleDevice(host.platform); + + const { + data: deviceCertificates, + isLoading: isLoadingDeviceCertificates, + isError: isErrorDeviceCertificates, + } = useQuery( + ["hostCertificates", deviceAuthToken], + () => + deviceUserAPI.getDeviceCertificates( + deviceAuthToken, + DEFAULT_CERTIFICATES_PAGE, + DEFAULT_CERTIFICATES_PAGE_SIZE + ), + { + ...DEFAULT_USE_QUERY_OPTIONS, + enabled: !!deviceUserAPI && isAppleHost, + } + ); const summaryData = normalizeEmptyValues(pick(host, HOST_SUMMARY_DATA)); @@ -377,13 +387,9 @@ const DeviceUserPage = ({ const isSoftwareEnabled = !!globalConfig?.features ?.enable_software_inventory; - const isDarwinHost = host?.platform === "darwin"; - const isIosOrIpadosHost = - host?.platform === "ios" || host?.platform === "ipados"; - return (
- {!host || isLoadingHost ? ( + {!host || isLoadingHost || isLoadingDeviceCertificates ? ( ) : (
@@ -447,15 +453,14 @@ const DeviceUserPage = ({ deviceMapping={deviceMapping} munki={deviceMacAdminsData?.munki} /> - {(isIosOrIpadosHost || isDarwinHost) && - deviceCertificates?.certificates.length && ( - - )} + {isAppleHost && deviceCertificates?.certificates.length && ( + + )} {isPremiumTier && isSoftwareEnabled && hasSelfService && ( diff --git a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx index 7afb70d77c..2580cb0cd9 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx +++ b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx @@ -15,7 +15,7 @@ import activitiesAPI, { IHostPastActivitiesResponse, IHostUpcomingActivitiesResponse, } from "services/entities/activities"; -import hostAPI from "services/entities/hosts"; +import hostAPI, { IGetHostCertificatesResponse } from "services/entities/hosts"; import teamAPI, { ILoadTeamsResponse } from "services/entities/teams"; import { @@ -132,6 +132,8 @@ interface IHostDetailsSubNavItem { } const DEFAULT_ACTIVITY_PAGE_SIZE = 8; +const DEFAULT_CERTIFICATES_PAGE_SIZE = 500; +const DEFAULT_CERTIFICATES_PAGE = 0; const HostDetailsPage = ({ router, @@ -461,12 +463,30 @@ const HostDetailsPage = ({ data: hostCertificates, isLoading: isLoadingHostCertificates, isError: isErrorHostCertificates, - } = useQuery( - ["hostCertificates", host_id], - () => hostAPI.getHostCertificates(hostIdFromURL), + } = useQuery< + IGetHostCertificatesResponse, + Error, + IGetHostCertificatesResponse + >( + [ + "host-certificates", + host_id, + DEFAULT_CERTIFICATES_PAGE, + DEFAULT_CERTIFICATES_PAGE_SIZE, + ], + () => + hostAPI.getHostCertificates( + hostIdFromURL, + DEFAULT_CERTIFICATES_PAGE, + DEFAULT_CERTIFICATES_PAGE_SIZE + ), { ...DEFAULT_USE_QUERY_OPTIONS, - enabled: !!hostIdFromURL, + enabled: + !!hostIdFromURL && + (host?.platform === "darwin" || + host?.platform === "ios" || + host?.platform === "ipados"), } ); @@ -756,7 +776,8 @@ const HostDetailsPage = ({ !host || isLoadingHost || pastActivitiesIsLoading || - upcomingActivitiesIsLoading + upcomingActivitiesIsLoading || + isLoadingHostCertificates ) { return ; } @@ -828,8 +849,7 @@ const HostDetailsPage = ({ }; const isDarwinHost = host.platform === "darwin"; - const isIosOrIpadosHost = - host.platform === "ios" || host.platform === "ipados"; + const isIosOrIpadosHost = isIPadOrIPhone(host.platform); const detailsPanelClass = classNames(`${baseClass}__details-panel`, { [`${baseClass}__details-panel--ios-grid`]: isIosOrIpadosHost, diff --git a/frontend/pages/hosts/details/cards/Certificates/CertificatesTable/CertificatesTable.tsx b/frontend/pages/hosts/details/cards/Certificates/CertificatesTable/CertificatesTable.tsx index e6bb565b2a..6733521067 100644 --- a/frontend/pages/hosts/details/cards/Certificates/CertificatesTable/CertificatesTable.tsx +++ b/frontend/pages/hosts/details/cards/Certificates/CertificatesTable/CertificatesTable.tsx @@ -5,6 +5,7 @@ import { IHostCertificate } from "interfaces/certificates"; import TableContainer from "components/TableContainer"; import CustomLink from "components/CustomLink"; +import TableCount from "components/TableContainer/TableCount"; import generateTableConfig from "./CertificatesTableConfig"; @@ -50,6 +51,8 @@ const CertificatesTable = ({ isLoading={false} onClickRow={onClickTableRow} renderTableHelpText={() => helpText} + renderCount={() => } + disablePagination /> ); }; diff --git a/frontend/pages/hosts/details/modals/CertificateDetailsModal/CertificateDetailsModal.tsx b/frontend/pages/hosts/details/modals/CertificateDetailsModal/CertificateDetailsModal.tsx index d976ee3f73..1bcdc3968f 100644 --- a/frontend/pages/hosts/details/modals/CertificateDetailsModal/CertificateDetailsModal.tsx +++ b/frontend/pages/hosts/details/modals/CertificateDetailsModal/CertificateDetailsModal.tsx @@ -18,121 +18,203 @@ const CertificateDetailsModal = ({ certificate, onExit, }: ICertificateDetailsModalProps) => { + // Destructure the certificate object so we can check for presence of values + const { + subject: { + country: subjectCountry, + organization: subjectOrganization, + organizational_unit: subjectOrganizationalUnit, + common_name: subjectCommonName, + }, + issuer: { + country: issuerCountry, + organization: issuerOrganization, + organizational_unit: issuerOrganizationalUnit, + common_name: issuerCommonName, + }, + not_valid_before, + not_valid_after, + key_algorithm, + key_strength, + key_usage, + serial, + certificate_authority, + signing_algorithm, + } = certificate; + + const showSubjectSection = Boolean( + subjectCountry || + subjectOrganization || + subjectOrganizationalUnit || + subjectCommonName + ); + const showIssuerNameSection = Boolean( + issuerCommonName || + issuerCountry || + issuerOrganization || + issuerOrganizationalUnit + ); + const showValidityPeriodSection = Boolean( + not_valid_before || not_valid_after + ); + const showKeyInfoSection = Boolean( + key_algorithm || key_strength || key_usage || serial + ); + const showSignatureSection = Boolean(signing_algorithm); + return ( <>
-
-

Subject Name

-
- - - - -
-
-
-

Issuer name

-
- - - - -
-
-
-

Validity period

-
- - -
-
-
-

Key info

-
- - - - -
-
- + {showSubjectSection && ( +
+

Subject Name

+
+ {subjectCountry && ( + + )} + {subjectOrganization && ( + + )} + {subjectOrganizationalUnit && ( + + )} + {subjectCommonName && ( + + )} +
+
+ )} + {showIssuerNameSection && ( +
+

Issuer name

+
+ {issuerCountry && ( + + )} + {issuerOrganization && ( + + )} + {issuerOrganizationalUnit && ( + + )} + {issuerCommonName && ( + + )} +
+
+ )} + {showValidityPeriodSection && ( +
+

Validity period

+
+ {not_valid_before && ( + + )} + {not_valid_after && ( + + )} +
+
+ )} + {showKeyInfoSection && ( +
+

Key info

+
+ {key_algorithm && ( + + )} + {key_strength && ( + + )} + {key_usage && ( + + )} + {serial && ( + + )} +
+
+ )} + {/* will always show this section */}

Basic constraints

-
-
-
-

Signature

-
-
+ {showSignatureSection && ( +
+

Signature

+
+ +
+
+ )}
diff --git a/frontend/services/entities/device_user.ts b/frontend/services/entities/device_user.ts index 57fcb66306..72b7a93ac0 100644 --- a/frontend/services/entities/device_user.ts +++ b/frontend/services/entities/device_user.ts @@ -87,14 +87,18 @@ export default { }, getDeviceCertificates: ( - deviceToken: string + deviceToken: string, + page = 0, + perPage = 10 ): Promise => { const { DEVICE_CERTIFICATES } = endpoints; - const path = DEVICE_CERTIFICATES(deviceToken); + const path = `${DEVICE_CERTIFICATES( + deviceToken + )}?${buildQueryStringFromParams({ + page, + per_page: perPage, + })}`; - // return sendRequest("GET", path); - return new Promise((resolve) => { - resolve(createMockGetHostCertificatesResponse()); - }); + return sendRequest("GET", path); }, }; diff --git a/frontend/services/entities/hosts.ts b/frontend/services/entities/hosts.ts index 56b484ee8f..6d21f7500e 100644 --- a/frontend/services/entities/hosts.ts +++ b/frontend/services/entities/hosts.ts @@ -602,22 +602,16 @@ export default { }, getHostCertificates: ( - hostId: number + hostId: number, + page = 0, + perPage = 10 ): Promise => { const { HOST_CERTIFICATES } = endpoints; + const path = `${HOST_CERTIFICATES(hostId)}?${buildQueryStringFromParams({ + page, + per_page: perPage, + })}`; - // return sendRequest("GET", HOST_CERTIFICATES(hostId)); - return new Promise((resolve) => { - resolve( - createMockGetHostCertificatesResponse({ - certificates: [ - createMockHostCertificate({ - common_name: "Test 2", - not_valid_after: "2025-05-01T00:00:00.000Z", - }), - ], - }) - ); - }); + return sendRequest("GET", path); }, }; diff --git a/frontend/test/handlers/device-handler.ts b/frontend/test/handlers/device-handler.ts index ea8fb0532c..d089a4a50b 100644 --- a/frontend/test/handlers/device-handler.ts +++ b/frontend/test/handlers/device-handler.ts @@ -6,9 +6,11 @@ import createMockDeviceUser, { import createMockHost from "__mocks__/hostMock"; import createMockLicense from "__mocks__/licenseMock"; import createMockMacAdmins from "__mocks__/macAdminsMock"; +import { createMockHostCertificate } from "__mocks__/certificatesMock"; import { baseUrl } from "test/test-utils"; import { IDeviceUserResponse } from "interfaces/host"; import { IGetDeviceSoftwareResponse } from "services/entities/device_user"; +import { IGetHostCertificatesResponse } from "services/entities/hosts"; export const defaultDeviceHandler = http.get(baseUrl("/device/:token"), () => { return HttpResponse.json({ @@ -63,3 +65,16 @@ export const customDeviceSoftwareHandler = ( http.get(baseUrl("/device/:token/software"), () => { return HttpResponse.json(createMockDeviceSoftwareResponse(overrides)); }); + +export const defaultDeviceCertificatesHandler = http.get( + baseUrl("/device/:token/certificates"), + () => { + return HttpResponse.json({ + certificates: [createMockHostCertificate()], + meta: { + has_next_results: false, + has_previous_results: false, + }, + }); + } +); From b2bf148b653a2e3738854c853d73675db784579e Mon Sep 17 00:00:00 2001 From: gillespi314 <73313222+gillespi314@users.noreply.github.com> Date: Wed, 26 Feb 2025 12:49:43 -0600 Subject: [PATCH 11/14] Update migration timestamp --- ...sTable.go => 20250226000000_AddHostCertificatesTable.go} | 6 +++--- server/datastore/mysql/schema.sql | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) rename server/datastore/mysql/migrations/tables/{20250211141712_AddHostCertificatesTable.go => 20250226000000_AddHostCertificatesTable.go} (90%) diff --git a/server/datastore/mysql/migrations/tables/20250211141712_AddHostCertificatesTable.go b/server/datastore/mysql/migrations/tables/20250226000000_AddHostCertificatesTable.go similarity index 90% rename from server/datastore/mysql/migrations/tables/20250211141712_AddHostCertificatesTable.go rename to server/datastore/mysql/migrations/tables/20250226000000_AddHostCertificatesTable.go index a374ebdb6c..c8c151b472 100644 --- a/server/datastore/mysql/migrations/tables/20250211141712_AddHostCertificatesTable.go +++ b/server/datastore/mysql/migrations/tables/20250226000000_AddHostCertificatesTable.go @@ -5,10 +5,10 @@ import ( ) func init() { - MigrationClient.AddMigration(Up_20250211141712, Down_20250211141712) + MigrationClient.AddMigration(Up_20250226000000, Down_20250226000000) } -func Up_20250211141712(tx *sql.Tx) error { +func Up_20250226000000(tx *sql.Tx) error { _, err := tx.Exec(` CREATE TABLE host_certificates ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, @@ -42,6 +42,6 @@ CREATE TABLE host_certificates ( return err } -func Down_20250211141712(tx *sql.Tx) error { +func Down_20250226000000(tx *sql.Tx) error { return nil } diff --git a/server/datastore/mysql/schema.sql b/server/datastore/mysql/schema.sql index 6190e3ac1a..8c4eb4629a 100644 --- a/server/datastore/mysql/schema.sql +++ b/server/datastore/mysql/schema.sql @@ -1189,9 +1189,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=360 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB AUTO_INCREMENT=361 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'); +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'); /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `mobile_device_management_solutions` ( From e02ad241ea9802275d5d12feeb5540300247c220 Mon Sep 17 00:00:00 2001 From: Sarah Gillespie <73313222+gillespi314@users.noreply.github.com> Date: Wed, 26 Feb 2025 15:59:20 -0600 Subject: [PATCH 12/14] Allow empty values when parsing distinguished name (#26627) --- server/fleet/host_certificates.go | 4 ---- server/fleet/host_certificates_test.go | 7 ++++++- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/server/fleet/host_certificates.go b/server/fleet/host_certificates.go index 0d7a27975d..cfb5448ae8 100644 --- a/server/fleet/host_certificates.go +++ b/server/fleet/host_certificates.go @@ -203,10 +203,6 @@ func ExtractDetailsFromOsqueryDistinguishedName(str string) (*HostCertificateNam return nil, errors.New("invalid distinguished name, wrong key value pair format") } - if len(kv[1]) == 0 { - return nil, errors.New("invalid distinguished name, missing value") - } - switch strings.ToUpper(kv[0]) { case "C": details.Country = strings.Trim(kv[1], " ") diff --git a/server/fleet/host_certificates_test.go b/server/fleet/host_certificates_test.go index e6c6219e14..e57bdfe4d8 100644 --- a/server/fleet/host_certificates_test.go +++ b/server/fleet/host_certificates_test.go @@ -73,7 +73,12 @@ func TestExtractHostCertificateNameDetails(t *testing.T) { { name: "missing value", input: "/C=US/O=Fleet Device Management Inc./OU=Fleet Device Management Inc./CN=", - err: true, + expected: &HostCertificateNameDetails{ + Country: "US", + Organization: "Fleet Device Management Inc.", + OrganizationalUnit: "Fleet Device Management Inc.", + CommonName: "", + }, }, { name: "missing first slash", From ecaea6104de643c8e84f3d93c72c8271eb67e68f Mon Sep 17 00:00:00 2001 From: Sarah Gillespie <73313222+gillespi314@users.noreply.github.com> Date: Thu, 27 Feb 2025 05:33:49 -0600 Subject: [PATCH 13/14] Add host certificates refetch to UI (#26630) Fix unreleased UI bugs --- frontend/interfaces/platform.ts | 2 +- .../details/DeviceUserPage/DeviceUserPage.tsx | 45 +++++++------ .../HostDetailsPage/HostDetailsPage.tsx | 67 ++++++++++--------- 3 files changed, 60 insertions(+), 54 deletions(-) diff --git a/frontend/interfaces/platform.ts b/frontend/interfaces/platform.ts index 753cfc41b8..511b8b4f47 100644 --- a/frontend/interfaces/platform.ts +++ b/frontend/interfaces/platform.ts @@ -128,7 +128,7 @@ export const isLinuxLike = (platform: string) => { ); }; -export const isAppleDevice = (platform: string) => { +export const isAppleDevice = (platform = "") => { return HOST_APPLE_PLATFORMS.includes( platform as typeof HOST_APPLE_PLATFORMS[number] ); diff --git a/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx b/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx index d90ceebd29..ac66fd66e9 100644 --- a/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx +++ b/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx @@ -157,8 +157,31 @@ const DeviceUserPage = ({ } ); + const { + data: deviceCertificates, + isLoading: isLoadingDeviceCertificates, + isError: isErrorDeviceCertificates, + refetch: refetchDeviceCertificates, + } = useQuery( + ["hostCertificates", deviceAuthToken], + () => + deviceUserAPI.getDeviceCertificates( + deviceAuthToken, + DEFAULT_CERTIFICATES_PAGE, + DEFAULT_CERTIFICATES_PAGE_SIZE + ), + { + ...DEFAULT_USE_QUERY_OPTIONS, + // FIXME: is it worth disabling for unsupported platforms? we'd have to workaround the a + // catch-22 where we need to know the platform to know if it's supported but we also need to + // be able to include the cert refetch in the hosts query hook. + enabled: !!deviceUserAPI, + } + ); + const refetchExtensions = () => { deviceMapping !== null && refetchDeviceMapping(); + deviceCertificates && refetchDeviceCertificates(); }; const isRefetching = ({ @@ -253,25 +276,7 @@ const DeviceUserPage = ({ self_service: hasSelfService = false, } = dupResponse || {}; const isPremiumTier = license?.tier === "premium"; - const isAppleHost = host && isAppleDevice(host.platform); - - const { - data: deviceCertificates, - isLoading: isLoadingDeviceCertificates, - isError: isErrorDeviceCertificates, - } = useQuery( - ["hostCertificates", deviceAuthToken], - () => - deviceUserAPI.getDeviceCertificates( - deviceAuthToken, - DEFAULT_CERTIFICATES_PAGE, - DEFAULT_CERTIFICATES_PAGE_SIZE - ), - { - ...DEFAULT_USE_QUERY_OPTIONS, - enabled: !!deviceUserAPI && isAppleHost, - } - ); + const isAppleHost = isAppleDevice(host?.platform); const summaryData = normalizeEmptyValues(pick(host, HOST_SUMMARY_DATA)); @@ -453,7 +458,7 @@ const DeviceUserPage = ({ deviceMapping={deviceMapping} munki={deviceMacAdminsData?.munki} /> - {isAppleHost && deviceCertificates?.certificates.length && ( + {isAppleHost && !!deviceCertificates?.certificates.length && ( ( + [ + "host-certificates", + host_id, + DEFAULT_CERTIFICATES_PAGE, + DEFAULT_CERTIFICATES_PAGE_SIZE, + ], + () => + hostAPI.getHostCertificates( + hostIdFromURL, + DEFAULT_CERTIFICATES_PAGE, + DEFAULT_CERTIFICATES_PAGE_SIZE + ), + { + ...DEFAULT_USE_QUERY_OPTIONS, + // FIXME: is it worth disabling for unsupported platforms? we'd have to workaround the a + // catch-22 where we need to know the platform to know if it's supported but we also need to + // be able to include the cert refetch in the hosts query hook. + enabled: !!hostIdFromURL, + } + ); + const refetchExtensions = () => { deviceMapping !== null && refetchDeviceMapping(); macadmins !== null && refetchMacadmins(); mdm?.enrollment_status !== null && refetchMdm(); + hostCertificates && refetchHostCertificates(); }; const { @@ -462,37 +494,6 @@ const HostDetailsPage = ({ } ); - const { - data: hostCertificates, - isLoading: isLoadingHostCertificates, - isError: isErrorHostCertificates, - } = useQuery< - IGetHostCertificatesResponse, - Error, - IGetHostCertificatesResponse - >( - [ - "host-certificates", - host_id, - DEFAULT_CERTIFICATES_PAGE, - DEFAULT_CERTIFICATES_PAGE_SIZE, - ], - () => - hostAPI.getHostCertificates( - hostIdFromURL, - DEFAULT_CERTIFICATES_PAGE, - DEFAULT_CERTIFICATES_PAGE_SIZE - ), - { - ...DEFAULT_USE_QUERY_OPTIONS, - enabled: - !!hostIdFromURL && - (host?.platform === "darwin" || - host?.platform === "ios" || - host?.platform === "ipados"), - } - ); - const featuresConfig = host?.team_id ? teams?.find((t) => t.id === host.team_id)?.features : config?.features; @@ -957,7 +958,7 @@ const HostDetailsPage = ({ /> )} {(isIosOrIpadosHost || isDarwinHost) && - hostCertificates?.certificates.length && ( + !!hostCertificates?.certificates.length && ( - {host?.platform === "darwin" && macadmins?.munki?.version && ( + {isDarwinHost && macadmins?.munki?.version && ( Date: Thu, 27 Feb 2025 16:57:41 +0000 Subject: [PATCH 14/14] fix single row click issue and pagination (#26644) For #25464 quick fix for clicking rows and adding errors and pagination to UI --- .../details/DeviceUserPage/DeviceUserPage.tsx | 42 ++++++++++++---- .../HostDetailsPage/HostDetailsPage.tsx | 43 ++++++++++------- .../cards/Certificates/Certificates.tsx | 33 +++++++++++-- .../CertificatesTable/CertificatesTable.tsx | 48 +++++++++++++++---- 4 files changed, 126 insertions(+), 40 deletions(-) diff --git a/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx b/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx index ac66fd66e9..0730513d33 100644 --- a/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx +++ b/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx @@ -6,7 +6,9 @@ import { Tab, Tabs, TabList, TabPanel } from "react-tabs"; import { pick, findIndex } from "lodash"; import { NotificationContext } from "context/notification"; -import deviceUserAPI from "services/entities/device_user"; +import deviceUserAPI, { + IGetDeviceCertificatesResponse, +} from "services/entities/device_user"; import diskEncryptionAPI from "services/entities/disk_encryption"; import { IDeviceMappingResponse, @@ -75,7 +77,7 @@ const FREE_TAB_PATHS = [ PATHS.DEVICE_USER_DETAILS_SOFTWARE, ] as const; -const DEFAULT_CERTIFICATES_PAGE_SIZE = 500; +const DEFAULT_CERTIFICATES_PAGE_SIZE = 10; const DEFAULT_CERTIFICATES_PAGE = 0; interface IDeviceUserPageProps { @@ -125,10 +127,15 @@ const DeviceUserPage = ({ selectedSoftwareDetails, setSelectedSoftwareDetails, ] = useState(null); + + // certificates states const [ selectedCertificate, setSelectedCertificate, ] = useState(null); + const [certificatePage, setCertificatePage] = useState( + DEFAULT_CERTIFICATES_PAGE + ); const { data: deviceMapping, refetch: refetchDeviceMapping } = useQuery( ["deviceMapping", deviceAuthToken], @@ -162,14 +169,22 @@ const DeviceUserPage = ({ isLoading: isLoadingDeviceCertificates, isError: isErrorDeviceCertificates, refetch: refetchDeviceCertificates, - } = useQuery( - ["hostCertificates", deviceAuthToken], - () => - deviceUserAPI.getDeviceCertificates( - deviceAuthToken, - DEFAULT_CERTIFICATES_PAGE, - DEFAULT_CERTIFICATES_PAGE_SIZE - ), + } = useQuery< + IGetDeviceCertificatesResponse, + Error, + IGetDeviceCertificatesResponse, + Array<{ scope: string; token: string; page: number; perPage: number }> + >( + [ + { + scope: "device-certificates", + token: deviceAuthToken, + page: certificatePage, + perPage: DEFAULT_CERTIFICATES_PAGE_SIZE, + }, + ], + ({ queryKey: [{ token, page, perPage }] }) => + deviceUserAPI.getDeviceCertificates(token, page, perPage), { ...DEFAULT_USE_QUERY_OPTIONS, // FIXME: is it worth disabling for unsupported platforms? we'd have to workaround the a @@ -462,8 +477,15 @@ const DeviceUserPage = ({ setCertificatePage(certificatePage + 1)} + onPreviousPage={() => + setCertificatePage(certificatePage - 1) + } /> )} diff --git a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx index cbed2f3ebd..92fc9f327b 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx +++ b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx @@ -132,7 +132,7 @@ interface IHostDetailsSubNavItem { } const DEFAULT_ACTIVITY_PAGE_SIZE = 8; -const DEFAULT_CERTIFICATES_PAGE_SIZE = 500; +const DEFAULT_CERTIFICATES_PAGE_SIZE = 10; const DEFAULT_CERTIFICATES_PAGE = 0; const HostDetailsPage = ({ @@ -208,10 +208,6 @@ const HostDetailsPage = ({ selectedCancelActivity, setSelectedCancelActivity, ] = useState(null); - const [ - selectedCertificate, - setSelectedCertificate, - ] = useState(null); // activity states const [activeActivityTab, setActiveActivityTab] = useState< @@ -219,6 +215,15 @@ const HostDetailsPage = ({ >("past"); const [activityPage, setActivityPage] = useState(0); + // certificates states + const [ + selectedCertificate, + setSelectedCertificate, + ] = useState(null); + const [certificatePage, setCertificatePage] = useState( + DEFAULT_CERTIFICATES_PAGE + ); + const { data: teams } = useQuery( "teams", () => teamAPI.loadAll(), @@ -282,20 +287,19 @@ const HostDetailsPage = ({ } = useQuery< IGetHostCertificatesResponse, Error, - IGetHostCertificatesResponse + IGetHostCertificatesResponse, + Array<{ scope: string; hostId: number; page: number; perPage: number }> >( [ - "host-certificates", - host_id, - DEFAULT_CERTIFICATES_PAGE, - DEFAULT_CERTIFICATES_PAGE_SIZE, + { + scope: "host-certificates", + hostId: hostIdFromURL, + page: certificatePage, + perPage: DEFAULT_CERTIFICATES_PAGE_SIZE, + }, ], - () => - hostAPI.getHostCertificates( - hostIdFromURL, - DEFAULT_CERTIFICATES_PAGE, - DEFAULT_CERTIFICATES_PAGE_SIZE - ), + ({ queryKey: [{ hostId, page, perPage }] }) => + hostAPI.getHostCertificates(hostId, page, perPage), { ...DEFAULT_USE_QUERY_OPTIONS, // FIXME: is it worth disabling for unsupported platforms? we'd have to workaround the a @@ -963,6 +967,13 @@ const HostDetailsPage = ({ data={hostCertificates} hostPlatform={host.platform} onSelectCertificate={onSelectCertificate} + isError={isErrorHostCertificates} + page={certificatePage} + pageSize={DEFAULT_CERTIFICATES_PAGE_SIZE} + onNextPage={() => setCertificatePage(certificatePage + 1)} + onPreviousPage={() => + setCertificatePage(certificatePage - 1) + } /> )} diff --git a/frontend/pages/hosts/details/cards/Certificates/Certificates.tsx b/frontend/pages/hosts/details/cards/Certificates/Certificates.tsx index d003dfaea1..56433cc922 100644 --- a/frontend/pages/hosts/details/cards/Certificates/Certificates.tsx +++ b/frontend/pages/hosts/details/cards/Certificates/Certificates.tsx @@ -5,6 +5,7 @@ import { HostPlatform } from "interfaces/platform"; import { IGetHostCertificatesResponse } from "services/entities/hosts"; import Card from "components/Card"; +import DataError from "components/DataError"; import CertificatesTable from "./CertificatesTable"; @@ -13,16 +14,42 @@ const baseClass = "certificates-card"; interface ICertificatesProps { data: IGetHostCertificatesResponse; hostPlatform: HostPlatform; + page: number; + pageSize: number; + isError: boolean; isMyDevicePage?: boolean; onSelectCertificate: (certificate: IHostCertificate) => void; + onNextPage: () => void; + onPreviousPage: () => void; } const CertificatesCard = ({ data, hostPlatform, + isError, + page, + pageSize, isMyDevicePage = false, onSelectCertificate, + onNextPage, + onPreviousPage, }: ICertificatesProps) => { + const renderContent = () => { + if (isError) return ; + + return ( + + ); + }; + return (

Certificates

- + {renderContent()}
); }; diff --git a/frontend/pages/hosts/details/cards/Certificates/CertificatesTable/CertificatesTable.tsx b/frontend/pages/hosts/details/cards/Certificates/CertificatesTable/CertificatesTable.tsx index 6733521067..9ae3fc00fa 100644 --- a/frontend/pages/hosts/details/cards/Certificates/CertificatesTable/CertificatesTable.tsx +++ b/frontend/pages/hosts/details/cards/Certificates/CertificatesTable/CertificatesTable.tsx @@ -1,33 +1,57 @@ -import React from "react"; -import { Row } from "react-table"; +import React, { useCallback } from "react"; import { IHostCertificate } from "interfaces/certificates"; +import { IGetHostCertificatesResponse } from "services/entities/hosts"; import TableContainer from "components/TableContainer"; import CustomLink from "components/CustomLink"; import TableCount from "components/TableContainer/TableCount"; +import { ITableQueryData } from "components/TableContainer/TableContainer"; import generateTableConfig from "./CertificatesTableConfig"; const baseClass = "certificates-table"; interface ICertificatesTableProps { - data: IHostCertificate[]; + data: IGetHostCertificatesResponse; showHelpText: boolean; + page: number; + pageSize: number; onSelectCertificate: (certificate: IHostCertificate) => void; + onNextPage: () => void; + onPreviousPage: () => void; } const CertificatesTable = ({ data, showHelpText, + page, + pageSize, onSelectCertificate, + onNextPage, + onPreviousPage, }: ICertificatesTableProps) => { const tableConfig = generateTableConfig(); - const onClickTableRow = (row: Row) => { + const onClickTableRow = (row: any) => { onSelectCertificate(row.original); }; + const onQueryChange = useCallback( + async (newTableQuery: ITableQueryData) => { + console.log(newTableQuery); + + if (page === newTableQuery.pageIndex) return; + + if (newTableQuery.pageIndex > page) { + onNextPage(); + } else { + onPreviousPage(); + } + }, + [onNextPage, onPreviousPage, page] + ); + const helpText = showHelpText ? (

Showing certificates in the system keychain. To get all certificates, you @@ -41,18 +65,24 @@ const CertificatesTable = ({ ) : null; return ( - > + null} isAllPagesSelected={false} showMarkAllPages={false} isLoading={false} - onClickRow={onClickTableRow} + disableMultiRowSelect + onSelectSingleRow={onClickTableRow} renderTableHelpText={() => helpText} - renderCount={() => } - disablePagination + renderCount={() => ( + + )} + pageSize={pageSize} + defaultPageIndex={page} + onQueryChange={onQueryChange} + disableNextPage={data?.meta.has_next_results === false} /> ); };