From 53d984079957fea6dad3ee7e1821cf13f0305981 Mon Sep 17 00:00:00 2001 From: Martin Angers Date: Tue, 10 Dec 2024 11:40:46 -0500 Subject: [PATCH 01/23] SSVL: database schema changes (#24591) --- ...pAppTeamAndSoftwareInstallerLabelsTable.go | 79 +++++++++++++++++++ server/datastore/mysql/schema.sql | 36 ++++++++- 2 files changed, 113 insertions(+), 2 deletions(-) create mode 100644 server/datastore/mysql/migrations/tables/20241210094935_AddVppAppTeamAndSoftwareInstallerLabelsTable.go diff --git a/server/datastore/mysql/migrations/tables/20241210094935_AddVppAppTeamAndSoftwareInstallerLabelsTable.go b/server/datastore/mysql/migrations/tables/20241210094935_AddVppAppTeamAndSoftwareInstallerLabelsTable.go new file mode 100644 index 0000000000..dfa53efa2b --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20241210094935_AddVppAppTeamAndSoftwareInstallerLabelsTable.go @@ -0,0 +1,79 @@ +package tables + +import ( + "database/sql" + + "github.com/pkg/errors" +) + +func init() { + MigrationClient.AddMigration(Up_20241210094935, Down_20241210094935) +} + +func Up_20241210094935(tx *sql.Tx) error { + createVppAppStmt := ` +CREATE TABLE IF NOT EXISTS vpp_app_team_labels ( + id INT(10) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + vpp_app_team_id INT(10) UNSIGNED NOT NULL, + + -- unlike for configuration profiles, the referenced label for software + -- cannot be deleted, so we make it NOT NULL and no need to capture the name. + label_id INT(10) UNSIGNED NOT NULL, + + -- if exclude is true, "exclude_any" condition, otherwise "include_any" + -- (we don't support include/exclude all for now, so not adding a + -- "require_all" column). + exclude TINYINT(1) NOT NULL DEFAULT 0, + + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + UNIQUE KEY idx_vpp_app_team_labels_vpp_app_team_id_label_id (vpp_app_team_id, label_id), + + FOREIGN KEY (vpp_app_team_id) REFERENCES vpp_apps_teams(id) ON DELETE CASCADE, + + -- because we want to prevent deleting a label if it is referenced by a vpp app, + -- we explicitly enforce this at the database level with the RESTRICT clause. + FOREIGN KEY (label_id) REFERENCES labels(id) ON DELETE RESTRICT +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +` + if _, err := tx.Exec(createVppAppStmt); err != nil { + return errors.Wrap(err, "create vpp_app_team_labels table") + } + + createSoftwareInstallerStmt := ` +CREATE TABLE IF NOT EXISTS software_installer_labels ( + id INT(10) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + software_installer_id INT(10) UNSIGNED NOT NULL, + + -- unlike for configuration profiles, the referenced label for software + -- cannot be deleted, so we make it NOT NULL and no need to capture the name. + label_id INT(10) UNSIGNED NOT NULL, + + -- if exclude is true, "exclude_any" condition, otherwise "include_any" + -- (we don't support include/exclude all for now, so not adding a + -- "require_all" column). + exclude TINYINT(1) NOT NULL DEFAULT 0, + + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + UNIQUE KEY idx_software_installer_labels_software_installer_id_label_id (software_installer_id, label_id), + + FOREIGN KEY (software_installer_id) REFERENCES software_installers(id) ON DELETE CASCADE, + + -- because we want to prevent deleting a label if it is referenced by an installer, + -- we explicitly enforce this at the database level with the RESTRICT clause. + FOREIGN KEY (label_id) REFERENCES labels(id) ON DELETE RESTRICT +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +` + if _, err := tx.Exec(createSoftwareInstallerStmt); err != nil { + return errors.Wrap(err, "create software_installer_labels table") + } + + return nil +} + +func Down_20241210094935(tx *sql.Tx) error { + return nil +} diff --git a/server/datastore/mysql/schema.sql b/server/datastore/mysql/schema.sql index 9a781eea1b..39da8f4bbc 100644 --- a/server/datastore/mysql/schema.sql +++ b/server/datastore/mysql/schema.sql @@ -1104,9 +1104,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=336 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB AUTO_INCREMENT=337 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'); +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,20241210094935,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` ( @@ -1778,6 +1778,22 @@ CREATE TABLE `software_host_counts` ( /*!40101 SET character_set_client = @saved_cs_client */; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `software_installer_labels` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `software_installer_id` int unsigned NOT NULL, + `label_id` int unsigned NOT NULL, + `exclude` tinyint(1) NOT NULL DEFAULT '0', + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `idx_software_installer_labels_software_installer_id_label_id` (`software_installer_id`,`label_id`), + KEY `label_id` (`label_id`), + CONSTRAINT `software_installer_labels_ibfk_1` FOREIGN KEY (`software_installer_id`) REFERENCES `software_installers` (`id`) ON DELETE CASCADE, + CONSTRAINT `software_installer_labels_ibfk_2` FOREIGN KEY (`label_id`) REFERENCES `labels` (`id`) ON DELETE RESTRICT +) 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 `software_installers` ( `id` int unsigned NOT NULL AUTO_INCREMENT, `team_id` int unsigned DEFAULT NULL, @@ -1922,6 +1938,22 @@ CREATE TABLE `verification_tokens` ( /*!40101 SET character_set_client = @saved_cs_client */; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `vpp_app_team_labels` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `vpp_app_team_id` int unsigned NOT NULL, + `label_id` int unsigned NOT NULL, + `exclude` tinyint(1) NOT NULL DEFAULT '0', + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `idx_vpp_app_team_labels_vpp_app_team_id_label_id` (`vpp_app_team_id`,`label_id`), + KEY `label_id` (`label_id`), + CONSTRAINT `vpp_app_team_labels_ibfk_1` FOREIGN KEY (`vpp_app_team_id`) REFERENCES `vpp_apps_teams` (`id`) ON DELETE CASCADE, + CONSTRAINT `vpp_app_team_labels_ibfk_2` FOREIGN KEY (`label_id`) REFERENCES `labels` (`id`) ON DELETE RESTRICT +) 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 `vpp_apps` ( `adam_id` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL, `title_id` int unsigned DEFAULT NULL, From 2dfd8ed73e18f550c3a2d36fade952a6cce03656 Mon Sep 17 00:00:00 2001 From: Martin Angers Date: Tue, 10 Dec 2024 16:20:40 -0500 Subject: [PATCH 02/23] SSVL: db migration add microsecond precision to timestamps (#24620) --- ...5_AddVppAppTeamAndSoftwareInstallerLabelsTable.go | 12 ++++++------ server/datastore/mysql/schema.sql | 8 ++++---- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/server/datastore/mysql/migrations/tables/20241210094935_AddVppAppTeamAndSoftwareInstallerLabelsTable.go b/server/datastore/mysql/migrations/tables/20241210094935_AddVppAppTeamAndSoftwareInstallerLabelsTable.go index dfa53efa2b..69e901c52f 100644 --- a/server/datastore/mysql/migrations/tables/20241210094935_AddVppAppTeamAndSoftwareInstallerLabelsTable.go +++ b/server/datastore/mysql/migrations/tables/20241210094935_AddVppAppTeamAndSoftwareInstallerLabelsTable.go @@ -21,12 +21,12 @@ CREATE TABLE IF NOT EXISTS vpp_app_team_labels ( label_id INT(10) UNSIGNED NOT NULL, -- if exclude is true, "exclude_any" condition, otherwise "include_any" - -- (we don't support include/exclude all for now, so not adding a + -- (we don't support include/exclude all for now, so not adding a -- "require_all" column). exclude TINYINT(1) NOT NULL DEFAULT 0, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + updated_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), UNIQUE KEY idx_vpp_app_team_labels_vpp_app_team_id_label_id (vpp_app_team_id, label_id), @@ -51,12 +51,12 @@ CREATE TABLE IF NOT EXISTS software_installer_labels ( label_id INT(10) UNSIGNED NOT NULL, -- if exclude is true, "exclude_any" condition, otherwise "include_any" - -- (we don't support include/exclude all for now, so not adding a + -- (we don't support include/exclude all for now, so not adding a -- "require_all" column). exclude TINYINT(1) NOT NULL DEFAULT 0, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + updated_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), UNIQUE KEY idx_software_installer_labels_software_installer_id_label_id (software_installer_id, label_id), diff --git a/server/datastore/mysql/schema.sql b/server/datastore/mysql/schema.sql index 39da8f4bbc..d8bed3850b 100644 --- a/server/datastore/mysql/schema.sql +++ b/server/datastore/mysql/schema.sql @@ -1783,8 +1783,8 @@ CREATE TABLE `software_installer_labels` ( `software_installer_id` int unsigned NOT NULL, `label_id` int unsigned NOT NULL, `exclude` tinyint(1) NOT NULL DEFAULT '0', - `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, - `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `created_at` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + `updated_at` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), PRIMARY KEY (`id`), UNIQUE KEY `idx_software_installer_labels_software_installer_id_label_id` (`software_installer_id`,`label_id`), KEY `label_id` (`label_id`), @@ -1943,8 +1943,8 @@ CREATE TABLE `vpp_app_team_labels` ( `vpp_app_team_id` int unsigned NOT NULL, `label_id` int unsigned NOT NULL, `exclude` tinyint(1) NOT NULL DEFAULT '0', - `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, - `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `created_at` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + `updated_at` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), PRIMARY KEY (`id`), UNIQUE KEY `idx_vpp_app_team_labels_vpp_app_team_id_label_id` (`vpp_app_team_id`,`label_id`), KEY `label_id` (`label_id`), From 129eabe21c3a713d546cb5787a7ee3cb77ab253c Mon Sep 17 00:00:00 2001 From: Sarah Gillespie <73313222+gillespi314@users.noreply.github.com> Date: Tue, 10 Dec 2024 16:39:56 -0600 Subject: [PATCH 03/23] Update software installer types (#24626) --- server/fleet/software_installer.go | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/server/fleet/software_installer.go b/server/fleet/software_installer.go index c1e376cc8d..73fee6431d 100644 --- a/server/fleet/software_installer.go +++ b/server/fleet/software_installer.go @@ -127,6 +127,10 @@ type SoftwareInstaller struct { // AutomaticInstallPolicies is the list of policies that trigger automatic // installation of this software. AutomaticInstallPolicies []AutomaticInstallPolicy `json:"automatic_install_policies" db:"-"` + // LablesIncludeAny is the list of "include any" labels for this software installer (if not nil). + LabelsIncludeAny []SoftwareScopeLabel `json:"labels_include_any" db:"labels_include_any"` + // LabelsExcludeAny is the list of "exclude any" labels for this software installer (if not nil). + LabelsExcludeAny []SoftwareScopeLabel `json:"labels_exclude_any" db:"labels_exclude_any"` } // SoftwarePackageResponse is the response type used when applying software by batch. @@ -331,7 +335,9 @@ type UploadSoftwareInstallerPayload struct { PackageIDs []string UninstallScript string Extension string - InstallDuringSetup *bool // keep saved value if nil, otherwise set as indicated + InstallDuringSetup *bool // keep saved value if nil, otherwise set as indicated + LabelsIncludeAny []string // names of "include any" labels + LabelsExcludeAny []string // names of "exclude any" labels } type UpdateSoftwareInstallerPayload struct { @@ -356,6 +362,8 @@ type UpdateSoftwareInstallerPayload struct { Filename string Version string PackageIDs []string + LabelsIncludeAny []string // names of "include any" labels + LabelsExcludeAny []string // names of "exclude any" labels } // DownloadSoftwareInstallerPayload is the payload for downloading a software installer. @@ -616,3 +624,18 @@ func NewTempFileReader(from io.Reader, tempDirFn func() string) (*TempFileReader } return tfr, nil } + +// SoftwareScopeLabel represents the many-to-many relationship between +// software titles and labels. +// +// NOTE: json representation of the fields is a bit awkward to match the +// required API response, as this struct is returned within software title details. +// +// NOTE: depending on how/where this struct is used, fields MAY BE +// UNRELIABLE insofar as they represent default, empty values. +type SoftwareScopeLabel struct { + LabelName string `db:"label_name" json:"name"` + LabelID uint `db:"label_id" json:"id"` // label id in database, which may be the empty value in some cases where id is not known in advance (e.g., if labels are created during gitops processing) + Exclude bool `db:"exclude" json:"-"` // not rendered in JSON, used when processing LabelsIncludeAny and LabelsExcludeAny on parent title (may be the empty value in some cases) + TitleID uint `db:"title_id" json:"-"` // not rendered in JSON, used to store the associated title ID (may be the empty value in some cases) +} From 7b5e9b376012b49ac56e8acc45eaee30d864b2a2 Mon Sep 17 00:00:00 2001 From: gillespi314 <73313222+gillespi314@users.noreply.github.com> Date: Wed, 11 Dec 2024 12:11:21 -0600 Subject: [PATCH 04/23] Update schema --- server/datastore/mysql/schema.sql | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/datastore/mysql/schema.sql b/server/datastore/mysql/schema.sql index a9e9ca0008..ca77e9805f 100644 --- a/server/datastore/mysql/schema.sql +++ b/server/datastore/mysql/schema.sql @@ -1104,9 +1104,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=337 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB AUTO_INCREMENT=338 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'); +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,20241210094935,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 23a61469660d6979a2a55e2ff1b4646ce92f3121 Mon Sep 17 00:00:00 2001 From: Martin Angers Date: Wed, 11 Dec 2024 14:21:10 -0500 Subject: [PATCH 05/23] SSVL: prevent deletion of a label if used to scope software installers (#24644) --- ...t-label-deletion-if-referenced-by-software | 1 + server/datastore/mysql/labels.go | 3 ++ server/datastore/mysql/labels_test.go | 50 +++++++++++++++-- server/service/integration_enterprise_test.go | 54 +++++++++++++++++++ server/service/testing_client.go | 36 ++++++------- 5 files changed, 121 insertions(+), 23 deletions(-) create mode 100644 changes/24536-prevent-label-deletion-if-referenced-by-software diff --git a/changes/24536-prevent-label-deletion-if-referenced-by-software b/changes/24536-prevent-label-deletion-if-referenced-by-software new file mode 100644 index 0000000000..ef3e4753f3 --- /dev/null +++ b/changes/24536-prevent-label-deletion-if-referenced-by-software @@ -0,0 +1 @@ +* Added a validation to prevent label deletion if it is used to scope the hosts targeted by a software installer. diff --git a/server/datastore/mysql/labels.go b/server/datastore/mysql/labels.go index 4111c81b6e..cb98aa67f4 100644 --- a/server/datastore/mysql/labels.go +++ b/server/datastore/mysql/labels.go @@ -252,6 +252,9 @@ func (ds *Datastore) DeleteLabel(ctx context.Context, name string) error { _, err = tx.ExecContext(ctx, `DELETE FROM labels WHERE id = ?`, labelID) if err != nil { + if isMySQLForeignKey(err) { + return ctxerr.Wrap(ctx, foreignKey("labels", name), "delete label") + } return ctxerr.Wrapf(ctx, err, "delete label") } diff --git a/server/datastore/mysql/labels_test.go b/server/datastore/mysql/labels_test.go index 483daf78e0..0a405acafd 100644 --- a/server/datastore/mysql/labels_test.go +++ b/server/datastore/mysql/labels_test.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "strconv" + "strings" "testing" "time" @@ -918,25 +919,64 @@ func testLabelsRecordNonexistentQueryLabelExecution(t *testing.T, db *Datastore) } func testDeleteLabel(t *testing.T, db *Datastore) { - l, err := db.NewLabel(context.Background(), &fleet.Label{ + ctx := context.Background() + l, err := db.NewLabel(ctx, &fleet.Label{ Name: t.Name(), Query: "query1", }) require.NoError(t, err) - p, err := db.NewPack(context.Background(), &fleet.Pack{ + p, err := db.NewPack(ctx, &fleet.Pack{ Name: t.Name(), LabelIDs: []uint{l.ID}, }) require.NoError(t, err) - require.NoError(t, db.DeleteLabel(context.Background(), l.Name)) + require.NoError(t, db.DeleteLabel(ctx, l.Name)) - newP, err := db.Pack(context.Background(), p.ID) + newP, err := db.Pack(ctx, p.ID) require.NoError(t, err) require.Empty(t, newP.Labels) - require.NoError(t, db.DeletePack(context.Background(), newP.Name)) + require.NoError(t, db.DeletePack(ctx, newP.Name)) + + // delete a non-existing label + err = db.DeleteLabel(ctx, "no-such-label") + require.Error(t, err) + var nfe fleet.NotFoundError + require.ErrorAs(t, err, &nfe) + + // create a software installer and scope it via a label + u := test.NewUser(t, db, "user1", "user1@example.com", false) + installer, err := fleet.NewTempFileReader(strings.NewReader("echo"), t.TempDir) + require.NoError(t, err) + installerID, _, err := db.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "install foo", + InstallerFile: installer, + StorageID: uuid.NewString(), + Filename: "foo.pkg", + Title: "foo", + Source: "apps", + Version: "0.0.1", + UserID: u.ID, + }) + require.NoError(t, err) + + l2, err := db.NewLabel(ctx, &fleet.Label{ + Name: t.Name() + "2", + Query: "query2", + }) + require.NoError(t, err) + + ExecAdhocSQL(t, db, func(q sqlx.ExtContext) error { + _, err := q.ExecContext(ctx, `INSERT INTO software_installer_labels (software_installer_id, label_id) VALUES (?, ?)`, installerID, l2.ID) + return err + }) + + // try to delete that label referenced by software installer + err = db.DeleteLabel(ctx, l2.Name) + require.Error(t, err) + require.True(t, fleet.IsForeignKey(err)) } func testLabelsSummary(t *testing.T, db *Datastore) { diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index 06cf5c9c6d..fd21771b1d 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -15527,3 +15527,57 @@ func (s *integrationEnterpriseTestSuite) TestWindowsMigrateMDMNotEnabled() { errMsg := extractServerErrorText(res.Body) require.Contains(t, errMsg, "Windows MDM is not enabled") } + +func (s *integrationEnterpriseTestSuite) TestDeleteLabels() { + t := s.T() + + // create a couple labels + var newLabelResp createLabelResponse + s.DoJSON("POST", "/api/v1/fleet/labels", fleet.LabelPayload{ + Name: "TestDeleteLabels1", + Platform: "darwin", + Query: "SELECT 1", + }, http.StatusOK, &newLabelResp) + lbl1 := newLabelResp.Label.ID + s.DoJSON("POST", "/api/v1/fleet/labels", fleet.LabelPayload{ + Name: "TestDeleteLabels2", + Platform: "darwin", + Query: "SELECT 2", + }, http.StatusOK, &newLabelResp) + lbl2 := newLabelResp.Label.ID + + // create a software installer + installer := &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "install", + Filename: "ruby.deb", + SelfService: false, + TeamID: nil, + } + s.uploadSoftwareInstaller(t, installer, http.StatusOK, "") + + // associate lbl1 with the installer + // TODO(mna): use API or Datastore method once implemented + ctx := context.Background() + mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + var id uint + err := sqlx.GetContext(ctx, q, &id, `SELECT id FROM software_installers WHERE global_or_team_id = 0 AND filename = ?`, installer.Filename) + if err != nil { + return err + } + _, err = q.ExecContext(ctx, `INSERT INTO software_installer_labels (software_installer_id, label_id) VALUES (?, ?)`, id, lbl1) + return err + }) + + // try to delete the label associated with the installer + res := s.Do("DELETE", "/api/v1/fleet/labels/id/"+fmt.Sprint(lbl1), nil, http.StatusUnprocessableEntity) + errMsg := extractServerErrorText(res.Body) + require.Contains(t, errMsg, "foreign key constraint on labels: TestDeleteLabels1") + + // try to delete a label that does not exist by id and name + var delLabelResp deleteLabelByIDResponse + s.DoJSON("DELETE", "/api/v1/fleet/labels/id/"+fmt.Sprint(lbl1+1000), nil, http.StatusNotFound, &delLabelResp) + s.DoJSON("DELETE", "/api/v1/fleet/labels/no-such-label", nil, http.StatusNotFound, &delLabelResp) + + // delete the unused label2 + s.DoJSON("DELETE", "/api/v1/fleet/labels/id/"+fmt.Sprint(lbl2), nil, http.StatusOK, &delLabelResp) +} diff --git a/server/service/testing_client.go b/server/service/testing_client.go index 062939a0e7..e8168e693f 100644 --- a/server/service/testing_client.go +++ b/server/service/testing_client.go @@ -122,6 +122,24 @@ func (ts *withServer) commonTearDownTest(t *testing.T) { require.NoError(t, ts.ds.DeleteHost(ctx, host.ID)) } + teams, err := ts.ds.ListTeams(ctx, fleet.TeamFilter{User: &u}, fleet.ListOptions{}) + require.NoError(t, err) + for _, tm := range teams { + err := ts.ds.DeleteTeam(ctx, tm.ID) + require.NoError(t, err) + } + + mysql.ExecAdhocSQL(t, ts.ds, func(q sqlx.ExtContext) error { + _, err := q.ExecContext(ctx, `DELETE FROM policies;`) + return err + }) + + // Clean software installers in "No team" (the others are deleted in ts.ds.DeleteTeam above). + mysql.ExecAdhocSQL(t, ts.ds, func(q sqlx.ExtContext) error { + _, err := q.ExecContext(ctx, `DELETE FROM software_installers WHERE global_or_team_id = 0;`) + return err + }) + lbls, err := ts.ds.ListLabels(ctx, fleet.TeamFilter{}, fleet.ListOptions{}) require.NoError(t, err) for _, lbl := range lbls { @@ -152,24 +170,6 @@ func (ts *withServer) commonTearDownTest(t *testing.T) { } } - teams, err := ts.ds.ListTeams(ctx, fleet.TeamFilter{User: &u}, fleet.ListOptions{}) - require.NoError(t, err) - for _, tm := range teams { - err := ts.ds.DeleteTeam(ctx, tm.ID) - require.NoError(t, err) - } - - mysql.ExecAdhocSQL(t, ts.ds, func(q sqlx.ExtContext) error { - _, err := q.ExecContext(ctx, `DELETE FROM policies;`) - return err - }) - - // Clean software installers in "No team" (the others are deleted in ts.ds.DeleteTeam above). - mysql.ExecAdhocSQL(t, ts.ds, func(q sqlx.ExtContext) error { - _, err := q.ExecContext(ctx, `DELETE FROM software_installers WHERE global_or_team_id = 0;`) - return err - }) - // Clean scripts in "No team" (the others are deleted in ts.ds.DeleteTeam above). mysql.ExecAdhocSQL(t, ts.ds, func(q sqlx.ExtContext) error { _, err := q.ExecContext(ctx, `DELETE FROM scripts WHERE global_or_team_id = 0;`) From 7e3a7ba6eede7c7c43683e92c0e40496e4123f37 Mon Sep 17 00:00:00 2001 From: Jahziel Villasana-Espinoza Date: Mon, 16 Dec 2024 17:02:06 -0500 Subject: [PATCH 06/23] feat: filter host software by label scoping (#24801) > Related issue: #24534 # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/Committing-Changes.md#changes-files) for more information. - [x] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements) - [x] Added/updated tests - [x] Manual QA for all new/changed functionality --- changes/24534-hide-software-2 | 1 + server/datastore/mysql/software.go | 54 +++++- server/datastore/mysql/software_test.go | 156 ++++++++++++++++++ server/service/integration_enterprise_test.go | 63 +++++++ 4 files changed, 273 insertions(+), 1 deletion(-) create mode 100644 changes/24534-hide-software-2 diff --git a/changes/24534-hide-software-2 b/changes/24534-hide-software-2 new file mode 100644 index 0000000000..9b73513ddd --- /dev/null +++ b/changes/24534-hide-software-2 @@ -0,0 +1 @@ +- Add functionality to filter host software based on label scoping. \ No newline at end of file diff --git a/server/datastore/mysql/software.go b/server/datastore/mysql/software.go index 77cc5ee14b..7f7c811dcb 100644 --- a/server/datastore/mysql/software.go +++ b/server/datastore/mysql/software.go @@ -2375,7 +2375,59 @@ INNER JOIN software_cve scve ON scve.software_id = s.id hvsi.removed = 0 ) AND -- either the software installer or the vpp app exists for the host's team - ( si.id IS NOT NULL OR vat.platform = :host_platform ) + ( si.id IS NOT NULL OR vat.platform = :host_platform ) AND + -- label membership check + ( + -- do the label membership check only for software installers + CASE WHEN si.ID IS NOT NULL THEN + ( + EXISTS ( + + SELECT 1 FROM ( + + -- no labels + SELECT 0 AS count_installer_labels, 0 AS count_host_labels + WHERE NOT EXISTS ( + SELECT 1 FROM software_installer_labels sil WHERE sil.software_installer_id = si.id + ) + + UNION + + -- include any + SELECT + COUNT(*) AS count_installer_labels, + COUNT(lm.label_id) AS count_host_labels + FROM + software_installer_labels sil + LEFT OUTER JOIN label_membership lm ON lm.label_id = sil.label_id + AND lm.host_id = :host_id + WHERE + sil.software_installer_id = si.id + AND sil.exclude = 0 + HAVING + count_installer_labels > 0 AND count_host_labels > 0 + + UNION + + -- exclude any + SELECT + COUNT(*) AS count_installer_labels, + COUNT(lm.label_id) AS count_host_labels + FROM + software_installer_labels sil + LEFT OUTER JOIN label_membership lm ON lm.label_id = sil.label_id + AND lm.host_id = :host_id + WHERE + sil.software_installer_id = si.id + AND sil.exclude = 1 + HAVING + count_installer_labels > 0 AND count_host_labels = 0 + ) t + ) + ) + -- it's some other type of software that has been checked above + ELSE true END + ) %s %s `, onlySelfServiceClause, excludeVPPAppsClause) diff --git a/server/datastore/mysql/software_test.go b/server/datastore/mysql/software_test.go index ddf3d3b47c..acb62687f8 100644 --- a/server/datastore/mysql/software_test.go +++ b/server/datastore/mysql/software_test.go @@ -69,6 +69,7 @@ func TestSoftware(t *testing.T) { {"ListHostSoftwareInstallThenTransferTeam", testListHostSoftwareInstallThenTransferTeam}, {"ListHostSoftwareInstallThenDeleteInstallers", testListHostSoftwareInstallThenDeleteInstallers}, {"ListSoftwareVersionsVulnerabilityFilters", testListSoftwareVersionsVulnerabilityFilters}, + {"TestListHostSoftwareWithLabelScoping", testListHostSoftwareWithLabelScoping}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -5246,3 +5247,158 @@ func testListSoftwareVersionsVulnerabilityFilters(t *testing.T, ds *Datastore) { }) } } + +func testListHostSoftwareWithLabelScoping(t *testing.T, ds *Datastore) { + ctx := context.Background() + + // create a host + host := test.NewHost(t, ds, "host1", "", "host1key", "host1uuid", time.Now(), test.WithPlatform("darwin")) + nanoEnroll(t, ds, host, false) + user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true) + + // create some software: custom installers and FMA + tfr1, err := fleet.NewTempFileReader(strings.NewReader("hello"), t.TempDir) + require.NoError(t, err) + installerID1, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "hello", + PreInstallQuery: "SELECT 1", + PostInstallScript: "world", + UninstallScript: "goodbye", + InstallerFile: tfr1, + StorageID: "storage1", + Filename: "file1", + Title: "file1", + Version: "1.0", + Source: "apps", + UserID: user1.ID, + BundleIdentifier: "bi1", + Platform: "darwin", + }) + require.NoError(t, err) + + // we should see installer1, since it has no label associated yet + opts := fleet.HostSoftwareTitleListOptions{ + ListOptions: fleet.ListOptions{ + PerPage: 11, + IncludeMetadata: true, + OrderKey: "name", + TestSecondaryOrderKey: "source", + }, + IncludeAvailableForInstall: true, + } + software, _, err := ds.ListHostSoftware(ctx, host, opts) + require.NoError(t, err) + require.Len(t, software, 1) + require.Equal(t, "file1", software[0].SoftwarePackage.Name) + + label1, err := ds.NewLabel(ctx, &fleet.Label{Name: "label1" + t.Name()}) + require.NoError(t, err) + + // assign the label to the host + require.NoError(t, ds.AddLabelsToHost(ctx, host.ID, []uint{label1.ID})) + + // assign the label to the software installer + // TODO(JVE): update this once the DS method exists + updateInstallerLabel := func(siID, labelID uint, exclude bool) { + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + _, err = q.ExecContext( + ctx, + `INSERT INTO software_installer_labels (software_installer_id, label_id, exclude) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE exclude = VALUES(exclude)`, + siID, labelID, exclude, + ) + return err + }) + } + updateInstallerLabel(installerID1, label1.ID, true) + + // should be empty as the installer label is "exclude any" + software, _, err = ds.ListHostSoftware(ctx, host, opts) + require.NoError(t, err) + require.Empty(t, software) + + // Update the label to be "include any" + updateInstallerLabel(installerID1, label1.ID, false) + + software, _, err = ds.ListHostSoftware(ctx, host, opts) + require.NoError(t, err) + require.Len(t, software, 1) + require.Equal(t, "file1", software[0].SoftwarePackage.Name) + + // Add an installer. No label yet. + installerID2, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "hello", + PreInstallQuery: "SELECT 1", + PostInstallScript: "world", + UninstallScript: "goodbye", + InstallerFile: tfr1, + StorageID: "storage2", + Filename: "file2", + Title: "file2", + Version: "2.0", + Source: "apps", + UserID: user1.ID, + BundleIdentifier: "bi2", + Platform: "darwin", + }) + require.NoError(t, err) + + // There's 2 installers now: installerID1 and installerID2 (because it has no labels associated) + software, _, err = ds.ListHostSoftware(ctx, host, opts) + require.NoError(t, err) + require.Len(t, software, 2) + + // Add "exclude any" labels to installer2 + label2, err := ds.NewLabel(ctx, &fleet.Label{Name: "label2" + t.Name()}) + require.NoError(t, err) + + label3, err := ds.NewLabel(ctx, &fleet.Label{Name: "label3" + t.Name()}) + require.NoError(t, err) + + updateInstallerLabel(installerID2, label2.ID, true) + updateInstallerLabel(installerID2, label3.ID, true) + + // Now host has label1, label2 + require.NoError(t, ds.AddLabelsToHost(ctx, host.ID, []uint{label2.ID})) + + // List should be back to 1 + software, _, err = ds.ListHostSoftware(ctx, host, opts) + require.NoError(t, err) + require.Len(t, software, 1) + + // Add an installer. No label yet. + installerID3, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "hello", + PreInstallQuery: "SELECT 1", + PostInstallScript: "world", + UninstallScript: "goodbye", + InstallerFile: tfr1, + StorageID: "storage3", + Filename: "file3", + Title: "file3", + Version: "3.0", + Source: "apps", + UserID: user1.ID, + BundleIdentifier: "bi3", + Platform: "darwin", + }) + require.NoError(t, err) + + // Add a new label and apply it to the installer. There are no hosts with this label. + label4, err := ds.NewLabel(ctx, &fleet.Label{Name: "label4" + t.Name()}) + require.NoError(t, err) + + updateInstallerLabel(installerID3, label4.ID, true) + + // We should have [installerID1, installerID3] + software, _, err = ds.ListHostSoftware(ctx, host, opts) + require.NoError(t, err) + require.Len(t, software, 2) + + // Now include hosts with label4. No host has this label, so we shouldn't see installerID3 anymore. + updateInstallerLabel(installerID3, label4.ID, false) + + // We should have [installerID1] + software, _, err = ds.ListHostSoftware(ctx, host, opts) + require.NoError(t, err) + require.Len(t, software, 1) +} diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index 10f9d41d9c..9d874eb01c 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -10379,6 +10379,69 @@ func (s *integrationEnterpriseTestSuite) TestListHostSoftware() { require.Equal(t, payload.Filename, getDeviceSw.Software[0].SoftwarePackage.Name) require.Equal(t, payload.Version, getDeviceSw.Software[0].SoftwarePackage.Version) + // ========================================= + // test label scoping + // ========================================= + + // TODO(JVE): remove/update this once the API is in place + updateInstallerLabel := func(siID, labelID uint, exclude bool) { + mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + _, err = q.ExecContext( + ctx, + `INSERT INTO software_installer_labels (software_installer_id, label_id, exclude) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE exclude = VALUES(exclude)`, + siID, labelID, exclude, + ) + return err + }) + } + + var installerID uint + mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + return sqlx.GetContext(ctx, q, &installerID, "SELECT id FROM software_installers WHERE title_id = ?", titleID) + }) + require.NotEmpty(t, installerID) + + // create some labels + var labelResp createLabelResponse + s.DoJSON("POST", "/api/latest/fleet/labels", &createLabelRequest{fleet.LabelPayload{ + Name: "label1", + Hosts: []string{host.Hostname}, + }}, http.StatusOK, &labelResp) + require.NotZero(t, labelResp.Label.ID) + + s.DoJSON("POST", "/api/latest/fleet/labels", &createLabelRequest{fleet.LabelPayload{ + Name: "label2", + Hosts: []string{host.Hostname}, + }}, http.StatusOK, &labelResp) + require.NotZero(t, labelResp.Label.ID) + + // Set to "exclude any". Installer should be missing from the response for both host details and + // for self service + updateInstallerLabel(installerID, labelResp.Label.ID, true) + getHostSw = getHostSoftwareResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host.ID), nil, http.StatusOK, &getHostSw, "self_service", "true") + require.Empty(t, getHostSw.Software) + + res = s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/software?self_service=1", nil, http.StatusOK) + getDeviceSw = getDeviceSoftwareResponse{} + err = json.NewDecoder(res.Body).Decode(&getDeviceSw) + require.NoError(t, err) + require.Empty(t, getDeviceSw.Software) + + // Set to "include any". Installer should be in response. + updateInstallerLabel(installerID, labelResp.Label.ID, false) + getHostSw = getHostSoftwareResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host.ID), nil, http.StatusOK, &getHostSw, "self_service", "true") + require.Len(t, getHostSw.Software, 1) + require.Equal(t, getHostSw.Software[0].Name, "ruby") + + res = s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/software?self_service=1", nil, http.StatusOK) + getDeviceSw = getDeviceSoftwareResponse{} + err = json.NewDecoder(res.Body).Decode(&getDeviceSw) + require.NoError(t, err) + require.Len(t, getDeviceSw.Software, 1) + require.Equal(t, getDeviceSw.Software[0].Name, "ruby") + // request installation on the host var installResp installSoftwareResponse s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", From 4f954908ffb277cb19f47167a7794b5e08f08143 Mon Sep 17 00:00:00 2001 From: Sarah Gillespie <73313222+gillespi314@users.noreply.github.com> Date: Mon, 16 Dec 2024 18:17:13 -0600 Subject: [PATCH 07/23] SSVL: Add labels to upload endpoint; add validations and related datastore methods (#24733) --- ee/server/service/maintained_apps.go | 8 + ee/server/service/software_installers.go | 37 ++++ server/datastore/mysql/activities_test.go | 51 +++-- server/datastore/mysql/hosts_test.go | 1 + server/datastore/mysql/labels_test.go | 17 +- .../datastore/mysql/maintained_apps_test.go | 3 + server/datastore/mysql/policies_test.go | 14 ++ server/datastore/mysql/scripts_test.go | 2 + .../datastore/mysql/setup_experience_test.go | 11 +- server/datastore/mysql/software_installers.go | 177 ++++++++++++--- .../mysql/software_installers_test.go | 206 ++++++++++-------- server/datastore/mysql/software_test.go | 41 ++-- .../datastore/mysql/software_titles_test.go | 83 ++++--- server/datastore/mysql/testing_utils.go | 10 +- server/fleet/labels.go | 27 +++ server/fleet/service.go | 7 +- server/fleet/software_installer.go | 3 + server/service/handler.go | 1 + server/service/integration_core_test.go | 17 +- server/service/integration_enterprise_test.go | 52 ++++- server/service/integration_mdm_test.go | 1 + server/service/labels.go | 37 ++++ server/service/maintained_apps.go | 38 +++- server/service/software_installers.go | 28 +++ server/service/testing_client.go | 7 + 25 files changed, 644 insertions(+), 235 deletions(-) diff --git a/ee/server/service/maintained_apps.go b/ee/server/service/maintained_apps.go index 0171378515..2c83ed39b3 100644 --- a/ee/server/service/maintained_apps.go +++ b/ee/server/service/maintained_apps.go @@ -26,11 +26,16 @@ func (svc *Service) AddFleetMaintainedApp( appID uint, installScript, preInstallQuery, postInstallScript, uninstallScript string, selfService bool, + labelsIncludeAny, labelsExcludeAny []string, ) (titleID uint, err error) { if err := svc.authz.Authorize(ctx, &fleet.SoftwareInstaller{TeamID: teamID}, fleet.ActionWrite); err != nil { return 0, err } + if len(labelsIncludeAny) > 0 && len(labelsExcludeAny) > 0 { + return 0, &fleet.BadRequestError{Message: `Only one of "labels_include_any" or "labels_exclude_any" can be included.`} + } + vc, ok := viewer.FromContext(ctx) if !ok { return 0, fleet.ErrNoContext @@ -117,6 +122,9 @@ func (svc *Service) AddFleetMaintainedApp( UninstallScript: uninstallScript, } + // TODO: labels validations, for now just use empty struct + payload.ValidatedLabels = &fleet.LabelIdentsWithScope{} + // Create record in software installers table _, titleID, err = svc.ds.MatchOrCreateSoftwareInstaller(ctx, payload) if err != nil { diff --git a/ee/server/service/software_installers.go b/ee/server/service/software_installers.go index 623378d0db..1deefc6fcb 100644 --- a/ee/server/service/software_installers.go +++ b/ee/server/service/software_installers.go @@ -37,6 +37,13 @@ func (svc *Service) UploadSoftwareInstaller(ctx context.Context, payload *fleet. return err } + // validate labels before we do anything else + validatedLabels, err := svc.validateSoftwareLabels(ctx, payload.LabelsIncludeAny, payload.LabelsExcludeAny) + if err != nil { + return ctxerr.Wrap(ctx, err, "validating software labels") + } + payload.ValidatedLabels = validatedLabels + vc, ok := viewer.FromContext(ctx) if !ok { return fleet.ErrNoContext @@ -96,6 +103,36 @@ func (svc *Service) UploadSoftwareInstaller(ctx context.Context, payload *fleet. return nil } +func (svc *Service) validateSoftwareLabels(ctx context.Context, labelsIncludeAny, labelsExcludeAny []string) (*fleet.LabelIdentsWithScope, error) { + var names []string + var scope fleet.LabelScope + switch { + case len(labelsIncludeAny) > 0 && len(labelsExcludeAny) > 0: + return nil, &fleet.BadRequestError{Message: `Only one of "labels_include_any" or "labels_exclude_any" can be included.`} + case len(labelsIncludeAny) > 0: + names = labelsIncludeAny + scope = fleet.LabelScopeIncludeAny + case len(labelsExcludeAny) > 0: + names = labelsExcludeAny + scope = fleet.LabelScopeExcludeAny + } + + if len(names) == 0 { + // nothing to validate, return empty result + return &fleet.LabelIdentsWithScope{}, nil + } + + byName, err := svc.BatchValidateLabels(ctx, names) + if err != nil { + return nil, err + } + + return &fleet.LabelIdentsWithScope{ + LabelScope: scope, + ByName: byName, + }, nil +} + var packageIDRegex = regexp.MustCompile(`((("\$PACKAGE_ID")|(\$PACKAGE_ID))(?P\W|$))|(("\${PACKAGE_ID}")|(\${PACKAGE_ID}))`) func preProcessUninstallScript(payload *fleet.UploadSoftwareInstallerPayload) { diff --git a/server/datastore/mysql/activities_test.go b/server/datastore/mysql/activities_test.go index 481f7f46ae..45b87374ba 100644 --- a/server/datastore/mysql/activities_test.go +++ b/server/datastore/mysql/activities_test.go @@ -397,40 +397,43 @@ func testListHostUpcomingActivities(t *testing.T, ds *Datastore) { installer1, err := fleet.NewTempFileReader(strings.NewReader("echo"), t.TempDir) require.NoError(t, err) sw1, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ - InstallScript: "install foo", - InstallerFile: installer1, - StorageID: uuid.NewString(), - Filename: "foo.pkg", - Title: "foo", - Source: "apps", - Version: "0.0.1", - UserID: u.ID, + InstallScript: "install foo", + InstallerFile: installer1, + StorageID: uuid.NewString(), + Filename: "foo.pkg", + Title: "foo", + Source: "apps", + Version: "0.0.1", + UserID: u.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, }) require.NoError(t, err) installer2, err := fleet.NewTempFileReader(strings.NewReader("echo"), t.TempDir) require.NoError(t, err) sw2, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ - InstallScript: "install bar", - InstallerFile: installer2, - StorageID: uuid.NewString(), - Filename: "bar.pkg", - Title: "bar", - Source: "apps", - Version: "0.0.2", - UserID: u.ID, + InstallScript: "install bar", + InstallerFile: installer2, + StorageID: uuid.NewString(), + Filename: "bar.pkg", + Title: "bar", + Source: "apps", + Version: "0.0.2", + UserID: u.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, }) require.NoError(t, err) installer3, err := fleet.NewTempFileReader(strings.NewReader("echo"), t.TempDir) require.NoError(t, err) sw3, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ - InstallScript: "install to delete", - InstallerFile: installer3, - StorageID: uuid.NewString(), - Filename: "todelete.pkg", - Title: "todelete", - Source: "apps", - Version: "0.0.3", - UserID: u.ID, + InstallScript: "install to delete", + InstallerFile: installer3, + StorageID: uuid.NewString(), + Filename: "todelete.pkg", + Title: "todelete", + Source: "apps", + Version: "0.0.3", + UserID: u.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, }) require.NoError(t, err) sw1Meta, err := ds.GetSoftwareInstallerMetadataByID(ctx, sw1) diff --git a/server/datastore/mysql/hosts_test.go b/server/datastore/mysql/hosts_test.go index 9c701ef261..5e7db67502 100644 --- a/server/datastore/mysql/hosts_test.go +++ b/server/datastore/mysql/hosts_test.go @@ -6908,6 +6908,7 @@ func testHostsDeleteHosts(t *testing.T, ds *Datastore) { PreInstallQuery: "", Title: "ChocolateRain", UserID: user1.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, }) require.NoError(t, err) _, err = ds.InsertSoftwareInstallRequest(context.Background(), host.ID, softwareInstaller, false, nil) diff --git a/server/datastore/mysql/labels_test.go b/server/datastore/mysql/labels_test.go index 0a405acafd..3095b1c057 100644 --- a/server/datastore/mysql/labels_test.go +++ b/server/datastore/mysql/labels_test.go @@ -951,14 +951,15 @@ func testDeleteLabel(t *testing.T, db *Datastore) { installer, err := fleet.NewTempFileReader(strings.NewReader("echo"), t.TempDir) require.NoError(t, err) installerID, _, err := db.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ - InstallScript: "install foo", - InstallerFile: installer, - StorageID: uuid.NewString(), - Filename: "foo.pkg", - Title: "foo", - Source: "apps", - Version: "0.0.1", - UserID: u.ID, + InstallScript: "install foo", + InstallerFile: installer, + StorageID: uuid.NewString(), + Filename: "foo.pkg", + Title: "foo", + Source: "apps", + Version: "0.0.1", + UserID: u.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, }) require.NoError(t, err) diff --git a/server/datastore/mysql/maintained_apps_test.go b/server/datastore/mysql/maintained_apps_test.go index 399bcf81f4..21a19665b8 100644 --- a/server/datastore/mysql/maintained_apps_test.go +++ b/server/datastore/mysql/maintained_apps_test.go @@ -214,6 +214,7 @@ func testListAvailableApps(t *testing.T, ds *Datastore) { UserID: user.ID, Platform: string(fleet.MacOSPlatform), BundleIdentifier: "irrelevant_1", + ValidatedLabels: &fleet.LabelIdentsWithScope{}, }) require.NoError(t, err) @@ -233,6 +234,7 @@ func testListAvailableApps(t *testing.T, ds *Datastore) { UserID: user.ID, Platform: string(fleet.MacOSPlatform), BundleIdentifier: "fleet.maintained1", + ValidatedLabels: &fleet.LabelIdentsWithScope{}, }) require.NoError(t, err) @@ -252,6 +254,7 @@ func testListAvailableApps(t *testing.T, ds *Datastore) { UserID: user.ID, Platform: string(fleet.IOSPlatform), BundleIdentifier: "fleet.maintained1", + ValidatedLabels: &fleet.LabelIdentsWithScope{}, }) require.NoError(t, err) diff --git a/server/datastore/mysql/policies_test.go b/server/datastore/mysql/policies_test.go index 14cb9128ba..aa549bb87b 100644 --- a/server/datastore/mysql/policies_test.go +++ b/server/datastore/mysql/policies_test.go @@ -287,6 +287,7 @@ func testGlobalPolicyPendingScriptsAndInstalls(t *testing.T, ds *Datastore) { Version: "1.0", Source: "apps", UserID: user.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, }) require.NoError(t, err) policy2, err := ds.NewGlobalPolicy(ctx, &user.ID, fleet.PolicyPayload{ @@ -885,6 +886,7 @@ func testTeamPolicyPendingScriptsAndInstalls(t *testing.T, ds *Datastore) { Source: "apps", UserID: user.ID, TeamID: &team2.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, }) require.NoError(t, err) policy2, err := ds.NewTeamPolicy(ctx, team2.ID, nil, fleet.PolicyPayload{ @@ -1444,6 +1446,7 @@ func testPoliciesByID(t *testing.T, ds *Datastore) { Source: "apps", UserID: user1.ID, TeamID: &team1.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, }) require.NoError(t, err) policy2.SoftwareInstallerID = ptr.Uint(installerID) @@ -4192,6 +4195,7 @@ func testTeamPoliciesWithInstaller(t *testing.T, ds *Datastore) { Source: "apps", UserID: user1.ID, TeamID: &team1.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, }) require.NoError(t, err) require.Nil(t, p1.SoftwareInstallerID) @@ -4230,6 +4234,7 @@ func testTeamPoliciesWithInstaller(t *testing.T, ds *Datastore) { Source: "apps", UserID: user1.ID, TeamID: ptr.Uint(fleet.PolicyNoTeamID), + ValidatedLabels: &fleet.LabelIdentsWithScope{}, }) require.NoError(t, err) p4, err := ds.NewTeamPolicy(ctx, fleet.PolicyNoTeamID, &user1.ID, fleet.PolicyPayload{ @@ -4451,6 +4456,7 @@ func testApplyPolicySpecWithInstallers(t *testing.T, ds *Datastore) { Source: "apps", UserID: user1.ID, TeamID: &team1.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, }) require.NoError(t, err) installer1, err := ds.GetSoftwareInstallerMetadataByID(ctx, installer1ID) @@ -4470,6 +4476,7 @@ func testApplyPolicySpecWithInstallers(t *testing.T, ds *Datastore) { Source: "deb_packages", UserID: user1.ID, TeamID: &team2.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, }) require.NoError(t, err) installer2, err := ds.GetSoftwareInstallerMetadataByID(ctx, installer2ID) @@ -4489,6 +4496,7 @@ func testApplyPolicySpecWithInstallers(t *testing.T, ds *Datastore) { Source: "rpm_packages", UserID: user1.ID, TeamID: nil, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, }) require.NoError(t, err) installer3, err := ds.GetSoftwareInstallerMetadataByID(ctx, installer3ID) @@ -4509,6 +4517,7 @@ func testApplyPolicySpecWithInstallers(t *testing.T, ds *Datastore) { Source: "programs", UserID: user1.ID, TeamID: &team1.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, }) require.NoError(t, err) installer5, err := ds.GetSoftwareInstallerMetadataByID(ctx, installer5ID) @@ -4700,6 +4709,7 @@ func testApplyPolicySpecWithInstallers(t *testing.T, ds *Datastore) { Source: "apps", UserID: user1.ID, TeamID: &team2.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, }) require.NoError(t, err) installer4, err := ds.GetSoftwareInstallerMetadataByID(ctx, installer4ID) @@ -5229,6 +5239,7 @@ func testPoliciesBySoftwareTitleID(t *testing.T, ds *Datastore) { Source: "apps", UserID: user1.ID, TeamID: &team1.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, }) require.NoError(t, err) policy1.SoftwareInstallerID = ptr.Uint(installer1ID) @@ -5248,6 +5259,7 @@ func testPoliciesBySoftwareTitleID(t *testing.T, ds *Datastore) { Source: "apps", UserID: user1.ID, TeamID: &team2.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, }) require.NoError(t, err) policy2.SoftwareInstallerID = ptr.Uint(installer2ID) @@ -5304,6 +5316,7 @@ func testPoliciesBySoftwareTitleID(t *testing.T, ds *Datastore) { Source: "apps", UserID: user1.ID, TeamID: nil, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, }) require.NoError(t, err) @@ -5319,6 +5332,7 @@ func testPoliciesBySoftwareTitleID(t *testing.T, ds *Datastore) { Source: "apps", UserID: user1.ID, TeamID: nil, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, }) require.NoError(t, err) diff --git a/server/datastore/mysql/scripts_test.go b/server/datastore/mysql/scripts_test.go index 473cad793c..a8bd33d8ca 100644 --- a/server/datastore/mysql/scripts_test.go +++ b/server/datastore/mysql/scripts_test.go @@ -1298,6 +1298,7 @@ func testCleanupUnusedScriptContents(t *testing.T, ds *Datastore) { Version: "1.0", Source: "apps", UserID: user1.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, }) require.NoError(t, err) @@ -1358,6 +1359,7 @@ func testCleanupUnusedScriptContents(t *testing.T, ds *Datastore) { Version: "1.0", Source: "apps", UserID: user1.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, }) require.NoError(t, err) diff --git a/server/datastore/mysql/setup_experience_test.go b/server/datastore/mysql/setup_experience_test.go index c459cdef21..0f217d4c85 100644 --- a/server/datastore/mysql/setup_experience_test.go +++ b/server/datastore/mysql/setup_experience_test.go @@ -72,6 +72,7 @@ func testEnqueueSetupExperienceItems(t *testing.T, ds *Datastore) { UserID: user1.ID, TeamID: &team1.ID, Platform: string(fleet.MacOSPlatform), + ValidatedLabels: &fleet.LabelIdentsWithScope{}, }) require.NoError(t, err) @@ -91,6 +92,7 @@ func testEnqueueSetupExperienceItems(t *testing.T, ds *Datastore) { UserID: user1.ID, TeamID: &team2.ID, Platform: string(fleet.MacOSPlatform), + ValidatedLabels: &fleet.LabelIdentsWithScope{}, }) require.NoError(t, err) @@ -330,6 +332,7 @@ func testGetSetupExperienceTitles(t *testing.T, ds *Datastore) { UserID: user1.ID, TeamID: &team1.ID, Platform: string(fleet.MacOSPlatform), + ValidatedLabels: &fleet.LabelIdentsWithScope{}, }) require.NoError(t, err) @@ -349,6 +352,7 @@ func testGetSetupExperienceTitles(t *testing.T, ds *Datastore) { UserID: user1.ID, TeamID: &team2.ID, Platform: string(fleet.MacOSPlatform), + ValidatedLabels: &fleet.LabelIdentsWithScope{}, }) require.NoError(t, err) @@ -368,6 +372,7 @@ func testGetSetupExperienceTitles(t *testing.T, ds *Datastore) { UserID: user1.ID, TeamID: &team2.ID, Platform: string(fleet.IOSPlatform), + ValidatedLabels: &fleet.LabelIdentsWithScope{}, }) require.NoError(t, err) @@ -464,6 +469,7 @@ func testSetSetupExperienceTitles(t *testing.T, ds *Datastore) { UserID: user1.ID, TeamID: &team1.ID, Platform: string(fleet.MacOSPlatform), + ValidatedLabels: &fleet.LabelIdentsWithScope{}, }) _ = installerID1 require.NoError(t, err) @@ -483,6 +489,7 @@ func testSetSetupExperienceTitles(t *testing.T, ds *Datastore) { UserID: user1.ID, TeamID: &team1.ID, Platform: string(fleet.MacOSPlatform), + ValidatedLabels: &fleet.LabelIdentsWithScope{}, }) _ = installerID2 require.NoError(t, err) @@ -503,6 +510,7 @@ func testSetSetupExperienceTitles(t *testing.T, ds *Datastore) { UserID: user1.ID, TeamID: &team2.ID, Platform: string(fleet.MacOSPlatform), + ValidatedLabels: &fleet.LabelIdentsWithScope{}, }) _ = installerID3 require.NoError(t, err) @@ -523,6 +531,7 @@ func testSetSetupExperienceTitles(t *testing.T, ds *Datastore) { UserID: user1.ID, TeamID: &team2.ID, Platform: string(fleet.IOSPlatform), + ValidatedLabels: &fleet.LabelIdentsWithScope{}, }) _ = installerID4 require.NoError(t, err) @@ -678,7 +687,7 @@ func testSetupExperienceStatusResults(t *testing.T, ds *Datastore) { // We need a new user first user, err := ds.NewUser(ctx, &fleet.User{Name: "Foo", Email: "foo@example.com", GlobalRole: ptr.String("admin"), Password: []byte("12characterslong!")}) require.NoError(t, err) - installerID, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{Filename: "test.app", Version: "1.0.0", UserID: user.ID}) + installerID, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{Filename: "test.app", Version: "1.0.0", UserID: user.ID, ValidatedLabels: &fleet.LabelIdentsWithScope{}}) require.NoError(t, err) installer, err := ds.GetSoftwareInstallerMetadataByID(ctx, installerID) require.NoError(t, err) diff --git a/server/datastore/mysql/software_installers.go b/server/datastore/mysql/software_installers.go index 1081f53f91..b604f7f84f 100644 --- a/server/datastore/mysql/software_installers.go +++ b/server/datastore/mysql/software_installers.go @@ -11,6 +11,7 @@ import ( "github.com/fleetdm/fleet/v4/server/authz" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/go-kit/kit/log/level" "github.com/google/uuid" "github.com/jmoiron/sqlx" ) @@ -74,6 +75,12 @@ func (ds *Datastore) GetSoftwareInstallDetails(ctx context.Context, executionId } func (ds *Datastore) MatchOrCreateSoftwareInstaller(ctx context.Context, payload *fleet.UploadSoftwareInstallerPayload) (installerID, titleID uint, err error) { + if payload.ValidatedLabels == nil { + // caller must ensure this is not nil; if caller intends no labels to be created, + // payload.ValidatedLabels should point to an empty struct. + return 0, 0, errors.New("validated labels must not be nil") + } + titleID, err = ds.getOrGenerateSoftwareInstallerTitleID(ctx, payload) if err != nil { return 0, 0, ctxerr.Wrap(ctx, err, "get or generate software installer title ID") @@ -112,7 +119,8 @@ func (ds *Datastore) MatchOrCreateSoftwareInstaller(ctx context.Context, payload } } - stmt := ` + if err = ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { + stmt := ` INSERT INTO software_installers ( team_id, global_or_team_id, @@ -134,39 +142,49 @@ INSERT INTO software_installers ( fleet_library_app_id ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, (SELECT name FROM users WHERE id = ?), (SELECT email FROM users WHERE id = ?), ?)` - args := []interface{}{ - tid, - globalOrTeamID, - titleID, - payload.StorageID, - payload.Filename, - payload.Extension, - payload.Version, - strings.Join(payload.PackageIDs, ","), - installScriptID, - payload.PreInstallQuery, - postInstallScriptID, - uninstallScriptID, - payload.Platform, - payload.SelfService, - payload.UserID, - payload.UserID, - payload.UserID, - payload.FleetLibraryAppID, - } - - res, err := ds.writer(ctx).ExecContext(ctx, stmt, args...) - if err != nil { - if IsDuplicate(err) { - // already exists for this team/no team - err = alreadyExists("SoftwareInstaller", payload.Title) + args := []interface{}{ + tid, + globalOrTeamID, + titleID, + payload.StorageID, + payload.Filename, + payload.Extension, + payload.Version, + strings.Join(payload.PackageIDs, ","), + installScriptID, + payload.PreInstallQuery, + postInstallScriptID, + uninstallScriptID, + payload.Platform, + payload.SelfService, + payload.UserID, + payload.UserID, + payload.UserID, + payload.FleetLibraryAppID, } + + res, err := tx.ExecContext(ctx, stmt, args...) + if err != nil { + if IsDuplicate(err) { + // already exists for this team/no team + err = alreadyExists("SoftwareInstaller", payload.Title) + } + return err + } + + id, _ := res.LastInsertId() + installerID = uint(id) //nolint:gosec // dismiss G115 + + if err := setOrUpdateSoftwareInstallerLabelsDB(ctx, tx, installerID, *payload.ValidatedLabels); err != nil { + return ctxerr.Wrap(ctx, err, "upsert software installer labels") + } + + return nil + }); err != nil { return 0, 0, ctxerr.Wrap(ctx, err, "insert software installer") } - id, _ := res.LastInsertId() - - return uint(id), titleID, nil //nolint:gosec // dismiss G115 + return installerID, titleID, nil } func (ds *Datastore) getOrGenerateSoftwareInstallerTitleID(ctx context.Context, payload *fleet.UploadSoftwareInstallerPayload) (uint, error) { @@ -219,6 +237,61 @@ func (ds *Datastore) addSoftwareTitleToMatchingSoftware(ctx context.Context, tit return ctxerr.Wrap(ctx, err, "adding fk reference in software to software_titles") } +// setOrUpdateSoftwareInstallerLabelsDB sets or updates the label associations for the specified software +// installer. If no labels are provided, it will remove all label associations with the software installer. +func setOrUpdateSoftwareInstallerLabelsDB(ctx context.Context, tx sqlx.ExtContext, installerID uint, labels fleet.LabelIdentsWithScope) error { + labelIds := make([]uint, 0, len(labels.ByName)) + for _, label := range labels.ByName { + labelIds = append(labelIds, label.LabelID) + } + + // remove existing labels + delArgs := []interface{}{installerID} + delStmt := `DELETE FROM software_installer_labels WHERE software_installer_id = ?` + if len(labelIds) > 0 { + inStmt, args, err := sqlx.In(` AND label_id NOT IN (?)`, labelIds) + if err != nil { + return ctxerr.Wrap(ctx, err, "build delete existing software installer labels query") + } + delArgs = append(delArgs, args...) + delStmt += inStmt + } + _, err := tx.ExecContext(ctx, delStmt, delArgs...) + if err != nil { + return ctxerr.Wrap(ctx, err, "delete existing software installer labels") + } + + // insert new labels + if len(labelIds) > 0 { + var exclude bool + switch labels.LabelScope { + case fleet.LabelScopeIncludeAny: + exclude = false + case fleet.LabelScopeExcludeAny: + exclude = true + default: + // this should never happen + return ctxerr.New(ctx, "invalid label scope") + } + + stmt := `INSERT INTO software_installer_labels (software_installer_id, label_id, exclude) VALUES %s ON DUPLICATE KEY UPDATE software_installer_id = software_installer_id, label_id = label_id, exclude = VALUES(exclude)` + var placeholders string + var insertArgs []interface{} + for _, lid := range labelIds { + placeholders += "(?, ?, ?)," + insertArgs = append(insertArgs, installerID, lid, exclude) + } + placeholders = strings.TrimSuffix(placeholders, ",") + + _, err = tx.ExecContext(ctx, fmt.Sprintf(stmt, placeholders), insertArgs...) + if err != nil { + return ctxerr.Wrap(ctx, err, "insert software installer label") + } + } + + return nil +} + func (ds *Datastore) UpdateInstallerSelfServiceFlag(ctx context.Context, selfService bool, id uint) error { _, err := ds.writer(ctx).ExecContext(ctx, `UPDATE software_installers SET self_service = ? WHERE id = ?`, selfService, id) if err != nil { @@ -405,6 +478,28 @@ WHERE return nil, ctxerr.Wrap(ctx, err, "get software installer metadata") } + // TODO: do we want to include labels on other queries that return software installer metadata + // (e.g., GetSoftwareInstallerMetadataByID)? + labels, err := ds.getSoftwareInstallerLabels(ctx, dest.InstallerID) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "get software installer labels") + } + var exclAny, inclAny []fleet.SoftwareScopeLabel + for _, l := range labels { + if l.Exclude { + exclAny = append(exclAny, l) + } else { + inclAny = append(inclAny, l) + } + } + + if len(inclAny) > 0 && len(exclAny) > 0 { + // there's a bug somewhere + level.Debug(ds.logger).Log("msg", "software installer has both include and exclude labels", "installer_id", dest.InstallerID, "include", fmt.Sprintf("%v", inclAny), "exclude", fmt.Sprintf("%v", exclAny)) + } + dest.LabelsExcludeAny = exclAny + dest.LabelsIncludeAny = inclAny + policies, err := ds.getPoliciesBySoftwareTitleIDs(ctx, []uint{titleID}, teamID) if err != nil { return nil, ctxerr.Wrap(ctx, err, "get policies by software title ID") @@ -414,6 +509,28 @@ WHERE return &dest, nil } +func (ds *Datastore) getSoftwareInstallerLabels(ctx context.Context, installerID uint) ([]fleet.SoftwareScopeLabel, error) { + query := ` +SELECT + label_id, + exclude, + l.name as label_name, + si.title_id +FROM + software_installer_labels sil + JOIN software_installers si ON si.id = sil.software_installer_id + JOIN labels l ON l.id = sil.label_id +WHERE + software_installer_id = ?` + + var labels []fleet.SoftwareScopeLabel + if err := sqlx.SelectContext(ctx, ds.reader(ctx), &labels, query, installerID); err != nil { + return nil, ctxerr.Wrap(ctx, err, "get software installer labels") + } + + return labels, nil +} + var ( errDeleteInstallerWithAssociatedPolicy = &fleet.ConflictError{Message: "Couldn't delete. Policy automation uses this software. Please disable policy automation for this software and try again."} errDeleteInstallerInstalledDuringSetup = &fleet.ConflictError{Message: "Couldn't delete. This software is installed when new Macs boot. Please remove software in Controls > Setup experience and try again."} diff --git a/server/datastore/mysql/software_installers_test.go b/server/datastore/mysql/software_installers_test.go index 7af41846a4..27012aa468 100644 --- a/server/datastore/mysql/software_installers_test.go +++ b/server/datastore/mysql/software_installers_test.go @@ -68,6 +68,7 @@ func testListPendingSoftwareInstalls(t *testing.T, ds *Datastore) { Version: "1.0", Source: "apps", UserID: user1.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, }) require.NoError(t, err) @@ -84,6 +85,7 @@ func testListPendingSoftwareInstalls(t *testing.T, ds *Datastore) { Version: "2.0", Source: "apps", UserID: user1.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, }) require.NoError(t, err) @@ -101,6 +103,7 @@ func testListPendingSoftwareInstalls(t *testing.T, ds *Datastore) { Source: "apps", SelfService: true, UserID: user1.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, }) require.NoError(t, err) @@ -203,12 +206,13 @@ func testSoftwareInstallRequests(t *testing.T, ds *Datastore) { require.Nil(t, si) installerID, titleID, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ - Title: "foo", - Source: "bar", - InstallScript: "echo", - TeamID: teamID, - Filename: "foo.pkg", - UserID: user1.ID, + Title: "foo", + Source: "bar", + InstallScript: "echo", + TeamID: teamID, + Filename: "foo.pkg", + UserID: user1.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, }) require.NoError(t, err) installerMeta, err := ds.GetSoftwareInstallerMetadataByID(ctx, installerID) @@ -503,13 +507,14 @@ func testGetSoftwareInstallResult(t *testing.T, ds *Datastore) { // create a host and software installer swFilename := "file_" + tc.name + ".pkg" installerID, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ - Title: "foo" + tc.name, - Source: "bar" + tc.name, - InstallScript: "echo " + tc.name, - Version: "1.11", - TeamID: &teamID, - Filename: swFilename, - UserID: user1.ID, + Title: "foo" + tc.name, + Source: "bar" + tc.name, + InstallScript: "echo " + tc.name, + Version: "1.11", + TeamID: &teamID, + Filename: swFilename, + UserID: user1.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, }) require.NoError(t, err) host, err := ds.NewHost(ctx, &fleet.Host{ @@ -634,13 +639,14 @@ func testCleanupUnusedSoftwareInstallers(t *testing.T, ds *Datastore) { assertExisting([]string{ins0}) swi, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ - InstallScript: "install", - InstallerFile: tfr0, - StorageID: ins0, - Filename: "installer0", - Title: "ins0", - Source: "apps", - UserID: user1.ID, + InstallScript: "install", + InstallerFile: tfr0, + StorageID: ins0, + Filename: "installer0", + Title: "ins0", + Source: "apps", + UserID: user1.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, }) require.NoError(t, err) @@ -925,6 +931,7 @@ func testGetSoftwareInstallerMetadataByTeamAndTitleID(t *testing.T, ds *Datastor Filename: "foo.pkg", Platform: "darwin", UserID: user1.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, }) require.NoError(t, err) installerMeta, err := ds.GetSoftwareInstallerMetadataByID(ctx, installerID) @@ -939,12 +946,13 @@ func testGetSoftwareInstallerMetadataByTeamAndTitleID(t *testing.T, ds *Datastor require.Equal(t, "SELECT 1", metaByTeamAndTitle.PreInstallQuery) installerID, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ - Title: "bar", - Source: "bar", - InstallScript: "echo install", - TeamID: &team.ID, - Filename: "foo.pkg", - UserID: user1.ID, + Title: "bar", + Source: "bar", + InstallScript: "echo install", + TeamID: &team.ID, + Filename: "foo.pkg", + UserID: user1.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, }) require.NoError(t, err) installerMeta, err = ds.GetSoftwareInstallerMetadataByID(ctx, installerID) @@ -978,14 +986,15 @@ func testHasSelfServiceSoftwareInstallers(t *testing.T, ds *Datastore) { // Create a non-self service installer _, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ - Title: "foo", - Source: "bar", - InstallScript: "echo install", - TeamID: &team.ID, - Filename: "foo.pkg", - Platform: platform, - SelfService: false, - UserID: user1.ID, + Title: "foo", + Source: "bar", + InstallScript: "echo install", + TeamID: &team.ID, + Filename: "foo.pkg", + Platform: platform, + SelfService: false, + UserID: user1.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, }) require.NoError(t, err) hasSelfService, err = ds.HasSelfServiceSoftwareInstallers(ctx, platform, nil) @@ -997,14 +1006,15 @@ func testHasSelfServiceSoftwareInstallers(t *testing.T, ds *Datastore) { // Create a self-service installer for team _, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ - Title: "foo2", - Source: "bar2", - InstallScript: "echo install", - TeamID: &team.ID, - Filename: "foo2.pkg", - Platform: platform, - SelfService: true, - UserID: user1.ID, + Title: "foo2", + Source: "bar2", + InstallScript: "echo install", + TeamID: &team.ID, + Filename: "foo2.pkg", + Platform: platform, + SelfService: true, + UserID: user1.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, }) require.NoError(t, err) hasSelfService, err = ds.HasSelfServiceSoftwareInstallers(ctx, platform, nil) @@ -1036,14 +1046,15 @@ func testHasSelfServiceSoftwareInstallers(t *testing.T, ds *Datastore) { // Create a global self-service installer _, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ - Title: "foo global", - Source: "bar", - InstallScript: "echo install", - TeamID: nil, - Filename: "foo global.pkg", - Platform: platform, - SelfService: true, - UserID: user1.ID, + Title: "foo global", + Source: "bar", + InstallScript: "echo install", + TeamID: nil, + Filename: "foo global.pkg", + Platform: platform, + SelfService: true, + UserID: user1.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, }) require.NoError(t, err) hasSelfService, err = ds.HasSelfServiceSoftwareInstallers(ctx, "ubuntu", nil) @@ -1087,15 +1098,16 @@ func testDeleteSoftwareInstallers(t *testing.T, ds *Datastore) { require.NoError(t, err) softwareInstallerID, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ - InstallScript: "install", - InstallerFile: tfr0, - StorageID: ins0, - Filename: "installer.pkg", - Title: "ins0", - Source: "apps", - Platform: "darwin", - TeamID: &team1.ID, - UserID: user1.ID, + InstallScript: "install", + InstallerFile: tfr0, + StorageID: ins0, + Filename: "installer.pkg", + Title: "ins0", + Source: "apps", + Platform: "darwin", + TeamID: &team1.ID, + UserID: user1.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, }) require.NoError(t, err) @@ -1161,15 +1173,16 @@ func testDeletePendingSoftwareInstallsForPolicy(t *testing.T, ds *Datastore) { require.NoError(t, err) installerID1, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ - InstallScript: "install", - InstallerFile: tfr0, - StorageID: ins0, - Filename: "installer.pkg", - Title: "ins0", - Source: "apps", - Platform: "darwin", - TeamID: &team1.ID, - UserID: user1.ID, + InstallScript: "install", + InstallerFile: tfr0, + StorageID: ins0, + Filename: "installer.pkg", + Title: "ins0", + Source: "apps", + Platform: "darwin", + TeamID: &team1.ID, + UserID: user1.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, }) require.NoError(t, err) @@ -1181,15 +1194,16 @@ func testDeletePendingSoftwareInstallsForPolicy(t *testing.T, ds *Datastore) { require.NoError(t, err) installerID2, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ - InstallScript: "install", - InstallerFile: tfr0, - StorageID: ins0, - Filename: "installer.pkg", - Title: "ins1", - Source: "apps", - Platform: "darwin", - TeamID: &team1.ID, - UserID: user1.ID, + InstallScript: "install", + InstallerFile: tfr0, + StorageID: ins0, + Filename: "installer.pkg", + Title: "ins1", + Source: "apps", + Platform: "darwin", + TeamID: &team1.ID, + UserID: user1.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, }) require.NoError(t, err) @@ -1276,27 +1290,29 @@ func testGetHostLastInstallData(t *testing.T, ds *Datastore) { require.NoError(t, err) softwareInstallerID1, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ - InstallScript: "install", - InstallerFile: tfr0, - StorageID: ins0, - Filename: "installer.pkg", - Title: "ins1", - Source: "apps", - Platform: "darwin", - TeamID: &team1.ID, - UserID: user1.ID, + InstallScript: "install", + InstallerFile: tfr0, + StorageID: ins0, + Filename: "installer.pkg", + Title: "ins1", + Source: "apps", + Platform: "darwin", + TeamID: &team1.ID, + UserID: user1.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, }) require.NoError(t, err) softwareInstallerID2, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ - InstallScript: "install2", - InstallerFile: tfr0, - StorageID: ins0, - Filename: "installer2.pkg", - Title: "ins2", - Source: "apps", - Platform: "darwin", - TeamID: &team1.ID, - UserID: user1.ID, + InstallScript: "install2", + InstallerFile: tfr0, + StorageID: ins0, + Filename: "installer2.pkg", + Title: "ins2", + Source: "apps", + Platform: "darwin", + TeamID: &team1.ID, + UserID: user1.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, }) require.NoError(t, err) diff --git a/server/datastore/mysql/software_test.go b/server/datastore/mysql/software_test.go index acb62687f8..aecf7731b7 100644 --- a/server/datastore/mysql/software_test.go +++ b/server/datastore/mysql/software_test.go @@ -4616,15 +4616,16 @@ func testListHostSoftwareInstallThenTransferTeam(t *testing.T, ds *Datastore) { tfr1, err := fleet.NewTempFileReader(strings.NewReader("hello"), t.TempDir) require.NoError(t, err) installerTm1, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ - InstallScript: "hello", - InstallerFile: tfr1, - StorageID: "storage1", - Filename: "file1", - Title: "file1", - Version: "1.0", - Source: "apps", - TeamID: &team1.ID, - UserID: user.ID, + InstallScript: "hello", + InstallerFile: tfr1, + StorageID: "storage1", + Filename: "file1", + Title: "file1", + Version: "1.0", + Source: "apps", + TeamID: &team1.ID, + UserID: user.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, }) require.NoError(t, err) @@ -4728,15 +4729,16 @@ func testListHostSoftwareInstallThenDeleteInstallers(t *testing.T, ds *Datastore tfr1, err := fleet.NewTempFileReader(strings.NewReader("hello"), t.TempDir) require.NoError(t, err) installerTm1, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ - InstallScript: "hello", - InstallerFile: tfr1, - StorageID: "storage1", - Filename: "file1", - Title: "file1", - Version: "1.0", - Source: "apps", - TeamID: &team1.ID, - UserID: user.ID, + InstallScript: "hello", + InstallerFile: tfr1, + StorageID: "storage1", + Filename: "file1", + Title: "file1", + Version: "1.0", + Source: "apps", + TeamID: &team1.ID, + UserID: user.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, }) require.NoError(t, err) @@ -5273,6 +5275,7 @@ func testListHostSoftwareWithLabelScoping(t *testing.T, ds *Datastore) { UserID: user1.ID, BundleIdentifier: "bi1", Platform: "darwin", + ValidatedLabels: &fleet.LabelIdentsWithScope{}, }) require.NoError(t, err) @@ -5339,6 +5342,7 @@ func testListHostSoftwareWithLabelScoping(t *testing.T, ds *Datastore) { UserID: user1.ID, BundleIdentifier: "bi2", Platform: "darwin", + ValidatedLabels: &fleet.LabelIdentsWithScope{}, }) require.NoError(t, err) @@ -5380,6 +5384,7 @@ func testListHostSoftwareWithLabelScoping(t *testing.T, ds *Datastore) { UserID: user1.ID, BundleIdentifier: "bi3", Platform: "darwin", + ValidatedLabels: &fleet.LabelIdentsWithScope{}, }) require.NoError(t, err) diff --git a/server/datastore/mysql/software_titles_test.go b/server/datastore/mysql/software_titles_test.go index cdc538cbdc..57cf6af4e6 100644 --- a/server/datastore/mysql/software_titles_test.go +++ b/server/datastore/mysql/software_titles_test.go @@ -301,11 +301,12 @@ func testOrderSoftwareTitles(t *testing.T, ds *Datastore) { // create a software installer not installed on any host installer1, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ - Title: "installer1", - Source: "apps", - InstallScript: "echo", - Filename: "installer1.pkg", - UserID: user1.ID, + Title: "installer1", + Source: "apps", + InstallScript: "echo", + Filename: "installer1.pkg", + UserID: user1.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, }) require.NoError(t, err) require.NotZero(t, installer1) @@ -316,11 +317,12 @@ func testOrderSoftwareTitles(t *testing.T, ds *Datastore) { }) // create a software installer with an install request on host1 installer2, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ - Title: "installer2", - Source: "apps", - InstallScript: "echo", - Filename: "installer2.pkg", - UserID: user1.ID, + Title: "installer2", + Source: "apps", + InstallScript: "echo", + Filename: "installer2.pkg", + UserID: user1.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, }) require.NoError(t, err) _, err = ds.InsertSoftwareInstallRequest(ctx, host1.ID, installer2, false, nil) @@ -639,6 +641,7 @@ func testTeamFilterSoftwareTitles(t *testing.T, ds *Datastore) { BundleIdentifier: "foo.bar", TeamID: &team1.ID, UserID: user1.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, }) require.NoError(t, err) require.NotZero(t, installer1) @@ -649,12 +652,13 @@ func testTeamFilterSoftwareTitles(t *testing.T, ds *Datastore) { }) // create a software installer for team2 installer2, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ - Title: "installer2", - Source: "apps", - InstallScript: "echo", - Filename: "installer2.pkg", - TeamID: &team2.ID, - UserID: user1.ID, + Title: "installer2", + Source: "apps", + InstallScript: "echo", + Filename: "installer2.pkg", + TeamID: &team2.ID, + UserID: user1.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, }) require.NoError(t, err) require.NotZero(t, installer2) @@ -876,20 +880,22 @@ func testListSoftwareTitlesInstallersOnly(t *testing.T, ds *Datastore) { // create a couple software installers not installed on any host installer1, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ - Title: "installer1", - Source: "apps", - InstallScript: "echo", - Filename: "installer1.pkg", - UserID: user1.ID, + Title: "installer1", + Source: "apps", + InstallScript: "echo", + Filename: "installer1.pkg", + UserID: user1.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, }) require.NoError(t, err) require.NotZero(t, installer1) installer2, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ - Title: "installer2", - Source: "apps", - InstallScript: "echo", - Filename: "installer2.pkg", - UserID: user1.ID, + Title: "installer2", + Source: "apps", + InstallScript: "echo", + Filename: "installer2.pkg", + UserID: user1.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, }) require.NoError(t, err) require.NotZero(t, installer2) @@ -981,20 +987,22 @@ func testListSoftwareTitlesAvailableForInstallFilter(t *testing.T, ds *Datastore // create 2 software installers installer1, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ - Title: "installer1", - Source: "apps", - InstallScript: "echo", - Filename: "installer1.pkg", - UserID: user1.ID, + Title: "installer1", + Source: "apps", + InstallScript: "echo", + Filename: "installer1.pkg", + UserID: user1.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, }) require.NoError(t, err) require.NotZero(t, installer1) installer2, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ - Title: "installer2", - Source: "apps", - InstallScript: "echo", - Filename: "installer2.pkg", - UserID: user1.ID, + Title: "installer2", + Source: "apps", + InstallScript: "echo", + Filename: "installer2.pkg", + UserID: user1.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, }) require.NoError(t, err) require.NotZero(t, installer2) @@ -1178,6 +1186,7 @@ func testListSoftwareTitlesAllTeams(t *testing.T, ds *Datastore) { Filename: "foobar.pkg", TeamID: nil, UserID: user1.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, }) require.NoError(t, err) @@ -1361,6 +1370,7 @@ func testUploadedSoftwareExists(t *testing.T, ds *Datastore) { Filename: "installer1.pkg", BundleIdentifier: "com.foo.installer1", UserID: user1.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, }) require.NoError(t, err) require.NotZero(t, installer1) @@ -1372,6 +1382,7 @@ func testUploadedSoftwareExists(t *testing.T, ds *Datastore) { TeamID: &tm.ID, BundleIdentifier: "com.foo.installer2", UserID: user1.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, }) require.NoError(t, err) require.NotZero(t, installer2) diff --git a/server/datastore/mysql/testing_utils.go b/server/datastore/mysql/testing_utils.go index f2b13717db..eeee8710bb 100644 --- a/server/datastore/mysql/testing_utils.go +++ b/server/datastore/mysql/testing_utils.go @@ -67,6 +67,14 @@ func connectMySQL(t testing.TB, testName string, opts *DatastoreTestOptions) *Da tc := config.TestConfig() tc.Osquery.MinSoftwareLastOpenedAtDiff = defaultMinLastOpenedAtDiff + // TODO: for some reason we never log datastore messages when running integration tests, why? + // + // Changes below assume that we want to follows the same pattern as the rest of the codebase. + dslogger := log.NewLogfmtLogger(os.Stdout) + if os.Getenv("FLEET_INTEGRATION_TESTS_DISABLE_LOG") != "" { + dslogger = log.NewNopLogger() + } + // set SQL mode to ANSI, as it's a special mode equivalent to: // REAL_AS_FLOAT, PIPES_AS_CONCAT, ANSI_QUOTES, IGNORE_SPACE, and // ONLY_FULL_GROUP_BY @@ -76,7 +84,7 @@ func connectMySQL(t testing.TB, testName string, opts *DatastoreTestOptions) *Da // standard SQL. // // https://dev.mysql.com/doc/refman/8.0/en/sql-mode.html#sqlmode_ansi - ds, err := New(cfg, clock.NewMockClock(), Logger(log.NewNopLogger()), LimitAttempts(1), replicaOpt, SQLMode("ANSI"), WithFleetConfig(&tc)) + ds, err := New(cfg, clock.NewMockClock(), Logger(dslogger), LimitAttempts(1), replicaOpt, SQLMode("ANSI"), WithFleetConfig(&tc)) require.Nil(t, err) if opts.DummyReplica { diff --git a/server/fleet/labels.go b/server/fleet/labels.go index b9158ebb8b..01538b6152 100644 --- a/server/fleet/labels.go +++ b/server/fleet/labels.go @@ -194,3 +194,30 @@ func DetectMissingLabels(validLabelMap map[string]uint, unvalidatedLabels []stri return missingLabels } + +// LabelIdent is a simple struct to hold the ID and Name of a label +type LabelIdent struct { + LabelID uint + LabelName string +} + +// LabelScope identifies the manner by which labels may be used to scope entities, such as MDM +// profiles and software installers, to subsets of hosts. +type LabelScope string + +const ( + // LabelScopeExcludeAny indicates that a label-scoped entity (e.g., MDM profiles, software + // installers) should NOT be applied to a host if the host is a mamber of any of the associated labels. + LabelScopeExcludeAny LabelScope = "exclude_any" + // LabelScopeIncludeAny indicates that a label-scoped entity (e.g., MDM profiles, software + // installers) should be applied to a host that if the host is a member of all of the associated labels. + LabelScopeIncludeAny LabelScope = "include_any" + // LabelScopeIncludeAll indicates that a label-scoped entity (e.g., MDM profiles, software + // installers) should be applied to a host if the host is a member of all of the associated labels. + LabelScopeIncludeAll LabelScope = "include_all" +) + +type LabelIdentsWithScope struct { + LabelScope LabelScope + ByName map[string]LabelIdent +} diff --git a/server/fleet/service.go b/server/fleet/service.go index d499971259..fc92c8fb05 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -259,6 +259,11 @@ type Service interface { // ListHostsInLabel returns a slice of hosts in the label with the given ID. ListHostsInLabel(ctx context.Context, lid uint, opt HostListOptions) ([]*Host, error) + // BatchValidateLabels validates that each of the provided label names exists. The returned map + // is keyed by label name. Caller must ensure that appropirate authorization checks are + // performed prior to calling this method. + BatchValidateLabels(ctx context.Context, labelNames []string) (map[string]LabelIdent, error) + // ///////////////////////////////////////////////////////////////////////////// // QueryService @@ -1163,7 +1168,7 @@ type Service interface { // Fleet-maintained apps // AddFleetMaintainedApp adds a Fleet-maintained app to the given team. - AddFleetMaintainedApp(ctx context.Context, teamID *uint, appID uint, installScript, preInstallQuery, postInstallScript, uninstallScript string, selfService bool) (uint, error) + AddFleetMaintainedApp(ctx context.Context, teamID *uint, appID uint, installScript, preInstallQuery, postInstallScript, uninstallScript string, selfService bool, labelsIncludeAny, labelsExcludeAny []string) (uint, error) // ListFleetMaintainedApps lists Fleet-maintained apps available to a specific team ListFleetMaintainedApps(ctx context.Context, teamID *uint, opts ListOptions) ([]MaintainedApp, *PaginationMetadata, error) // GetFleetMaintainedApp returns a Fleet-maintained app by ID diff --git a/server/fleet/software_installer.go b/server/fleet/software_installer.go index 73fee6431d..5c8a7a089a 100644 --- a/server/fleet/software_installer.go +++ b/server/fleet/software_installer.go @@ -338,6 +338,9 @@ type UploadSoftwareInstallerPayload struct { InstallDuringSetup *bool // keep saved value if nil, otherwise set as indicated LabelsIncludeAny []string // names of "include any" labels LabelsExcludeAny []string // names of "exclude any" labels + // ValidatedLabels is a struct that contains the validated labels for the software installer. It + // is nil if the labels have not been validated. + ValidatedLabels *LabelIdentsWithScope } type UpdateSoftwareInstallerPayload struct { diff --git a/server/service/handler.go b/server/service/handler.go index 465350f63e..1c89abd466 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -404,6 +404,7 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC // Fleet-maintained apps ue.POST("/api/_version_/fleet/software/fleet_maintained_apps", addFleetMaintainedAppEndpoint, addFleetMaintainedAppRequest{}) + ue.PATCH("/api/_version_/fleet/software/fleet_maintained_apps", editFleetMaintainedAppEndpoint, editFleetMaintainedAppRequest{}) ue.GET("/api/_version_/fleet/software/fleet_maintained_apps", listFleetMaintainedAppsEndpoint, listFleetMaintainedAppsRequest{}) ue.GET("/api/_version_/fleet/software/fleet_maintained_apps/{app_id}", getFleetMaintainedApp, getFleetMaintainedAppRequest{}) diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index 89d7a97c00..2234005d1e 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -11904,14 +11904,15 @@ func (s *integrationTestSuite) TestListHostUpcomingActivities() { tfr1, err := fleet.NewTempFileReader(strings.NewReader("echo"), t.TempDir) require.NoError(t, err) sw1, _, err := s.ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ - InstallScript: "install foo", - InstallerFile: tfr1, - StorageID: uuid.NewString(), - Filename: "foo.pkg", - Title: "foo", - Source: "apps", - Version: "0.0.1", - UserID: adminUser.ID, + InstallScript: "install foo", + InstallerFile: tfr1, + StorageID: uuid.NewString(), + Filename: "foo.pkg", + Title: "foo", + Source: "apps", + Version: "0.0.1", + UserID: adminUser.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, }) require.NoError(t, err) s1Meta, err := s.ds.GetSoftwareInstallerMetadataByID(ctx, sw1) diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index 9d874eb01c..a21ea904b0 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -10634,21 +10634,63 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndD require.Equal(t, checkSoftwareTitle(t, payload.Title, "deb_packages"), *meta.TitleID) require.NotZero(t, meta.UploadedAt) + // get metadata by team and title ID so we can check labels + meta2, err := s.ds.GetSoftwareInstallerMetadataByTeamAndTitleID(context.Background(), payload.TeamID, *meta.TitleID, false) + require.NoError(t, err) + + // check labels include any + require.Len(t, meta2.LabelsIncludeAny, len(payload.LabelsIncludeAny)) + byName := make(map[string]struct{}, len(meta2.LabelsIncludeAny)) + for _, l := range meta2.LabelsIncludeAny { + byName[l.LabelName] = struct{}{} + require.Equal(t, *meta2.TitleID, l.TitleID) + require.False(t, l.Exclude) + } + require.Len(t, byName, len(payload.LabelsIncludeAny)) + for _, l := range payload.LabelsIncludeAny { + _, ok := byName[l] + require.True(t, ok) + } + + // check labels exclude any + require.Len(t, meta2.LabelsExcludeAny, len(payload.LabelsExcludeAny)) + byName = make(map[string]struct{}, len(meta2.LabelsExcludeAny)) + for _, l := range meta.LabelsExcludeAny { + byName[l.LabelName] = struct{}{} + require.Equal(t, *meta2.TitleID, l.TitleID) + require.True(t, l.Exclude) + } + require.Len(t, byName, len(payload.LabelsExcludeAny)) + for _, l := range payload.LabelsExcludeAny { + _, ok := byName[l] + require.True(t, ok) + } + return meta.InstallerID, *meta.TitleID } t.Run("upload no team software installer", func(t *testing.T) { + // status is reflected in list hosts responses and counts when filtering by software title and status + // create a label to test also the counts per label with the software install status filter + var labelResp createLabelResponse + s.DoJSON("POST", "/api/latest/fleet/labels", &createLabelRequest{fleet.LabelPayload{ + Name: t.Name(), + Query: "select 1", + }}, http.StatusOK, &labelResp) + require.NotZero(t, labelResp.Label.ID) + payload := &fleet.UploadSoftwareInstallerPayload{ InstallScript: "some install script", PreInstallQuery: "some pre install query", PostInstallScript: "some post install script", Filename: "ruby.deb", // additional fields below are pre-populated so we can re-use the payload later for the test assertions - Title: "ruby", - Version: "1:2.5.1", - Source: "deb_packages", - StorageID: "df06d9ce9e2090d9cb2e8cd1f4d7754a803dc452bf93e3204e3acd3b95508628", - Platform: "linux", + Title: "ruby", + Version: "1:2.5.1", + Source: "deb_packages", + StorageID: "df06d9ce9e2090d9cb2e8cd1f4d7754a803dc452bf93e3204e3acd3b95508628", + Platform: "linux", + LabelsIncludeAny: []string{t.Name()}, } s.uploadSoftwareInstaller(t, payload, http.StatusOK, "") diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go index e023683c61..fa8f13911d 100644 --- a/server/service/integration_mdm_test.go +++ b/server/service/integration_mdm_test.go @@ -12017,6 +12017,7 @@ func (s *integrationMDMTestSuite) TestSetupExperience() { UserID: user1.ID, TeamID: &team1.ID, Platform: string(fleet.MacOSPlatform), + ValidatedLabels: &fleet.LabelIdentsWithScope{}, }) _ = installerID1 require.NoError(t, err) diff --git a/server/service/labels.go b/server/service/labels.go index 6bec5392bc..2b0ce6f598 100644 --- a/server/service/labels.go +++ b/server/service/labels.go @@ -5,6 +5,8 @@ import ( "fmt" "net/http" + "github.com/fleetdm/fleet/v4/server" + authz_ctx "github.com/fleetdm/fleet/v4/server/contexts/authz" "github.com/fleetdm/fleet/v4/server/contexts/ctxdb" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" "github.com/fleetdm/fleet/v4/server/contexts/license" @@ -649,3 +651,38 @@ func (svc *Service) GetLabelSpec(ctx context.Context, name string) (*fleet.Label return svc.ds.GetLabelSpec(ctx, name) } + +func (svc *Service) BatchValidateLabels(ctx context.Context, labelNames []string) (map[string]fleet.LabelIdent, error) { + if authctx, ok := authz_ctx.FromContext(ctx); !ok { + return nil, fleet.NewAuthRequiredError("batch validate labels: missing authorization context") + } else if !authctx.Checked() { + return nil, fleet.NewAuthRequiredError("batch validate labels: method requires previous authorization") + } + + if len(labelNames) == 0 { + return nil, nil + } + + uniqueNames := server.RemoveDuplicatesFromSlice(labelNames) + + labels, err := svc.ds.LabelIDsByName(ctx, uniqueNames) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "getting label IDs by name") + } + + if len(labels) != len(uniqueNames) { + return nil, &fleet.BadRequestError{ + Message: "some or all the labels provided don't exist", + InternalErr: fmt.Errorf("names provided: %v", labelNames), + } + } + + byName := make(map[string]fleet.LabelIdent, len(labels)) + for labelName, labelID := range labels { + byName[labelName] = fleet.LabelIdent{ + LabelName: labelName, + LabelID: labelID, + } + } + return byName, nil +} diff --git a/server/service/maintained_apps.go b/server/service/maintained_apps.go index 04d7883579..f9a267cf70 100644 --- a/server/service/maintained_apps.go +++ b/server/service/maintained_apps.go @@ -10,13 +10,15 @@ import ( ) type addFleetMaintainedAppRequest struct { - TeamID *uint `json:"team_id"` - AppID uint `json:"fleet_maintained_app_id"` - InstallScript string `json:"install_script"` - PreInstallQuery string `json:"pre_install_query"` - PostInstallScript string `json:"post_install_script"` - SelfService bool `json:"self_service"` - UninstallScript string `json:"uninstall_script"` + TeamID *uint `json:"team_id"` + AppID uint `json:"fleet_maintained_app_id"` + InstallScript string `json:"install_script"` + PreInstallQuery string `json:"pre_install_query"` + PostInstallScript string `json:"post_install_script"` + SelfService bool `json:"self_service"` + UninstallScript string `json:"uninstall_script"` + LabelsIncludeAny []string `json:"labels_include_any"` + LabelsExcludeAny []string `json:"labels_exclude_any"` } type addFleetMaintainedAppResponse struct { @@ -39,6 +41,8 @@ func addFleetMaintainedAppEndpoint(ctx context.Context, request interface{}, svc req.PostInstallScript, req.UninstallScript, req.SelfService, + req.LabelsIncludeAny, + req.LabelsExcludeAny, ) if err != nil { if errors.Is(err, context.DeadlineExceeded) { @@ -50,7 +54,7 @@ func addFleetMaintainedAppEndpoint(ctx context.Context, request interface{}, svc return &addFleetMaintainedAppResponse{SoftwareTitleID: titleId}, nil } -func (svc *Service) AddFleetMaintainedApp(ctx context.Context, teamID *uint, appID uint, installScript, preInstallQuery, postInstallScript, uninstallScript string, selfService bool) (uint, error) { +func (svc *Service) AddFleetMaintainedApp(ctx context.Context, teamID *uint, appID uint, installScript, preInstallQuery, postInstallScript, uninstallScript string, selfService bool, labelsIncludeAny, labelsExcludeAny []string) (uint, error) { // skipauth: No authorization check needed due to implementation returning // only license error. svc.authz.SkipAuthorization(ctx) @@ -58,6 +62,24 @@ func (svc *Service) AddFleetMaintainedApp(ctx context.Context, teamID *uint, app return 0, fleet.ErrMissingLicense } +type editFleetMaintainedAppRequest struct { + TeamID *uint `json:"team_id"` + AppID uint `json:"fleet_maintained_app_id"` + InstallScript string `json:"install_script"` + PreInstallQuery string `json:"pre_install_query"` + PostInstallScript string `json:"post_install_script"` + SelfService bool `json:"self_service"` + UninstallScript string `json:"uninstall_script"` + LabelsIncludeAny []string `json:"labels_include_any"` + LabelsExcludeAny []string `json:"labels_exclude_any"` +} + +func editFleetMaintainedAppEndpoint(ctx context.Context, request any, svc fleet.Service) (errorer, error) { + // TODO: implement this + + return nil, errors.New("not implemented") +} + type listFleetMaintainedAppsRequest struct { fleet.ListOptions TeamID *uint `query:"team_id,optional"` diff --git a/server/service/software_installers.go b/server/service/software_installers.go index ba6bf84bb2..aae91e69b0 100644 --- a/server/service/software_installers.go +++ b/server/service/software_installers.go @@ -30,6 +30,8 @@ type uploadSoftwareInstallerRequest struct { PostInstallScript string SelfService bool UninstallScript string + LabelsIncludeAny []string + LabelsExcludeAny []string } type updateSoftwareInstallerRequest struct { @@ -41,6 +43,8 @@ type updateSoftwareInstallerRequest struct { PostInstallScript *string UninstallScript *string SelfService *bool + LabelsIncludeAny []string + LabelsExcludeAny []string } type uploadSoftwareInstallerResponse struct { @@ -131,6 +135,16 @@ func (updateSoftwareInstallerRequest) DecodeRequest(ctx context.Context, r *http decoded.SelfService = &parsed } + // decode labels + var existsInclAny, existsExclAny bool + decoded.LabelsIncludeAny, existsInclAny = r.MultipartForm.Value[string(fleet.LabelsIncludeAny)] + decoded.LabelsExcludeAny, existsExclAny = r.MultipartForm.Value[string(fleet.LabelsExcludeAny)] + + // validate that only one of the labels type is provided + if existsInclAny && existsExclAny { + return nil, &fleet.BadRequestError{Message: `Only one of "labels_include_any" or "labels_exclude_any" can be included.`} + } + return &decoded, nil } @@ -145,6 +159,8 @@ func updateSoftwareInstallerEndpoint(ctx context.Context, request interface{}, s PostInstallScript: req.PostInstallScript, UninstallScript: req.UninstallScript, SelfService: req.SelfService, + LabelsIncludeAny: req.LabelsIncludeAny, + LabelsExcludeAny: req.LabelsExcludeAny, } if req.File != nil { ff, err := req.File.Open() @@ -261,6 +277,16 @@ func (uploadSoftwareInstallerRequest) DecodeRequest(ctx context.Context, r *http decoded.SelfService = parsed } + // decode labels + var existsInclAny, existsExclAny bool + decoded.LabelsIncludeAny, existsInclAny = r.MultipartForm.Value[string(fleet.LabelsIncludeAny)] + decoded.LabelsExcludeAny, existsExclAny = r.MultipartForm.Value[string(fleet.LabelsExcludeAny)] + + // validate that only one of the labels type is provided + if existsInclAny && existsExclAny { + return nil, &fleet.BadRequestError{Message: `Only one of "labels_include_any" or "labels_exclude_any" can be included.`} + } + return &decoded, nil } @@ -289,6 +315,8 @@ func uploadSoftwareInstallerEndpoint(ctx context.Context, request interface{}, s Filename: req.File.Filename, SelfService: req.SelfService, UninstallScript: req.UninstallScript, + LabelsIncludeAny: req.LabelsIncludeAny, + LabelsExcludeAny: req.LabelsExcludeAny, } if err := svc.UploadSoftwareInstaller(ctx, payload); err != nil { diff --git a/server/service/testing_client.go b/server/service/testing_client.go index ba4939a641..a5777d1d23 100644 --- a/server/service/testing_client.go +++ b/server/service/testing_client.go @@ -14,6 +14,7 @@ import ( "os" "path/filepath" "regexp" + "strings" "sync" "testing" "time" @@ -582,6 +583,12 @@ func (ts *withServer) uploadSoftwareInstaller( if payload.SelfService { require.NoError(t, w.WriteField("self_service", "true")) } + if payload.LabelsIncludeAny != nil { + require.NoError(t, w.WriteField("labels_include_any", strings.Join(payload.LabelsIncludeAny, ","))) + } + if payload.LabelsExcludeAny != nil { + require.NoError(t, w.WriteField("labels_exclude_any", strings.Join(payload.LabelsExcludeAny, ","))) + } w.Close() From 0876a9d69501cc42d8e68d2dccaf4f7159b93b03 Mon Sep 17 00:00:00 2001 From: Gabriel Hernandez Date: Tue, 17 Dec 2024 10:41:57 -0600 Subject: [PATCH 08/23] Add UI for scoping software to fleet apps and custom packages via labels (#24793) relates to #24538, #24542, #24540, #24537 implements the UI for scoping software to fleet maintained apps and custom packages. This includes: **adding custom target label selection to fleet maintained app form** image **adding custom target label selection to custom package form** image ***adding custom target label selection on edit software modals** image also includes various small copy changes. - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. - [ ] Added/updated tests - [x] Manual QA for all new/changed functionality --- ...538-24542-UI-for-scope-software-via-labels | 1 + .../PlatformSelector/PlatformSelector.tsx | 5 +- .../components/PlatformSelector/_styles.scss | 4 + .../TargetLabelSelector.tsx | 210 ++++++++++++++++++ .../TargetLabelSelector/_styles.scss | 57 +++++ .../components/TargetLabelSelector/index.ts | 1 + frontend/interfaces/software.ts | 3 + .../AddProfileModal/AddProfileModal.tsx | 177 +++------------ .../components/AddProfileModal/helpers.tsx | 12 +- .../SoftwareCustomPackage.tsx | 70 ++++-- .../SoftwareCustomPackage/_styles.scss | 4 + .../FleetAppDetailsForm.tsx | 53 ++++- .../{helpers.ts => helpers.tsx} | 67 +++++- .../FleetMaintainedAppDetailsPage.tsx | 30 ++- .../_styles.scss | 4 + .../EditSoftwareModal/EditSoftwareModal.tsx | 26 ++- .../SoftwarePackageCard.tsx | 1 - .../components/PackageForm/PackageForm.tsx | 53 ++++- .../components/PackageForm/helpers.ts | 42 +++- .../DeleteLabelModal/DeleteLabelModal.tsx | 9 +- frontend/services/entities/software.ts | 21 ++ 21 files changed, 644 insertions(+), 206 deletions(-) create mode 100644 changes/24538-24542-UI-for-scope-software-via-labels create mode 100644 frontend/components/TargetLabelSelector/TargetLabelSelector.tsx create mode 100644 frontend/components/TargetLabelSelector/_styles.scss create mode 100644 frontend/components/TargetLabelSelector/index.ts rename frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareFleetMaintained/FleetMaintainedAppDetailsPage/FleetAppDetailsForm/{helpers.ts => helpers.tsx} (52%) diff --git a/changes/24538-24542-UI-for-scope-software-via-labels b/changes/24538-24542-UI-for-scope-software-via-labels new file mode 100644 index 0000000000..d8d65558df --- /dev/null +++ b/changes/24538-24542-UI-for-scope-software-via-labels @@ -0,0 +1 @@ +- add UI for scoping software via labels diff --git a/frontend/components/PlatformSelector/PlatformSelector.tsx b/frontend/components/PlatformSelector/PlatformSelector.tsx index 4cbd7f5686..3eb907f9b2 100644 --- a/frontend/components/PlatformSelector/PlatformSelector.tsx +++ b/frontend/components/PlatformSelector/PlatformSelector.tsx @@ -35,7 +35,7 @@ export const PlatformSelector = ({ return (
- Checks on: + Targets:
- Your policy will only be checked on the selected platform(s). + To apply the profile to new hosts, you'll have to delete it and + upload a new profile.
); diff --git a/frontend/components/PlatformSelector/_styles.scss b/frontend/components/PlatformSelector/_styles.scss index 63fe84e7e9..edd5155687 100644 --- a/frontend/components/PlatformSelector/_styles.scss +++ b/frontend/components/PlatformSelector/_styles.scss @@ -11,4 +11,8 @@ .form-field__label--disabled { color: $ui-fleet-black-50; } + + &__platform-checkbox-wrapper { + width: auto; + } } diff --git a/frontend/components/TargetLabelSelector/TargetLabelSelector.tsx b/frontend/components/TargetLabelSelector/TargetLabelSelector.tsx new file mode 100644 index 0000000000..d4fe8c6468 --- /dev/null +++ b/frontend/components/TargetLabelSelector/TargetLabelSelector.tsx @@ -0,0 +1,210 @@ +import React, { ReactNode } from "react"; +import { Link } from "react-router"; +import classnames from "classnames"; + +import PATHS from "router/paths"; +import { IDropdownOption } from "interfaces/dropdownOption"; +import { ILabelSummary } from "interfaces/label"; + +// @ts-ignore +import Dropdown from "components/forms/fields/Dropdown"; +import Radio from "components/forms/fields/Radio"; +import DataError from "components/DataError"; +import Spinner from "components/Spinner"; +import Checkbox from "components/forms/fields/Checkbox"; + +const baseClass = "target-label-selector"; + +export const listNamesFromSelectedLabels = (dict: Record) => { + return Object.entries(dict).reduce((acc, [labelName, isSelected]) => { + if (isSelected) { + acc.push(labelName); + } + return acc; + }, [] as string[]); +}; + +export const generateLabelKey = ( + target: string, + customTargetOption: string, + selectedLabels: Record +) => { + if (target !== "Custom") { + return {}; + } + + return { + [customTargetOption]: listNamesFromSelectedLabels(selectedLabels), + }; +}; + +interface ITargetChooserProps { + selectedTarget: string; + onSelect: (val: string) => void; +} + +const TargetChooser = ({ selectedTarget, onSelect }: ITargetChooserProps) => { + return ( +
+
Target
+ + +
+ ); +}; + +interface ILabelChooserProps { + isError: boolean; + isLoading: boolean; + labels: ILabelSummary[]; + selectedLabels: Record; + selectedCustomTarget: string; + customTargetOptions: IDropdownOption[]; + dropdownHelpText?: ReactNode; + onSelectCustomTarget: (val: string) => void; + onSelectLabel: ({ name, value }: { name: string; value: boolean }) => void; +} + +const LabelChooser = ({ + isError, + isLoading, + labels, + dropdownHelpText, + selectedLabels, + selectedCustomTarget, + customTargetOptions, + onSelectCustomTarget, + onSelectLabel, +}: ILabelChooserProps) => { + const getHelpText = (value: string) => { + if (dropdownHelpText) return dropdownHelpText; + return customTargetOptions.find((option) => option.value === value) + ?.helpText; + }; + + const renderLabels = () => { + if (isLoading) { + return ; + } + + if (isError) { + return ; + } + + if (!labels.length) { + return ( +
+ + Add labels to target + specific hosts. + +
+ ); + } + + return labels.map((label) => { + return ( +
+ +
{label.name}
+
+ ); + }); + }; + + return ( +
+ +
+ {getHelpText(selectedCustomTarget)} +
+
{renderLabels()}
+
+ ); +}; + +interface ITargetLabelSelectorProps { + selectedTargetType: string; + selectedCustomTarget: string; + customTargetOptions: IDropdownOption[]; + selectedLabels: Record; + labels: ILabelSummary[]; + /** set this prop to show a help text. If it is encluded then it will override + * the selected options defined `helpText` + */ + dropdownHelpText?: ReactNode; + isLoadingLabels?: boolean; + isErrorLabels?: boolean; + className?: string; + onSelectTargetType: (val: string) => void; + onSelectCustomTarget: (val: string) => void; + onSelectLabel: ({ name, value }: { name: string; value: boolean }) => void; +} + +const TargetLabelSelector = ({ + selectedTargetType, + selectedCustomTarget, + customTargetOptions, + selectedLabels, + dropdownHelpText, + className, + labels, + isLoadingLabels = false, + isErrorLabels = false, + onSelectTargetType, + onSelectCustomTarget, + onSelectLabel, +}: ITargetLabelSelectorProps) => { + const classNames = classnames(baseClass, className); + + return ( +
+ + {selectedTargetType === "Custom" && ( + + )} +
+ ); +}; + +export default TargetLabelSelector; diff --git a/frontend/components/TargetLabelSelector/_styles.scss b/frontend/components/TargetLabelSelector/_styles.scss new file mode 100644 index 0000000000..e4f304db2d --- /dev/null +++ b/frontend/components/TargetLabelSelector/_styles.scss @@ -0,0 +1,57 @@ +.target-label-selector { + font-size: $x-small; + + &__custom-label-chooser { + margin-top: $pad-medium; + } + + &__description { + margin: $pad-medium 0; + } + + &__no-labels { + display: flex; + height: 187px; + flex-direction: column; + align-items: center; + gap: $pad-small; + justify-content: center; + + span { + color: $ui-fleet-black-75; + } + } + + &__checkboxes { + display: flex; + max-height: 187px; + flex-direction: column; + border-radius: $border-radius; + border: 1px solid $ui-fleet-black-10; + overflow-y: auto; + + .loading-spinner { + margin: 69.5px auto; + } + } + + &__label { + width: 100%; + padding: $pad-small $pad-medium; + box-sizing: border-box; + display: flex; + align-items: center; + + &:not(:last-child) { + border-bottom: 1px solid $ui-fleet-black-10; + } + + .form-field--checkbox { + width: auto; + } + } + + &__label-name { + padding-left: $pad-large; + } +} diff --git a/frontend/components/TargetLabelSelector/index.ts b/frontend/components/TargetLabelSelector/index.ts new file mode 100644 index 0000000000..ea5f05f17c --- /dev/null +++ b/frontend/components/TargetLabelSelector/index.ts @@ -0,0 +1 @@ +export { default } from "./TargetLabelSelector"; diff --git a/frontend/interfaces/software.ts b/frontend/interfaces/software.ts index 43077e0650..24e33eea46 100644 --- a/frontend/interfaces/software.ts +++ b/frontend/interfaces/software.ts @@ -4,6 +4,7 @@ import PropTypes from "prop-types"; import { IconNames } from "components/icons"; import vulnerabilityInterface from "./vulnerability"; +import { ILabelSummary } from "./label"; export default PropTypes.shape({ type: PropTypes.string, @@ -126,6 +127,8 @@ export interface ISoftwareTitleDetails { bundle_identifier?: string; browser?: BrowserType; versions_count?: number; + labels_include_any?: ILabelSummary[]; + labels_exclude_any?: ILabelSummary[]; } export interface ISoftwareVulnerability { diff --git a/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ProfileUploader/components/AddProfileModal/AddProfileModal.tsx b/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ProfileUploader/components/AddProfileModal/AddProfileModal.tsx index 3aa447c5d2..ac89c69425 100644 --- a/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ProfileUploader/components/AddProfileModal/AddProfileModal.tsx +++ b/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ProfileUploader/components/AddProfileModal/AddProfileModal.tsx @@ -11,16 +11,13 @@ import labelsAPI, { getCustomLabels } from "services/entities/labels"; import mdmAPI from "services/entities/mdm"; // @ts-ignore -import Dropdown from "components/forms/fields/Dropdown"; import Button from "components/buttons/Button"; import Card from "components/Card"; -import Checkbox from "components/forms/fields/Checkbox"; import DataError from "components/DataError"; import Icon from "components/Icon"; import Modal from "components/Modal"; -import Radio from "components/forms/fields/Radio"; import Spinner from "components/Spinner"; - +import TargetLabelSelector from "components/TargetLabelSelector"; import ProfileGraphic from "../AddProfileGraphic"; import { @@ -30,9 +27,7 @@ import { } from "../../helpers"; import { CUSTOM_TARGET_OPTIONS, - CustomTargetOption, generateLabelKey, - getDescriptionText, listNamesFromSelectedLabels, } from "./helpers"; @@ -91,118 +86,6 @@ const FileDetails = ({ details: { name, platform } }: IFileDetailsProps) => ( ); -interface ITargetChooserProps { - selectedTarget: string; - setSelectedTarget: React.Dispatch>; -} - -const TargetChooser = ({ - selectedTarget, - setSelectedTarget, -}: ITargetChooserProps) => { - return ( -
-
Target
- - -
- ); -}; - -interface ILabelChooserProps { - isError: boolean; - isLoading: boolean; - labels: ILabelSummary[]; - selectedLabels: Record; - customTargetOption: CustomTargetOption; - setSelectedLabels: React.Dispatch< - React.SetStateAction> - >; - onSelectCustomTargetOption: (val: CustomTargetOption) => void; -} - -const LabelChooser = ({ - isError, - isLoading, - labels, - selectedLabels, - customTargetOption, - setSelectedLabels, - onSelectCustomTargetOption, -}: ILabelChooserProps) => { - const updateSelectedLabels = useCallback( - ({ name, value }: { name: string; value: boolean }) => { - setSelectedLabels((prevItems) => ({ ...prevItems, [name]: value })); - }, - [setSelectedLabels] - ); - - const renderLabels = () => { - if (isLoading) { - return ; - } - - if (isError) { - return ; - } - - if (!labels.length) { - return ( -
- No labels exist in Fleet - Add labels to target specific hosts. -
- ); - } - - return labels.map((label) => { - return ( -
- -
{label.name}
-
- ); - }); - }; - - return ( -
- -
- {getDescriptionText(customTargetOption)} -
-
{renderLabels()}
-
- ); -}; - interface IAddProfileModalProps { currentTeamId: number; isPremiumTier: boolean; @@ -223,14 +106,13 @@ const AddProfileModal = ({ name: string; platform: string; } | null>(null); - const [selectedTarget, setSelectedTarget] = useState("All hosts"); // "All hosts" | "Custom" + const [selectedTargetType, setSelectedTargetType] = useState("All hosts"); const [selectedLabels, setSelectedLabels] = useState>( {} ); - const [ - customTargetOption, - setCustomTargetOption, - ] = useState("labelsIncludeAll"); + const [selectedCustomTarget, setSelectedCustomTarget] = useState( + "labelsIncludeAll" + ); const fileRef = useRef(null); @@ -242,7 +124,6 @@ const AddProfileModal = ({ } = useQuery( ["custom_labels"], () => labelsAPI.summary().then((res) => getCustomLabels(res.labels)), - { enabled: isPremiumTier, refetchOnWindowFocus: false, @@ -268,8 +149,8 @@ const AddProfileModal = ({ setIsLoading(true); try { const labelKey = generateLabelKey( - selectedTarget, - customTargetOption, + selectedTargetType, + selectedCustomTarget, selectedLabels ); await mdmAPI.uploadProfile({ @@ -308,8 +189,16 @@ const AddProfileModal = ({ } }; - const onSelectCustomTargetOption = (val: CustomTargetOption) => { - setCustomTargetOption(val); + const onSelectTargetType = (val: string) => { + setSelectedTargetType(val); + }; + + const onSelectCustomTargetOption = (val: string) => { + setSelectedCustomTarget(val); + }; + + const onSelectLabel = ({ name, value }: { name: string; value: boolean }) => { + setSelectedLabels((prevItems) => ({ ...prevItems, [name]: value })); }; return ( @@ -327,23 +216,19 @@ const AddProfileModal = ({ )} {isPremiumTier && ( -
- - {selectedTarget === "Custom" && ( - - )} -
+ )}
+ string; type IValidationMessage = string | IMessageFunc; +type IFormValidationKey = keyof Omit; interface IValidation { name: string; @@ -16,7 +21,7 @@ interface IValidation { } const FORM_VALIDATION_CONFIG: Record< - "preInstallQuery", + IFormValidationKey, { validations: IValidation[] } > = { preInstallQuery: { @@ -33,6 +38,22 @@ const FORM_VALIDATION_CONFIG: Record< }, ], }, + customTarget: { + validations: [ + { + name: "requiredLabelTargets", + isValid: (formData) => { + if (formData.targetType === "All hosts") return true; + // there must be at least one label target selected + return ( + Object.keys(formData.labelTargets).find( + (key) => formData.labelTargets[key] + ) !== undefined + ); + }, + }, + ], + }, }; const getErrorMessage = ( @@ -54,7 +75,7 @@ export const generateFormValidation = ( }; Object.keys(FORM_VALIDATION_CONFIG).forEach((key) => { - const objKey = key as keyof typeof FORM_VALIDATION_CONFIG; + const objKey = key as IFormValidationKey; const failedValidation = FORM_VALIDATION_CONFIG[objKey].validations.find( (validation) => !validation.isValid(formData) ); @@ -74,3 +95,45 @@ export const generateFormValidation = ( return formValidation; }; + +export const CUSTOM_TARGET_OPTIONS: IDropdownOption[] = [ + { + value: "labelsIncludeAny", + label: "Include any", + disabled: false, + }, + { + value: "labelsExcludeAny", + label: "Exclude any", + disabled: false, + }, +]; + +export const generateHelpText = (installType: string, customTarget: string) => { + if (customTarget === "labelsIncludeAny") { + return installType === "manual" ? ( + <> + Software will only be available for install on hosts that{" "} + have any of these labels: + + ) : ( + <> + Software will only be installed on hosts that have any of these + labels: + + ); + } + + // this is the case for labelsExcludeAny + return installType === "manual" ? ( + <> + Software will only be available for install on hosts that{" "} + don't have any of these labels: + + ) : ( + <> + Software will only be installed on hosts that don't have any{" "} + of these labels:{" "} + + ); +}; diff --git a/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareFleetMaintained/FleetMaintainedAppDetailsPage/FleetMaintainedAppDetailsPage.tsx b/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareFleetMaintained/FleetMaintainedAppDetailsPage/FleetMaintainedAppDetailsPage.tsx index 657851e5b2..1bae3b9bc8 100644 --- a/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareFleetMaintained/FleetMaintainedAppDetailsPage/FleetMaintainedAppDetailsPage.tsx +++ b/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareFleetMaintained/FleetMaintainedAppDetailsPage/FleetMaintainedAppDetailsPage.tsx @@ -8,11 +8,13 @@ import { buildQueryStringFromParams } from "utilities/url"; import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants"; import softwareAPI from "services/entities/software"; import teamPoliciesAPI from "services/entities/team_policies"; +import labelsAPI, { getCustomLabels } from "services/entities/labels"; import { QueryContext } from "context/query"; import { AppContext } from "context/app"; import { NotificationContext } from "context/notification"; import { getErrorReason } from "interfaces/errors"; import { Platform, PLATFORM_DISPLAY_NAMES } from "interfaces/platform"; +import { ILabelSummary } from "interfaces/label"; import useToggleSidePanel from "hooks/useToggleSidePanel"; import BackLink from "components/BackLink"; @@ -113,7 +115,11 @@ const FleetMaintainedAppDetailsPage = ({ setShowAddFleetAppSoftwareModal, ] = useState(false); - const { data: fleetApp, isLoading, isError } = useQuery( + const { + data: fleetApp, + isLoading: isLoadingFleetApp, + isError: isErrorFleetApp, + } = useQuery( ["fleet-maintained-app", appId], () => softwareAPI.getFleetMainainedApp(appId), { @@ -123,6 +129,21 @@ const FleetMaintainedAppDetailsPage = ({ } ); + const { + data: labels, + isLoading: isLoadingLabels, + isError: isErrorLabels, + } = useQuery( + ["custom_labels"], + () => labelsAPI.summary().then((res) => getCustomLabels(res.labels)), + + { + ...DEFAULT_USE_QUERY_OPTIONS, + enabled: isPremiumTier, + staleTime: 10000, + } + ); + const onOsqueryTableSelect = (tableName: string) => { setSelectedOsqueryTable(tableName); }; @@ -221,12 +242,12 @@ const FleetMaintainedAppDetailsPage = ({ return ; } - if (isLoading) { + if (isLoadingFleetApp || isLoadingLabels) { return ; } - if (isError) { - return ; + if (isErrorFleetApp || isErrorLabels) { + return ; } if (fleetApp) { @@ -245,6 +266,7 @@ const FleetMaintainedAppDetailsPage = ({ version={fleetApp.version} /> void; onExit: () => void; @@ -56,9 +60,24 @@ const EditSoftwareModal = ({ software: null, installScript: "", selfService: false, + targetType: "", + customTarget: "", + labelTargets: {}, }); const [uploadProgress, setUploadProgress] = useState(0); + const { + data: labels, + isLoading: isLoadingLabels, + isError: isErrorLabels, + } = useQuery( + ["custom_labels"], + () => labelsAPI.summary().then((res) => getCustomLabels(res.labels)), + { + ...DEFAULT_USE_QUERY_OPTIONS, + } + ); + // Work around to not lose Edit Software modal data when Save changes modal opens // by using CSS to hide Edit Software modal when Save changes modal is open useEffect(() => { @@ -213,6 +232,7 @@ const EditSoftwareModal = ({ width="large" > setShowEditSoftwareModal(false)} - router={router} refetchSoftwareTitle={refetchSoftwareTitle} /> )} diff --git a/frontend/pages/SoftwarePage/components/PackageForm/PackageForm.tsx b/frontend/pages/SoftwarePage/components/PackageForm/PackageForm.tsx index 58442aa3f5..b36379d69c 100644 --- a/frontend/pages/SoftwarePage/components/PackageForm/PackageForm.tsx +++ b/frontend/pages/SoftwarePage/components/PackageForm/PackageForm.tsx @@ -6,15 +6,17 @@ import { NotificationContext } from "context/notification"; import { getFileDetails } from "utilities/file/fileUtils"; import getDefaultInstallScript from "utilities/software_install_scripts"; import getDefaultUninstallScript from "utilities/software_uninstall_scripts"; +import { ILabelSummary } from "interfaces/label"; import Button from "components/buttons/Button"; import Checkbox from "components/forms/fields/Checkbox"; import FileUploader from "components/FileUploader"; import TooltipWrapper from "components/TooltipWrapper"; +import TargetLabelSelector from "components/TargetLabelSelector"; import PackageAdvancedOptions from "../PackageAdvancedOptions"; -import { generateFormValidation } from "./helpers"; +import { CUSTOM_TARGET_OPTIONS, generateFormValidation } from "./helpers"; export const baseClass = "package-form"; @@ -25,18 +27,20 @@ export interface IPackageFormData { postInstallScript?: string; uninstallScript?: string; selfService: boolean; + targetType: string; + customTarget: string; + labelTargets: Record; } export interface IFormValidation { isValid: boolean; software: { isValid: boolean }; preInstallQuery?: { isValid: boolean; message?: string }; - postInstallScript?: { isValid: boolean; message?: string }; - uninstallScript?: { isValid: boolean; message?: string }; - selfService?: { isValid: boolean }; + customTarget?: { isValid: boolean }; } interface IPackageFormProps { + labels: ILabelSummary[]; showSchemaButton?: boolean; onCancel: () => void; onSubmit: (formData: IPackageFormData) => void; @@ -54,6 +58,7 @@ interface IPackageFormProps { const ACCEPTED_EXTENSIONS = ".pkg,.msi,.exe,.deb,.rpm"; const PackageForm = ({ + labels, showSchemaButton = false, onClickShowSchema, onCancel, @@ -69,15 +74,17 @@ const PackageForm = ({ }: IPackageFormProps) => { const { renderFlash } = useContext(NotificationContext); - const initialFormData = { + const [formData, setFormData] = useState({ software: defaultSoftware || null, installScript: defaultInstallScript || "", preInstallQuery: defaultPreInstallQuery || "", postInstallScript: defaultPostInstallScript || "", uninstallScript: defaultUninstallScript || "", selfService: defaultSelfService || false, - }; - const [formData, setFormData] = useState(initialFormData); + targetType: "All hosts", + customTarget: "labelsIncludeAny", + labelTargets: {}, + }); const [formValidation, setFormValidation] = useState({ isValid: false, software: { isValid: false }, @@ -156,6 +163,26 @@ const PackageForm = ({ setFormValidation(generateFormValidation(newData)); }; + const onSelectTargetType = (value: string) => { + const newData = { ...formData, targetType: value }; + setFormData(newData); + setFormValidation(generateFormValidation(newData)); + }; + + const onSelectCustomTarget = (value: string) => { + const newData = { ...formData, customTarget: value }; + setFormData(newData); + }; + + const onSelectLabel = ({ name, value }: { name: string; value: boolean }) => { + const newData = { + ...formData, + labelTargets: { ...formData.labelTargets, [name]: value }, + }; + setFormData(newData); + setFormValidation(generateFormValidation(newData)); + }; + const isSubmitDisabled = !formValidation.isValid; const classNames = classnames(baseClass, className); @@ -176,6 +203,17 @@ const PackageForm = ({ formData.software ? getFileDetails(formData.software) : undefined } /> + string; type IValidationMessage = string | IMessageFunc; +type IFormValidationKey = keyof Omit; interface IValidation { name: string; @@ -17,11 +20,11 @@ interface IValidation { message?: IValidationMessage; } -/** configuration defines validations for each filed in the form. It defines rules +/** configuration defines validations for each field in the form. It defines rules * to determine if a field is valid, and rules for generating an error message. */ const FORM_VALIDATION_CONFIG: Record< - IPackageFormValidatorKey, + IFormValidationKey, { validations: IValidation[] } > = { software: { @@ -46,13 +49,21 @@ const FORM_VALIDATION_CONFIG: Record< }, ], }, - postInstallScript: { - // no validations related to postInstallScript - validations: [], - }, - selfService: { - // no validations related to self service - validations: [], + customTarget: { + validations: [ + { + name: "requiredLabelTargets", + isValid: (formData) => { + if (formData.targetType === "All hosts") return true; + // there must be at least one label target selected + return ( + Object.keys(formData.labelTargets).find( + (key) => formData.labelTargets[key] + ) !== undefined + ); + }, + }, + ], }, }; @@ -97,3 +108,16 @@ export const generateFormValidation = (formData: IPackageFormData) => { }; export default generateFormValidation; + +export const CUSTOM_TARGET_OPTIONS: IDropdownOption[] = [ + { + value: "labelsIncludeAny", + label: "Include any", + disabled: false, + }, + { + value: "labelsExcludeAny", + label: "Exclude any", + disabled: false, + }, +]; diff --git a/frontend/pages/hosts/ManageHostsPage/components/DeleteLabelModal/DeleteLabelModal.tsx b/frontend/pages/hosts/ManageHostsPage/components/DeleteLabelModal/DeleteLabelModal.tsx index 29086ee6e4..8780026f1d 100644 --- a/frontend/pages/hosts/ManageHostsPage/components/DeleteLabelModal/DeleteLabelModal.tsx +++ b/frontend/pages/hosts/ManageHostsPage/components/DeleteLabelModal/DeleteLabelModal.tsx @@ -24,7 +24,14 @@ const DeleteLabelModal = ({ className={baseClass} > <> -

Are you sure you wish to delete this label?

+

+ If a configuration profile uses this label as a custom target, the + profile will break: it won't be applied to new hosts. +

+

+ To apply the profile to new hosts, you'll have to delete it and + upload a new profile. +

); }, - editedSoftware: (activity: IActivity) => { + editedSoftware: ( + activity: IActivity, + onDetailsClick?: (type: ActivityType, details: IActivityDetails) => void + ) => { + const { + software_title, + software_package, + self_service, + labels_include_any, + labels_exclude_any, + } = activity.details || {}; + return ( <> {" "} - edited software {activity.details?.software_title} ( - {activity.details?.software_package}) on{" "} + edited {activity.details?.software_package} on{" "} {activity.details?.team_name ? ( <> - {" "} the {activity.details?.team_name} team. ) : ( "no team." - )} + )}{" "} + ); }, - deletedSoftware: (activity: IActivity) => { + deletedSoftware: ( + activity: IActivity, + onDetailsClick?: (type: ActivityType, details: IActivityDetails) => void + ) => { + const { + software_title, + software_package, + self_service, + labels_include_any, + labels_exclude_any, + } = activity.details || {}; + return ( <> {" "} - deleted software {activity.details?.software_title} ( - {activity.details?.software_package}) from{" "} + deleted {activity.details?.software_package} from{" "} {activity.details?.team_name ? ( <> - {" "} the {activity.details?.team_name} team. ) : ( "no team." - )} + )}{" "} + ); }, @@ -1334,13 +1409,13 @@ const getDetail = ( return TAGGED_TEMPLATES.resentConfigProfile(activity); } case ActivityType.AddedSoftware: { - return TAGGED_TEMPLATES.addedSoftware(activity); + return TAGGED_TEMPLATES.addedSoftware(activity, onDetailsClick); } case ActivityType.EditedSoftware: { - return TAGGED_TEMPLATES.editedSoftware(activity); + return TAGGED_TEMPLATES.editedSoftware(activity, onDetailsClick); } case ActivityType.DeletedSoftware: { - return TAGGED_TEMPLATES.deletedSoftware(activity); + return TAGGED_TEMPLATES.deletedSoftware(activity, onDetailsClick); } case ActivityType.InstalledSoftware: { return TAGGED_TEMPLATES.installedSoftware(activity, onDetailsClick); diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/components/SoftwareDetailsModal/SoftwareDetailsModal.tsx b/frontend/pages/DashboardPage/cards/ActivityFeed/components/SoftwareDetailsModal/SoftwareDetailsModal.tsx new file mode 100644 index 0000000000..b36c3345fe --- /dev/null +++ b/frontend/pages/DashboardPage/cards/ActivityFeed/components/SoftwareDetailsModal/SoftwareDetailsModal.tsx @@ -0,0 +1,111 @@ +import React from "react"; + +import { IActivityDetails } from "interfaces/activity"; +import { ILabelSoftwareTitle } from "interfaces/label"; + +import Modal from "components/Modal"; +import Button from "components/buttons/Button"; +import DataSet from "components/DataSet"; +import TooltipWrapper from "components/TooltipWrapper"; + +const baseClass = "software-details-modal"; + +interface ITargetValueProps { + labels: ILabelSoftwareTitle[]; +} + +const TargetValue = ({ labels }: ITargetValueProps) => { + if (labels.length === 1) { + return <>labels[0].name; + } + return ( + ( + <> + {label.name} +
+ + ))} + > + {labels.length} labels +
+ ); +}; + +const generateTargetTitle = ( + labelIncludeAny?: ILabelSoftwareTitle[], + labelExcludeAny?: ILabelSoftwareTitle[] +) => { + if (labelIncludeAny && labelIncludeAny.length > 0) { + return "Targets (include any)"; + } else if (labelExcludeAny && labelExcludeAny.length > 0) { + return "Targets (exclude any)"; + } + return "Targets"; +}; + +const generateTargetValue = ( + labelIncludeAny?: ILabelSoftwareTitle[], + labelExcludeAny?: ILabelSoftwareTitle[] +) => { + // handle single label case + if (labelIncludeAny) { + return ; + } else if (labelExcludeAny) { + return ; + } + return "None"; +}; + +interface ISoftwareDetailsModalProps { + details: IActivityDetails; + onCancel: () => void; +} + +const SoftwareDetailsModal = ({ + details, + onCancel, +}: ISoftwareDetailsModalProps) => { + const { labels_include_any, labels_exclude_any } = details; + const hasTargets = labels_include_any || labels_exclude_any; + + return ( + + <> +
+ + + + {hasTargets && ( + + )} +
+
+ +
+ +
+ ); +}; + +export default SoftwareDetailsModal; diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/components/SoftwareDetailsModal/_styles.scss b/frontend/pages/DashboardPage/cards/ActivityFeed/components/SoftwareDetailsModal/_styles.scss new file mode 100644 index 0000000000..023d021a98 --- /dev/null +++ b/frontend/pages/DashboardPage/cards/ActivityFeed/components/SoftwareDetailsModal/_styles.scss @@ -0,0 +1,10 @@ +.software-details-modal { + &__modal-content { + display: flex; + gap: $pad-xxlarge; + } + + .react-tooltip { + min-width: 120px; + } +} diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/components/SoftwareDetailsModal/index.ts b/frontend/pages/DashboardPage/cards/ActivityFeed/components/SoftwareDetailsModal/index.ts new file mode 100644 index 0000000000..8a8e498b21 --- /dev/null +++ b/frontend/pages/DashboardPage/cards/ActivityFeed/components/SoftwareDetailsModal/index.ts @@ -0,0 +1 @@ +export { default } from "./SoftwareDetailsModal"; diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwarePackageCard/SoftwarePackageCard.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwarePackageCard/SoftwarePackageCard.tsx index 20f7cb629d..264056404a 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwarePackageCard/SoftwarePackageCard.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwarePackageCard/SoftwarePackageCard.tsx @@ -4,7 +4,6 @@ import React, { useLayoutEffect, useState, } from "react"; -import { InjectedRouter } from "react-router"; import PATHS from "router/paths"; import { AppContext } from "context/app"; @@ -239,7 +238,6 @@ interface ISoftwarePackageCardProps { // NOTE: we will only have this if we are working with a software package. softwarePackage?: ISoftwarePackage; onDelete: () => void; - router: InjectedRouter; refetchSoftwareTitle: () => void; } @@ -256,7 +254,6 @@ const SoftwarePackageCard = ({ softwareId, teamId, onDelete, - router, refetchSoftwareTitle, }: ISoftwarePackageCardProps) => { const { diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsPage.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsPage.tsx index cc55f31e28..5c312a6317 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsPage.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsPage.tsx @@ -148,7 +148,6 @@ const SoftwareTitleDetailsPage = ({ softwareId={softwareId} teamId={currentTeamId ?? APP_CONTEXT_NO_TEAM_ID} onDelete={onDeleteInstaller} - router={router} refetchSoftwareTitle={refetchSoftwareTitle} /> ); diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/helpers.tests.ts b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/helpers.tests.ts index 8e452a1b59..b6157de82e 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/helpers.tests.ts +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/helpers.tests.ts @@ -9,6 +9,8 @@ describe("SoftwareTitleDetailsPage helpers", () => { name: "Test Software", versions: [{ id: 1, version: "1.0.0", vulnerabilities: [] }], software_package: { + labels_include_any: null, + labels_exclude_any: null, name: "TestPackage.pkg", version: "1.0.0", self_service: true, diff --git a/frontend/pages/SoftwarePage/components/PackageForm/PackageForm.tsx b/frontend/pages/SoftwarePage/components/PackageForm/PackageForm.tsx index b36379d69c..cdf2213c96 100644 --- a/frontend/pages/SoftwarePage/components/PackageForm/PackageForm.tsx +++ b/frontend/pages/SoftwarePage/components/PackageForm/PackageForm.tsx @@ -16,7 +16,13 @@ import TargetLabelSelector from "components/TargetLabelSelector"; import PackageAdvancedOptions from "../PackageAdvancedOptions"; -import { CUSTOM_TARGET_OPTIONS, generateFormValidation } from "./helpers"; +import { + CUSTOM_TARGET_OPTIONS, + generateFormValidation, + generateSelectedLabels, + getCustomTarget, + getTargetType, +} from "./helpers"; export const baseClass = "package-form"; @@ -81,9 +87,9 @@ const PackageForm = ({ postInstallScript: defaultPostInstallScript || "", uninstallScript: defaultUninstallScript || "", selfService: defaultSelfService || false, - targetType: "All hosts", - customTarget: "labelsIncludeAny", - labelTargets: {}, + targetType: getTargetType(defaultSoftware), + customTarget: getCustomTarget(defaultSoftware), + labelTargets: generateSelectedLabels(defaultSoftware), }); const [formValidation, setFormValidation] = useState({ isValid: false, diff --git a/frontend/pages/SoftwarePage/components/PackageForm/helpers.ts b/frontend/pages/SoftwarePage/components/PackageForm/helpers.ts index 697b86c6b3..557f4820ab 100644 --- a/frontend/pages/SoftwarePage/components/PackageForm/helpers.ts +++ b/frontend/pages/SoftwarePage/components/PackageForm/helpers.ts @@ -1,15 +1,11 @@ import { IDropdownOption } from "interfaces/dropdownOption"; +import { ISoftwarePackage } from "interfaces/software"; // @ts-ignore import validateQuery from "components/forms/validators/validate_query"; import { IPackageFormData, IFormValidation } from "./PackageForm"; -type IPackageFormValidatorKey = Exclude< - keyof IPackageFormData, - "installScript" | "uninstallScript" ->; - type IMessageFunc = (formData: IPackageFormData) => string; type IValidationMessage = string | IMessageFunc; type IFormValidationKey = keyof Omit; @@ -121,3 +117,43 @@ export const CUSTOM_TARGET_OPTIONS: IDropdownOption[] = [ disabled: false, }, ]; + +export const getTargetType = (softwarePackage: ISoftwarePackage) => { + if (!softwarePackage) return "All hosts"; + + return !softwarePackage.labels_include_any && + !softwarePackage.labels_exclude_any + ? "All hosts" + : "Custom"; +}; + +export const getCustomTarget = (softwarePackage: ISoftwarePackage) => { + if (!softwarePackage) return "labelsIncludeAny"; + + return softwarePackage.labels_include_any + ? "labelsIncludeAny" + : "labelsExcludeAny"; +}; + +export const generateSelectedLabels = (softwarePackage: ISoftwarePackage) => { + if ( + !softwarePackage || + (!softwarePackage.labels_include_any && !softwarePackage.labels_exclude_any) + ) { + return {}; + } + + const customTypeKey = softwarePackage.labels_include_any + ? "labels_include_any" + : "labels_exclude_any"; + + return ( + softwarePackage[customTypeKey]?.reduce>( + (acc, label) => { + acc[label.name] = true; + return acc; + }, + {} + ) ?? {} + ); +}; From 1e949c8d5c33ece15e8b1e9e33b61b06d1f9bdf4 Mon Sep 17 00:00:00 2001 From: Sarah Gillespie <73313222+gillespi314@users.noreply.github.com> Date: Thu, 19 Dec 2024 15:09:17 -0600 Subject: [PATCH 18/23] Add unit tests for software label validations (#24894) --- ee/server/service/maintained_apps.go | 2 +- ee/server/service/software_installers.go | 25 +- server/service/integration_enterprise_test.go | 35 +-- server/service/labels_test.go | 106 +++++++ server/service/software_installers_test.go | 295 +++++++++++++++++- 5 files changed, 431 insertions(+), 32 deletions(-) diff --git a/ee/server/service/maintained_apps.go b/ee/server/service/maintained_apps.go index c27c1d0d0c..4f4269944e 100644 --- a/ee/server/service/maintained_apps.go +++ b/ee/server/service/maintained_apps.go @@ -38,7 +38,7 @@ func (svc *Service) AddFleetMaintainedApp( } // validate labels before we do anything else - validatedLabels, err := svc.validateSoftwareLabels(ctx, labelsIncludeAny, labelsExcludeAny) + validatedLabels, err := ValidateSoftwareLabels(ctx, svc, labelsIncludeAny, labelsExcludeAny) if err != nil { return 0, ctxerr.Wrap(ctx, err, "validating software labels") } diff --git a/ee/server/service/software_installers.go b/ee/server/service/software_installers.go index 33f45cf411..ca9bf5b12f 100644 --- a/ee/server/service/software_installers.go +++ b/ee/server/service/software_installers.go @@ -17,6 +17,7 @@ import ( "github.com/fleetdm/fleet/v4/pkg/file" "github.com/fleetdm/fleet/v4/pkg/fleethttp" "github.com/fleetdm/fleet/v4/server/authz" + authz_ctx "github.com/fleetdm/fleet/v4/server/contexts/authz" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" hostctx "github.com/fleetdm/fleet/v4/server/contexts/host" "github.com/fleetdm/fleet/v4/server/contexts/viewer" @@ -37,7 +38,7 @@ func (svc *Service) UploadSoftwareInstaller(ctx context.Context, payload *fleet. } // validate labels before we do anything else - validatedLabels, err := svc.validateSoftwareLabels(ctx, payload.LabelsIncludeAny, payload.LabelsExcludeAny) + validatedLabels, err := ValidateSoftwareLabels(ctx, svc, payload.LabelsIncludeAny, payload.LabelsExcludeAny) if err != nil { return ctxerr.Wrap(ctx, err, "validating software labels") } @@ -105,7 +106,13 @@ func (svc *Service) UploadSoftwareInstaller(ctx context.Context, payload *fleet. return nil } -func (svc *Service) validateSoftwareLabels(ctx context.Context, labelsIncludeAny, labelsExcludeAny []string) (*fleet.LabelIdentsWithScope, error) { +func ValidateSoftwareLabels(ctx context.Context, svc fleet.Service, labelsIncludeAny, labelsExcludeAny []string) (*fleet.LabelIdentsWithScope, error) { + if authctx, ok := authz_ctx.FromContext(ctx); !ok { + return nil, fleet.NewAuthRequiredError("validate software labels: missing authorization context") + } else if !authctx.Checked() { + return nil, fleet.NewAuthRequiredError("validate software labels: method requires previous authorization") + } + var names []string var scope fleet.LabelScope switch { @@ -217,7 +224,7 @@ func (svc *Service) UpdateSoftwareInstaller(ctx context.Context, payload *fleet. dirty["SelfService"] = true } - shouldUpdateLabels, validatedLabels, err := svc.validateSoftwareLabelsForUpdate(ctx, existingInstaller, payload.LabelsIncludeAny, payload.LabelsExcludeAny) + shouldUpdateLabels, validatedLabels, err := ValidateSoftwareLabelsForUpdate(ctx, svc, existingInstaller, payload.LabelsIncludeAny, payload.LabelsExcludeAny) if err != nil { return nil, ctxerr.Wrap(ctx, err, "validating software labels for update") } @@ -409,7 +416,13 @@ func (svc *Service) UpdateSoftwareInstaller(ctx context.Context, payload *fleet. return updatedInstaller, nil } -func (svc *Service) validateSoftwareLabelsForUpdate(ctx context.Context, existingInstaller *fleet.SoftwareInstaller, includeAny, excludeAny []string) (shouldUpdate bool, validatedLabels *fleet.LabelIdentsWithScope, err error) { +func ValidateSoftwareLabelsForUpdate(ctx context.Context, svc fleet.Service, existingInstaller *fleet.SoftwareInstaller, includeAny, excludeAny []string) (shouldUpdate bool, validatedLabels *fleet.LabelIdentsWithScope, err error) { + if authctx, ok := authz_ctx.FromContext(ctx); !ok { + return false, nil, fleet.NewAuthRequiredError("batch validate labels: missing authorization context") + } else if !authctx.Checked() { + return false, nil, fleet.NewAuthRequiredError("batch validate labels: method requires previous authorization") + } + if existingInstaller == nil { return false, nil, errors.New("existing installer must be provided") } @@ -423,7 +436,7 @@ func (svc *Service) validateSoftwareLabelsForUpdate(ctx context.Context, existin return false, nil, nil } - incoming, err := svc.validateSoftwareLabels(ctx, includeAny, excludeAny) + incoming, err := ValidateSoftwareLabels(ctx, svc, includeAny, excludeAny) if err != nil { return false, nil, err } @@ -1293,7 +1306,7 @@ func (svc *Service) BatchSetSoftwareInstallers( fmt.Sprintf("Couldn't edit software. URL (%q) is invalid", payload.URL), ) } - validatedLabels, err := svc.validateSoftwareLabels(ctx, payload.LabelsIncludeAny, payload.LabelsExcludeAny) + validatedLabels, err := ValidateSoftwareLabels(ctx, svc, payload.LabelsIncludeAny, payload.LabelsExcludeAny) if err != nil { return "", err } diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index 9e6c1da9c0..c9b23f1246 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -11,7 +11,6 @@ import ( "errors" "fmt" "io" - "mime/multipart" "net/http" "net/http/httptest" "os" @@ -10766,17 +10765,11 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndD s.uploadSoftwareInstaller(t, payload, http.StatusConflict, "already exists") // patch the software installer to change the labels - var b bytes.Buffer - w := multipart.NewWriter(&b) - require.NoError(t, w.WriteField("team_id", "0")) - require.NoError(t, w.WriteField("labels_exclude_any", t.Name())) - w.Close() - headers := map[string]string{ - "Content-Type": w.FormDataContentType(), - "Accept": "application/json", - "Authorization": fmt.Sprintf("Bearer %s", s.token), - } - s.DoRawWithHeaders("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/package", titleID), b.Bytes(), http.StatusOK, headers) + body, headers := generateMultipartRequest(t, "", "", nil, s.token, map[string][]string{ + "team_id": {"0"}, + "labels_exclude_any": {t.Name()}, + }) + s.DoRawWithHeaders("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/package", titleID), body.Bytes(), http.StatusOK, headers) expectedPayload := *payload expectedPayload.LabelsIncludeAny = nil expectedPayload.LabelsExcludeAny = []string{labelResp.Label.Name} @@ -10792,17 +10785,11 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndD require.Contains(t, extractServerErrorText(resp.Body), "Couldn't install. Host isn't member of the labels defined for this software title.") // patch the software installer again but this time change the pre install query and leave the labels as is - var b2 bytes.Buffer - w2 := multipart.NewWriter(&b2) - require.NoError(t, w2.WriteField("team_id", "0")) - require.NoError(t, w2.WriteField("pre_install_query", "some other pre install query")) - w2.Close() - headers = map[string]string{ - "Content-Type": w2.FormDataContentType(), - "Accept": "application/json", - "Authorization": fmt.Sprintf("Bearer %s", s.token), - } - s.DoRawWithHeaders("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/package", titleID), b2.Bytes(), http.StatusOK, headers) + body, headers = generateMultipartRequest(t, "", "", nil, s.token, map[string][]string{ + "team_id": {"0"}, + "pre_install_query": {"some other pre install query"}, + }) + s.DoRawWithHeaders("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/package", titleID), body.Bytes(), http.StatusOK, headers) expectedPayload.PreInstallQuery = "some other pre install query" expectedPayload.LabelsIncludeAny = nil // no change expectedPayload.LabelsExcludeAny = []string{labelResp.Label.Name} // no change @@ -10829,7 +10816,7 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndD s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", host.ID, titleID), nil, http.StatusAccepted) // update the installer succeeds - body, headers := generateMultipartRequest(t, "software", + body, headers = generateMultipartRequest(t, "software", "", []byte{}, s.token, map[string][]string{"self_service": {"true"}, "team_id": {"0"}}) s.DoRawWithHeaders("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/package", titleID), body.Bytes(), http.StatusOK, headers) diff --git a/server/service/labels_test.go b/server/service/labels_test.go index 78b0f95033..9dd95b9369 100644 --- a/server/service/labels_test.go +++ b/server/service/labels_test.go @@ -5,6 +5,7 @@ import ( "testing" "time" + authz_ctx "github.com/fleetdm/fleet/v4/server/contexts/authz" "github.com/fleetdm/fleet/v4/server/contexts/viewer" "github.com/fleetdm/fleet/v4/server/datastore/mysql" "github.com/fleetdm/fleet/v4/server/fleet" @@ -312,3 +313,108 @@ func TestLabelsWithReplica(t *testing.T) { require.ElementsMatch(t, []uint{h1.ID}, hostIDs) require.Equal(t, 1, lbl.HostCount) } + +func TestBatchValidateLabels(t *testing.T) { + ds := new(mock.Store) + svc, ctx := newTestService(t, ds, nil, nil) + + t.Run("no auth context", func(t *testing.T) { + _, err := svc.BatchValidateLabels(context.Background(), nil) + require.ErrorContains(t, err, "Authentication required") + }) + + authCtx := authz_ctx.AuthorizationContext{} + ctx = authz_ctx.NewContext(ctx, &authCtx) + + t.Run("no auth checked", func(t *testing.T) { + _, err := svc.BatchValidateLabels(ctx, nil) + require.ErrorContains(t, err, "Authentication required") + }) + + // validator requires that an authz check has been performed upstream so we'll set it now for + // the rest of the tests + authCtx.SetChecked() + + mockLabels := map[string]uint{ + "foo": 1, + "bar": 2, + "baz": 3, + } + + mockLabelIdent := func(name string, id uint) fleet.LabelIdent { + return fleet.LabelIdent{LabelID: id, LabelName: name} + } + + ds.LabelIDsByNameFunc = func(ctx context.Context, names []string) (map[string]uint, error) { + res := make(map[string]uint) + if names == nil { + return res, nil + } + for _, name := range names { + if id, ok := mockLabels[name]; ok { + res[name] = id + } + } + return res, nil + } + + testCases := []struct { + name string + labelNames []string + expectLabels map[string]fleet.LabelIdent + expectError string + }{ + { + "no labels", + nil, + nil, + "", + }, + { + "include labels", + []string{"foo", "bar"}, + map[string]fleet.LabelIdent{ + "foo": mockLabelIdent("foo", 1), + "bar": mockLabelIdent("bar", 2), + }, + "", + }, + { + "non-existent label", + []string{"foo", "qux"}, + nil, + "some or all the labels provided don't exist", + }, + { + "duplicate label", + []string{"foo", "foo"}, + map[string]fleet.LabelIdent{ + "foo": mockLabelIdent("foo", 1), + }, + "", + }, + { + "empty slice", + []string{}, + nil, + "", + }, + { + "empty string", + []string{""}, + nil, + "some or all the labels provided don't exist", + }, + } + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + got, err := svc.BatchValidateLabels(ctx, tt.labelNames) + if tt.expectError != "" { + require.Contains(t, err.Error(), tt.expectError) + } else { + require.NoError(t, err) + require.Equal(t, tt.expectLabels, got) + } + }) + } +} diff --git a/server/service/software_installers_test.go b/server/service/software_installers_test.go index ece1bba81a..3b1da3d569 100644 --- a/server/service/software_installers_test.go +++ b/server/service/software_installers_test.go @@ -5,6 +5,8 @@ import ( "testing" "time" + eeservice "github.com/fleetdm/fleet/v4/ee/server/service" + authz_ctx "github.com/fleetdm/fleet/v4/server/contexts/authz" "github.com/fleetdm/fleet/v4/server/contexts/viewer" "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/mock" @@ -116,7 +118,8 @@ func TestSoftwareInstallersAuth(t *testing.T) { } 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{}, nil } @@ -154,3 +157,293 @@ func TestSoftwareInstallersAuth(t *testing.T) { }) } } + +func TestValidateSoftwareInstallerLabels(t *testing.T) { + ds := new(mock.Store) + + license := &fleet.LicenseInfo{Tier: fleet.TierPremium, Expiration: time.Now().Add(24 * time.Hour)} + + svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: license}) + + t.Run("validate no update", func(t *testing.T) { + t.Run("no auth context", func(t *testing.T) { + _, err := eeservice.ValidateSoftwareLabels(context.Background(), svc, nil, nil) + require.ErrorContains(t, err, "Authentication required") + }) + + authCtx := authz_ctx.AuthorizationContext{} + ctx = authz_ctx.NewContext(ctx, &authCtx) + + t.Run("no auth checked", func(t *testing.T) { + _, err := eeservice.ValidateSoftwareLabels(ctx, svc, nil, nil) + require.ErrorContains(t, err, "Authentication required") + }) + + // validator requires that an authz check has been performed upstream so we'll set it now for + // the rest of the tests + authCtx.SetChecked() + + mockLabels := map[string]uint{ + "foo": 1, + "bar": 2, + "baz": 3, + } + + ds.LabelIDsByNameFunc = func(ctx context.Context, names []string) (map[string]uint, error) { + res := make(map[string]uint) + if names == nil { + return res, nil + } + for _, name := range names { + if id, ok := mockLabels[name]; ok { + res[name] = id + } + } + return res, nil + } + + testCases := []struct { + name string + payloadIncludeAny []string + payloadExcludeAny []string + expectLabels map[string]fleet.LabelIdent + expectScope fleet.LabelScope + expectError string + }{ + { + "no labels", + nil, + nil, + nil, + "", + "", + }, + { + "include labels", + []string{"foo", "bar"}, + nil, + map[string]fleet.LabelIdent{ + "foo": {LabelID: 1, LabelName: "foo"}, + "bar": {LabelID: 2, LabelName: "bar"}, + }, + fleet.LabelScopeIncludeAny, + "", + }, + { + "exclude labels", + nil, + []string{"bar", "baz"}, + map[string]fleet.LabelIdent{ + "bar": {LabelID: 2, LabelName: "bar"}, + "baz": {LabelID: 3, LabelName: "baz"}, + }, + fleet.LabelScopeExcludeAny, + "", + }, + { + "include and exclude labels", + []string{"foo"}, + []string{"bar"}, + nil, + "", + `Only one of "labels_include_any" or "labels_exclude_any" can be included.`, + }, + { + "non-existent label", + []string{"foo", "qux"}, + nil, + nil, + "", + "some or all the labels provided don't exist", + }, + { + "duplicate label", + []string{"foo", "foo"}, + nil, + map[string]fleet.LabelIdent{ + "foo": {LabelID: 1, LabelName: "foo"}, + }, + fleet.LabelScopeIncludeAny, + "", + }, + { + "empty slice", + nil, + []string{}, + nil, + "", + "", + }, + { + "empty string", + nil, + []string{""}, + nil, + "", + "some or all the labels provided don't exist", + }, + } + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + got, err := eeservice.ValidateSoftwareLabels(ctx, svc, tt.payloadIncludeAny, tt.payloadExcludeAny) + if tt.expectError != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tt.expectError) + } else { + require.NoError(t, err) + require.NotNil(t, got) + require.Equal(t, tt.expectScope, got.LabelScope) + require.Equal(t, tt.expectLabels, got.ByName) + } + }) + } + }) + + t.Run("validate update", func(t *testing.T) { + t.Run("no auth context", func(t *testing.T) { + _, _, err := eeservice.ValidateSoftwareLabelsForUpdate(context.Background(), svc, nil, nil, nil) + require.ErrorContains(t, err, "Authentication required") + }) + + authCtx := authz_ctx.AuthorizationContext{} + ctx = authz_ctx.NewContext(ctx, &authCtx) + + t.Run("no auth checked", func(t *testing.T) { + _, _, err := eeservice.ValidateSoftwareLabelsForUpdate(ctx, svc, nil, nil, nil) + require.ErrorContains(t, err, "Authentication required") + }) + + // validator requires that an authz check has been performed upstream so we'll set it now for + // the rest of the tests + authCtx.SetChecked() + + mockLabels := map[string]uint{ + "foo": 1, + "bar": 2, + "baz": 3, + } + + ds.LabelIDsByNameFunc = func(ctx context.Context, names []string) (map[string]uint, error) { + res := make(map[string]uint) + if names == nil { + return res, nil + } + for _, name := range names { + if id, ok := mockLabels[name]; ok { + res[name] = id + } + } + return res, nil + } + + testCases := []struct { + name string + existingInstaller *fleet.SoftwareInstaller + payloadIncludeAny []string + payloadExcludeAny []string + shouldUpdate bool + expectLabels map[string]fleet.LabelIdent + expectScope fleet.LabelScope + expectError string + }{ + { + "no installer", + nil, + nil, + []string{"foo"}, + false, + nil, + "", + "existing installer must be provided", + }, + { + "no labels", + &fleet.SoftwareInstaller{}, + nil, + nil, + false, + nil, + "", + "", + }, + { + "add label", + &fleet.SoftwareInstaller{ + LabelsIncludeAny: []fleet.SoftwareScopeLabel{{LabelID: 1, LabelName: "foo"}}, + LabelsExcludeAny: []fleet.SoftwareScopeLabel{}, + }, + []string{"foo", "bar"}, + nil, + true, + map[string]fleet.LabelIdent{ + "foo": {LabelID: 1, LabelName: "foo"}, + "bar": {LabelID: 2, LabelName: "bar"}, + }, + fleet.LabelScopeIncludeAny, + "", + }, + { + "change scope", + &fleet.SoftwareInstaller{ + LabelsIncludeAny: []fleet.SoftwareScopeLabel{{LabelID: 1, LabelName: "foo"}}, + LabelsExcludeAny: []fleet.SoftwareScopeLabel{}, + }, + nil, + []string{"foo"}, + true, + map[string]fleet.LabelIdent{ + "foo": {LabelID: 1, LabelName: "foo"}, + }, + fleet.LabelScopeExcludeAny, + "", + }, + { + "remove label", + &fleet.SoftwareInstaller{ + LabelsIncludeAny: []fleet.SoftwareScopeLabel{{LabelID: 1, LabelName: "foo"}}, + LabelsExcludeAny: []fleet.SoftwareScopeLabel{}, + }, + []string{}, + nil, + true, + nil, + "", + "", + }, + { + "no change", + &fleet.SoftwareInstaller{ + LabelsIncludeAny: []fleet.SoftwareScopeLabel{{LabelID: 1, LabelName: "foo"}}, + LabelsExcludeAny: []fleet.SoftwareScopeLabel{}, + }, + []string{"foo"}, + nil, + false, + nil, + "", + "", + }, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + shouldUpate, got, err := eeservice.ValidateSoftwareLabelsForUpdate(ctx, svc, tt.existingInstaller, tt.payloadIncludeAny, tt.payloadExcludeAny) + if tt.expectError != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tt.expectError) + } else { + require.NoError(t, err) + if tt.shouldUpdate { + require.True(t, shouldUpate) + require.NotNil(t, got) + require.Equal(t, tt.expectScope, got.LabelScope) + require.Equal(t, tt.expectLabels, got.ByName) + } else { + require.False(t, shouldUpate) + require.Nil(t, got) + } + } + }) + } + }) +} From e6efcf723867b3b77c7a79432d31a22bbc807bc9 Mon Sep 17 00:00:00 2001 From: Gabriel Hernandez Date: Thu, 19 Dec 2024 15:31:36 -0600 Subject: [PATCH 19/23] finish UI api integration for editing a custom package (#24929) relates to #24828 Finish up the UI integration for the API to edit a custom package - [x] Manual QA for all new/changed functionality --- frontend/__mocks__/softwareMock.ts | 1 + frontend/interfaces/software.ts | 1 + .../EditSoftwareModal/EditSoftwareModal.tsx | 23 ++++++++---- .../SoftwarePackageCard.tsx | 2 +- .../SoftwareTitleDetailsPage/helpers.tests.ts | 1 + .../components/PackageForm/PackageForm.tsx | 1 + frontend/services/entities/software.ts | 35 +++++++++++++++++-- 7 files changed, 55 insertions(+), 9 deletions(-) diff --git a/frontend/__mocks__/softwareMock.ts b/frontend/__mocks__/softwareMock.ts index 04b015c43f..13a9a34b6f 100644 --- a/frontend/__mocks__/softwareMock.ts +++ b/frontend/__mocks__/softwareMock.ts @@ -200,6 +200,7 @@ const DEFAULT_SOFTWARE_PACKAGE_MOCK: ISoftwarePackage = { version: "1.2.3", uploaded_at: "2020-01-01T00:00:00.000Z", install_script: "sudo installer -pkg /temp/FalconSensor-6.44.pkg -target /", + uninstall_script: "sudo rm -rf /Applications/Falcon.app", pre_install_query: "SELECT 1 FROM macos_profiles WHERE uuid='abc123';", post_install_script: "sudo /Applications/Falcon.app/Contents/Resources/falconctl license abc123", diff --git a/frontend/interfaces/software.ts b/frontend/interfaces/software.ts index 9516221396..f67eb25140 100644 --- a/frontend/interfaces/software.ts +++ b/frontend/interfaces/software.ts @@ -69,6 +69,7 @@ export interface ISoftwarePackage { version: string; uploaded_at: string; install_script: string; + uninstall_script: string; pre_install_query?: string; post_install_script?: string; self_service: boolean; diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditSoftwareModal/EditSoftwareModal.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditSoftwareModal/EditSoftwareModal.tsx index c88cb69a2c..b79033a8d6 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditSoftwareModal/EditSoftwareModal.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditSoftwareModal/EditSoftwareModal.tsx @@ -5,6 +5,7 @@ import { isAxiosError } from "axios"; import { getErrorReason } from "interfaces/errors"; import { ILabelSummary } from "interfaces/label"; +import { ISoftwarePackage } from "interfaces/software"; import { NotificationContext } from "context/notification"; import softwareAPI, { @@ -26,6 +27,12 @@ import Modal from "components/Modal"; import PackageForm from "pages/SoftwarePage/components/PackageForm"; import { IPackageFormData } from "pages/SoftwarePage/components/PackageForm/PackageForm"; +import { + generateSelectedLabels, + getCustomTarget, + getTargetType, +} from "pages/SoftwarePage/components/PackageForm/helpers"; + import { getErrorMessage } from "./helpers"; import ConfirmSaveChangesModal from "../ConfirmSaveChangesModal"; @@ -34,7 +41,7 @@ const baseClass = "edit-software-modal"; interface IEditSoftwareModalProps { softwareId: number; teamId: number; - software?: any; // TODO + software: ISoftwarePackage; // TODO refetchSoftwareTitle: () => void; onExit: () => void; } @@ -137,6 +144,7 @@ const EditSoftwareModal = ({ try { await softwareAPI.editSoftwarePackage({ data: formData, + orignalPackage: software, softwareId, teamId, onUploadProgress: (progressEvent) => { @@ -166,7 +174,7 @@ const EditSoftwareModal = ({ if (isTimeout) { renderFlash( "error", - `Couldn’t upload. Request timeout. Please make sure your server and load balancer timeout is long enough.` + `Couldn't upload. Request timeout. Please make sure your server and load balancer timeout is long enough.` ); } else if (reason.includes("Fleet couldn't read the version from")) { renderFlash( @@ -203,18 +211,21 @@ const EditSoftwareModal = ({ postInstallScript: software.post_install_script || "", uninstallScript: software.uninstall_script || "", selfService: software.self_service || false, + targetType: getTargetType(software), + customTarget: getCustomTarget(software), + labelTargets: generateSelectedLabels(software), }); setPendingUpdates(formData); const onlySelfServiceUpdated = Object.keys(updates).length === 1 && "selfService" in updates; - if (!onlySelfServiceUpdated) { - // Open the confirm save changes modal - setShowConfirmSaveChangesModal(true); - } else { + if (onlySelfServiceUpdated) { // Proceed with saving changes (API expects only changes) onSaveSoftwareChanges(formData); + } else { + // Open the confirm save changes modal + setShowConfirmSaveChangesModal(true); } }; diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwarePackageCard/SoftwarePackageCard.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwarePackageCard/SoftwarePackageCard.tsx index 264056404a..a23eaed60a 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwarePackageCard/SoftwarePackageCard.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwarePackageCard/SoftwarePackageCard.tsx @@ -390,7 +390,7 @@ const SoftwarePackageCard = ({ teamId={teamId} />
- {showEditSoftwareModal && ( + {showEditSoftwareModal && softwarePackage && ( { failed_uninstall: 1, }, install_script: "echo foo", + uninstall_script: "echo bar", icon_url: "https://example.com/icon.png", automatic_install_policies: [], last_install: null, diff --git a/frontend/pages/SoftwarePage/components/PackageForm/PackageForm.tsx b/frontend/pages/SoftwarePage/components/PackageForm/PackageForm.tsx index cdf2213c96..d728b72805 100644 --- a/frontend/pages/SoftwarePage/components/PackageForm/PackageForm.tsx +++ b/frontend/pages/SoftwarePage/components/PackageForm/PackageForm.tsx @@ -178,6 +178,7 @@ const PackageForm = ({ const onSelectCustomTarget = (value: string) => { const newData = { ...formData, customTarget: value }; setFormData(newData); + setFormValidation(generateFormValidation(newData)); }; const onSelectLabel = ({ name, value }: { name: string; value: boolean }) => { diff --git a/frontend/services/entities/software.ts b/frontend/services/entities/software.ts index 546424c026..9f4ead95b1 100644 --- a/frontend/services/entities/software.ts +++ b/frontend/services/entities/software.ts @@ -10,6 +10,7 @@ import { ISoftwareTitleDetails, IFleetMaintainedApp, IFleetMaintainedAppDetails, + ISoftwarePackage, } from "interfaces/software"; import { buildQueryStringFromParams, @@ -18,6 +19,7 @@ import { import { IPackageFormData } from "pages/SoftwarePage/components/PackageForm/PackageForm"; import { IAddFleetMaintainedData } from "pages/SoftwarePage/SoftwareAddPage/SoftwareFleetMaintained/FleetMaintainedAppDetailsPage/FleetMaintainedAppDetailsPage"; import { listNamesFromSelectedLabels } from "components/TargetLabelSelector/TargetLabelSelector"; +import { join } from "path"; export interface ISoftwareApiParams { page?: number; @@ -281,11 +283,15 @@ export default { if (data.targetType === "Custom") { const selectedLabels = listNamesFromSelectedLabels(data.labelTargets); + let labelKey = ""; if (data.customTarget === "labelsIncludeAny") { - formData.append("labels_include_any", selectedLabels.join(",")); + labelKey = "labels_include_any"; } else { - formData.append("labels_exclude_any", selectedLabels.join(",")); + labelKey = "labels_exclude_any"; } + selectedLabels?.forEach((label) => { + formData.append(labelKey, label); + }); } return sendRequestWithProgress({ @@ -301,6 +307,7 @@ export default { editSoftwarePackage: ({ data, + orignalPackage, softwareId, teamId, timeout, @@ -308,6 +315,7 @@ export default { signal, }: { data: IPackageFormData; + orignalPackage: ISoftwarePackage; softwareId: number; teamId: number; timeout?: number; @@ -325,6 +333,29 @@ export default { formData.append("post_install_script", data.postInstallScript || ""); formData.append("uninstall_script", data.uninstallScript || ""); + // clear out labels if targetType is "All hosts" + if (data.targetType === "All hosts") { + if (orignalPackage.labels_include_any) { + formData.append("labels_include_any", ""); + } else { + formData.append("labels_exclude_any", ""); + } + } + + // add custom labels if targetType is "Custom" + if (data.targetType === "Custom") { + const selectedLabels = listNamesFromSelectedLabels(data.labelTargets); + let labelKey = ""; + if (data.customTarget === "labelsIncludeAny") { + labelKey = "labels_include_any"; + } else { + labelKey = "labels_exclude_any"; + } + selectedLabels?.forEach((label) => { + formData.append(labelKey, label); + }); + } + return sendRequestWithProgress({ method: "PATCH", path: EDIT_SOFTWARE_PACKAGE(softwareId), From 863e680253c1676ec4715fd2850a48fd83186ada Mon Sep 17 00:00:00 2001 From: Gabriel Hernandez Date: Thu, 19 Dec 2024 16:42:23 -0600 Subject: [PATCH 20/23] update the label error message (#24932) relates to #24537 updates the delete label error when label is used for software - [x] Manual QA for all new/changed functionality --- .../hosts/ManageHostsPage/ManageHostsPage.tsx | 10 ++------ .../pages/hosts/ManageHostsPage/helpers.ts | 24 +++++++++++++++++++ 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx b/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx index 341caa4e7f..7c50898133 100644 --- a/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx +++ b/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx @@ -99,7 +99,7 @@ import { MANAGE_HOSTS_PAGE_FILTER_KEYS, MANAGE_HOSTS_PAGE_LABEL_INCOMPATIBLE_QUERY_PARAMS, } from "./HostsPageConfig"; -import { isAcceptableStatus } from "./helpers"; +import { getDeleteLabelErrorMessages, isAcceptableStatus } from "./helpers"; import DeleteSecretModal from "../../../components/EnrollSecrets/DeleteSecretModal"; import SecretEditorModal from "../../../components/EnrollSecrets/SecretEditorModal"; @@ -1061,13 +1061,7 @@ const ManageHostsPage = ({ ); renderFlash("success", "Successfully deleted label."); } catch (error) { - console.error(error); - renderFlash( - "error", - getErrorReason(error).includes("built-in") - ? "Built-in labels can’t be modified or deleted." - : "Could not delete label. Please try again." - ); + renderFlash("error", getDeleteLabelErrorMessages(error)); } finally { setIsUpdatingLabel(false); } diff --git a/frontend/pages/hosts/ManageHostsPage/helpers.ts b/frontend/pages/hosts/ManageHostsPage/helpers.ts index ca78607641..5a30ea4405 100644 --- a/frontend/pages/hosts/ManageHostsPage/helpers.ts +++ b/frontend/pages/hosts/ManageHostsPage/helpers.ts @@ -1,3 +1,5 @@ +import { getErrorReason } from "interfaces/errors"; + export const isAcceptableStatus = (filter: string): boolean => { return ( filter === "new" || @@ -21,3 +23,25 @@ export const isValidPemCertificate = (cert: string): boolean => { return regexPemHeader.test(cert) && regexPemFooter.test(cert); }; + +const hasStatusKey = (value: unknown): value is { status: number } => { + return ( + typeof value === "object" && + value !== null && + "status" in value && + typeof (value as any).status === "number" + ); +}; + +export const getDeleteLabelErrorMessages = (error: unknown): string => { + // unprocessable content status. Label is used in a custom profile + // or software target. we have to check that status exists on the error object + // before we can access it. + if (hasStatusKey(error) && error.status === 422) { + return getErrorReason(error).includes("built-in") + ? "Built-in labels can't be modified or deleted." + : "Couldn't delete. Software uses this label as a custom target. Please delete the software and try again."; + } + + return "Could not delete label. Please try again."; +}; From 8694e981d59f0c55b1a286608d034159f99b72f5 Mon Sep 17 00:00:00 2001 From: Sarah Gillespie <73313222+gillespi314@users.noreply.github.com> Date: Fri, 20 Dec 2024 10:49:28 -0600 Subject: [PATCH 21/23] Fix decoder for software installer label payloads; update tests (#24934) Co-authored-by: Jahziel Villasana-Espinoza --- server/service/integration_enterprise_test.go | 179 ++++++++++++++++++ server/service/software_installers.go | 46 ++++- server/service/testing_client.go | 9 +- 3 files changed, 222 insertions(+), 12 deletions(-) diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index c9b23f1246..3259d3a46d 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -11,6 +11,7 @@ import ( "errors" "fmt" "io" + "mime/multipart" "net/http" "net/http/httptest" "os" @@ -15005,6 +15006,184 @@ func (s *integrationEnterpriseTestSuite) TestPolicyAutomationsSoftwareInstallers require.Nil(t, hostVanillaOsquery5Team1LastInstall) } +func (s *integrationEnterpriseTestSuite) TestPolicyAutomationsSoftwareInstallersLabelScoping() { + t := s.T() + ctx := context.Background() + host, err := s.ds.NewHost(context.Background(), &fleet.Host{ + DetailUpdatedAt: time.Now(), + LabelUpdatedAt: time.Now(), + PolicyUpdatedAt: time.Now(), + SeenTime: time.Now().Add(-1 * time.Minute), + OsqueryHostID: ptr.String(t.Name()), + NodeKey: ptr.String(t.Name()), + UUID: uuid.New().String(), + Hostname: fmt.Sprintf("%sfoo.local", t.Name()), + Platform: "linux", + }) + require.NoError(t, err) + orbitKey := setOrbitEnrollment(t, host, s.ds) + host.OrbitNodeKey = &orbitKey + + // Create a few labels + var newLabelResp createLabelResponse + s.DoJSON("POST", "/api/v1/fleet/labels", fleet.LabelPayload{ + Name: uuid.NewString(), + Query: "SELECT 1", + }, http.StatusOK, &newLabelResp) + lbl1 := newLabelResp.Label + + newLabelResp = createLabelResponse{} + s.DoJSON("POST", "/api/v1/fleet/labels", fleet.LabelPayload{ + Name: uuid.NewString(), + Query: "SELECT 2", + }, http.StatusOK, &newLabelResp) + lbl2 := newLabelResp.Label + + newLabelResp = createLabelResponse{} + s.DoJSON("POST", "/api/v1/fleet/labels", fleet.LabelPayload{ + Name: uuid.NewString(), + Query: "SELECT 3", + }, http.StatusOK, &newLabelResp) + lbl3 := newLabelResp.Label + + // Add label1 and label2 to the host + err = s.ds.RecordLabelQueryExecutions(context.Background(), host, map[uint]*bool{lbl1.ID: ptr.Bool(true), lbl2.ID: ptr.Bool(true)}, time.Now(), false) + require.NoError(t, err) + + // upload software. Add label1 and label3 as "exclude any" labels. + rubyPayload := &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "some deb install script", + Filename: "ruby.deb", + TeamID: nil, + LabelsExcludeAny: []string{lbl1.Name, lbl3.Name}, + Platform: "linux", + } + s.uploadSoftwareInstaller(t, rubyPayload, http.StatusOK, "") + + // Get software title ID of the uploaded installer. + resp := listSoftwareTitlesResponse{} + s.DoJSON( + "GET", "/api/latest/fleet/software/titles", + listSoftwareTitlesRequest{}, + http.StatusOK, &resp, + "query", "ruby", + "team_id", "0", + ) + require.Len(t, resp.SoftwareTitles, 1) + require.NotNil(t, resp.SoftwareTitles[0].SoftwarePackage) + rubyDebTitleID := resp.SoftwareTitles[0].ID + + var rubyDetail getSoftwareTitleResponse + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", rubyDebTitleID), nil, http.StatusOK, &rubyDetail) + require.NotNil(t, rubyDetail.SoftwareTitle) + require.NotNil(t, rubyDetail.SoftwareTitle.SoftwarePackage) + rubyInstallerID := rubyDetail.SoftwareTitle.SoftwarePackage.InstallerID + + policy1, err := s.ds.NewTeamPolicy(ctx, 0, nil, fleet.PolicyPayload{ + Name: "policy1", + Query: "SELECT 1;", + Platform: "linux", + }) + require.NoError(t, err) + + mtplr := modifyTeamPolicyResponse{} + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/0/policies/%d", policy1.ID), modifyTeamPolicyRequest{ + ModifyPolicyPayload: fleet.ModifyPolicyPayload{ + SoftwareTitleID: optjson.Any[uint]{Set: true, Valid: true, Value: rubyDebTitleID}, + }, + }, http.StatusOK, &mtplr) + + host1LastInstall, err := s.ds.GetHostLastInstallData(ctx, host.ID, rubyInstallerID) + require.NoError(t, err) + require.Nil(t, host1LastInstall) + + // Send back a failed result for the policy. + distributedResp := submitDistributedQueryResultsResponse{} + s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults( + host, + map[uint]*bool{ + policy1.ID: ptr.Bool(false), + }, + ), http.StatusOK, &distributedResp) + err = s.ds.UpdateHostPolicyCounts(ctx) + require.NoError(t, err) + policy1, err = s.ds.Policy(ctx, policy1.ID) + require.NoError(t, err) + // Because the installer is not in scope, we do not mark the policy as failed. + require.Equal(t, uint(0), policy1.PassingHostCount) + require.Equal(t, uint(0), policy1.FailingHostCount) + + // No installation attempt, because we skipped due to label scoping + host1LastInstall, err = s.ds.GetHostLastInstallData(ctx, host.ID, rubyInstallerID) + require.NoError(t, err) + require.Nil(t, host1LastInstall) + + vimPayload := &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "some deb install script", + Filename: "vim.deb", + TeamID: nil, + LabelsIncludeAny: []string{lbl1.Name, lbl2.Name}, + Platform: "linux", + } + s.uploadSoftwareInstaller(t, vimPayload, http.StatusOK, "") + + resp = listSoftwareTitlesResponse{} + s.DoJSON( + "GET", "/api/latest/fleet/software/titles", + listSoftwareTitlesRequest{}, + http.StatusOK, &resp, + "query", "vim", + "team_id", "0", + ) + require.Len(t, resp.SoftwareTitles, 1) + require.NotNil(t, resp.SoftwareTitles[0].SoftwarePackage) + vimTitleID := resp.SoftwareTitles[0].ID + + var vimDetail getSoftwareTitleResponse + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", vimTitleID), nil, http.StatusOK, &vimDetail) + require.NotNil(t, vimDetail.SoftwareTitle) + require.NotNil(t, vimDetail.SoftwareTitle.SoftwarePackage) + vimInstallerID := vimDetail.SoftwareTitle.SoftwarePackage.InstallerID + + policy2, err := s.ds.NewTeamPolicy(ctx, 0, nil, fleet.PolicyPayload{ + Name: "policy2", + Query: "SELECT 2;", + Platform: "linux", + }) + require.NoError(t, err) + + mtplr = modifyTeamPolicyResponse{} + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/0/policies/%d", policy2.ID), modifyTeamPolicyRequest{ + ModifyPolicyPayload: fleet.ModifyPolicyPayload{ + SoftwareTitleID: optjson.Any[uint]{Set: true, Valid: true, Value: vimTitleID}, + }, + }, http.StatusOK, &mtplr) + + host1LastInstall, err = s.ds.GetHostLastInstallData(ctx, host.ID, vimInstallerID) + require.NoError(t, err) + require.Nil(t, host1LastInstall) + + distributedResp = submitDistributedQueryResultsResponse{} + s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults( + host, + map[uint]*bool{ + policy2.ID: ptr.Bool(false), + }, + ), http.StatusOK, &distributedResp) + err = s.ds.UpdateHostPolicyCounts(ctx) + require.NoError(t, err) + policy2, err = s.ds.Policy(ctx, policy2.ID) + require.NoError(t, err) + // Because the installer is in scope, we do mark the policy as failed. + require.Equal(t, uint(0), policy2.PassingHostCount) + require.Equal(t, uint(1), policy2.FailingHostCount) + + // We have an installation attempt for vim, because it's in scope + host1LastInstall, err = s.ds.GetHostLastInstallData(ctx, host.ID, vimInstallerID) + require.NoError(t, err) + require.NotNil(t, host1LastInstall) +} + func (s *integrationEnterpriseTestSuite) TestPolicyAutomationsScripts() { t := s.T() ctx := context.Background() diff --git a/server/service/software_installers.go b/server/service/software_installers.go index 6711063ecd..a2b66dd5e7 100644 --- a/server/service/software_installers.go +++ b/server/service/software_installers.go @@ -136,14 +136,27 @@ func (updateSoftwareInstallerRequest) DecodeRequest(ctx context.Context, r *http } // decode labels + var inclAny, exclAny []string var existsInclAny, existsExclAny bool - decoded.LabelsIncludeAny, existsInclAny = r.MultipartForm.Value[string(fleet.LabelsIncludeAny)] - if !existsInclAny { + + inclAny, existsInclAny = r.MultipartForm.Value[string(fleet.LabelsIncludeAny)] + switch { + case !existsInclAny: decoded.LabelsIncludeAny = nil + case len(inclAny) == 1 && inclAny[0] == "": + decoded.LabelsIncludeAny = []string{} + default: + decoded.LabelsIncludeAny = inclAny } - decoded.LabelsExcludeAny, existsExclAny = r.MultipartForm.Value[string(fleet.LabelsExcludeAny)] - if !existsExclAny { + + exclAny, existsExclAny = r.MultipartForm.Value[string(fleet.LabelsExcludeAny)] + switch { + case !existsExclAny: decoded.LabelsExcludeAny = nil + case len(exclAny) == 1 && exclAny[0] == "": + decoded.LabelsExcludeAny = []string{} + default: + decoded.LabelsExcludeAny = exclAny } return &decoded, nil @@ -279,13 +292,28 @@ func (uploadSoftwareInstallerRequest) DecodeRequest(ctx context.Context, r *http } // decode labels + // decode labels + var inclAny, exclAny []string var existsInclAny, existsExclAny bool - decoded.LabelsIncludeAny, existsInclAny = r.MultipartForm.Value[string(fleet.LabelsIncludeAny)] - decoded.LabelsExcludeAny, existsExclAny = r.MultipartForm.Value[string(fleet.LabelsExcludeAny)] - // validate that only one of the labels type is provided - if existsInclAny && existsExclAny { - return nil, &fleet.BadRequestError{Message: `Only one of "labels_include_any" or "labels_exclude_any" can be included.`} + inclAny, existsInclAny = r.MultipartForm.Value[string(fleet.LabelsIncludeAny)] + switch { + case !existsInclAny: + decoded.LabelsIncludeAny = nil + case len(inclAny) == 1 && inclAny[0] == "": + decoded.LabelsIncludeAny = []string{} + default: + decoded.LabelsIncludeAny = inclAny + } + + exclAny, existsExclAny = r.MultipartForm.Value[string(fleet.LabelsExcludeAny)] + switch { + case !existsExclAny: + decoded.LabelsExcludeAny = nil + case len(exclAny) == 1 && exclAny[0] == "": + decoded.LabelsExcludeAny = []string{} + default: + decoded.LabelsExcludeAny = exclAny } return &decoded, nil diff --git a/server/service/testing_client.go b/server/service/testing_client.go index a5777d1d23..735730e3e4 100644 --- a/server/service/testing_client.go +++ b/server/service/testing_client.go @@ -14,7 +14,6 @@ import ( "os" "path/filepath" "regexp" - "strings" "sync" "testing" "time" @@ -584,10 +583,14 @@ func (ts *withServer) uploadSoftwareInstaller( require.NoError(t, w.WriteField("self_service", "true")) } if payload.LabelsIncludeAny != nil { - require.NoError(t, w.WriteField("labels_include_any", strings.Join(payload.LabelsIncludeAny, ","))) + for _, l := range payload.LabelsIncludeAny { + require.NoError(t, w.WriteField("labels_include_any", l)) + } } if payload.LabelsExcludeAny != nil { - require.NoError(t, w.WriteField("labels_exclude_any", strings.Join(payload.LabelsExcludeAny, ","))) + for _, l := range payload.LabelsExcludeAny { + require.NoError(t, w.WriteField("labels_exclude_any", l)) + } } w.Close() From 4a4ebfb15676aeff63bd6a3a25a77fbc361faf00 Mon Sep 17 00:00:00 2001 From: Jahziel Villasana-Espinoza Date: Fri, 20 Dec 2024 16:56:51 -0500 Subject: [PATCH 22/23] fix: better filtering to handle de-scoping after uninstall edge case (#24963) > Issue found during testing # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements) - [x] Added/updated tests - [x] Manual QA for all new/changed functionality --- server/datastore/mysql/software.go | 65 +++++++++- server/service/integration_enterprise_test.go | 113 ++++++++++++++++++ 2 files changed, 177 insertions(+), 1 deletion(-) diff --git a/server/datastore/mysql/software.go b/server/datastore/mysql/software.go index d8e8544130..9d929c2289 100644 --- a/server/datastore/mysql/software.go +++ b/server/datastore/mysql/software.go @@ -2296,6 +2296,8 @@ INNER JOIN software_cve scve ON scve.software_id = s.id host_vpp_software_installs hvsi ON vat.adam_id = hvsi.adam_id AND hvsi.host_id = :host_id AND hvsi.removed = 0 LEFT OUTER JOIN nano_command_results ncr ON ncr.command_uuid = hvsi.command_uuid + LEFT OUTER JOIN + host_script_results hsr ON hsr.host_id = :host_id AND hsr.execution_id = hsi.last_uninstall_execution_id WHERE -- use the latest VPP install attempt only ( hvsi.id IS NULL OR hvsi.id = ( @@ -2309,6 +2311,67 @@ INNER JOIN software_cve scve ON scve.software_id = s.id -- on host (via installer or VPP app). If only available for install is -- requested, then the software installed on host clause is empty. ( %s hsi.host_id IS NOT NULL OR hvsi.host_id IS NOT NULL ) + AND + -- label membership check + ( + -- do the label membership check only for software installers + CASE WHEN (si.ID IS NOT NULL AND hsi.last_uninstalled_at IS NOT NULL AND hsr.exit_code = 0) THEN + ( + EXISTS ( + + SELECT 1 FROM ( + + -- no labels + SELECT 0 AS count_installer_labels, 0 AS count_host_labels, 0 as count_host_updated_after_labels + WHERE NOT EXISTS ( + SELECT 1 FROM software_installer_labels sil WHERE sil.software_installer_id = si.id + ) + + UNION + + -- include any + SELECT + COUNT(*) AS count_installer_labels, + COUNT(lm.label_id) AS count_host_labels, + 0 as count_host_updated_after_labels + FROM + software_installer_labels sil + LEFT OUTER JOIN label_membership lm ON lm.label_id = sil.label_id + AND lm.host_id = :host_id + WHERE + sil.software_installer_id = si.id + AND sil.exclude = 0 + HAVING + count_installer_labels > 0 AND count_host_labels > 0 + + UNION + + -- exclude any, ignore software that depends on labels created + -- _after_ the label_updated_at timestamp of the host (because + -- we don't have results for that label yet, the host may or may + -- not be a member). + SELECT + COUNT(*) AS count_installer_labels, + COUNT(lm.label_id) AS count_host_labels, + SUM(CASE WHEN lbl.created_at IS NOT NULL AND :host_label_updated_at >= lbl.created_at THEN 1 ELSE 0 END) as count_host_updated_after_labels + FROM + software_installer_labels sil + LEFT OUTER JOIN labels lbl + ON lbl.id = sil.label_id + LEFT OUTER JOIN label_membership lm + ON lm.label_id = sil.label_id AND lm.host_id = :host_id + WHERE + sil.software_installer_id = si.id + AND sil.exclude = 1 + HAVING + count_installer_labels > 0 AND count_installer_labels = count_host_updated_after_labels AND count_host_labels = 0 + ) t + ) + ) + -- it's some other type of software that has been checked above + ELSE true END + ) + %s `, status, softwareIsInstalledOnHostClause, onlySelfServiceClause) @@ -2474,12 +2537,12 @@ INNER JOIN software_cve scve ON scve.software_id = s.id "mdm_status_format_error": fleet.MDMAppleStatusCommandFormatError, "global_or_team_id": globalOrTeamID, "is_mdm_enrolled": opts.IsMDMEnrolled, + "host_label_updated_at": host.LabelUpdatedAt, } stmt := stmtInstalled if opts.OnlyAvailableForInstall || (opts.IncludeAvailableForInstall && !opts.VulnerableOnly) { namedArgs["vpp_apps_platforms"] = fleet.VPPAppsPlatforms - namedArgs["host_label_updated_at"] = host.LabelUpdatedAt if fleet.IsLinux(host.Platform) { namedArgs["host_compatible_platforms"] = fleet.HostLinuxOSs } else { diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index 3259d3a46d..23db4cea59 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -16179,3 +16179,116 @@ func (s *integrationEnterpriseTestSuite) TestDeleteLabels() { // delete the unused label2 s.DoJSON("DELETE", "/api/v1/fleet/labels/id/"+fmt.Sprint(lbl2), nil, http.StatusOK, &delLabelResp) } + +func (s *integrationEnterpriseTestSuite) TestListHostSoftwareWithLabelScoping() { + ctx := context.Background() + t := s.T() + + host := createOrbitEnrolledHost(t, "linux", "", s.ds) + + // Create software installers and corresponding host install requests. + payload := &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "install script", + PreInstallQuery: "pre install query", + PostInstallScript: "post install script", + Filename: "ruby.deb", + Title: "ruby", + } + s.uploadSoftwareInstaller(t, payload, http.StatusOK, "") + titleID := getSoftwareTitleID(t, s.ds, payload.Title, "deb_packages") + + latestInstallUUID := func() string { + var id string + mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + return sqlx.GetContext(ctx, q, &id, `SELECT execution_id FROM host_software_installs ORDER BY id DESC LIMIT 1`) + }) + return id + } + + // create install request for the software and record a successful result + resp := installSoftwareResponse{} + s.DoJSON("POST", fmt.Sprintf("/api/v1/fleet/hosts/%d/software/%d/install", host.ID, titleID), nil, http.StatusAccepted, &resp) + installUUID := latestInstallUUID() + + s.Do("POST", "/api/fleet/orbit/software_install/result", + json.RawMessage(fmt.Sprintf(`{ + "orbit_node_key": %q, + "install_uuid": %q, + "pre_install_condition_output": "", + "install_script_exit_code": 0, + "install_script_output": "success" + }`, *host.OrbitNodeKey, installUUID)), + http.StatusNoContent) + + // Software is now installed on the host. We should see it in the host software list + getHostSw := getHostSoftwareResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host.ID), nil, http.StatusOK, &getHostSw) + require.Len(t, getHostSw.Software, 1) + require.Equal(t, getHostSw.Software[0].Name, "ruby") + + // De-scope the software by adding an exclude any label that the host has. + // TODO(JVE): remove/update this once the API is in place + updateInstallerLabel := func(siID, labelID uint, exclude bool) { + mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + _, err := q.ExecContext( + ctx, + `INSERT INTO software_installer_labels (software_installer_id, label_id, exclude) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE exclude = VALUES(exclude)`, + siID, labelID, exclude, + ) + return err + }) + } + + var installerID uint + mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + return sqlx.GetContext(ctx, q, &installerID, "SELECT id FROM software_installers WHERE title_id = ?", titleID) + }) + require.NotEmpty(t, installerID) + + // create some labels and assign them to the host + var labelResp createLabelResponse + s.DoJSON("POST", "/api/latest/fleet/labels", &createLabelRequest{fleet.LabelPayload{ + Name: "label1", + Hosts: []string{host.Hostname}, + }}, http.StatusOK, &labelResp) + require.NotZero(t, labelResp.Label.ID) + lbl1 := labelResp.Label + + s.DoJSON("POST", "/api/latest/fleet/labels", &createLabelRequest{fleet.LabelPayload{ + Name: "label2", + Query: "SELECT 1", + }}, http.StatusOK, &labelResp) + require.NotZero(t, labelResp.Label.ID) + lbl2 := labelResp.Label + err := s.ds.RecordLabelQueryExecutions(context.Background(), host, map[uint]*bool{lbl2.ID: ptr.Bool(true)}, time.Now(), false) + require.NoError(t, err) + + updateInstallerLabel(installerID, lbl1.ID, true) + updateInstallerLabel(installerID, lbl2.ID, true) + + // We should still see the software at this point, because we haven't uninstalled it yet + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host.ID), nil, http.StatusOK, &getHostSw) + require.Len(t, getHostSw.Software, 1) + + // uninstall the software + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/uninstall", host.ID, titleID), nil, http.StatusAccepted, &resp) + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host.ID), nil, http.StatusOK, &getHostSw) + require.Len(t, getHostSw.Software, 1) + assert.NotNil(t, getHostSw.Software[0].SoftwarePackage.LastInstall) + assert.Equal(t, fleet.SoftwareUninstallPending, *getHostSw.Software[0].Status) + require.NotNil(t, getHostSw.Software[0].SoftwarePackage.LastUninstall) + uninstallExecutionID := getHostSw.Software[0].SoftwarePackage.LastUninstall.ExecutionID + + // Host sends failed uninstall result + var orbitPostScriptResp orbitPostScriptResultResponse + s.DoJSON("POST", "/api/fleet/orbit/scripts/result", + json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q, "execution_id": %q, "exit_code": 0, "output": "uninstall"}`, *host.OrbitNodeKey, + uninstallExecutionID)), + http.StatusOK, &orbitPostScriptResp) + + // Now that the software is uninstalled, we should no longer see it in the host software list, + // because it is de-scoped via labels. + getHostSw = getHostSoftwareResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host.ID), nil, http.StatusOK, &getHostSw) + require.Empty(t, getHostSw.Software) +} From c79875c9636829762fe428d564e2cf9f37c4a588 Mon Sep 17 00:00:00 2001 From: gillespi314 <73313222+gillespi314@users.noreply.github.com> Date: Fri, 20 Dec 2024 17:12:56 -0600 Subject: [PATCH 23/23] Add changes file --- changes/22813-software-scope-labels | 1 + 1 file changed, 1 insertion(+) create mode 100644 changes/22813-software-scope-labels diff --git a/changes/22813-software-scope-labels b/changes/22813-software-scope-labels new file mode 100644 index 0000000000..4a4681a2c6 --- /dev/null +++ b/changes/22813-software-scope-labels @@ -0,0 +1 @@ +- Added features to scope Fleet-maintained apps and custom packages via labels in UI, API, and CLI.