From d249aa888b8aa78e52ca4ab95ce211908ded2796 Mon Sep 17 00:00:00 2001 From: Martin Angers Date: Tue, 25 Feb 2025 14:44:48 -0500 Subject: [PATCH 01/13] Android: add 'Android' builtin label, add new Android hosts to 'All hosts' and this label (#26585) --- server/datastore/mysql/android.go | 51 ++++++++++++++++- server/datastore/mysql/android_test.go | 27 +++++++++ server/datastore/mysql/apple_mdm.go | 2 +- server/datastore/mysql/hosts.go | 1 + .../20250225085436_AddAndroidBuiltinLabel.go | 56 +++++++++++++++++++ server/datastore/mysql/schema.sql | 8 +-- server/fleet/hosts.go | 5 +- server/fleet/labels.go | 2 + server/fleet/mdm.go | 1 + server/service/hosts.go | 1 + server/test/new_objects.go | 7 +++ 11 files changed, 152 insertions(+), 9 deletions(-) create mode 100644 server/datastore/mysql/migrations/tables/20250225085436_AddAndroidBuiltinLabel.go diff --git a/server/datastore/mysql/android.go b/server/datastore/mysql/android.go index b4bff11baf..e10c3fd60c 100644 --- a/server/datastore/mysql/android.go +++ b/server/datastore/mysql/android.go @@ -4,11 +4,13 @@ import ( "context" "database/sql" "errors" + "fmt" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" "github.com/fleetdm/fleet/v4/server/datastore/mysql/common_mysql" "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/mdm/android" + "github.com/go-kit/log/level" "github.com/jmoiron/sqlx" ) @@ -38,7 +40,7 @@ func (ds *Datastore) NewAndroidHost(ctx context.Context, host *fleet.AndroidHost detail_updated_at, label_updated_at ) VALUES ( - :node_key, + :node_key, :hostname, :computer_name, :platform, @@ -77,6 +79,10 @@ func (ds *Datastore) NewAndroidHost(ctx context.Context, host *fleet.AndroidHost if err != nil { return ctxerr.Wrap(ctx, err, "new Android host display name") } + err = ds.insertAndroidHostLabelMembershipTx(ctx, tx, host.Host.ID) + if err != nil { + return ctxerr.Wrap(ctx, err, "new Android host label membership") + } host.Device, err = ds.androidDS.CreateDeviceTx(ctx, tx, host.Device) if err != nil { @@ -141,7 +147,7 @@ func (ds *Datastore) AndroidHostLite(ctx context.Context, enterpriseSpecificID s TeamID *uint `db:"team_id"` *android.Device } - stmt := `SELECT + stmt := `SELECT h.team_id, ad.id, ad.host_id, @@ -170,3 +176,44 @@ func (ds *Datastore) AndroidHostLite(ctx context.Context, enterpriseSpecificID s result.SetNodeKey(enterpriseSpecificID) return result, nil } + +func (ds *Datastore) insertAndroidHostLabelMembershipTx(ctx context.Context, tx sqlx.ExtContext, hostID uint) error { + // Insert the host in the builtin label memberships, adding them to the "All + // Hosts" and "Android" labels. + var labels []struct { + ID uint `db:"id"` + Name string `db:"name"` + } + err := sqlx.SelectContext(ctx, tx, &labels, `SELECT id, name FROM labels WHERE label_type = 1 AND (name = ? OR name = ?)`, + fleet.BuiltinLabelNameAllHosts, fleet.BuiltinLabelNameAndroid) + switch { + case err != nil: + return ctxerr.Wrap(ctx, err, "get builtin labels") + case len(labels) != 2: + // Builtin labels can get deleted so it is important that we check that + // they still exist before we continue. + // Note that this is the same behavior as for the iOS/iPadOS host labels. + level.Error(ds.logger).Log("err", fmt.Sprintf("expected 2 builtin labels but got %d", len(labels))) + return nil + } + + // We cannot assume IDs on labels, thus we look by name. + var allHostsLabelID, androidLabelID uint + for _, label := range labels { + switch label.Name { + case fleet.BuiltinLabelNameAllHosts: + allHostsLabelID = label.ID + case fleet.BuiltinLabelNameAndroid: + androidLabelID = label.ID + } + } + + _, err = tx.ExecContext(ctx, ` + INSERT INTO label_membership (host_id, label_id) VALUES (?, ?), (?, ?) + ON DUPLICATE KEY UPDATE host_id = host_id`, + hostID, allHostsLabelID, hostID, androidLabelID) + if err != nil { + return ctxerr.Wrap(ctx, err, "set label membership") + } + return nil +} diff --git a/server/datastore/mysql/android_test.go b/server/datastore/mysql/android_test.go index 551f0b2d3b..f9b533fcab 100644 --- a/server/datastore/mysql/android_test.go +++ b/server/datastore/mysql/android_test.go @@ -8,12 +8,15 @@ import ( "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/mdm/android" "github.com/fleetdm/fleet/v4/server/ptr" + "github.com/fleetdm/fleet/v4/server/test" + "github.com/jmoiron/sqlx" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestAndroid(t *testing.T) { ds := CreateMySQLDS(t) + TruncateTables(t, ds) cases := []struct { name string @@ -31,6 +34,8 @@ func TestAndroid(t *testing.T) { } func testNewAndroidHost(t *testing.T, ds *Datastore) { + test.AddBuiltinLabels(t, ds) + const enterpriseSpecificID = "enterprise_specific_id" host := createAndroidHost(enterpriseSpecificID) @@ -39,6 +44,12 @@ func testNewAndroidHost(t *testing.T, ds *Datastore) { assert.NotZero(t, result.Host.ID) assert.NotZero(t, result.Device.ID) + lbls, err := ds.ListLabelsForHost(testCtx(), result.Host.ID) + require.NoError(t, err) + require.Len(t, lbls, 2) + names := []string{lbls[0].Name, lbls[1].Name} + require.ElementsMatch(t, []string{fleet.BuiltinLabelNameAllHosts, fleet.BuiltinLabelNameAndroid}, names) + resultLite, err := ds.AndroidHostLite(testCtx(), enterpriseSpecificID) require.NoError(t, err) assert.Equal(t, result.Host.ID, resultLite.Host.ID) @@ -50,6 +61,22 @@ func testNewAndroidHost(t *testing.T, ds *Datastore) { require.NoError(t, err) assert.Equal(t, result.Host.ID, resultCopy.Host.ID) assert.Equal(t, result.Device.ID, resultCopy.Device.ID) + + // create another host, this time delete the Android label + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + _, err := q.ExecContext(testCtx(), `DELETE FROM labels WHERE name = ?`, fleet.BuiltinLabelNameAndroid) + return err + }) + const enterpriseSpecificID2 = "enterprise_specific_id2" + host2 := createAndroidHost(enterpriseSpecificID2) + + // still passes, but no label membership was recorded + result, err = ds.NewAndroidHost(testCtx(), host2) + require.NoError(t, err) + + lbls, err = ds.ListLabelsForHost(testCtx(), result.Host.ID) + require.NoError(t, err) + require.Empty(t, lbls) } func createAndroidHost(enterpriseSpecificID string) *fleet.AndroidHost { diff --git a/server/datastore/mysql/apple_mdm.go b/server/datastore/mysql/apple_mdm.go index f93e2673a4..a78b007f74 100644 --- a/server/datastore/mysql/apple_mdm.go +++ b/server/datastore/mysql/apple_mdm.go @@ -1342,7 +1342,7 @@ func upsertMDMAppleHostLabelMembershipDB(ctx context.Context, tx sqlx.ExtContext // query results are received; however, we want to insert pending MDM hosts // now because it may still be some time before osquery is running on these // devices. Because these are Apple devices, we're adding them to the "All - // Hosts" and "macOS" labels. + // Hosts" and one of "macOS", "iOS", "iPadOS" labels. labels := []struct { ID uint `db:"id"` Name string `db:"name"` diff --git a/server/datastore/mysql/hosts.go b/server/datastore/mysql/hosts.go index 5246056554..ddacde9070 100644 --- a/server/datastore/mysql/hosts.go +++ b/server/datastore/mysql/hosts.go @@ -4155,6 +4155,7 @@ func (ds *Datastore) AggregatedMDMSolutions(ctx context.Context, teamID *uint, p func (ds *Datastore) GenerateAggregatedMunkiAndMDM(ctx context.Context) error { var ( + // TODO(android): add android to this list? platforms = []string{"", "darwin", "windows", "ios", "ipados"} teamIDs []uint ) diff --git a/server/datastore/mysql/migrations/tables/20250225085436_AddAndroidBuiltinLabel.go b/server/datastore/mysql/migrations/tables/20250225085436_AddAndroidBuiltinLabel.go new file mode 100644 index 0000000000..4cdd9c7da0 --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20250225085436_AddAndroidBuiltinLabel.go @@ -0,0 +1,56 @@ +package tables + +import ( + "database/sql" + "fmt" + "time" + + "github.com/VividCortex/mysqlerr" + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/go-sql-driver/mysql" +) + +func init() { + MigrationClient.AddMigration(Up_20250225085436, Down_20250225085436) +} + +func Up_20250225085436(tx *sql.Tx) error { + const stmt = ` + INSERT INTO labels ( + name, + description, + query, + platform, + label_type, + label_membership_type, + created_at, + updated_at + ) VALUES (?, ?, '', ?, ?, ?, ?, ?) +` + + // hard-coded timestamps are used so that schema.sql is stable + ts := time.Date(2025, 2, 25, 0, 0, 0, 0, time.UTC) + _, err := tx.Exec( + stmt, + fleet.BuiltinLabelNameAndroid, + "All Android hosts", + "android", + fleet.LabelTypeBuiltIn, + fleet.LabelMembershipTypeManual, + ts, + ts, + ) + if err != nil { + if driverErr, ok := err.(*mysql.MySQLError); ok { + if driverErr.Number == mysqlerr.ER_DUP_ENTRY { + return fmt.Errorf("a label with the name %q already exists, please rename it before applying this migration: %w", fleet.BuiltinLabelNameAndroid, err) + } + } + return err + } + return nil +} + +func Down_20250225085436(tx *sql.Tx) error { + return nil +} diff --git a/server/datastore/mysql/schema.sql b/server/datastore/mysql/schema.sql index 73f8d428f1..767fd7a2cd 100644 --- a/server/datastore/mysql/schema.sql +++ b/server/datastore/mysql/schema.sql @@ -864,9 +864,9 @@ CREATE TABLE `labels` ( PRIMARY KEY (`id`), UNIQUE KEY `idx_label_unique_name` (`name`), FULLTEXT KEY `labels_search` (`name`) -) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; -INSERT INTO `labels` VALUES (1,'2024-04-03 00:00:00','2024-04-03 00:00:00','macOS 14+ (Sonoma+)','macOS hosts with version 14 and above','select 1 from os_version where platform = \'darwin\' and major >= 14;','darwin',1,0),(2,'2024-06-28 00:00:00','2024-06-28 00:00:00','iOS','All iOS hosts','','ios',1,1),(3,'2024-06-28 00:00:00','2024-06-28 00:00:00','iPadOS','All iPadOS hosts','','ipados',1,1),(4,'2024-09-27 00:00:00','2024-09-27 00:00:00','Fedora Linux','All Fedora hosts','select 1 from os_version where name = \'Fedora Linux\';','rhel',1,0); +INSERT INTO `labels` VALUES (1,'2024-04-03 00:00:00','2024-04-03 00:00:00','macOS 14+ (Sonoma+)','macOS hosts with version 14 and above','select 1 from os_version where platform = \'darwin\' and major >= 14;','darwin',1,0),(2,'2024-06-28 00:00:00','2024-06-28 00:00:00','iOS','All iOS hosts','','ios',1,1),(3,'2024-06-28 00:00:00','2024-06-28 00:00:00','iPadOS','All iPadOS hosts','','ipados',1,1),(4,'2024-09-27 00:00:00','2024-09-27 00:00:00','Fedora Linux','All Fedora hosts','select 1 from os_version where name = \'Fedora Linux\';','rhel',1,0),(5,'2025-02-25 00:00:00','2025-02-25 00:00:00','Android','All Android hosts','','android',1,1); /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `locks` ( @@ -1159,9 +1159,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=359 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB AUTO_INCREMENT=360 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; -INSERT INTO `migration_status_tables` VALUES (1,0,1,'2020-01-01 01:01:01'),(2,20161118193812,1,'2020-01-01 01:01:01'),(3,20161118211713,1,'2020-01-01 01:01:01'),(4,20161118212436,1,'2020-01-01 01:01:01'),(5,20161118212515,1,'2020-01-01 01:01:01'),(6,20161118212528,1,'2020-01-01 01:01:01'),(7,20161118212538,1,'2020-01-01 01:01:01'),(8,20161118212549,1,'2020-01-01 01:01:01'),(9,20161118212557,1,'2020-01-01 01:01:01'),(10,20161118212604,1,'2020-01-01 01:01:01'),(11,20161118212613,1,'2020-01-01 01:01:01'),(12,20161118212621,1,'2020-01-01 01:01:01'),(13,20161118212630,1,'2020-01-01 01:01:01'),(14,20161118212641,1,'2020-01-01 01:01:01'),(15,20161118212649,1,'2020-01-01 01:01:01'),(16,20161118212656,1,'2020-01-01 01:01:01'),(17,20161118212758,1,'2020-01-01 01:01:01'),(18,20161128234849,1,'2020-01-01 01:01:01'),(19,20161230162221,1,'2020-01-01 01:01:01'),(20,20170104113816,1,'2020-01-01 01:01:01'),(21,20170105151732,1,'2020-01-01 01:01:01'),(22,20170108191242,1,'2020-01-01 01:01:01'),(23,20170109094020,1,'2020-01-01 01:01:01'),(24,20170109130438,1,'2020-01-01 01:01:01'),(25,20170110202752,1,'2020-01-01 01:01:01'),(26,20170111133013,1,'2020-01-01 01:01:01'),(27,20170117025759,1,'2020-01-01 01:01:01'),(28,20170118191001,1,'2020-01-01 01:01:01'),(29,20170119234632,1,'2020-01-01 01:01:01'),(30,20170124230432,1,'2020-01-01 01:01:01'),(31,20170127014618,1,'2020-01-01 01:01:01'),(32,20170131232841,1,'2020-01-01 01:01:01'),(33,20170223094154,1,'2020-01-01 01:01:01'),(34,20170306075207,1,'2020-01-01 01:01:01'),(35,20170309100733,1,'2020-01-01 01:01:01'),(36,20170331111922,1,'2020-01-01 01:01:01'),(37,20170502143928,1,'2020-01-01 01:01:01'),(38,20170504130602,1,'2020-01-01 01:01:01'),(39,20170509132100,1,'2020-01-01 01:01:01'),(40,20170519105647,1,'2020-01-01 01:01:01'),(41,20170519105648,1,'2020-01-01 01:01:01'),(42,20170831234300,1,'2020-01-01 01:01:01'),(43,20170831234301,1,'2020-01-01 01:01:01'),(44,20170831234303,1,'2020-01-01 01:01:01'),(45,20171116163618,1,'2020-01-01 01:01:01'),(46,20171219164727,1,'2020-01-01 01:01:01'),(47,20180620164811,1,'2020-01-01 01:01:01'),(48,20180620175054,1,'2020-01-01 01:01:01'),(49,20180620175055,1,'2020-01-01 01:01:01'),(50,20191010101639,1,'2020-01-01 01:01:01'),(51,20191010155147,1,'2020-01-01 01:01:01'),(52,20191220130734,1,'2020-01-01 01:01:01'),(53,20200311140000,1,'2020-01-01 01:01:01'),(54,20200405120000,1,'2020-01-01 01:01:01'),(55,20200407120000,1,'2020-01-01 01:01:01'),(56,20200420120000,1,'2020-01-01 01:01:01'),(57,20200504120000,1,'2020-01-01 01:01:01'),(58,20200512120000,1,'2020-01-01 01:01:01'),(59,20200707120000,1,'2020-01-01 01:01:01'),(60,20201011162341,1,'2020-01-01 01:01:01'),(61,20201021104586,1,'2020-01-01 01:01:01'),(62,20201102112520,1,'2020-01-01 01:01:01'),(63,20201208121729,1,'2020-01-01 01:01:01'),(64,20201215091637,1,'2020-01-01 01:01:01'),(65,20210119174155,1,'2020-01-01 01:01:01'),(66,20210326182902,1,'2020-01-01 01:01:01'),(67,20210421112652,1,'2020-01-01 01:01:01'),(68,20210506095025,1,'2020-01-01 01:01:01'),(69,20210513115729,1,'2020-01-01 01:01:01'),(70,20210526113559,1,'2020-01-01 01:01:01'),(71,20210601000001,1,'2020-01-01 01:01:01'),(72,20210601000002,1,'2020-01-01 01:01:01'),(73,20210601000003,1,'2020-01-01 01:01:01'),(74,20210601000004,1,'2020-01-01 01:01:01'),(75,20210601000005,1,'2020-01-01 01:01:01'),(76,20210601000006,1,'2020-01-01 01:01:01'),(77,20210601000007,1,'2020-01-01 01:01:01'),(78,20210601000008,1,'2020-01-01 01:01:01'),(79,20210606151329,1,'2020-01-01 01:01:01'),(80,20210616163757,1,'2020-01-01 01:01:01'),(81,20210617174723,1,'2020-01-01 01:01:01'),(82,20210622160235,1,'2020-01-01 01:01:01'),(83,20210623100031,1,'2020-01-01 01:01:01'),(84,20210623133615,1,'2020-01-01 01:01:01'),(85,20210708143152,1,'2020-01-01 01:01:01'),(86,20210709124443,1,'2020-01-01 01:01:01'),(87,20210712155608,1,'2020-01-01 01:01:01'),(88,20210714102108,1,'2020-01-01 01:01:01'),(89,20210719153709,1,'2020-01-01 01:01:01'),(90,20210721171531,1,'2020-01-01 01:01:01'),(91,20210723135713,1,'2020-01-01 01:01:01'),(92,20210802135933,1,'2020-01-01 01:01:01'),(93,20210806112844,1,'2020-01-01 01:01:01'),(94,20210810095603,1,'2020-01-01 01:01:01'),(95,20210811150223,1,'2020-01-01 01:01:01'),(96,20210818151827,1,'2020-01-01 01:01:01'),(97,20210818151828,1,'2020-01-01 01:01:01'),(98,20210818182258,1,'2020-01-01 01:01:01'),(99,20210819131107,1,'2020-01-01 01:01:01'),(100,20210819143446,1,'2020-01-01 01:01:01'),(101,20210903132338,1,'2020-01-01 01:01:01'),(102,20210915144307,1,'2020-01-01 01:01:01'),(103,20210920155130,1,'2020-01-01 01:01:01'),(104,20210927143115,1,'2020-01-01 01:01:01'),(105,20210927143116,1,'2020-01-01 01:01:01'),(106,20211013133706,1,'2020-01-01 01:01:01'),(107,20211013133707,1,'2020-01-01 01:01:01'),(108,20211102135149,1,'2020-01-01 01:01:01'),(109,20211109121546,1,'2020-01-01 01:01:01'),(110,20211110163320,1,'2020-01-01 01:01:01'),(111,20211116184029,1,'2020-01-01 01:01:01'),(112,20211116184030,1,'2020-01-01 01:01:01'),(113,20211202092042,1,'2020-01-01 01:01:01'),(114,20211202181033,1,'2020-01-01 01:01:01'),(115,20211207161856,1,'2020-01-01 01:01:01'),(116,20211216131203,1,'2020-01-01 01:01:01'),(117,20211221110132,1,'2020-01-01 01:01:01'),(118,20220107155700,1,'2020-01-01 01:01:01'),(119,20220125105650,1,'2020-01-01 01:01:01'),(120,20220201084510,1,'2020-01-01 01:01:01'),(121,20220208144830,1,'2020-01-01 01:01:01'),(122,20220208144831,1,'2020-01-01 01:01:01'),(123,20220215152203,1,'2020-01-01 01:01:01'),(124,20220223113157,1,'2020-01-01 01:01:01'),(125,20220307104655,1,'2020-01-01 01:01:01'),(126,20220309133956,1,'2020-01-01 01:01:01'),(127,20220316155700,1,'2020-01-01 01:01:01'),(128,20220323152301,1,'2020-01-01 01:01:01'),(129,20220330100659,1,'2020-01-01 01:01:01'),(130,20220404091216,1,'2020-01-01 01:01:01'),(131,20220419140750,1,'2020-01-01 01:01:01'),(132,20220428140039,1,'2020-01-01 01:01:01'),(133,20220503134048,1,'2020-01-01 01:01:01'),(134,20220524102918,1,'2020-01-01 01:01:01'),(135,20220526123327,1,'2020-01-01 01:01:01'),(136,20220526123328,1,'2020-01-01 01:01:01'),(137,20220526123329,1,'2020-01-01 01:01:01'),(138,20220608113128,1,'2020-01-01 01:01:01'),(139,20220627104817,1,'2020-01-01 01:01:01'),(140,20220704101843,1,'2020-01-01 01:01:01'),(141,20220708095046,1,'2020-01-01 01:01:01'),(142,20220713091130,1,'2020-01-01 01:01:01'),(143,20220802135510,1,'2020-01-01 01:01:01'),(144,20220818101352,1,'2020-01-01 01:01:01'),(145,20220822161445,1,'2020-01-01 01:01:01'),(146,20220831100036,1,'2020-01-01 01:01:01'),(147,20220831100151,1,'2020-01-01 01:01:01'),(148,20220908181826,1,'2020-01-01 01:01:01'),(149,20220914154915,1,'2020-01-01 01:01:01'),(150,20220915165115,1,'2020-01-01 01:01:01'),(151,20220915165116,1,'2020-01-01 01:01:01'),(152,20220928100158,1,'2020-01-01 01:01:01'),(153,20221014084130,1,'2020-01-01 01:01:01'),(154,20221027085019,1,'2020-01-01 01:01:01'),(155,20221101103952,1,'2020-01-01 01:01:01'),(156,20221104144401,1,'2020-01-01 01:01:01'),(157,20221109100749,1,'2020-01-01 01:01:01'),(158,20221115104546,1,'2020-01-01 01:01:01'),(159,20221130114928,1,'2020-01-01 01:01:01'),(160,20221205112142,1,'2020-01-01 01:01:01'),(161,20221216115820,1,'2020-01-01 01:01:01'),(162,20221220195934,1,'2020-01-01 01:01:01'),(163,20221220195935,1,'2020-01-01 01:01:01'),(164,20221223174807,1,'2020-01-01 01:01:01'),(165,20221227163855,1,'2020-01-01 01:01:01'),(166,20221227163856,1,'2020-01-01 01:01:01'),(167,20230202224725,1,'2020-01-01 01:01:01'),(168,20230206163608,1,'2020-01-01 01:01:01'),(169,20230214131519,1,'2020-01-01 01:01:01'),(170,20230303135738,1,'2020-01-01 01:01:01'),(171,20230313135301,1,'2020-01-01 01:01:01'),(172,20230313141819,1,'2020-01-01 01:01:01'),(173,20230315104937,1,'2020-01-01 01:01:01'),(174,20230317173844,1,'2020-01-01 01:01:01'),(175,20230320133602,1,'2020-01-01 01:01:01'),(176,20230330100011,1,'2020-01-01 01:01:01'),(177,20230330134823,1,'2020-01-01 01:01:01'),(178,20230405232025,1,'2020-01-01 01:01:01'),(179,20230408084104,1,'2020-01-01 01:01:01'),(180,20230411102858,1,'2020-01-01 01:01:01'),(181,20230421155932,1,'2020-01-01 01:01:01'),(182,20230425082126,1,'2020-01-01 01:01:01'),(183,20230425105727,1,'2020-01-01 01:01:01'),(184,20230501154913,1,'2020-01-01 01:01:01'),(185,20230503101418,1,'2020-01-01 01:01:01'),(186,20230515144206,1,'2020-01-01 01:01:01'),(187,20230517140952,1,'2020-01-01 01:01:01'),(188,20230517152807,1,'2020-01-01 01:01:01'),(189,20230518114155,1,'2020-01-01 01:01:01'),(190,20230520153236,1,'2020-01-01 01:01:01'),(191,20230525151159,1,'2020-01-01 01:01:01'),(192,20230530122103,1,'2020-01-01 01:01:01'),(193,20230602111827,1,'2020-01-01 01:01:01'),(194,20230608103123,1,'2020-01-01 01:01:01'),(195,20230629140529,1,'2020-01-01 01:01:01'),(196,20230629140530,1,'2020-01-01 01:01:01'),(197,20230711144622,1,'2020-01-01 01:01:01'),(198,20230721135421,1,'2020-01-01 01:01:01'),(199,20230721161508,1,'2020-01-01 01:01:01'),(200,20230726115701,1,'2020-01-01 01:01:01'),(201,20230807100822,1,'2020-01-01 01:01:01'),(202,20230814150442,1,'2020-01-01 01:01:01'),(203,20230823122728,1,'2020-01-01 01:01:01'),(204,20230906152143,1,'2020-01-01 01:01:01'),(205,20230911163618,1,'2020-01-01 01:01:01'),(206,20230912101759,1,'2020-01-01 01:01:01'),(207,20230915101341,1,'2020-01-01 01:01:01'),(208,20230918132351,1,'2020-01-01 01:01:01'),(209,20231004144339,1,'2020-01-01 01:01:01'),(210,20231009094541,1,'2020-01-01 01:01:01'),(211,20231009094542,1,'2020-01-01 01:01:01'),(212,20231009094543,1,'2020-01-01 01:01:01'),(213,20231009094544,1,'2020-01-01 01:01:01'),(214,20231016091915,1,'2020-01-01 01:01:01'),(215,20231024174135,1,'2020-01-01 01:01:01'),(216,20231025120016,1,'2020-01-01 01:01:01'),(217,20231025160156,1,'2020-01-01 01:01:01'),(218,20231031165350,1,'2020-01-01 01:01:01'),(219,20231106144110,1,'2020-01-01 01:01:01'),(220,20231107130934,1,'2020-01-01 01:01:01'),(221,20231109115838,1,'2020-01-01 01:01:01'),(222,20231121054530,1,'2020-01-01 01:01:01'),(223,20231122101320,1,'2020-01-01 01:01:01'),(224,20231130132828,1,'2020-01-01 01:01:01'),(225,20231130132931,1,'2020-01-01 01:01:01'),(226,20231204155427,1,'2020-01-01 01:01:01'),(227,20231206142340,1,'2020-01-01 01:01:01'),(228,20231207102320,1,'2020-01-01 01:01:01'),(229,20231207102321,1,'2020-01-01 01:01:01'),(230,20231207133731,1,'2020-01-01 01:01:01'),(231,20231212094238,1,'2020-01-01 01:01:01'),(232,20231212095734,1,'2020-01-01 01:01:01'),(233,20231212161121,1,'2020-01-01 01:01:01'),(234,20231215122713,1,'2020-01-01 01:01:01'),(235,20231219143041,1,'2020-01-01 01:01:01'),(236,20231224070653,1,'2020-01-01 01:01:01'),(237,20240110134315,1,'2020-01-01 01:01:01'),(238,20240119091637,1,'2020-01-01 01:01:01'),(239,20240126020642,1,'2020-01-01 01:01:01'),(240,20240126020643,1,'2020-01-01 01:01:01'),(241,20240129162819,1,'2020-01-01 01:01:01'),(242,20240130115133,1,'2020-01-01 01:01:01'),(243,20240131083822,1,'2020-01-01 01:01:01'),(244,20240205095928,1,'2020-01-01 01:01:01'),(245,20240205121956,1,'2020-01-01 01:01:01'),(246,20240209110212,1,'2020-01-01 01:01:01'),(247,20240212111533,1,'2020-01-01 01:01:01'),(248,20240221112844,1,'2020-01-01 01:01:01'),(249,20240222073518,1,'2020-01-01 01:01:01'),(250,20240222135115,1,'2020-01-01 01:01:01'),(251,20240226082255,1,'2020-01-01 01:01:01'),(252,20240228082706,1,'2020-01-01 01:01:01'),(253,20240301173035,1,'2020-01-01 01:01:01'),(254,20240302111134,1,'2020-01-01 01:01:01'),(255,20240312103753,1,'2020-01-01 01:01:01'),(256,20240313143416,1,'2020-01-01 01:01:01'),(257,20240314085226,1,'2020-01-01 01:01:01'),(258,20240314151747,1,'2020-01-01 01:01:01'),(259,20240320145650,1,'2020-01-01 01:01:01'),(260,20240327115530,1,'2020-01-01 01:01:01'),(261,20240327115617,1,'2020-01-01 01:01:01'),(262,20240408085837,1,'2020-01-01 01:01:01'),(263,20240415104633,1,'2020-01-01 01:01:01'),(264,20240430111727,1,'2020-01-01 01:01:01'),(265,20240515200020,1,'2020-01-01 01:01:01'),(266,20240521143023,1,'2020-01-01 01:01:01'),(267,20240521143024,1,'2020-01-01 01:01:01'),(268,20240601174138,1,'2020-01-01 01:01:01'),(269,20240607133721,1,'2020-01-01 01:01:01'),(270,20240612150059,1,'2020-01-01 01:01:01'),(271,20240613162201,1,'2020-01-01 01:01:01'),(272,20240613172616,1,'2020-01-01 01:01:01'),(273,20240618142419,1,'2020-01-01 01:01:01'),(274,20240625093543,1,'2020-01-01 01:01:01'),(275,20240626195531,1,'2020-01-01 01:01:01'),(276,20240702123921,1,'2020-01-01 01:01:01'),(277,20240703154849,1,'2020-01-01 01:01:01'),(278,20240707134035,1,'2020-01-01 01:01:01'),(279,20240707134036,1,'2020-01-01 01:01:01'),(280,20240709124958,1,'2020-01-01 01:01:01'),(281,20240709132642,1,'2020-01-01 01:01:01'),(282,20240709183940,1,'2020-01-01 01:01:01'),(283,20240710155623,1,'2020-01-01 01:01:01'),(284,20240723102712,1,'2020-01-01 01:01:01'),(285,20240725152735,1,'2020-01-01 01:01:01'),(286,20240725182118,1,'2020-01-01 01:01:01'),(287,20240726100517,1,'2020-01-01 01:01:01'),(288,20240730171504,1,'2020-01-01 01:01:01'),(289,20240730174056,1,'2020-01-01 01:01:01'),(290,20240730215453,1,'2020-01-01 01:01:01'),(291,20240730374423,1,'2020-01-01 01:01:01'),(292,20240801115359,1,'2020-01-01 01:01:01'),(293,20240802101043,1,'2020-01-01 01:01:01'),(294,20240802113716,1,'2020-01-01 01:01:01'),(295,20240814135330,1,'2020-01-01 01:01:01'),(296,20240815000000,1,'2020-01-01 01:01:01'),(297,20240815000001,1,'2020-01-01 01:01:01'),(298,20240816103247,1,'2020-01-01 01:01:01'),(299,20240820091218,1,'2020-01-01 01:01:01'),(300,20240826111228,1,'2020-01-01 01:01:01'),(301,20240826160025,1,'2020-01-01 01:01:01'),(302,20240829165448,1,'2020-01-01 01:01:01'),(303,20240829165605,1,'2020-01-01 01:01:01'),(304,20240829165715,1,'2020-01-01 01:01:01'),(305,20240829165930,1,'2020-01-01 01:01:01'),(306,20240829170023,1,'2020-01-01 01:01:01'),(307,20240829170033,1,'2020-01-01 01:01:01'),(308,20240829170044,1,'2020-01-01 01:01:01'),(309,20240905105135,1,'2020-01-01 01:01:01'),(310,20240905140514,1,'2020-01-01 01:01:01'),(311,20240905200000,1,'2020-01-01 01:01:01'),(312,20240905200001,1,'2020-01-01 01:01:01'),(313,20241002104104,1,'2020-01-01 01:01:01'),(314,20241002104105,1,'2020-01-01 01:01:01'),(315,20241002104106,1,'2020-01-01 01:01:01'),(316,20241002210000,1,'2020-01-01 01:01:01'),(317,20241003145349,1,'2020-01-01 01:01:01'),(318,20241004005000,1,'2020-01-01 01:01:01'),(319,20241008083925,1,'2020-01-01 01:01:01'),(320,20241009090010,1,'2020-01-01 01:01:01'),(321,20241017163402,1,'2020-01-01 01:01:01'),(322,20241021224359,1,'2020-01-01 01:01:01'),(323,20241022140321,1,'2020-01-01 01:01:01'),(324,20241025111236,1,'2020-01-01 01:01:01'),(325,20241025112748,1,'2020-01-01 01:01:01'),(326,20241025141855,1,'2020-01-01 01:01:01'),(327,20241110152839,1,'2020-01-01 01:01:01'),(328,20241110152840,1,'2020-01-01 01:01:01'),(329,20241110152841,1,'2020-01-01 01:01:01'),(330,20241116233322,1,'2020-01-01 01:01:01'),(331,20241122171434,1,'2020-01-01 01:01:01'),(332,20241125150614,1,'2020-01-01 01:01:01'),(333,20241203125346,1,'2020-01-01 01:01:01'),(334,20241203130032,1,'2020-01-01 01:01:01'),(335,20241205122800,1,'2020-01-01 01:01:01'),(336,20241209164540,1,'2020-01-01 01:01:01'),(337,20241210140021,1,'2020-01-01 01:01:01'),(338,20241219180042,1,'2020-01-01 01:01:01'),(339,20241220100000,1,'2020-01-01 01:01:01'),(340,20241220114903,1,'2020-01-01 01:01:01'),(341,20241220114904,1,'2020-01-01 01:01:01'),(342,20241224000000,1,'2020-01-01 01:01:01'),(343,20241230000000,1,'2020-01-01 01:01:01'),(344,20241231112624,1,'2020-01-01 01:01:01'),(345,20250102121439,1,'2020-01-01 01:01:01'),(346,20250121094045,1,'2020-01-01 01:01:01'),(347,20250121094500,1,'2020-01-01 01:01:01'),(348,20250121094600,1,'2020-01-01 01:01:01'),(349,20250121094700,1,'2020-01-01 01:01:01'),(350,20250124194347,1,'2020-01-01 01:01:01'),(351,20250127162751,1,'2020-01-01 01:01:01'),(352,20250213104005,1,'2020-01-01 01:01:01'),(353,20250214205657,1,'2020-01-01 01:01:01'),(354,20250217093329,1,'2020-01-01 01:01:01'),(355,20250219090511,1,'2020-01-01 01:01:01'),(356,20250219100000,1,'2020-01-01 01:01:01'),(357,20250219142401,1,'2020-01-01 01:01:01'),(358,20250224184002,1,'2020-01-01 01:01:01'); +INSERT INTO `migration_status_tables` VALUES (1,0,1,'2020-01-01 01:01:01'),(2,20161118193812,1,'2020-01-01 01:01:01'),(3,20161118211713,1,'2020-01-01 01:01:01'),(4,20161118212436,1,'2020-01-01 01:01:01'),(5,20161118212515,1,'2020-01-01 01:01:01'),(6,20161118212528,1,'2020-01-01 01:01:01'),(7,20161118212538,1,'2020-01-01 01:01:01'),(8,20161118212549,1,'2020-01-01 01:01:01'),(9,20161118212557,1,'2020-01-01 01:01:01'),(10,20161118212604,1,'2020-01-01 01:01:01'),(11,20161118212613,1,'2020-01-01 01:01:01'),(12,20161118212621,1,'2020-01-01 01:01:01'),(13,20161118212630,1,'2020-01-01 01:01:01'),(14,20161118212641,1,'2020-01-01 01:01:01'),(15,20161118212649,1,'2020-01-01 01:01:01'),(16,20161118212656,1,'2020-01-01 01:01:01'),(17,20161118212758,1,'2020-01-01 01:01:01'),(18,20161128234849,1,'2020-01-01 01:01:01'),(19,20161230162221,1,'2020-01-01 01:01:01'),(20,20170104113816,1,'2020-01-01 01:01:01'),(21,20170105151732,1,'2020-01-01 01:01:01'),(22,20170108191242,1,'2020-01-01 01:01:01'),(23,20170109094020,1,'2020-01-01 01:01:01'),(24,20170109130438,1,'2020-01-01 01:01:01'),(25,20170110202752,1,'2020-01-01 01:01:01'),(26,20170111133013,1,'2020-01-01 01:01:01'),(27,20170117025759,1,'2020-01-01 01:01:01'),(28,20170118191001,1,'2020-01-01 01:01:01'),(29,20170119234632,1,'2020-01-01 01:01:01'),(30,20170124230432,1,'2020-01-01 01:01:01'),(31,20170127014618,1,'2020-01-01 01:01:01'),(32,20170131232841,1,'2020-01-01 01:01:01'),(33,20170223094154,1,'2020-01-01 01:01:01'),(34,20170306075207,1,'2020-01-01 01:01:01'),(35,20170309100733,1,'2020-01-01 01:01:01'),(36,20170331111922,1,'2020-01-01 01:01:01'),(37,20170502143928,1,'2020-01-01 01:01:01'),(38,20170504130602,1,'2020-01-01 01:01:01'),(39,20170509132100,1,'2020-01-01 01:01:01'),(40,20170519105647,1,'2020-01-01 01:01:01'),(41,20170519105648,1,'2020-01-01 01:01:01'),(42,20170831234300,1,'2020-01-01 01:01:01'),(43,20170831234301,1,'2020-01-01 01:01:01'),(44,20170831234303,1,'2020-01-01 01:01:01'),(45,20171116163618,1,'2020-01-01 01:01:01'),(46,20171219164727,1,'2020-01-01 01:01:01'),(47,20180620164811,1,'2020-01-01 01:01:01'),(48,20180620175054,1,'2020-01-01 01:01:01'),(49,20180620175055,1,'2020-01-01 01:01:01'),(50,20191010101639,1,'2020-01-01 01:01:01'),(51,20191010155147,1,'2020-01-01 01:01:01'),(52,20191220130734,1,'2020-01-01 01:01:01'),(53,20200311140000,1,'2020-01-01 01:01:01'),(54,20200405120000,1,'2020-01-01 01:01:01'),(55,20200407120000,1,'2020-01-01 01:01:01'),(56,20200420120000,1,'2020-01-01 01:01:01'),(57,20200504120000,1,'2020-01-01 01:01:01'),(58,20200512120000,1,'2020-01-01 01:01:01'),(59,20200707120000,1,'2020-01-01 01:01:01'),(60,20201011162341,1,'2020-01-01 01:01:01'),(61,20201021104586,1,'2020-01-01 01:01:01'),(62,20201102112520,1,'2020-01-01 01:01:01'),(63,20201208121729,1,'2020-01-01 01:01:01'),(64,20201215091637,1,'2020-01-01 01:01:01'),(65,20210119174155,1,'2020-01-01 01:01:01'),(66,20210326182902,1,'2020-01-01 01:01:01'),(67,20210421112652,1,'2020-01-01 01:01:01'),(68,20210506095025,1,'2020-01-01 01:01:01'),(69,20210513115729,1,'2020-01-01 01:01:01'),(70,20210526113559,1,'2020-01-01 01:01:01'),(71,20210601000001,1,'2020-01-01 01:01:01'),(72,20210601000002,1,'2020-01-01 01:01:01'),(73,20210601000003,1,'2020-01-01 01:01:01'),(74,20210601000004,1,'2020-01-01 01:01:01'),(75,20210601000005,1,'2020-01-01 01:01:01'),(76,20210601000006,1,'2020-01-01 01:01:01'),(77,20210601000007,1,'2020-01-01 01:01:01'),(78,20210601000008,1,'2020-01-01 01:01:01'),(79,20210606151329,1,'2020-01-01 01:01:01'),(80,20210616163757,1,'2020-01-01 01:01:01'),(81,20210617174723,1,'2020-01-01 01:01:01'),(82,20210622160235,1,'2020-01-01 01:01:01'),(83,20210623100031,1,'2020-01-01 01:01:01'),(84,20210623133615,1,'2020-01-01 01:01:01'),(85,20210708143152,1,'2020-01-01 01:01:01'),(86,20210709124443,1,'2020-01-01 01:01:01'),(87,20210712155608,1,'2020-01-01 01:01:01'),(88,20210714102108,1,'2020-01-01 01:01:01'),(89,20210719153709,1,'2020-01-01 01:01:01'),(90,20210721171531,1,'2020-01-01 01:01:01'),(91,20210723135713,1,'2020-01-01 01:01:01'),(92,20210802135933,1,'2020-01-01 01:01:01'),(93,20210806112844,1,'2020-01-01 01:01:01'),(94,20210810095603,1,'2020-01-01 01:01:01'),(95,20210811150223,1,'2020-01-01 01:01:01'),(96,20210818151827,1,'2020-01-01 01:01:01'),(97,20210818151828,1,'2020-01-01 01:01:01'),(98,20210818182258,1,'2020-01-01 01:01:01'),(99,20210819131107,1,'2020-01-01 01:01:01'),(100,20210819143446,1,'2020-01-01 01:01:01'),(101,20210903132338,1,'2020-01-01 01:01:01'),(102,20210915144307,1,'2020-01-01 01:01:01'),(103,20210920155130,1,'2020-01-01 01:01:01'),(104,20210927143115,1,'2020-01-01 01:01:01'),(105,20210927143116,1,'2020-01-01 01:01:01'),(106,20211013133706,1,'2020-01-01 01:01:01'),(107,20211013133707,1,'2020-01-01 01:01:01'),(108,20211102135149,1,'2020-01-01 01:01:01'),(109,20211109121546,1,'2020-01-01 01:01:01'),(110,20211110163320,1,'2020-01-01 01:01:01'),(111,20211116184029,1,'2020-01-01 01:01:01'),(112,20211116184030,1,'2020-01-01 01:01:01'),(113,20211202092042,1,'2020-01-01 01:01:01'),(114,20211202181033,1,'2020-01-01 01:01:01'),(115,20211207161856,1,'2020-01-01 01:01:01'),(116,20211216131203,1,'2020-01-01 01:01:01'),(117,20211221110132,1,'2020-01-01 01:01:01'),(118,20220107155700,1,'2020-01-01 01:01:01'),(119,20220125105650,1,'2020-01-01 01:01:01'),(120,20220201084510,1,'2020-01-01 01:01:01'),(121,20220208144830,1,'2020-01-01 01:01:01'),(122,20220208144831,1,'2020-01-01 01:01:01'),(123,20220215152203,1,'2020-01-01 01:01:01'),(124,20220223113157,1,'2020-01-01 01:01:01'),(125,20220307104655,1,'2020-01-01 01:01:01'),(126,20220309133956,1,'2020-01-01 01:01:01'),(127,20220316155700,1,'2020-01-01 01:01:01'),(128,20220323152301,1,'2020-01-01 01:01:01'),(129,20220330100659,1,'2020-01-01 01:01:01'),(130,20220404091216,1,'2020-01-01 01:01:01'),(131,20220419140750,1,'2020-01-01 01:01:01'),(132,20220428140039,1,'2020-01-01 01:01:01'),(133,20220503134048,1,'2020-01-01 01:01:01'),(134,20220524102918,1,'2020-01-01 01:01:01'),(135,20220526123327,1,'2020-01-01 01:01:01'),(136,20220526123328,1,'2020-01-01 01:01:01'),(137,20220526123329,1,'2020-01-01 01:01:01'),(138,20220608113128,1,'2020-01-01 01:01:01'),(139,20220627104817,1,'2020-01-01 01:01:01'),(140,20220704101843,1,'2020-01-01 01:01:01'),(141,20220708095046,1,'2020-01-01 01:01:01'),(142,20220713091130,1,'2020-01-01 01:01:01'),(143,20220802135510,1,'2020-01-01 01:01:01'),(144,20220818101352,1,'2020-01-01 01:01:01'),(145,20220822161445,1,'2020-01-01 01:01:01'),(146,20220831100036,1,'2020-01-01 01:01:01'),(147,20220831100151,1,'2020-01-01 01:01:01'),(148,20220908181826,1,'2020-01-01 01:01:01'),(149,20220914154915,1,'2020-01-01 01:01:01'),(150,20220915165115,1,'2020-01-01 01:01:01'),(151,20220915165116,1,'2020-01-01 01:01:01'),(152,20220928100158,1,'2020-01-01 01:01:01'),(153,20221014084130,1,'2020-01-01 01:01:01'),(154,20221027085019,1,'2020-01-01 01:01:01'),(155,20221101103952,1,'2020-01-01 01:01:01'),(156,20221104144401,1,'2020-01-01 01:01:01'),(157,20221109100749,1,'2020-01-01 01:01:01'),(158,20221115104546,1,'2020-01-01 01:01:01'),(159,20221130114928,1,'2020-01-01 01:01:01'),(160,20221205112142,1,'2020-01-01 01:01:01'),(161,20221216115820,1,'2020-01-01 01:01:01'),(162,20221220195934,1,'2020-01-01 01:01:01'),(163,20221220195935,1,'2020-01-01 01:01:01'),(164,20221223174807,1,'2020-01-01 01:01:01'),(165,20221227163855,1,'2020-01-01 01:01:01'),(166,20221227163856,1,'2020-01-01 01:01:01'),(167,20230202224725,1,'2020-01-01 01:01:01'),(168,20230206163608,1,'2020-01-01 01:01:01'),(169,20230214131519,1,'2020-01-01 01:01:01'),(170,20230303135738,1,'2020-01-01 01:01:01'),(171,20230313135301,1,'2020-01-01 01:01:01'),(172,20230313141819,1,'2020-01-01 01:01:01'),(173,20230315104937,1,'2020-01-01 01:01:01'),(174,20230317173844,1,'2020-01-01 01:01:01'),(175,20230320133602,1,'2020-01-01 01:01:01'),(176,20230330100011,1,'2020-01-01 01:01:01'),(177,20230330134823,1,'2020-01-01 01:01:01'),(178,20230405232025,1,'2020-01-01 01:01:01'),(179,20230408084104,1,'2020-01-01 01:01:01'),(180,20230411102858,1,'2020-01-01 01:01:01'),(181,20230421155932,1,'2020-01-01 01:01:01'),(182,20230425082126,1,'2020-01-01 01:01:01'),(183,20230425105727,1,'2020-01-01 01:01:01'),(184,20230501154913,1,'2020-01-01 01:01:01'),(185,20230503101418,1,'2020-01-01 01:01:01'),(186,20230515144206,1,'2020-01-01 01:01:01'),(187,20230517140952,1,'2020-01-01 01:01:01'),(188,20230517152807,1,'2020-01-01 01:01:01'),(189,20230518114155,1,'2020-01-01 01:01:01'),(190,20230520153236,1,'2020-01-01 01:01:01'),(191,20230525151159,1,'2020-01-01 01:01:01'),(192,20230530122103,1,'2020-01-01 01:01:01'),(193,20230602111827,1,'2020-01-01 01:01:01'),(194,20230608103123,1,'2020-01-01 01:01:01'),(195,20230629140529,1,'2020-01-01 01:01:01'),(196,20230629140530,1,'2020-01-01 01:01:01'),(197,20230711144622,1,'2020-01-01 01:01:01'),(198,20230721135421,1,'2020-01-01 01:01:01'),(199,20230721161508,1,'2020-01-01 01:01:01'),(200,20230726115701,1,'2020-01-01 01:01:01'),(201,20230807100822,1,'2020-01-01 01:01:01'),(202,20230814150442,1,'2020-01-01 01:01:01'),(203,20230823122728,1,'2020-01-01 01:01:01'),(204,20230906152143,1,'2020-01-01 01:01:01'),(205,20230911163618,1,'2020-01-01 01:01:01'),(206,20230912101759,1,'2020-01-01 01:01:01'),(207,20230915101341,1,'2020-01-01 01:01:01'),(208,20230918132351,1,'2020-01-01 01:01:01'),(209,20231004144339,1,'2020-01-01 01:01:01'),(210,20231009094541,1,'2020-01-01 01:01:01'),(211,20231009094542,1,'2020-01-01 01:01:01'),(212,20231009094543,1,'2020-01-01 01:01:01'),(213,20231009094544,1,'2020-01-01 01:01:01'),(214,20231016091915,1,'2020-01-01 01:01:01'),(215,20231024174135,1,'2020-01-01 01:01:01'),(216,20231025120016,1,'2020-01-01 01:01:01'),(217,20231025160156,1,'2020-01-01 01:01:01'),(218,20231031165350,1,'2020-01-01 01:01:01'),(219,20231106144110,1,'2020-01-01 01:01:01'),(220,20231107130934,1,'2020-01-01 01:01:01'),(221,20231109115838,1,'2020-01-01 01:01:01'),(222,20231121054530,1,'2020-01-01 01:01:01'),(223,20231122101320,1,'2020-01-01 01:01:01'),(224,20231130132828,1,'2020-01-01 01:01:01'),(225,20231130132931,1,'2020-01-01 01:01:01'),(226,20231204155427,1,'2020-01-01 01:01:01'),(227,20231206142340,1,'2020-01-01 01:01:01'),(228,20231207102320,1,'2020-01-01 01:01:01'),(229,20231207102321,1,'2020-01-01 01:01:01'),(230,20231207133731,1,'2020-01-01 01:01:01'),(231,20231212094238,1,'2020-01-01 01:01:01'),(232,20231212095734,1,'2020-01-01 01:01:01'),(233,20231212161121,1,'2020-01-01 01:01:01'),(234,20231215122713,1,'2020-01-01 01:01:01'),(235,20231219143041,1,'2020-01-01 01:01:01'),(236,20231224070653,1,'2020-01-01 01:01:01'),(237,20240110134315,1,'2020-01-01 01:01:01'),(238,20240119091637,1,'2020-01-01 01:01:01'),(239,20240126020642,1,'2020-01-01 01:01:01'),(240,20240126020643,1,'2020-01-01 01:01:01'),(241,20240129162819,1,'2020-01-01 01:01:01'),(242,20240130115133,1,'2020-01-01 01:01:01'),(243,20240131083822,1,'2020-01-01 01:01:01'),(244,20240205095928,1,'2020-01-01 01:01:01'),(245,20240205121956,1,'2020-01-01 01:01:01'),(246,20240209110212,1,'2020-01-01 01:01:01'),(247,20240212111533,1,'2020-01-01 01:01:01'),(248,20240221112844,1,'2020-01-01 01:01:01'),(249,20240222073518,1,'2020-01-01 01:01:01'),(250,20240222135115,1,'2020-01-01 01:01:01'),(251,20240226082255,1,'2020-01-01 01:01:01'),(252,20240228082706,1,'2020-01-01 01:01:01'),(253,20240301173035,1,'2020-01-01 01:01:01'),(254,20240302111134,1,'2020-01-01 01:01:01'),(255,20240312103753,1,'2020-01-01 01:01:01'),(256,20240313143416,1,'2020-01-01 01:01:01'),(257,20240314085226,1,'2020-01-01 01:01:01'),(258,20240314151747,1,'2020-01-01 01:01:01'),(259,20240320145650,1,'2020-01-01 01:01:01'),(260,20240327115530,1,'2020-01-01 01:01:01'),(261,20240327115617,1,'2020-01-01 01:01:01'),(262,20240408085837,1,'2020-01-01 01:01:01'),(263,20240415104633,1,'2020-01-01 01:01:01'),(264,20240430111727,1,'2020-01-01 01:01:01'),(265,20240515200020,1,'2020-01-01 01:01:01'),(266,20240521143023,1,'2020-01-01 01:01:01'),(267,20240521143024,1,'2020-01-01 01:01:01'),(268,20240601174138,1,'2020-01-01 01:01:01'),(269,20240607133721,1,'2020-01-01 01:01:01'),(270,20240612150059,1,'2020-01-01 01:01:01'),(271,20240613162201,1,'2020-01-01 01:01:01'),(272,20240613172616,1,'2020-01-01 01:01:01'),(273,20240618142419,1,'2020-01-01 01:01:01'),(274,20240625093543,1,'2020-01-01 01:01:01'),(275,20240626195531,1,'2020-01-01 01:01:01'),(276,20240702123921,1,'2020-01-01 01:01:01'),(277,20240703154849,1,'2020-01-01 01:01:01'),(278,20240707134035,1,'2020-01-01 01:01:01'),(279,20240707134036,1,'2020-01-01 01:01:01'),(280,20240709124958,1,'2020-01-01 01:01:01'),(281,20240709132642,1,'2020-01-01 01:01:01'),(282,20240709183940,1,'2020-01-01 01:01:01'),(283,20240710155623,1,'2020-01-01 01:01:01'),(284,20240723102712,1,'2020-01-01 01:01:01'),(285,20240725152735,1,'2020-01-01 01:01:01'),(286,20240725182118,1,'2020-01-01 01:01:01'),(287,20240726100517,1,'2020-01-01 01:01:01'),(288,20240730171504,1,'2020-01-01 01:01:01'),(289,20240730174056,1,'2020-01-01 01:01:01'),(290,20240730215453,1,'2020-01-01 01:01:01'),(291,20240730374423,1,'2020-01-01 01:01:01'),(292,20240801115359,1,'2020-01-01 01:01:01'),(293,20240802101043,1,'2020-01-01 01:01:01'),(294,20240802113716,1,'2020-01-01 01:01:01'),(295,20240814135330,1,'2020-01-01 01:01:01'),(296,20240815000000,1,'2020-01-01 01:01:01'),(297,20240815000001,1,'2020-01-01 01:01:01'),(298,20240816103247,1,'2020-01-01 01:01:01'),(299,20240820091218,1,'2020-01-01 01:01:01'),(300,20240826111228,1,'2020-01-01 01:01:01'),(301,20240826160025,1,'2020-01-01 01:01:01'),(302,20240829165448,1,'2020-01-01 01:01:01'),(303,20240829165605,1,'2020-01-01 01:01:01'),(304,20240829165715,1,'2020-01-01 01:01:01'),(305,20240829165930,1,'2020-01-01 01:01:01'),(306,20240829170023,1,'2020-01-01 01:01:01'),(307,20240829170033,1,'2020-01-01 01:01:01'),(308,20240829170044,1,'2020-01-01 01:01:01'),(309,20240905105135,1,'2020-01-01 01:01:01'),(310,20240905140514,1,'2020-01-01 01:01:01'),(311,20240905200000,1,'2020-01-01 01:01:01'),(312,20240905200001,1,'2020-01-01 01:01:01'),(313,20241002104104,1,'2020-01-01 01:01:01'),(314,20241002104105,1,'2020-01-01 01:01:01'),(315,20241002104106,1,'2020-01-01 01:01:01'),(316,20241002210000,1,'2020-01-01 01:01:01'),(317,20241003145349,1,'2020-01-01 01:01:01'),(318,20241004005000,1,'2020-01-01 01:01:01'),(319,20241008083925,1,'2020-01-01 01:01:01'),(320,20241009090010,1,'2020-01-01 01:01:01'),(321,20241017163402,1,'2020-01-01 01:01:01'),(322,20241021224359,1,'2020-01-01 01:01:01'),(323,20241022140321,1,'2020-01-01 01:01:01'),(324,20241025111236,1,'2020-01-01 01:01:01'),(325,20241025112748,1,'2020-01-01 01:01:01'),(326,20241025141855,1,'2020-01-01 01:01:01'),(327,20241110152839,1,'2020-01-01 01:01:01'),(328,20241110152840,1,'2020-01-01 01:01:01'),(329,20241110152841,1,'2020-01-01 01:01:01'),(330,20241116233322,1,'2020-01-01 01:01:01'),(331,20241122171434,1,'2020-01-01 01:01:01'),(332,20241125150614,1,'2020-01-01 01:01:01'),(333,20241203125346,1,'2020-01-01 01:01:01'),(334,20241203130032,1,'2020-01-01 01:01:01'),(335,20241205122800,1,'2020-01-01 01:01:01'),(336,20241209164540,1,'2020-01-01 01:01:01'),(337,20241210140021,1,'2020-01-01 01:01:01'),(338,20241219180042,1,'2020-01-01 01:01:01'),(339,20241220100000,1,'2020-01-01 01:01:01'),(340,20241220114903,1,'2020-01-01 01:01:01'),(341,20241220114904,1,'2020-01-01 01:01:01'),(342,20241224000000,1,'2020-01-01 01:01:01'),(343,20241230000000,1,'2020-01-01 01:01:01'),(344,20241231112624,1,'2020-01-01 01:01:01'),(345,20250102121439,1,'2020-01-01 01:01:01'),(346,20250121094045,1,'2020-01-01 01:01:01'),(347,20250121094500,1,'2020-01-01 01:01:01'),(348,20250121094600,1,'2020-01-01 01:01:01'),(349,20250121094700,1,'2020-01-01 01:01:01'),(350,20250124194347,1,'2020-01-01 01:01:01'),(351,20250127162751,1,'2020-01-01 01:01:01'),(352,20250213104005,1,'2020-01-01 01:01:01'),(353,20250214205657,1,'2020-01-01 01:01:01'),(354,20250217093329,1,'2020-01-01 01:01:01'),(355,20250219090511,1,'2020-01-01 01:01:01'),(356,20250219100000,1,'2020-01-01 01:01:01'),(357,20250219142401,1,'2020-01-01 01:01:01'),(358,20250224184002,1,'2020-01-01 01:01:01'),(359,20250225085436,1,'2020-01-01 01:01:01'); /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `mobile_device_management_solutions` ( diff --git a/server/fleet/hosts.go b/server/fleet/hosts.go index 12ea8971ee..efbd20a83e 100644 --- a/server/fleet/hosts.go +++ b/server/fleet/hosts.go @@ -849,7 +849,7 @@ func (h *Host) FleetPlatform() string { // SupportsOsquery returns whether the device runs osquery. func (h *Host) SupportsOsquery() bool { - return h.Platform != "ios" && h.Platform != "ipados" + return h.Platform != "ios" && h.Platform != "ipados" && h.Platform != "android" } // HostLinuxOSs are the possible linux values for Host.Platform. @@ -894,7 +894,8 @@ func PlatformFromHost(hostPlatform string) string { // Fleet now supports Chrome via fleetd hostPlatform == "chrome", hostPlatform == "ios", - hostPlatform == "ipados": + hostPlatform == "ipados", + hostPlatform == "android": return hostPlatform default: return "" diff --git a/server/fleet/labels.go b/server/fleet/labels.go index 79a01af3ae..f14cb78a49 100644 --- a/server/fleet/labels.go +++ b/server/fleet/labels.go @@ -160,6 +160,7 @@ const ( BuiltinLabelIOS = "iOS" BuiltinLabelIPadOS = "iPadOS" BuiltinLabelFedoraLinux = "Fedora Linux" + BuiltinLabelNameAndroid = "Android" ) // ReservedLabelNames returns a map of label name strings @@ -178,6 +179,7 @@ func ReservedLabelNames() map[string]struct{} { BuiltinLabelIOS: {}, BuiltinLabelIPadOS: {}, BuiltinLabelFedoraLinux: {}, + BuiltinLabelNameAndroid: {}, } } diff --git a/server/fleet/mdm.go b/server/fleet/mdm.go index d4f3cfe6d6..86f953eace 100644 --- a/server/fleet/mdm.go +++ b/server/fleet/mdm.go @@ -770,6 +770,7 @@ func MDMPlatform(hostPlatform string) string { return "darwin" case "windows": return "windows" + // TODO(android): add android to this list? } return "" } diff --git a/server/service/hosts.go b/server/service/hosts.go index 5a3cf098ad..0bb51043da 100644 --- a/server/service/hosts.go +++ b/server/service/hosts.go @@ -1057,6 +1057,7 @@ func (svc *Service) RefetchHost(ctx context.Context, id uint) error { return ctxerr.Wrap(ctx, err, "save host") } + // TODO(android): add android to this list? if host != nil && (host.Platform == "ios" || host.Platform == "ipados") { // Get MDM commands already sent commands, err := svc.ds.GetHostMDMCommands(ctx, host.ID) diff --git a/server/test/new_objects.go b/server/test/new_objects.go index f56496faea..0e5d6caa66 100644 --- a/server/test/new_objects.go +++ b/server/test/new_objects.go @@ -183,6 +183,13 @@ func AddBuiltinLabels(t *testing.T, ds fleet.Datastore) { LabelType: fleet.LabelTypeBuiltIn, LabelMembershipType: fleet.LabelMembershipTypeDynamic, }, + { + Name: "Android", + Platform: "android", + Query: "", + LabelType: fleet.LabelTypeBuiltIn, + LabelMembershipType: fleet.LabelMembershipTypeManual, + }, } names := fleet.ReservedLabelNames() From 5a37455a7b3db4cad53ac32885a4a89663e17d0c Mon Sep 17 00:00:00 2001 From: Allen Houchins <32207388+allenhouchins@users.noreply.github.com> Date: Tue, 25 Feb 2025 15:00:18 -0600 Subject: [PATCH 02/13] Update workstations-canary.yml (#26595) Added santa-block-script.sh to the Workstations (canary) team --- it-and-security/teams/workstations-canary.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/it-and-security/teams/workstations-canary.yml b/it-and-security/teams/workstations-canary.yml index a09b4e5a47..9e428bb311 100644 --- a/it-and-security/teams/workstations-canary.yml +++ b/it-and-security/teams/workstations-canary.yml @@ -131,6 +131,7 @@ controls: - path: ../lib/macos/scripts/remove-old-nudge.sh - path: ../lib/macos/scripts/mdm-migration.sh - path: ../lib/macos/scripts/system-maintenance.sh + - path: ../lib/macos/scripts/santa-block-script.sh - path: ../lib/windows/scripts/remove-fleetd.ps1 - path: ../lib/windows/scripts/turn-off-mdm.ps1 - path: ../lib/windows/scripts/install-bitdefender.ps1 From 347ab3955a0c9d1c340fb6e14ab8acf99b3044a1 Mon Sep 17 00:00:00 2001 From: Dante Catalfamo <43040593+dantecatalfamo@users.noreply.github.com> Date: Tue, 25 Feb 2025 16:27:58 -0500 Subject: [PATCH 03/13] Always allow passwords for users (#26334) For #25834 --- changes/25834-always-allow-passwords | 1 + .../components/UserForm/UserForm.tsx | 1 - server/fleet/emails.go | 3 +++ server/service/integration_core_test.go | 10 +++++++++- server/service/integration_desktop_test.go | 15 ++++++++++++++- server/service/users.go | 17 +++++++++++------ 6 files changed, 38 insertions(+), 9 deletions(-) create mode 100644 changes/25834-always-allow-passwords diff --git a/changes/25834-always-allow-passwords b/changes/25834-always-allow-passwords new file mode 100644 index 0000000000..aa11717ed9 --- /dev/null +++ b/changes/25834-always-allow-passwords @@ -0,0 +1 @@ +- Fixed password authentication getting disabled when SMTP isn't configured diff --git a/frontend/pages/admin/UserManagementPage/components/UserForm/UserForm.tsx b/frontend/pages/admin/UserManagementPage/components/UserForm/UserForm.tsx index cb5dd38f2f..e1148242f4 100644 --- a/frontend/pages/admin/UserManagementPage/components/UserForm/UserForm.tsx +++ b/frontend/pages/admin/UserManagementPage/components/UserForm/UserForm.tsx @@ -589,7 +589,6 @@ const UserForm = ({ id="password-authentication" // allow the user to change auth back to password if they only changed the form to SSO in // the current session, that is, in the db, the user is still password authenticated - disabled={!(smtpConfigured || sesConfigured) && !initiallyPasswordAuth} checked={!formData.sso_enabled} value="false" name="authentication-type" diff --git a/server/fleet/emails.go b/server/fleet/emails.go index cd21071c81..941dc5b9ef 100644 --- a/server/fleet/emails.go +++ b/server/fleet/emails.go @@ -1,10 +1,13 @@ package fleet import ( + "errors" "regexp" "time" ) +var ErrPasswordResetNotConfigured = errors.New("Cannot send password reset. SMTP or SES Is not configured.") + // Mailer is an email campaign // Types which implement the Campaign interface // can be marshalled into an email body diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index 9cea3fd4f8..140b916f9d 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -8298,8 +8298,16 @@ func (s *integrationTestSuite) TestPasswordReset() { require.NotZero(t, createResp.User.ID) u := *createResp.User + // Request password reset when SMTP/SES is not configured + res := s.DoRawNoAuth("POST", "/api/latest/fleet/forgot_password", jsonMustMarshal(t, forgotPasswordRequest{Email: "invalid@asd.com"}), http.StatusInternalServerError) + res.Body.Close() + + // Configure SMTP + var configResp appConfigResponse + s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage("{\"smtp_settings\":{\"enable_smtp\":true,\"sender_address\":\"user@example.com\",\"server\":\"127.0.0.1\",\"port\":1025,\"authentication_type\":\"authtype_none\"}}"), http.StatusOK, &configResp) + // request forgot password, invalid email - res := s.DoRawNoAuth("POST", "/api/latest/fleet/forgot_password", jsonMustMarshal(t, forgotPasswordRequest{Email: "invalid@asd.com"}), http.StatusAccepted) + res = s.DoRawNoAuth("POST", "/api/latest/fleet/forgot_password", jsonMustMarshal(t, forgotPasswordRequest{Email: "invalid@asd.com"}), http.StatusAccepted) res.Body.Close() // TODO: tested manually (adds too much time to the test), works but hitting the rate diff --git a/server/service/integration_desktop_test.go b/server/service/integration_desktop_test.go index 78773273be..2377b84bb2 100644 --- a/server/service/integration_desktop_test.go +++ b/server/service/integration_desktop_test.go @@ -254,7 +254,7 @@ func (s *integrationTestSuite) TestRateLimitOfEndpoints() { endpoint: "/api/latest/fleet/forgot_password", verb: "POST", payload: forgotPasswordRequest{Email: "some@one.com"}, - burst: forgotPasswordRateLimitMaxBurst - 1, + burst: forgotPasswordRateLimitMaxBurst - 2, status: http.StatusAccepted, }, { @@ -265,6 +265,12 @@ func (s *integrationTestSuite) TestRateLimitOfEndpoints() { }, } + // Mock working SMTP for password reset + config, err := s.ds.AppConfig(context.Background()) + require.NoError(s.T(), err) + config.SMTPSettings.SMTPConfigured = true + require.NoError(s.T(), s.ds.SaveAppConfig(context.Background(), config)) + for _, tCase := range testCases { b, err := json.Marshal(tCase.payload) require.NoError(s.T(), err) @@ -274,6 +280,13 @@ func (s *integrationTestSuite) TestRateLimitOfEndpoints() { } s.DoRawWithHeaders(tCase.verb, tCase.endpoint, b, http.StatusTooManyRequests, headers).Body.Close() } + + // Disable it again because integration tests leak state like a sieve + config, err = s.ds.AppConfig(context.Background()) + require.NoError(s.T(), err) + config.SMTPSettings.SMTPConfigured = false + require.NoError(s.T(), s.ds.SaveAppConfig(context.Background(), config)) + } func (s *integrationTestSuite) TestErrorReporting() { diff --git a/server/service/users.go b/server/service/users.go index 7843b4b2c2..978619bb14 100644 --- a/server/service/users.go +++ b/server/service/users.go @@ -1135,7 +1135,9 @@ func forgotPasswordEndpoint(ctx context.Context, request interface{}, svc fleet. // Any error returned by the service should not be returned to the // client to prevent information disclosure (it will be logged in the // server logs). - _ = svc.RequestPasswordReset(ctx, req.Email) + if err := svc.RequestPasswordReset(ctx, req.Email); errors.Is(err, fleet.ErrPasswordResetNotConfigured) { + return forgotPasswordResponse{Err: err}, nil + } return forgotPasswordResponse{}, nil } @@ -1150,6 +1152,14 @@ func (svc *Service) RequestPasswordReset(ctx context.Context, email string) erro time.Sleep(time.Until(start.Add(1 * time.Second))) }(time.Now()) + config, err := svc.ds.AppConfig(ctx) + if err != nil { + return err + } + if !svc.mailService.CanSendEmail(*config.SMTPSettings) { + return fleet.ErrPasswordResetNotConfigured + } + user, err := svc.ds.UserByEmail(ctx, email) if err != nil { return err @@ -1173,11 +1183,6 @@ func (svc *Service) RequestPasswordReset(ctx context.Context, email string) erro return err } - config, err := svc.ds.AppConfig(ctx) - if err != nil { - return err - } - var smtpSettings fleet.SMTPSettings if config.SMTPSettings != nil { smtpSettings = *config.SMTPSettings From ae00add76e4ed27e8b8cbdc221ba87d95497ac3f Mon Sep 17 00:00:00 2001 From: Lucas Manuel Rodriguez Date: Tue, 25 Feb 2025 18:33:24 -0300 Subject: [PATCH 04/13] Update alpine to patch vulnerability with severity "HIGH" (#26593) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The vulnerability was posted by a prospect. Posting manual command until we get #25902 done. ```sh trivy image --ignore-unfixed --pkg-types os,library --severity CRITICAL,HIGH --show-suppressed fleetdm/fleet:v4.64.1 [...] fleetdm/fleet:v4.64.1 (alpine 3.21.0) Total: 2 (HIGH: 2, CRITICAL: 0) ┌────────────┬────────────────┬──────────┬────────┬───────────────────┬───────────────┬──────────────────────────────────────────────────────────┐ │ Library │ Vulnerability │ Severity │ Status │ Installed Version │ Fixed Version │ Title │ ├────────────┼────────────────┼──────────┼────────┼───────────────────┼───────────────┼──────────────────────────────────────────────────────────┤ │ libcrypto3 │ CVE-2024-12797 │ HIGH │ fixed │ 3.3.2-r4 │ 3.3.3-r0 │ openssl: RFC7250 handshakes with unauthenticated servers │ │ │ │ │ │ │ │ don't abort as expected │ │ │ │ │ │ │ │ https://avd.aquasec.com/nvd/cve-2024-12797 │ ├────────────┤ │ │ │ │ │ │ │ libssl3 │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ └────────────┴────────────────┴──────────┴────────┴───────────────────┴───────────────┴──────────────────────────────────────────────────────────┘ ``` --- infrastructure/loadtesting/terraform/docker/loadtest.Dockerfile | 2 +- server/mdm/scep/Dockerfile | 2 +- tools/fleet-docker/Dockerfile | 2 +- tools/mdm/migration/mdmproxy/Dockerfile | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/infrastructure/loadtesting/terraform/docker/loadtest.Dockerfile b/infrastructure/loadtesting/terraform/docker/loadtest.Dockerfile index e97c4d10be..01a67cf6b0 100644 --- a/infrastructure/loadtesting/terraform/docker/loadtest.Dockerfile +++ b/infrastructure/loadtesting/terraform/docker/loadtest.Dockerfile @@ -3,7 +3,7 @@ ARG TAG RUN apk add git RUN git clone -b $TAG --depth=1 --no-tags --progress --no-recurse-submodules https://github.com/fleetdm/fleet.git && cd /go/fleet/cmd/osquery-perf/ && go build . -FROM alpine:3.21@sha256:2c43f33bd1502ec7818bce9eea60e062d04eeadc4aa31cad9dabecb1e48b647b +FROM alpine:3.21.3@sha256:a8560b36e8b8210634f77d9f7f9efd7ffa463e380b75e2e74aff4511df3ef88c LABEL maintainer="Fleet Developers" # Create FleetDM group and user diff --git a/server/mdm/scep/Dockerfile b/server/mdm/scep/Dockerfile index 89e0abca6d..d82f4ff912 100644 --- a/server/mdm/scep/Dockerfile +++ b/server/mdm/scep/Dockerfile @@ -1,4 +1,4 @@ -FROM alpine:3@sha256:c5b1261d6d3e43071626931fc004f70149baeba2c8ec672bd4f27761f8e1ad6b +FROM alpine:3.21.3@sha256:a8560b36e8b8210634f77d9f7f9efd7ffa463e380b75e2e74aff4511df3ef88c COPY ./scepclient-linux-amd64 /usr/bin/scepclient COPY ./scepserver-linux-amd64 /usr/bin/scepserver diff --git a/tools/fleet-docker/Dockerfile b/tools/fleet-docker/Dockerfile index 506b901628..5f80596651 100644 --- a/tools/fleet-docker/Dockerfile +++ b/tools/fleet-docker/Dockerfile @@ -1,4 +1,4 @@ -FROM alpine:3.21@sha256:2c43f33bd1502ec7818bce9eea60e062d04eeadc4aa31cad9dabecb1e48b647b +FROM alpine:3.21.3@sha256:a8560b36e8b8210634f77d9f7f9efd7ffa463e380b75e2e74aff4511df3ef88c LABEL maintainer="Fleet Developers" RUN apk --update add ca-certificates diff --git a/tools/mdm/migration/mdmproxy/Dockerfile b/tools/mdm/migration/mdmproxy/Dockerfile index 852f7d31e5..506d7a07b3 100644 --- a/tools/mdm/migration/mdmproxy/Dockerfile +++ b/tools/mdm/migration/mdmproxy/Dockerfile @@ -3,7 +3,7 @@ ARG TAG RUN apk update && apk add --no-cache git RUN git clone -b $TAG --depth=1 --no-tags --progress --no-recurse-submodules https://github.com/fleetdm/fleet.git && cd /go/fleet/tools/mdm/migration/mdmproxy && go build . -FROM alpine:3.21@sha256:2c43f33bd1502ec7818bce9eea60e062d04eeadc4aa31cad9dabecb1e48b647b +FROM alpine:3.21.3@sha256:a8560b36e8b8210634f77d9f7f9efd7ffa463e380b75e2e74aff4511df3ef88c LABEL maintainer="Fleet Developers" RUN apk update && apk add --no-cache tini From a1e752341b6dbb92d95ec62f6186ce0bd97d6f91 Mon Sep 17 00:00:00 2001 From: Dante Catalfamo <43040593+dantecatalfamo@users.noreply.github.com> Date: Tue, 25 Feb 2025 16:46:06 -0500 Subject: [PATCH 05/13] Only allow once instance of fleet desktop at once (#25821) #25396 --------- Co-authored-by: Lucas Manuel Rodriguez --- go.mod | 4 ++- go.sum | 4 +++ orbit/changes/25396-fleet-desktop-lockfile | 1 + orbit/cmd/desktop/desktop.go | 42 ++++++++++++++++++++++ orbit/cmd/orbit/orbit.go | 17 +++++---- orbit/pkg/platform/platform.go | 8 +++-- orbit/pkg/platform/platform_notwindows.go | 12 +++---- orbit/pkg/platform/platform_windows.go | 41 +++++++++++++-------- pkg/open/open_linux.go | 6 ++-- 9 files changed, 98 insertions(+), 37 deletions(-) create mode 100644 orbit/changes/25396-fleet-desktop-lockfile diff --git a/go.mod b/go.mod index 3f2ed70949..c63ea27795 100644 --- a/go.mod +++ b/go.mod @@ -134,7 +134,7 @@ require ( golang.org/x/net v0.33.0 golang.org/x/oauth2 v0.22.0 golang.org/x/sync v0.10.0 - golang.org/x/sys v0.28.0 + golang.org/x/sys v0.29.0 golang.org/x/term v0.27.0 golang.org/x/text v0.21.0 golang.org/x/tools v0.23.0 @@ -197,6 +197,8 @@ require ( github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/gobwas/glob v0.2.3 // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect + github.com/gofrs/flock v0.12.1 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/glog v1.2.4 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect diff --git a/go.sum b/go.sum index bcc3c180ff..fdac1395c8 100644 --- a/go.sum +++ b/go.sum @@ -338,6 +338,8 @@ github.com/gocarina/gocsv v0.0.0-20220310154401-d4df709ca055/go.mod h1:5YoVOkjYA github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E= +github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= @@ -1097,6 +1099,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= diff --git a/orbit/changes/25396-fleet-desktop-lockfile b/orbit/changes/25396-fleet-desktop-lockfile new file mode 100644 index 0000000000..7662dcf6ba --- /dev/null +++ b/orbit/changes/25396-fleet-desktop-lockfile @@ -0,0 +1 @@ +- Ensure only one copy of fleet desktop is running at a time diff --git a/orbit/cmd/desktop/desktop.go b/orbit/cmd/desktop/desktop.go index ebb8800ce9..bb76e305fd 100644 --- a/orbit/cmd/desktop/desktop.go +++ b/orbit/cmd/desktop/desktop.go @@ -25,6 +25,7 @@ import ( "github.com/fleetdm/fleet/v4/pkg/open" "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/service" + "github.com/gofrs/flock" "github.com/oklog/run" "github.com/rs/zerolog" "github.com/rs/zerolog/log" @@ -110,6 +111,21 @@ func main() { log.Info().Msgf("got a TUF update root: %s", tufUpdateRoot) } + // We've only seen this bug appear on Linux under certain very + // specific conditions + if runtime.GOOS == "linux" { + // Ensure only one instance of Fleet Desktop is running at a time + lockFile, err := getLockfile() + if err != nil { + log.Fatal().Err(err).Msg("could not secure lock file") + } + defer func() { + if err := lockFile.Unlock(); err != nil { + log.Error().Err(err).Msg("unlocking lockfile") + } + }() + } + // Setting up working runners such as signalHandler runner go setupRunners() @@ -564,6 +580,32 @@ func (m *mdmMigrationHandler) ShowInstructions() error { return nil } +// getLockfile checks for the fleet desktop lock file, and returns an error if it can't secure it. +func getLockfile() (*flock.Flock, error) { + dir, err := logDir() + if err != nil { + return nil, fmt.Errorf("unable to get logdir for lock: %w", err) + } + // Same as the log dir in setupLogs() + dir = filepath.Join(dir, "Fleet") + + lockFilePath := filepath.Join(dir, "fleet-desktop.lock") + log.Debug().Msgf("acquiring fleet desktop lockfile: %s", lockFilePath) + + lock := flock.New(lockFilePath) + locked, err := lock.TryLock() + if err != nil { + return nil, fmt.Errorf("error getting lock on %s: %w", lockFilePath, err) + } + if !locked { + return nil, errors.New("another instance of fleet desktop has the lock") + } + + log.Debug().Msgf("lock acquired on %s", lockFilePath) + + return lock, nil +} + // setupLogs configures our logging system to write logs to rolling files, if for some // reason we can't write a log file the logs are still printed to stderr. func setupLogs() { diff --git a/orbit/cmd/orbit/orbit.go b/orbit/cmd/orbit/orbit.go index 318f105f19..802cf145a7 100644 --- a/orbit/cmd/orbit/orbit.go +++ b/orbit/cmd/orbit/orbit.go @@ -1585,14 +1585,6 @@ func newDesktopRunner( func (d *desktopRunner) Execute() error { defer close(d.executeDoneCh) - log.Info().Msg("killing any pre-existing fleet-desktop instances") - - if err := platform.SignalProcessBeforeTerminate(constant.DesktopAppExecName); err != nil && - !errors.Is(err, platform.ErrProcessNotFound) && - !errors.Is(err, platform.ErrComChannelNotFound) { - log.Error().Err(err).Msg("desktop early terminate") - } - log.Info().Str("path", d.desktopPath).Msg("opening") url, err := url.Parse(d.fleetURL) if err != nil { @@ -1640,6 +1632,13 @@ func (d *desktopRunner) Execute() error { return true } + log.Info().Msg("killing any pre-existing fleet-desktop instances") + if err := platform.SignalProcessBeforeTerminate(constant.DesktopAppExecName); err != nil && + !errors.Is(err, platform.ErrProcessNotFound) && + !errors.Is(err, platform.ErrComChannelNotFound) { + log.Error().Err(err).Msg("desktop early terminate") + } + // Orbit runs as root user on Unix and as SYSTEM (Windows Service) user on Windows. // To be able to run the desktop application (mostly to register the icon in the system tray) // we need to run the application as the login user. @@ -1657,7 +1656,7 @@ func (d *desktopRunner) Execute() error { // Second retry logic to monitor fleet-desktop. // Call with waitFirst=true to give some time for the process to start. if done := retry(15*time.Second, true, d.interruptCh, func() bool { - switch _, err := platform.GetProcessByName(constant.DesktopAppExecName); { + switch _, err := platform.GetProcessesByName(constant.DesktopAppExecName); { case err == nil: return true // all good, process is running, retry. case errors.Is(err, platform.ErrProcessNotFound): diff --git a/orbit/pkg/platform/platform.go b/orbit/pkg/platform/platform.go index eb1f894811..af07c59a84 100644 --- a/orbit/pkg/platform/platform.go +++ b/orbit/pkg/platform/platform.go @@ -27,13 +27,15 @@ func killProcessByName(name string) error { return errors.New("process name should not be empty") } - foundProcess, err := GetProcessByName(name) + foundProcesses, err := GetProcessesByName(name) if err != nil { return fmt.Errorf("get process: %w", err) } - if err := foundProcess.Kill(); err != nil { - return fmt.Errorf("kill process %d: %w", foundProcess.Pid, err) + for _, foundProcess := range foundProcesses { + if err := foundProcess.Kill(); err != nil { + return fmt.Errorf("kill process %d: %w", foundProcess.Pid, err) + } } return nil diff --git a/orbit/pkg/platform/platform_notwindows.go b/orbit/pkg/platform/platform_notwindows.go index 930e6dc28a..d3c442c2a4 100644 --- a/orbit/pkg/platform/platform_notwindows.go +++ b/orbit/pkg/platform/platform_notwindows.go @@ -54,9 +54,9 @@ func SignalProcessBeforeTerminate(processName string) error { return nil } -// GetProcessByName gets a single running process object by its name. +// GetProcessesByName gets all running processes by its name. // Returns ErrProcessNotFound if the process was not found running. -func GetProcessByName(name string) (*gopsutil_process.Process, error) { +func GetProcessesByName(name string) ([]*gopsutil_process.Process, error) { if name == "" { return nil, errors.New("process name should not be empty") } @@ -66,7 +66,7 @@ func GetProcessByName(name string) (*gopsutil_process.Process, error) { return nil, err } - var foundProcess *gopsutil_process.Process + var foundProcesses []*gopsutil_process.Process for _, process := range processes { processName, err := process.Name() if err != nil { @@ -75,16 +75,16 @@ func GetProcessByName(name string) (*gopsutil_process.Process, error) { } if strings.HasPrefix(processName, name) { - foundProcess = process + foundProcesses = append(foundProcesses, process) break } } - if foundProcess == nil { + if len(foundProcesses) == 0 { return nil, ErrProcessNotFound } - return foundProcess, nil + return foundProcesses, nil } func GetSMBiosUUID() (string, UUIDSource, error) { diff --git a/orbit/pkg/platform/platform_windows.go b/orbit/pkg/platform/platform_windows.go index a0fc336b07..03a82ed5d6 100644 --- a/orbit/pkg/platform/platform_windows.go +++ b/orbit/pkg/platform/platform_windows.go @@ -131,20 +131,23 @@ func SignalProcessBeforeTerminate(processName string) error { return ErrComChannelNotFound } - foundProcess, err := GetProcessByName(processName) + foundProcesses, err := GetProcessesByName(processName) if err != nil { return fmt.Errorf("get process: %w", err) } - if err := foundProcess.Kill(); err != nil { - return fmt.Errorf("kill process %d: %w", foundProcess.Pid, err) + for _, foundProcess := range foundProcesses { + if err := foundProcess.Kill(); err != nil { + return fmt.Errorf("kill process %d: %w", foundProcess.Pid, err) + } } + return nil } -// GetProcessByName gets a single running process object by its name. +// GetProcessesByName returns a list of running process object by name. // Returns ErrProcessNotFound if the process was not found running. -func GetProcessByName(name string) (*gopsutil_process.Process, error) { +func GetProcessesByName(name string) ([]*gopsutil_process.Process, error) { if name == "" { return nil, errors.New("process name should not be empty") } @@ -164,7 +167,7 @@ func GetProcessByName(name string) (*gopsutil_process.Process, error) { // Closing the handle to avoid handle leaks. defer windows.CloseHandle(snapshot) //nolint:errcheck - var foundProcessID uint32 = 0 + var foundProcessIDs []uint32 // Initializing work structure PROCESSENTRY32W // https://learn.microsoft.com/en-us/windows/win32/api/tlhelp32/ns-tlhelp32-processentry32w @@ -180,10 +183,8 @@ func GetProcessByName(name string) (*gopsutil_process.Process, error) { // Process32First() is going to return ERROR_NO_MORE_FILES when no more threads present // it will return FALSE/nil otherwise for err == nil { - if strings.HasPrefix(syscall.UTF16ToString(procEntry.ExeFile[:]), name) { - foundProcessID = procEntry.ProcessID - break + foundProcessIDs = append(foundProcessIDs, procEntry.ProcessID) } // Process32Next() is calling to keep iterating the snapshot @@ -191,17 +192,27 @@ func GetProcessByName(name string) (*gopsutil_process.Process, error) { err = windows.Process32Next(snapshot, &procEntry) } - process, err := gopsutil_process.NewProcess(int32(foundProcessID)) - if err != nil { - return nil, fmt.Errorf("NewProcess: %w", err) + var processes []*gopsutil_process.Process + + for _, foundProcessID := range foundProcessIDs { + process, err := gopsutil_process.NewProcess(int32(foundProcessID)) + if err != nil { + continue + } + + isRunning, err := process.IsRunning() + if err != nil || !isRunning { + continue + } + + processes = append(processes, process) } - isRunning, err := process.IsRunning() - if err != nil || !isRunning { + if len(processes) == 0 { return nil, ErrProcessNotFound } - return process, nil + return processes, nil } // It obtains the BIOS UUID by calling "cmd.exe /c wmic csproduct get UUID" and parsing the results diff --git a/pkg/open/open_linux.go b/pkg/open/open_linux.go index b68c704d19..a84d6f8bf1 100644 --- a/pkg/open/open_linux.go +++ b/pkg/open/open_linux.go @@ -41,18 +41,18 @@ func browser(url string) error { // getXWaylandAuthority retrieves the X authority file path from // the running XWayland process environment. func getXWaylandAuthority() (xAuthorityPath string, err error) { - xWaylandProcess, err := platform.GetProcessByName("Xwayland") + xWaylandProcess, err := platform.GetProcessesByName("Xwayland") if err != nil { return "", fmt.Errorf("get process by name: %w", err) } - executablePath, err := xWaylandProcess.Exe() + executablePath, err := xWaylandProcess[0].Exe() if err != nil { return "", fmt.Errorf("get executable path: %w", err) } if executablePath != "/usr/bin/Xwayland" { return "", fmt.Errorf("invalid Xwayland path: %q", executablePath) } - envs, err := xWaylandProcess.Environ() + envs, err := xWaylandProcess[0].Environ() if err != nil { return "", fmt.Errorf("get environment: %w", err) } From 8fe2ee7ded702bd20b33819e0e5a3bcd17043ed7 Mon Sep 17 00:00:00 2001 From: Eric Date: Tue, 25 Feb 2025 17:19:14 -0600 Subject: [PATCH 06/13] 2025-02-25 Website test: Change homepage heading (#26529) Changes: - Reverted the homepage heading text changes from #25916 --- website/assets/js/pages/homepage.page.js | 3 +-- website/assets/styles/pages/homepage.less | 9 +++++++-- website/views/pages/homepage.ejs | 3 +++ website/views/partials/primary-tagline.partial.ejs | 9 +++++---- 4 files changed, 16 insertions(+), 8 deletions(-) diff --git a/website/assets/js/pages/homepage.page.js b/website/assets/js/pages/homepage.page.js index 0dc9751a2d..67f74322c8 100644 --- a/website/assets/js/pages/homepage.page.js +++ b/website/assets/js/pages/homepage.page.js @@ -24,14 +24,13 @@ parasails.registerPage('homepage', { // ║ ║╠╣ ║╣ ║ ╚╦╝║ ║ ║╣ // ╩═╝╩╚ ╚═╝╚═╝ ╩ ╚═╝╩═╝╚═╝ beforeMount: function() { - //… if(window.location.hash === '#unsubscribed'){ this.modal = 'unsubscribed'; window.location.hash = ''; } }, mounted: async function() { - this.animateHeroTicker(); + // this.animateHeroTicker(); if(['mdm', 'eo-it', undefined].includes(this.primaryBuyingSituation)){ this.animateBottomTicker(); } diff --git a/website/assets/styles/pages/homepage.less b/website/assets/styles/pages/homepage.less index 745d7b5a65..6445dc9489 100644 --- a/website/assets/styles/pages/homepage.less +++ b/website/assets/styles/pages/homepage.less @@ -52,7 +52,6 @@ padding-left: 32px; padding-bottom: 82px; max-width: 1200px; - height: 420px; } [purpose='ticker-container'] { overflow: hidden; @@ -88,7 +87,13 @@ max-width: 640px; margin-bottom: 16px; &.vm { - max-width: unset; + max-width: 420px; + } + &.eo-it { + max-width: 420px; + } + &.eo-security { + max-width: 438px; } } p { diff --git a/website/views/pages/homepage.ejs b/website/views/pages/homepage.ejs index 737c73d3aa..13b9982c18 100644 --- a/website/views/pages/homepage.ejs +++ b/website/views/pages/homepage.ejs @@ -6,6 +6,8 @@
<%/* Hero text */%>
+

<%- partial('../partials/primary-tagline.partial.ejs') %>

+ <% /*

One agent for
every @@ -37,6 +39,7 @@

+ */ %>

Replace the sprawl with <%= primaryBuyingSituation === 'vm'? 'secure, open-source reporting that works the way you want' : primaryBuyingSituation === 'eo-security'? 'open-source telemetry that works the way you want' : 'lightning fast device management that lets employees see what\'s going on' %>.

Learn how diff --git a/website/views/partials/primary-tagline.partial.ejs b/website/views/partials/primary-tagline.partial.ejs index 2e549fb754..bb04b6a2dc 100644 --- a/website/views/partials/primary-tagline.partial.ejs +++ b/website/views/partials/primary-tagline.partial.ejs @@ -1,6 +1,7 @@ <%= - (typeof primaryBuyingSituation !== 'undefined' && primaryBuyingSituation === 'vm') ? 'Focus on vulnerabilities, not vendors' // vm - : (typeof primaryBuyingSituation !== 'undefined' && primaryBuyingSituation === 'eo-security') ? 'Easily get security data'// eo-security - : (typeof primaryBuyingSituation !== 'undefined' && primaryBuyingSituation === 'eo-it') ? 'One system for every platform' : // eo-it - 'One system for every OS'// mdm or default (no buying situation) + typeof primaryBuyingSituation === 'undefined' ? 'Open device management for everyone' // Default (no buying situation) + : primaryBuyingSituation === 'vm' ? 'Focus on vulnerabilities, not vendors' // vm + : primaryBuyingSituation === 'eo-security' ? 'Easily get security data'// eo-security + : primaryBuyingSituation === 'eo-it' ? 'Untangle your endpoints' : // eo-it + 'Open device management for everyone'// mdm %> From 727f9aaf4c6f7413950414643db2fe46483cb40d Mon Sep 17 00:00:00 2001 From: Marko Lisica <83164494+marko-lisica@users.noreply.github.com> Date: Wed, 26 Feb 2025 00:35:41 +0100 Subject: [PATCH 07/13] Update Windows setup guide to include how to turn off MDM (#26562) Changes: - Windows MDM setup guide update: include instructions how to turn off MDM - Redirect: new link `learn-more-about/windows-mdm` that will be used in the error message. --- articles/windows-mdm-setup.md | 7 +++++++ it-and-security/lib/windows/scripts/turn-off-mdm.ps1 | 2 ++ website/config/routes.js | 1 + 3 files changed, 10 insertions(+) diff --git a/articles/windows-mdm-setup.md b/articles/windows-mdm-setup.md index a3b2587229..bd137c30aa 100644 --- a/articles/windows-mdm-setup.md +++ b/articles/windows-mdm-setup.md @@ -173,6 +173,13 @@ Once the automatic migration is enabled, Fleet sends a notification to each host You can track migration progress in Fleet. Learn how [here](https://fleetdm.com/guides/mdm-migration#check-migration-progress). +## Turn off Windows MDM + +1. Turn off MDM for each host, by running [this script](https://github.com/fleetdm/fleet/blob/main/it-and-security/lib/windows/scripts/turn-off-mdm.ps1) on all your Windows hosts. +2. Head to **Settings > Integrations > MDM**. +3. In the **Mobile device management (MDM)** section, select **Edit** next to "Windows MDM turned on." +4. Switch **Windows MDM on** to **Windows MDM off** and select **Save**. + diff --git a/it-and-security/lib/windows/scripts/turn-off-mdm.ps1 b/it-and-security/lib/windows/scripts/turn-off-mdm.ps1 index 2ffc3be05d..e78e8f0bc3 100644 --- a/it-and-security/lib/windows/scripts/turn-off-mdm.ps1 +++ b/it-and-security/lib/windows/scripts/turn-off-mdm.ps1 @@ -1,3 +1,5 @@ +# Please don't delete. This script is referenced in the guide here: https://fleetdm.com/guides/windows-mdm-setup#turn-off-windows-mdm + Add-Type -TypeDefinition @" using System; using System.Runtime.InteropServices; diff --git a/website/config/routes.js b/website/config/routes.js index b5499bc036..504c5f613e 100644 --- a/website/config/routes.js +++ b/website/config/routes.js @@ -870,6 +870,7 @@ module.exports.routes = { 'GET /learn-more-about/end-user-license-agreement': '/guides/macos-setup-experience#end-user-authentication-and-end-user-license-agreement-eula', 'GET /learn-more-about/end-user-authentication': '/guides/macos-setup-experience#end-user-authentication-and-end-user-license-agreement-eula', 'GET /learn-more-about/policy-templates': '/policies', + 'GET /learn-more-about/windows-mdm': '/guides/windows-mdm-setup', // Sitemap // ============================================================================================================= From 023acb85c2b77df7806b08e9b2405684af8add00 Mon Sep 17 00:00:00 2001 From: Allen Houchins <32207388+allenhouchins@users.noreply.github.com> Date: Tue, 25 Feb 2025 17:36:09 -0600 Subject: [PATCH 08/13] Update configuring-full-names-in-google-workspace.md (#26591) Fixed formatting issue. --- articles/configuring-full-names-in-google-workspace.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/articles/configuring-full-names-in-google-workspace.md b/articles/configuring-full-names-in-google-workspace.md index 39cdf668ce..bf34b6e567 100644 --- a/articles/configuring-full-names-in-google-workspace.md +++ b/articles/configuring-full-names-in-google-workspace.md @@ -1,7 +1,5 @@ # Configuring full names in Google Workspace for Fleet integration -## Introduction - Fleet requires user full names to be configured in your Identity Provider (IdP) using specific attributes. Since Google Workspace doesn't natively provide a full name attribute that matches Fleet's requirements, this guide will walk you through setting up automatic synchronization of full names using Google's custom attributes and Apps Script. ## What we're solving From 4b21633e31701238f643a5a1c0756d42f25a478f Mon Sep 17 00:00:00 2001 From: Martin Angers Date: Wed, 26 Feb 2025 09:52:41 -0500 Subject: [PATCH 09/13] Fix activity actor name for setup experience items (#26599) --- .../ActivityFeed/GlobalActivityItem/GlobalActivityItem.tsx | 2 +- .../InstalledSoftwareActivityItem.tsx | 2 +- .../RanScriptActivityItem/RanScriptActivityItem.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tsx b/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tsx index d7fffd988b..423ea53824 100644 --- a/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tsx +++ b/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tsx @@ -1340,7 +1340,7 @@ const GlobalActivityItem = ({ const hasDetails = ACTIVITIES_WITH_DETAILS.has(activity.type); const renderActivityPrefix = () => { - const DEFAULT_ACTOR_DISPLAY = {activity.actor_full_name} ; + const DEFAULT_ACTOR_DISPLAY = {activity.actor_full_name ?? "Fleet"} ; switch (activity.type) { case ActivityType.UserLoggedIn: diff --git a/frontend/pages/hosts/details/cards/Activity/ActivityItems/InstalledSoftwareActivityItem/InstalledSoftwareActivityItem.tsx b/frontend/pages/hosts/details/cards/Activity/ActivityItems/InstalledSoftwareActivityItem/InstalledSoftwareActivityItem.tsx index 16cd34a441..6d0a623cc4 100644 --- a/frontend/pages/hosts/details/cards/Activity/ActivityItems/InstalledSoftwareActivityItem/InstalledSoftwareActivityItem.tsx +++ b/frontend/pages/hosts/details/cards/Activity/ActivityItems/InstalledSoftwareActivityItem/InstalledSoftwareActivityItem.tsx @@ -21,7 +21,7 @@ const InstalledSoftwareActivityItem = ({ const actorDisplayName = self_service ? ( End user ) : ( - {actorName} + {actorName ?? "Fleet"} ); return ( diff --git a/frontend/pages/hosts/details/cards/Activity/ActivityItems/RanScriptActivityItem/RanScriptActivityItem.tsx b/frontend/pages/hosts/details/cards/Activity/ActivityItems/RanScriptActivityItem/RanScriptActivityItem.tsx index 19b8b97bd6..ae4be92455 100644 --- a/frontend/pages/hosts/details/cards/Activity/ActivityItems/RanScriptActivityItem/RanScriptActivityItem.tsx +++ b/frontend/pages/hosts/details/cards/Activity/ActivityItems/RanScriptActivityItem/RanScriptActivityItem.tsx @@ -26,7 +26,7 @@ const RanScriptActivityItem = ({ isSoloActivity={isSoloActivity} hideCancel={hideCancel} > - {activity.actor_full_name} + {activity.actor_full_name ?? "Fleet"} <> {" "} {ranScriptPrefix}{" "} From d903cf9081cbe52af027227a6deff0144da10678 Mon Sep 17 00:00:00 2001 From: Martin Angers Date: Wed, 26 Feb 2025 11:31:19 -0500 Subject: [PATCH 10/13] Android: add test to verify that filtering hosts by android platform is supported (#26613) --- go.mod | 1 - server/service/integration_core_test.go | 86 +++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index c63ea27795..11ffb92b99 100644 --- a/go.mod +++ b/go.mod @@ -197,7 +197,6 @@ require ( github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/gobwas/glob v0.2.3 // indirect - github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/gofrs/flock v0.12.1 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/glog v1.2.4 // indirect diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index 140b916f9d..26b0b1f93f 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -26,6 +26,7 @@ import ( "github.com/fleetdm/fleet/v4/server/datastore/mysql" "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/live_query/live_query_mock" + "github.com/fleetdm/fleet/v4/server/mdm/android" "github.com/fleetdm/fleet/v4/server/ptr" "github.com/fleetdm/fleet/v4/server/service/async" "github.com/fleetdm/fleet/v4/server/service/middleware/endpoint_utils" @@ -12991,3 +12992,88 @@ func (s *integrationTestSuite) TestSecretVariables() { require.Len(t, secrets, 1) assert.Equal(t, "value", secrets[0].Value) } + +func (s *integrationTestSuite) TestListAndroidHostsInLabel() { + t := s.T() + ctx := context.Background() + + hostIDs := createAndroidHosts(t, s.ds, 3, nil) + notAndroidHost := createOrbitEnrolledHost(t, "darwin", "-4", s.ds) + + // list labels, has the built-in ones, capture All and Android + var listResp listLabelsResponse + s.DoJSON("GET", "/api/latest/fleet/labels", nil, http.StatusOK, &listResp) + var allLblID, androidLblID uint + for _, lbl := range listResp.Labels { + switch lbl.Name { + case fleet.BuiltinLabelNameAllHosts: + allLblID = lbl.ID + case fleet.BuiltinLabelNameAndroid: + androidLblID = lbl.ID + } + } + require.NotZero(t, allLblID) + require.NotZero(t, androidLblID) + + err := s.ds.AddLabelsToHost(ctx, notAndroidHost.ID, []uint{allLblID}) + require.NoError(t, err) + + pluckHostIDs := func(hosts []fleet.HostResponse) []uint { + ids := make([]uint, 0, len(hosts)) + for _, h := range hosts { + ids = append(ids, h.ID) + } + return ids + } + + // list hosts in all hosts + var listHostsResp listHostsResponse + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d/hosts", allLblID), nil, http.StatusOK, &listHostsResp) + require.Len(t, listHostsResp.Hosts, len(hostIDs)+1) + wantIDs := append([]uint{notAndroidHost.ID}, hostIDs...) + require.ElementsMatch(t, wantIDs, pluckHostIDs(listHostsResp.Hosts)) + + // count hosts in label + var countResp countHostsResponse + s.DoJSON("GET", "/api/latest/fleet/hosts/count", nil, http.StatusOK, &countResp, "label_id", fmt.Sprint(allLblID)) + require.Equal(t, len(hostIDs)+1, countResp.Count) + + // list android hosts + listHostsResp = listHostsResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d/hosts", androidLblID), nil, http.StatusOK, &listHostsResp) + require.Len(t, listHostsResp.Hosts, len(hostIDs)) + require.ElementsMatch(t, hostIDs, pluckHostIDs(listHostsResp.Hosts)) + + countResp = countHostsResponse{} + s.DoJSON("GET", "/api/latest/fleet/hosts/count", nil, http.StatusOK, &countResp, "label_id", fmt.Sprint(androidLblID)) + require.Equal(t, len(hostIDs), countResp.Count) +} + +func createAndroidHosts(t *testing.T, ds *mysql.Datastore, count int, teamID *uint) []uint { + ids := make([]uint, 0, count) + for i := range count { + host := &fleet.AndroidHost{ + Host: &fleet.Host{ + Hostname: fmt.Sprintf("hostname%d", i), + ComputerName: fmt.Sprintf("computer_name%d", i), + Platform: "android", + OSVersion: "Android 14", + Build: fmt.Sprintf("build%d", i), + Memory: 1024, + TeamID: teamID, + HardwareSerial: uuid.NewString(), + }, + Device: &android.Device{ + DeviceID: uuid.NewString(), + EnterpriseSpecificID: ptr.String(uuid.NewString()), + AndroidPolicyID: ptr.Uint(1), + LastPolicySyncTime: ptr.Time(time.Time{}), + }, + } + host.SetNodeKey(*host.Device.EnterpriseSpecificID) + ahost, err := ds.NewAndroidHost(context.Background(), host) + require.NoError(t, err) + ids = append(ids, ahost.Host.ID) + } + return ids +} From 3d5666d4c6b6f90c7f0801d11f31a0846170d6da Mon Sep 17 00:00:00 2001 From: Victor Lyuboslavsky Date: Wed, 26 Feb 2025 10:47:05 -0600 Subject: [PATCH 11/13] Added GET enterprise API endpoint. (#26555) For #26218 - Added `GET /api/_version_/fleet/android_enterprise` andpoint and tests - Set up some testing infrastructure for Android service tests -- see new README.md # Checklist for submitter - [x] Added/updated automated tests - [x] Manual QA for all new/changed functionality --- Makefile | 2 +- server/authz/policy.rego | 2 +- server/mdm/android/arch_test.go | 1 + server/mdm/android/datastore.go | 2 +- server/mdm/android/mock/android.go | 4 + server/mdm/android/mock/datastore.go | 113 +++++++++++++ server/mdm/android/mock/datastore_setup.go | 28 ++++ server/mdm/android/mock/proxy.go | 77 +++++++++ server/mdm/android/mock/proxy_setup.go | 23 +++ server/mdm/android/mysql/enterprises.go | 2 +- server/mdm/android/mysql/enterprises_test.go | 6 +- .../mysql/{mysql_test.go => testing_utils.go} | 12 +- server/mdm/android/proxy.go | 22 +++ server/mdm/android/service.go | 20 +++ .../mdm/android/service/enterprises_test.go | 135 +++++++++++++++ server/mdm/android/service/handler.go | 15 +- server/mdm/android/service/proxy/proxy.go | 16 +- server/mdm/android/service/pubsub.go | 2 +- server/mdm/android/service/service.go | 85 ++++++---- server/mdm/android/tests/README.md | 5 + .../tests/enterprise/enterprise_test.go | 52 ++++++ server/mdm/android/tests/http.go | 49 ++++++ server/mdm/android/tests/testing_utils.go | 157 ++++++++++++++++++ server/service/endpoint_utils_test.go | 5 +- server/service/handler.go | 15 +- server/service/middleware/log/log.go | 13 ++ server/service/testing_client.go | 46 +---- server/test/httptest/README.md | 2 + server/test/httptest/http.go | 55 ++++++ 29 files changed, 854 insertions(+), 112 deletions(-) create mode 100644 server/mdm/android/mock/android.go create mode 100644 server/mdm/android/mock/datastore.go create mode 100644 server/mdm/android/mock/datastore_setup.go create mode 100644 server/mdm/android/mock/proxy.go create mode 100644 server/mdm/android/mock/proxy_setup.go rename server/mdm/android/mysql/{mysql_test.go => testing_utils.go} (71%) create mode 100644 server/mdm/android/proxy.go create mode 100644 server/mdm/android/service/enterprises_test.go create mode 100644 server/mdm/android/tests/README.md create mode 100644 server/mdm/android/tests/enterprise/enterprise_test.go create mode 100644 server/mdm/android/tests/http.go create mode 100644 server/mdm/android/tests/testing_utils.go create mode 100644 server/test/httptest/README.md create mode 100644 server/test/httptest/http.go diff --git a/Makefile b/Makefile index cd54e4c20a..839f2e8fd8 100644 --- a/Makefile +++ b/Makefile @@ -239,7 +239,7 @@ generate-dev: .prefix NODE_ENV=development yarn run webpack --progress --watch generate-mock: .prefix - go generate github.com/fleetdm/fleet/v4/server/mock github.com/fleetdm/fleet/v4/server/mock/mockresult github.com/fleetdm/fleet/v4/server/service/mock + go generate github.com/fleetdm/fleet/v4/server/mock github.com/fleetdm/fleet/v4/server/mock/mockresult github.com/fleetdm/fleet/v4/server/service/mock github.com/fleetdm/fleet/v4/server/mdm/android/mock generate-doc: .prefix go generate github.com/fleetdm/fleet/v4/server/fleet diff --git a/server/authz/policy.rego b/server/authz/policy.rego index 4982173225..937c73d279 100644 --- a/server/authz/policy.rego +++ b/server/authz/policy.rego @@ -1027,5 +1027,5 @@ allow { allow { object.type == "android_enterprise" subject.global_role == admin - action == write + action == [read, write][_] } diff --git a/server/mdm/android/arch_test.go b/server/mdm/android/arch_test.go index 3f07acf5b4..fd753ad881 100644 --- a/server/mdm/android/arch_test.go +++ b/server/mdm/android/arch_test.go @@ -22,6 +22,7 @@ func TestAllAndroidPackageDependencies(t *testing.T) { "github.com/fleetdm/fleet/v4/server/service/middleware/auth", "github.com/fleetdm/fleet/v4/server/service/middleware/authzcheck", "github.com/fleetdm/fleet/v4/server/service/middleware/endpoint_utils", + "github.com/fleetdm/fleet/v4/server/service/middleware/log", "github.com/fleetdm/fleet/v4/server/service/middleware/ratelimit", ). ShouldNotDependOn( diff --git a/server/mdm/android/datastore.go b/server/mdm/android/datastore.go index 8ff7806687..e5495f1916 100644 --- a/server/mdm/android/datastore.go +++ b/server/mdm/android/datastore.go @@ -11,7 +11,7 @@ type Datastore interface { GetEnterpriseByID(ctx context.Context, ID uint) (*EnterpriseDetails, error) GetEnterprise(ctx context.Context) (*Enterprise, error) UpdateEnterprise(ctx context.Context, enterprise *EnterpriseDetails) error - DeleteEnterprises(ctx context.Context) error + DeleteAllEnterprises(ctx context.Context) error DeleteOtherEnterprises(ctx context.Context, ID uint) error CreateDeviceTx(ctx context.Context, tx sqlx.ExtContext, device *Device) (*Device, error) diff --git a/server/mdm/android/mock/android.go b/server/mdm/android/mock/android.go new file mode 100644 index 0000000000..7f7da70861 --- /dev/null +++ b/server/mdm/android/mock/android.go @@ -0,0 +1,4 @@ +package mock + +//go:generate go run ../../../mock/mockimpl/impl.go -o proxy.go "p *Proxy" "android.Proxy" +//go:generate go run ../../../mock/mockimpl/impl.go -o datastore.go "ds *Datastore" "android.Datastore" diff --git a/server/mdm/android/mock/datastore.go b/server/mdm/android/mock/datastore.go new file mode 100644 index 0000000000..ac0c9f23d4 --- /dev/null +++ b/server/mdm/android/mock/datastore.go @@ -0,0 +1,113 @@ +// Automatically generated by mockimpl. DO NOT EDIT! + +package mock + +import ( + "context" + "sync" + + "github.com/fleetdm/fleet/v4/server/mdm/android" + "github.com/jmoiron/sqlx" +) + +var _ android.Datastore = (*Datastore)(nil) + +type CreateEnterpriseFunc func(ctx context.Context) (uint, error) + +type GetEnterpriseByIDFunc func(ctx context.Context, ID uint) (*android.EnterpriseDetails, error) + +type GetEnterpriseFunc func(ctx context.Context) (*android.Enterprise, error) + +type UpdateEnterpriseFunc func(ctx context.Context, enterprise *android.EnterpriseDetails) error + +type DeleteAllEnterprisesFunc func(ctx context.Context) error + +type DeleteOtherEnterprisesFunc func(ctx context.Context, ID uint) error + +type CreateDeviceTxFunc func(ctx context.Context, tx sqlx.ExtContext, device *android.Device) (*android.Device, error) + +type UpdateDeviceTxFunc func(ctx context.Context, tx sqlx.ExtContext, device *android.Device) error + +type Datastore struct { + CreateEnterpriseFunc CreateEnterpriseFunc + CreateEnterpriseFuncInvoked bool + + GetEnterpriseByIDFunc GetEnterpriseByIDFunc + GetEnterpriseByIDFuncInvoked bool + + GetEnterpriseFunc GetEnterpriseFunc + GetEnterpriseFuncInvoked bool + + UpdateEnterpriseFunc UpdateEnterpriseFunc + UpdateEnterpriseFuncInvoked bool + + DeleteAllEnterprisesFunc DeleteAllEnterprisesFunc + DeleteAllEnterprisesFuncInvoked bool + + DeleteOtherEnterprisesFunc DeleteOtherEnterprisesFunc + DeleteOtherEnterprisesFuncInvoked bool + + CreateDeviceTxFunc CreateDeviceTxFunc + CreateDeviceTxFuncInvoked bool + + UpdateDeviceTxFunc UpdateDeviceTxFunc + UpdateDeviceTxFuncInvoked bool + + mu sync.Mutex +} + +func (ds *Datastore) CreateEnterprise(ctx context.Context) (uint, error) { + ds.mu.Lock() + ds.CreateEnterpriseFuncInvoked = true + ds.mu.Unlock() + return ds.CreateEnterpriseFunc(ctx) +} + +func (ds *Datastore) GetEnterpriseByID(ctx context.Context, ID uint) (*android.EnterpriseDetails, error) { + ds.mu.Lock() + ds.GetEnterpriseByIDFuncInvoked = true + ds.mu.Unlock() + return ds.GetEnterpriseByIDFunc(ctx, ID) +} + +func (ds *Datastore) GetEnterprise(ctx context.Context) (*android.Enterprise, error) { + ds.mu.Lock() + ds.GetEnterpriseFuncInvoked = true + ds.mu.Unlock() + return ds.GetEnterpriseFunc(ctx) +} + +func (ds *Datastore) UpdateEnterprise(ctx context.Context, enterprise *android.EnterpriseDetails) error { + ds.mu.Lock() + ds.UpdateEnterpriseFuncInvoked = true + ds.mu.Unlock() + return ds.UpdateEnterpriseFunc(ctx, enterprise) +} + +func (ds *Datastore) DeleteAllEnterprises(ctx context.Context) error { + ds.mu.Lock() + ds.DeleteAllEnterprisesFuncInvoked = true + ds.mu.Unlock() + return ds.DeleteAllEnterprisesFunc(ctx) +} + +func (ds *Datastore) DeleteOtherEnterprises(ctx context.Context, ID uint) error { + ds.mu.Lock() + ds.DeleteOtherEnterprisesFuncInvoked = true + ds.mu.Unlock() + return ds.DeleteOtherEnterprisesFunc(ctx, ID) +} + +func (ds *Datastore) CreateDeviceTx(ctx context.Context, tx sqlx.ExtContext, device *android.Device) (*android.Device, error) { + ds.mu.Lock() + ds.CreateDeviceTxFuncInvoked = true + ds.mu.Unlock() + return ds.CreateDeviceTxFunc(ctx, tx, device) +} + +func (ds *Datastore) UpdateDeviceTx(ctx context.Context, tx sqlx.ExtContext, device *android.Device) error { + ds.mu.Lock() + ds.UpdateDeviceTxFuncInvoked = true + ds.mu.Unlock() + return ds.UpdateDeviceTxFunc(ctx, tx, device) +} diff --git a/server/mdm/android/mock/datastore_setup.go b/server/mdm/android/mock/datastore_setup.go new file mode 100644 index 0000000000..18d6284597 --- /dev/null +++ b/server/mdm/android/mock/datastore_setup.go @@ -0,0 +1,28 @@ +package mock + +import ( + "context" + + "github.com/fleetdm/fleet/v4/server/mdm/android" +) + +func (s *Datastore) InitCommonMocks() { + s.CreateEnterpriseFunc = func(ctx context.Context) (uint, error) { + return 1, nil + } + s.UpdateEnterpriseFunc = func(ctx context.Context, enterprise *android.EnterpriseDetails) error { + return nil + } + s.GetEnterpriseFunc = func(ctx context.Context) (*android.Enterprise, error) { + return &android.Enterprise{}, nil + } + s.GetEnterpriseByIDFunc = func(ctx context.Context, ID uint) (*android.EnterpriseDetails, error) { + return &android.EnterpriseDetails{}, nil + } + s.DeleteAllEnterprisesFunc = func(ctx context.Context) error { + return nil + } + s.DeleteOtherEnterprisesFunc = func(ctx context.Context, ID uint) error { + return nil + } +} diff --git a/server/mdm/android/mock/proxy.go b/server/mdm/android/mock/proxy.go new file mode 100644 index 0000000000..15a90a12c9 --- /dev/null +++ b/server/mdm/android/mock/proxy.go @@ -0,0 +1,77 @@ +// Automatically generated by mockimpl. DO NOT EDIT! + +package mock + +import ( + "context" + "sync" + + "github.com/fleetdm/fleet/v4/server/mdm/android" + "google.golang.org/api/androidmanagement/v1" +) + +var _ android.Proxy = (*Proxy)(nil) + +type SignupURLsCreateFunc func(callbackURL string) (*android.SignupDetails, error) + +type EnterprisesCreateFunc func(ctx context.Context, req android.ProxyEnterprisesCreateRequest) (string, string, error) + +type EnterprisesPoliciesPatchFunc func(enterpriseID string, policyName string, policy *androidmanagement.Policy) error + +type EnterprisesEnrollmentTokensCreateFunc func(enterpriseName string, token *androidmanagement.EnrollmentToken) (*androidmanagement.EnrollmentToken, error) + +type EnterpriseDeleteFunc func(enterpriseID string) error + +type Proxy struct { + SignupURLsCreateFunc SignupURLsCreateFunc + SignupURLsCreateFuncInvoked bool + + EnterprisesCreateFunc EnterprisesCreateFunc + EnterprisesCreateFuncInvoked bool + + EnterprisesPoliciesPatchFunc EnterprisesPoliciesPatchFunc + EnterprisesPoliciesPatchFuncInvoked bool + + EnterprisesEnrollmentTokensCreateFunc EnterprisesEnrollmentTokensCreateFunc + EnterprisesEnrollmentTokensCreateFuncInvoked bool + + EnterpriseDeleteFunc EnterpriseDeleteFunc + EnterpriseDeleteFuncInvoked bool + + mu sync.Mutex +} + +func (p *Proxy) SignupURLsCreate(callbackURL string) (*android.SignupDetails, error) { + p.mu.Lock() + p.SignupURLsCreateFuncInvoked = true + p.mu.Unlock() + return p.SignupURLsCreateFunc(callbackURL) +} + +func (p *Proxy) EnterprisesCreate(ctx context.Context, req android.ProxyEnterprisesCreateRequest) (string, string, error) { + p.mu.Lock() + p.EnterprisesCreateFuncInvoked = true + p.mu.Unlock() + return p.EnterprisesCreateFunc(ctx, req) +} + +func (p *Proxy) EnterprisesPoliciesPatch(enterpriseID string, policyName string, policy *androidmanagement.Policy) error { + p.mu.Lock() + p.EnterprisesPoliciesPatchFuncInvoked = true + p.mu.Unlock() + return p.EnterprisesPoliciesPatchFunc(enterpriseID, policyName, policy) +} + +func (p *Proxy) EnterprisesEnrollmentTokensCreate(enterpriseName string, token *androidmanagement.EnrollmentToken) (*androidmanagement.EnrollmentToken, error) { + p.mu.Lock() + p.EnterprisesEnrollmentTokensCreateFuncInvoked = true + p.mu.Unlock() + return p.EnterprisesEnrollmentTokensCreateFunc(enterpriseName, token) +} + +func (p *Proxy) EnterpriseDelete(enterpriseID string) error { + p.mu.Lock() + p.EnterpriseDeleteFuncInvoked = true + p.mu.Unlock() + return p.EnterpriseDeleteFunc(enterpriseID) +} diff --git a/server/mdm/android/mock/proxy_setup.go b/server/mdm/android/mock/proxy_setup.go new file mode 100644 index 0000000000..99caaef35d --- /dev/null +++ b/server/mdm/android/mock/proxy_setup.go @@ -0,0 +1,23 @@ +package mock + +import ( + "context" + + "github.com/fleetdm/fleet/v4/server/mdm/android" + "google.golang.org/api/androidmanagement/v1" +) + +func (p *Proxy) InitCommonMocks() { + p.EnterpriseDeleteFunc = func(enterpriseID string) error { + return nil + } + p.SignupURLsCreateFunc = func(callbackURL string) (*android.SignupDetails, error) { + return &android.SignupDetails{}, nil + } + p.EnterprisesCreateFunc = func(ctx context.Context, req android.ProxyEnterprisesCreateRequest) (string, string, error) { + return "enterpriseName", "projects/project/topics/topic", nil + } + p.EnterprisesPoliciesPatchFunc = func(enterpriseID string, policyName string, policy *androidmanagement.Policy) error { + return nil + } +} diff --git a/server/mdm/android/mysql/enterprises.go b/server/mdm/android/mysql/enterprises.go index 6527352c50..aedcd122cb 100644 --- a/server/mdm/android/mysql/enterprises.go +++ b/server/mdm/android/mysql/enterprises.go @@ -77,7 +77,7 @@ func (ds *Datastore) DeleteOtherEnterprises(ctx context.Context, id uint) error return nil } -func (ds *Datastore) DeleteEnterprises(ctx context.Context) error { +func (ds *Datastore) DeleteAllEnterprises(ctx context.Context) error { stmt := `DELETE FROM android_enterprises` _, err := ds.Writer(ctx).ExecContext(ctx, stmt) if err != nil { diff --git a/server/mdm/android/mysql/enterprises_test.go b/server/mdm/android/mysql/enterprises_test.go index 58128fde1a..c645f04f15 100644 --- a/server/mdm/android/mysql/enterprises_test.go +++ b/server/mdm/android/mysql/enterprises_test.go @@ -20,7 +20,7 @@ func TestEnterprise(t *testing.T) { }{ {"CreateGetEnterprise", testCreateGetEnterprise}, {"UpdateEnterprise", testUpdateEnterprise}, - {"DeleteEnterprises", testDeleteEnterprises}, + {"DeleteAllEnterprises", testDeleteEnterprises}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -75,7 +75,7 @@ func testUpdateEnterprise(t *testing.T, ds *Datastore) { } func testDeleteEnterprises(t *testing.T, ds *Datastore) { - err := ds.DeleteEnterprises(testCtx()) + err := ds.DeleteAllEnterprises(testCtx()) require.NoError(t, err) err = ds.DeleteOtherEnterprises(testCtx(), 9999) require.NoError(t, err) @@ -108,7 +108,7 @@ func testDeleteEnterprises(t *testing.T, ds *Datastore) { _, err = ds.GetEnterpriseByID(testCtx(), tempEnterprise.ID) assert.True(t, fleet.IsNotFound(err)) - err = ds.DeleteEnterprises(testCtx()) + err = ds.DeleteAllEnterprises(testCtx()) require.NoError(t, err) _, err = ds.GetEnterpriseByID(testCtx(), enterprise.ID) assert.True(t, fleet.IsNotFound(err)) diff --git a/server/mdm/android/mysql/mysql_test.go b/server/mdm/android/mysql/testing_utils.go similarity index 71% rename from server/mdm/android/mysql/mysql_test.go rename to server/mdm/android/mysql/testing_utils.go index ab75c05460..7017ecb8b2 100644 --- a/server/mdm/android/mysql/mysql_test.go +++ b/server/mdm/android/mysql/testing_utils.go @@ -12,9 +12,11 @@ import ( "github.com/stretchr/testify/require" ) -// Android MySQL testing utilities. +// Android MySQL testing utilities. This file should contain VERY LITTLE code since it is also compiled into the production binary. +// Whenever possible, new code should go into a dedicated testing package (e.g. mdm/android/mysql/tests/testing_utils.go). // These utilities are used to create a MySQL Datastore for testing the Android MDM MySQL implementation. -// They are located in the same package as the implementation to prevent a circular dependency. +// They are located in the same package as the implementation to prevent a circular dependency. If put it in a different package, +// the circular dependency would be: mysql -> testing_utils -> mysql func CreateMySQLDS(t testing.TB) *Datastore { return createMySQLDSWithOptions(t, nil) @@ -22,14 +24,14 @@ func CreateMySQLDS(t testing.TB) *Datastore { func createMySQLDSWithOptions(t testing.TB, opts *testing_utils.DatastoreTestOptions) *Datastore { cleanTestName, opts := testing_utils.ProcessOptions(t, opts) - ds := initializeDatabase(t, cleanTestName, opts) + ds := InitializeDatabase(t, cleanTestName, opts) t.Cleanup(func() { Close(ds) }) return ds } -// initializeDatabase loads the dumped schema into a newly created database in MySQL. +// InitializeDatabase loads the dumped schema into a newly created database in MySQL. // This is much faster than running the full set of migrations on each test. -func initializeDatabase(t testing.TB, testName string, opts *testing_utils.DatastoreTestOptions) *Datastore { +func InitializeDatabase(t testing.TB, testName string, opts *testing_utils.DatastoreTestOptions) *Datastore { _, filename, _, _ := runtime.Caller(0) schemaPath := path.Join(path.Dir(filename), "schema.sql") testing_utils.LoadSchema(t, testName, opts, schemaPath) diff --git a/server/mdm/android/proxy.go b/server/mdm/android/proxy.go new file mode 100644 index 0000000000..8e9c619522 --- /dev/null +++ b/server/mdm/android/proxy.go @@ -0,0 +1,22 @@ +package android + +import ( + "context" + + "google.golang.org/api/androidmanagement/v1" +) + +type Proxy interface { + SignupURLsCreate(callbackURL string) (*SignupDetails, error) + EnterprisesCreate(ctx context.Context, req ProxyEnterprisesCreateRequest) (string, string, error) + EnterprisesPoliciesPatch(enterpriseID string, policyName string, policy *androidmanagement.Policy) error + EnterprisesEnrollmentTokensCreate(enterpriseName string, token *androidmanagement.EnrollmentToken) (*androidmanagement.EnrollmentToken, error) + EnterpriseDelete(enterpriseID string) error +} + +type ProxyEnterprisesCreateRequest struct { + androidmanagement.Enterprise + EnterpriseToken string + SignupUrlName string + PubSubPushURL string +} diff --git a/server/mdm/android/service.go b/server/mdm/android/service.go index 929d926bfd..6583c4762a 100644 --- a/server/mdm/android/service.go +++ b/server/mdm/android/service.go @@ -7,9 +7,29 @@ import ( type Service interface { EnterpriseSignup(ctx context.Context) (*SignupDetails, error) EnterpriseSignupCallback(ctx context.Context, enterpriseID uint, enterpriseToken string) error + GetEnterprise(ctx context.Context) (*Enterprise, error) DeleteEnterprise(ctx context.Context) error // CreateEnrollmentToken creates an enrollment token for a new Android device. CreateEnrollmentToken(ctx context.Context, enrollSecret string) (*EnrollmentToken, error) ProcessPubSubPush(ctx context.Context, token string, message *PubSubMessage) error } + +// ///////////////////////////////////////////// +// Android API request and response structs + +type DefaultResponse struct { + Err error `json:"error,omitempty"` +} + +func (r DefaultResponse) Error() error { return r.Err } + +type GetEnterpriseResponse struct { + EnterpriseID string `json:"android_enterprise_id"` + DefaultResponse +} + +type EnterpriseSignupResponse struct { + Url string `json:"android_enterprise_signup_url"` + DefaultResponse +} diff --git a/server/mdm/android/service/enterprises_test.go b/server/mdm/android/service/enterprises_test.go new file mode 100644 index 0000000000..9a99ed730c --- /dev/null +++ b/server/mdm/android/service/enterprises_test.go @@ -0,0 +1,135 @@ +package service + +import ( + "context" + "os" + "testing" + + "github.com/fleetdm/fleet/v4/server/authz" + "github.com/fleetdm/fleet/v4/server/contexts/viewer" + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/mdm/android" + android_mock "github.com/fleetdm/fleet/v4/server/mdm/android/mock" + "github.com/fleetdm/fleet/v4/server/mock" + "github.com/fleetdm/fleet/v4/server/ptr" + kitlog "github.com/go-kit/log" + "github.com/stretchr/testify/require" +) + +func TestEnterprisesAuth(t *testing.T) { + proxy := android_mock.Proxy{} + proxy.InitCommonMocks() + logger := kitlog.NewLogfmtLogger(os.Stdout) + fleetDS := InitCommonDSMocks() + svc, err := NewServiceWithProxy(logger, fleetDS, &proxy) + require.NoError(t, err) + + testCases := []struct { + name string + user *fleet.User + shouldFailWrite bool + shouldFailRead bool + }{ + { + "global admin", + &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}, + false, + false, + }, + { + "global maintainer", + &fleet.User{GlobalRole: ptr.String(fleet.RoleMaintainer)}, + true, + true, + }, + { + "global gitops", + &fleet.User{GlobalRole: ptr.String(fleet.RoleGitOps)}, + true, + true, + }, + { + "global observer", + &fleet.User{GlobalRole: ptr.String(fleet.RoleObserver)}, + true, + true, + }, + { + "global observer+", + &fleet.User{GlobalRole: ptr.String(fleet.RoleObserverPlus)}, + true, + true, + }, + { + "team admin", + &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleAdmin}}}, + true, + true, + }, + { + "team maintainer", + &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleMaintainer}}}, + true, + true, + }, + { + "team observer", + &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleObserver}}}, + true, + true, + }, + { + "team observer+", + &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleObserverPlus}}}, + true, + true, + }, + } + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + ctx := viewer.NewContext(context.Background(), viewer.Viewer{User: tt.user}) + + _, err := svc.GetEnterprise(ctx) + checkAuthErr(t, tt.shouldFailRead, err) + + err = svc.DeleteEnterprise(ctx) + checkAuthErr(t, tt.shouldFailWrite, err) + + _, err = svc.EnterpriseSignup(ctx) + checkAuthErr(t, tt.shouldFailWrite, err) + }) + } + + t.Run("unauthorized", func(t *testing.T) { + err := svc.EnterpriseSignupCallback(context.Background(), 1, "token") + checkAuthErr(t, false, err) + }) +} + +func checkAuthErr(t *testing.T, shouldFail bool, err error) { + t.Helper() + if shouldFail { + require.Error(t, err) + var forbiddenError *authz.Forbidden + require.ErrorAs(t, err, &forbiddenError) + } else { + require.NoError(t, err) + } +} + +func InitCommonDSMocks() *mock.Store { + fleetDS := mock.Store{} + ds := android_mock.Datastore{} + ds.InitCommonMocks() + + fleetDS.GetAndroidDSFunc = func() android.Datastore { + return &ds + } + fleetDS.AppConfigFunc = func(_ context.Context) (*fleet.AppConfig, error) { + return &fleet.AppConfig{}, nil + } + fleetDS.SetAndroidEnabledAndConfiguredFunc = func(_ context.Context, configured bool) error { + return nil + } + return &fleetDS +} diff --git a/server/mdm/android/service/handler.go b/server/mdm/android/service/handler.go index b38aa553c0..a5accf6340 100644 --- a/server/mdm/android/service/handler.go +++ b/server/mdm/android/service/handler.go @@ -18,20 +18,21 @@ const pubSubPushPath = "/api/v1/fleet/android_enterprise/pubsub" func attachFleetAPIRoutes(r *mux.Router, fleetSvc fleet.Service, svc android.Service, opts []kithttp.ServerOption) { - // user-authenticated endpoints + // ////////////////////////////////////////// + // User-authenticated endpoints ue := newUserAuthenticatedEndpointer(fleetSvc, svc, opts, r, apiVersions()...) ue.GET("/api/_version_/fleet/android_enterprise/signup_url", enterpriseSignupEndpoint, nil) + ue.GET("/api/_version_/fleet/android_enterprise", getEnterpriseEndpoint, nil) ue.DELETE("/api/_version_/fleet/android_enterprise", deleteEnterpriseEndpoint, nil) - // unauthenticated endpoints - // They typically do one-time authentication by verifying that a valid secret token is provided with the request. + // ////////////////////////////////////////// + // Unauthenticated endpoints + // These endpoints should do custom one-time authentication by verifying that a valid secret token is provided with the request. ne := newNoAuthEndpointer(fleetSvc, svc, opts, r, apiVersions()...) - ne.GET("/api/_version_/fleet/android_enterprise/{id:[0-9]+}/connect", enterpriseSignupCallbackEndpoint, - enterpriseSignupCallbackRequest{}) - ne.GET("/api/_version_/fleet/android_enterprise/enrollment_token", enrollmentTokenEndpoint, - enrollmentTokenRequest{}) + ne.GET("/api/_version_/fleet/android_enterprise/{id:[0-9]+}/connect", enterpriseSignupCallbackEndpoint, enterpriseSignupCallbackRequest{}) + ne.GET("/api/_version_/fleet/android_enterprise/enrollment_token", enrollmentTokenEndpoint, enrollmentTokenRequest{}) ne.POST(pubSubPushPath, pubSubPushEndpoint, pubSubPushRequest{}) } diff --git a/server/mdm/android/service/proxy/proxy.go b/server/mdm/android/service/proxy/proxy.go index 18676126b8..72eb0fbb56 100644 --- a/server/mdm/android/service/proxy/proxy.go +++ b/server/mdm/android/service/proxy/proxy.go @@ -32,6 +32,9 @@ type Proxy struct { mgmt *androidmanagement.Service } +// Compile-time check to ensure that Proxy implements android.Proxy. +var _ android.Proxy = &Proxy{} + func NewProxy(ctx context.Context, logger kitlog.Logger) *Proxy { if androidServiceCredentials == "" { return nil @@ -74,28 +77,27 @@ func (p *Proxy) SignupURLsCreate(callbackURL string) (*android.SignupDetails, er }, nil } -func (p *Proxy) EnterprisesCreate(ctx context.Context, enabledNotificationTypes []string, enterpriseToken string, - signupUrlName string, pushURL string) (string, string, error) { +func (p *Proxy) EnterprisesCreate(ctx context.Context, req android.ProxyEnterprisesCreateRequest) (string, string, error) { if p == nil || p.mgmt == nil { return "", "", errors.New("android management service not initialized") } - topicName, err := p.createPubSubTopic(ctx, pushURL) + topicName, err := p.createPubSubTopic(ctx, req.PubSubPushURL) if err != nil { return "", "", fmt.Errorf("creating PubSub topic: %w", err) } enterprise, err := p.mgmt.Enterprises.Create(&androidmanagement.Enterprise{ - EnabledNotificationTypes: enabledNotificationTypes, + EnabledNotificationTypes: req.EnabledNotificationTypes, PubsubTopic: topicName, }). ProjectId(androidProjectID). - EnterpriseToken(enterpriseToken). - SignupUrlName(signupUrlName). + EnterpriseToken(req.EnterpriseToken). + SignupUrlName(req.SignupUrlName). Do() switch { case googleapi.IsNotModified(err): - return "", "", fmt.Errorf("android enterprise %s was already created", signupUrlName) + return "", "", fmt.Errorf("android enterprise %s was already created", req.SignupUrlName) case err != nil: return "", "", fmt.Errorf("creating enterprise: %w", err) } diff --git a/server/mdm/android/service/pubsub.go b/server/mdm/android/service/pubsub.go index 3383e352c9..7e710d2031 100644 --- a/server/mdm/android/service/pubsub.go +++ b/server/mdm/android/service/pubsub.go @@ -26,7 +26,7 @@ type pubSubPushRequest struct { func pubSubPushEndpoint(ctx context.Context, request interface{}, svc android.Service) fleet.Errorer { req := request.(*pubSubPushRequest) err := svc.ProcessPubSubPush(ctx, req.Token, &req.PubSubMessage) - return defaultResponse{Err: err} + return android.DefaultResponse{Err: err} } func (svc *Service) ProcessPubSubPush(ctx context.Context, token string, message *android.PubSubMessage) error { diff --git a/server/mdm/android/service/service.go b/server/mdm/android/service/service.go index f2ab5b4f65..e786f67c83 100644 --- a/server/mdm/android/service/service.go +++ b/server/mdm/android/service/service.go @@ -24,43 +24,39 @@ type Service struct { authz *authz.Authorizer ds android.Datastore fleetDS fleet.Datastore - proxy *proxy.Proxy + proxy android.Proxy } func NewService( ctx context.Context, logger kitlog.Logger, fleetDS fleet.Datastore, +) (android.Service, error) { + prx := proxy.NewProxy(ctx, logger) + return NewServiceWithProxy(logger, fleetDS, prx) +} + +func NewServiceWithProxy( + logger kitlog.Logger, + fleetDS fleet.Datastore, + proxy android.Proxy, ) (android.Service, error) { authorizer, err := authz.NewAuthorizer() if err != nil { return nil, fmt.Errorf("new authorizer: %w", err) } - prx := proxy.NewProxy(ctx, logger) - return &Service{ logger: logger, authz: authorizer, ds: fleetDS.GetAndroidDS(), fleetDS: fleetDS, - proxy: prx, + proxy: proxy, }, nil } -type defaultResponse struct { - Err error `json:"error,omitempty"` -} - -func (r defaultResponse) Error() error { return r.Err } - -func newErrResponse(err error) defaultResponse { - return defaultResponse{Err: err} -} - -type androidEnterpriseSignupResponse struct { - Url string `json:"android_enterprise_signup_url"` - defaultResponse +func newErrResponse(err error) android.DefaultResponse { + return android.DefaultResponse{Err: err} } func enterpriseSignupEndpoint(ctx context.Context, _ interface{}, svc android.Service) fleet.Errorer { @@ -68,7 +64,7 @@ func enterpriseSignupEndpoint(ctx context.Context, _ interface{}, svc android.Se if err != nil { return newErrResponse(err) } - return androidEnterpriseSignupResponse{Url: result.Url} + return android.EnterpriseSignupResponse{Url: result.Url} } func (svc *Service) EnterpriseSignup(ctx context.Context) (*android.SignupDetails, error) { @@ -125,7 +121,7 @@ type enterpriseSignupCallbackRequest struct { func enterpriseSignupCallbackEndpoint(ctx context.Context, request interface{}, svc android.Service) fleet.Errorer { req := request.(*enterpriseSignupCallbackRequest) err := svc.EnterpriseSignupCallback(ctx, req.ID, req.EnterpriseToken) - return defaultResponse{Err: err} + return android.DefaultResponse{Err: err} } func (svc *Service) EnterpriseSignupCallback(ctx context.Context, id uint, enterpriseToken string) error { @@ -156,10 +152,19 @@ func (svc *Service) EnterpriseSignupCallback(ctx context.Context, id uint, enter name, topicName, err := svc.proxy.EnterprisesCreate( ctx, - []string{android.PubSubEnrollment, android.PubSubStatusReport, android.PubSubCommand, android.PubSubUsageLogs}, - enterpriseToken, - enterprise.SignupName, - appConfig.ServerSettings.ServerURL+pubSubPushPath+"?token="+pubSubToken, + android.ProxyEnterprisesCreateRequest{ + Enterprise: androidmanagement.Enterprise{ + EnabledNotificationTypes: []string{ + android.PubSubEnrollment, + android.PubSubStatusReport, + android.PubSubCommand, + android.PubSubUsageLogs, + }, + }, + EnterpriseToken: enterpriseToken, + SignupUrlName: enterprise.SignupName, + PubSubPushURL: appConfig.ServerSettings.ServerURL + pubSubPushPath + "?token=" + pubSubToken, + }, ) if err != nil { return ctxerr.Wrap(ctx, err, "creating enterprise") @@ -222,9 +227,31 @@ func topicIDFromName(name string) (string, error) { return name[lastSlash+1:], nil } +func getEnterpriseEndpoint(ctx context.Context, _ interface{}, svc android.Service) fleet.Errorer { + enterprise, err := svc.GetEnterprise(ctx) + if err != nil { + return android.DefaultResponse{Err: err} + } + return android.GetEnterpriseResponse{EnterpriseID: enterprise.EnterpriseID} +} + +func (svc *Service) GetEnterprise(ctx context.Context) (*android.Enterprise, error) { + if err := svc.authz.Authorize(ctx, &android.Enterprise{}, fleet.ActionRead); err != nil { + return nil, err + } + enterprise, err := svc.ds.GetEnterprise(ctx) + switch { + case fleet.IsNotFound(err): + return nil, fleet.NewInvalidArgumentError("enterprise", "No enterprise found").WithStatus(http.StatusNotFound) + case err != nil: + return nil, ctxerr.Wrap(ctx, err, "getting enterprise") + } + return enterprise, nil +} + func deleteEnterpriseEndpoint(ctx context.Context, _ interface{}, svc android.Service) fleet.Errorer { err := svc.DeleteEnterprise(ctx) - return defaultResponse{Err: err} + return android.DefaultResponse{Err: err} } func (svc *Service) DeleteEnterprise(ctx context.Context) error { @@ -246,7 +273,7 @@ func (svc *Service) DeleteEnterprise(ctx context.Context) error { } } - err = svc.ds.DeleteEnterprises(ctx) + err = svc.ds.DeleteAllEnterprises(ctx) if err != nil { return ctxerr.Wrap(ctx, err, "deleting enterprises") } @@ -263,18 +290,18 @@ type enrollmentTokenRequest struct { EnrollSecret string `query:"enroll_secret"` } -type androidEnrollmentTokenResponse struct { +type enrollmentTokenResponse struct { *android.EnrollmentToken - defaultResponse + android.DefaultResponse } func enrollmentTokenEndpoint(ctx context.Context, request interface{}, svc android.Service) fleet.Errorer { req := request.(*enrollmentTokenRequest) token, err := svc.CreateEnrollmentToken(ctx, req.EnrollSecret) if err != nil { - return defaultResponse{Err: err} + return android.DefaultResponse{Err: err} } - return androidEnrollmentTokenResponse{EnrollmentToken: token} + return enrollmentTokenResponse{EnrollmentToken: token} } func (svc *Service) CreateEnrollmentToken(ctx context.Context, enrollSecret string) (*android.EnrollmentToken, error) { diff --git a/server/mdm/android/tests/README.md b/server/mdm/android/tests/README.md new file mode 100644 index 0000000000..5a0a375e39 --- /dev/null +++ b/server/mdm/android/tests/README.md @@ -0,0 +1,5 @@ +This package contains API Android tests with the real Android service and Android MySQL database. + +We use testify Suite to run these tests. Since [testify Suite does not support parallel execution](https://github.com/stretchr/testify/issues/187), +we put each test in their own package/directory. This allows these tests to run in parallel because each package is a separate compile unit. If you +create a large test, please put it in a separate file within the same Suite/package. diff --git a/server/mdm/android/tests/enterprise/enterprise_test.go b/server/mdm/android/tests/enterprise/enterprise_test.go new file mode 100644 index 0000000000..d8793239a6 --- /dev/null +++ b/server/mdm/android/tests/enterprise/enterprise_test.go @@ -0,0 +1,52 @@ +package enterprise_test + +import ( + "net/http" + "testing" + + "github.com/fleetdm/fleet/v4/server/mdm/android" + "github.com/fleetdm/fleet/v4/server/mdm/android/tests" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" +) + +func TestServiceEnterprise(t *testing.T) { + testingSuite := new(enterpriseTestSuite) + suite.Run(t, testingSuite) +} + +type enterpriseTestSuite struct { + tests.WithServer +} + +func (s *enterpriseTestSuite) SetupSuite() { + s.WithServer.SetupSuite(s.T(), "androidEnterpriseTestSuite") + s.Token = "bozo" +} + +func (s *enterpriseTestSuite) TearDownSuite() { + s.WithServer.TearDownSuite() +} + +func (s *enterpriseTestSuite) TestGetEnterprise() { + // Enterprise doesn't exist. + var resp android.GetEnterpriseResponse + s.DoJSON("GET", "/api/v1/fleet/android_enterprise", nil, http.StatusNotFound, &resp) + + // Create enterprise + var signupResp android.EnterpriseSignupResponse + s.DoJSON("GET", "/api/v1/fleet/android_enterprise/signup_url", nil, http.StatusOK, &signupResp) + assert.Equal(s.T(), tests.EnterpriseSignupURL, signupResp.Url) + s.T().Logf("callbackURL: %s", s.ProxyCallbackURL) + const enterpriseToken = "enterpriseToken" + s.DoJSON("GET", s.ProxyCallbackURL, nil, http.StatusOK, &resp, "enterpriseToken", enterpriseToken) + + // Now enterprise exists and we can retrieve it. + resp = android.GetEnterpriseResponse{} + s.DoJSON("GET", "/api/v1/fleet/android_enterprise", nil, http.StatusOK, &resp) + assert.Equal(s.T(), tests.EnterpriseID, resp.EnterpriseID) + + // Delete enterprise and make sure we can't find it. + s.Do("DELETE", "/api/v1/fleet/android_enterprise", nil, http.StatusOK) + s.DoJSON("GET", "/api/v1/fleet/android_enterprise", nil, http.StatusNotFound, &resp) +} diff --git a/server/mdm/android/tests/http.go b/server/mdm/android/tests/http.go new file mode 100644 index 0000000000..71a17b6009 --- /dev/null +++ b/server/mdm/android/tests/http.go @@ -0,0 +1,49 @@ +package tests + +import ( + "fmt" + "io" + "net/http" + + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/test/httptest" + "github.com/go-json-experiment/json" + "github.com/stretchr/testify/require" +) + +func (ts *WithServer) DoJSON(verb, path string, params interface{}, expectedStatusCode int, v interface{}, queryParams ...string) { + resp := ts.Do(verb, path, params, expectedStatusCode, queryParams...) + err := json.UnmarshalRead(resp.Body, v) + require.NoError(ts.T(), err) + if e, ok := v.(fleet.Errorer); ok { + require.NoError(ts.T(), e.Error()) + } +} + +func (ts *WithServer) Do(verb, path string, params interface{}, expectedStatusCode int, queryParams ...string) *http.Response { + j, err := json.Marshal(params) + require.NoError(ts.T(), err) + + resp := ts.DoRaw(verb, path, j, expectedStatusCode, queryParams...) + + ts.T().Cleanup(func() { + resp.Body.Close() + }) + return resp +} + +func (ts *WithServer) DoRaw(verb string, path string, rawBytes []byte, expectedStatusCode int, queryParams ...string) *http.Response { + return ts.DoRawWithHeaders(verb, path, rawBytes, expectedStatusCode, map[string]string{ + "Authorization": fmt.Sprintf("Bearer %s", ts.Token), + }, queryParams...) +} + +func (ts *WithServer) DoRawWithHeaders( + verb string, path string, rawBytes []byte, expectedStatusCode int, headers map[string]string, queryParams ...string, +) *http.Response { + return httptest.DoHTTPReq(ts.T(), decodeJSON, verb, rawBytes, ts.Server.URL+path, headers, expectedStatusCode, queryParams...) +} + +func decodeJSON(r io.Reader, v interface{}) error { + return json.UnmarshalRead(r, v) +} diff --git a/server/mdm/android/tests/testing_utils.go b/server/mdm/android/tests/testing_utils.go new file mode 100644 index 0000000000..acdf58378f --- /dev/null +++ b/server/mdm/android/tests/testing_utils.go @@ -0,0 +1,157 @@ +package tests + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/fleetdm/fleet/v4/server/config" + "github.com/fleetdm/fleet/v4/server/datastore/mysql/common_mysql/testing_utils" + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/mdm/android" + proxy_mock "github.com/fleetdm/fleet/v4/server/mdm/android/mock" + "github.com/fleetdm/fleet/v4/server/mdm/android/mysql" + "github.com/fleetdm/fleet/v4/server/mdm/android/service" + ds_mock "github.com/fleetdm/fleet/v4/server/mock" + "github.com/fleetdm/fleet/v4/server/ptr" + "github.com/fleetdm/fleet/v4/server/service/middleware/auth" + "github.com/fleetdm/fleet/v4/server/service/middleware/endpoint_utils" + "github.com/fleetdm/fleet/v4/server/service/middleware/log" + kithttp "github.com/go-kit/kit/transport/http" + kitlog "github.com/go-kit/log" + "github.com/gorilla/mux" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + "google.golang.org/api/androidmanagement/v1" +) + +const ( + EnterpriseSignupURL = "https://enterprise.google.com/signup/android/email?origin=android&thirdPartyToken=B4D779F1C4DD9A440" + EnterpriseID = "LC02k5wxw7" +) + +type WithServer struct { + suite.Suite + DS *mysql.Datastore + FleetDS ds_mock.Store + Server *httptest.Server + Token string + AppConfig fleet.AppConfig + Proxy proxy_mock.Proxy + ProxyCallbackURL string +} + +func (ts *WithServer) SetupSuite(t *testing.T, dbName string) { + ts.DS = CreateNamedMySQLDS(t, dbName) + ts.createCommonDSMocks() + + ts.Proxy = proxy_mock.Proxy{} + ts.createCommonProxyMocks(t) + + fleetSvc := mockService{} + logger := kitlog.NewLogfmtLogger(os.Stdout) + svc, err := service.NewServiceWithProxy(logger, &ts.FleetDS, &ts.Proxy) + require.NoError(t, err) + + ts.Server = runServerForTests(t, logger, &fleetSvc, svc) +} + +func (ts *WithServer) createCommonDSMocks() { + ts.FleetDS.GetAndroidDSFunc = func() android.Datastore { + return ts.DS + } + ts.FleetDS.AppConfigFunc = func(_ context.Context) (*fleet.AppConfig, error) { + return &ts.AppConfig, nil + } + ts.FleetDS.SetAndroidEnabledAndConfiguredFunc = func(_ context.Context, configured bool) error { + ts.AppConfig.MDM.AndroidEnabledAndConfigured = configured + return nil + } +} + +func (ts *WithServer) createCommonProxyMocks(t *testing.T) { + ts.Proxy.SignupURLsCreateFunc = func(callbackURL string) (*android.SignupDetails, error) { + ts.ProxyCallbackURL = callbackURL + return &android.SignupDetails{ + Url: EnterpriseSignupURL, + Name: "signupUrls/Cb08124d0999c464f", + }, nil + } + ts.Proxy.EnterprisesCreateFunc = func(ctx context.Context, req android.ProxyEnterprisesCreateRequest) (string, string, error) { + return EnterpriseID, "projects/android/topics/ae98ed130-5ce2-4ddb-a90a-191ec76976d5", nil + } + ts.Proxy.EnterprisesPoliciesPatchFunc = func(enterpriseID string, policyName string, policy *androidmanagement.Policy) error { + assert.Equal(t, EnterpriseID, enterpriseID) + return nil + } + ts.Proxy.EnterpriseDeleteFunc = func(enterpriseID string) error { + assert.Equal(t, EnterpriseID, enterpriseID) + return nil + } +} + +func (ts *WithServer) TearDownSuite() { + mysql.Close(ts.DS) +} + +type mockService struct { + mock.Mock + fleet.Service +} + +func (m *mockService) GetSessionByKey(ctx context.Context, sessionKey string) (*fleet.Session, error) { + return &fleet.Session{UserID: 1}, nil +} + +func (m *mockService) UserUnauthorized(ctx context.Context, userId uint) (*fleet.User, error) { + return &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}, nil +} + +func runServerForTests(t *testing.T, logger kitlog.Logger, fleetSvc fleet.Service, androidSvc android.Service) *httptest.Server { + + fleetAPIOptions := []kithttp.ServerOption{ + kithttp.ServerBefore( + kithttp.PopulateRequestContext, + auth.SetRequestsContexts(fleetSvc), + ), + kithttp.ServerErrorHandler(&endpoint_utils.ErrorHandler{Logger: logger}), + kithttp.ServerErrorEncoder(endpoint_utils.EncodeError), + kithttp.ServerAfter( + kithttp.SetContentType("application/json; charset=utf-8"), + log.LogRequestEnd(logger), + ), + } + + r := mux.NewRouter() + service.GetRoutes(fleetSvc, androidSvc)(r, fleetAPIOptions) + rootMux := http.NewServeMux() + rootMux.HandleFunc("/api/", r.ServeHTTP) + + server := httptest.NewUnstartedServer(rootMux) + serverConfig := config.ServerConfig{} + server.Config = serverConfig.DefaultHTTPServer(testCtx(), rootMux) + require.NotZero(t, server.Config.WriteTimeout) + server.Config.Handler = rootMux + server.Start() + t.Cleanup(func() { + server.Close() + }) + return server +} + +func testCtx() context.Context { + return context.Background() +} + +func CreateNamedMySQLDS(t *testing.T, name string) *mysql.Datastore { + if _, ok := os.LookupEnv("MYSQL_TEST"); !ok { + t.Skip("MySQL tests are disabled") + } + ds := mysql.InitializeDatabase(t, name, new(testing_utils.DatastoreTestOptions)) + t.Cleanup(func() { mysql.Close(ds) }) + return ds +} diff --git a/server/service/endpoint_utils_test.go b/server/service/endpoint_utils_test.go index 5831f7ddbf..ec0009b73a 100644 --- a/server/service/endpoint_utils_test.go +++ b/server/service/endpoint_utils_test.go @@ -16,6 +16,7 @@ import ( "github.com/fleetdm/fleet/v4/server/ptr" "github.com/fleetdm/fleet/v4/server/service/middleware/auth" "github.com/fleetdm/fleet/v4/server/service/middleware/endpoint_utils" + "github.com/fleetdm/fleet/v4/server/service/middleware/log" "github.com/go-kit/kit/endpoint" kithttp "github.com/go-kit/kit/transport/http" kitlog "github.com/go-kit/log" @@ -292,7 +293,7 @@ func TestEndpointer(t *testing.T) { kithttp.ServerErrorEncoder(endpoint_utils.EncodeError), kithttp.ServerAfter( kithttp.SetContentType("application/json; charset=utf-8"), - logRequestEnd(kitlog.NewNopLogger()), + log.LogRequestEnd(kitlog.NewNopLogger()), checkLicenseExpiration(svc), ), } @@ -412,7 +413,7 @@ func TestEndpointerCustomMiddleware(t *testing.T) { kithttp.ServerErrorEncoder(endpoint_utils.EncodeError), kithttp.ServerAfter( kithttp.SetContentType("application/json; charset=utf-8"), - logRequestEnd(kitlog.NewNopLogger()), + log.LogRequestEnd(kitlog.NewNopLogger()), checkLicenseExpiration(svc), ), } diff --git a/server/service/handler.go b/server/service/handler.go index f14eaa5a4a..0d01ad9b9d 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -11,7 +11,6 @@ import ( eeservice "github.com/fleetdm/fleet/v4/ee/server/service" "github.com/fleetdm/fleet/v4/server/config" - "github.com/fleetdm/fleet/v4/server/contexts/logging" "github.com/fleetdm/fleet/v4/server/contexts/publicip" "github.com/fleetdm/fleet/v4/server/fleet" apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple" @@ -26,6 +25,7 @@ import ( scepserver "github.com/fleetdm/fleet/v4/server/mdm/scep/server" "github.com/fleetdm/fleet/v4/server/service/middleware/auth" "github.com/fleetdm/fleet/v4/server/service/middleware/endpoint_utils" + "github.com/fleetdm/fleet/v4/server/service/middleware/log" "github.com/fleetdm/fleet/v4/server/service/middleware/mdmconfigured" "github.com/fleetdm/fleet/v4/server/service/middleware/ratelimit" kithttp "github.com/go-kit/kit/transport/http" @@ -42,17 +42,6 @@ import ( microsoft_mdm "github.com/fleetdm/fleet/v4/server/mdm/microsoft" ) -func logRequestEnd(logger kitlog.Logger) func(context.Context, http.ResponseWriter) context.Context { - return func(ctx context.Context, w http.ResponseWriter) context.Context { - logCtx, ok := logging.FromContext(ctx) - if !ok { - return ctx - } - logCtx.Log(ctx, logger) - return ctx - } -} - func checkLicenseExpiration(svc fleet.Service) func(context.Context, http.ResponseWriter) context.Context { return func(ctx context.Context, w http.ResponseWriter) context.Context { license, err := svc.License(ctx) @@ -103,7 +92,7 @@ func MakeHandler( kithttp.ServerErrorEncoder(endpoint_utils.EncodeError), kithttp.ServerAfter( kithttp.SetContentType("application/json; charset=utf-8"), - logRequestEnd(logger), + log.LogRequestEnd(logger), checkLicenseExpiration(svc), ), } diff --git a/server/service/middleware/log/log.go b/server/service/middleware/log/log.go index f1c7e5c1e5..007e2fbb1c 100644 --- a/server/service/middleware/log/log.go +++ b/server/service/middleware/log/log.go @@ -2,9 +2,11 @@ package log import ( "context" + "net/http" "github.com/fleetdm/fleet/v4/server/contexts/logging" "github.com/go-kit/kit/endpoint" + kitlog "github.com/go-kit/log" ) // Logged wraps an endpoint and adds the error if the context supports it @@ -24,3 +26,14 @@ func Logged(next endpoint.Endpoint) endpoint.Endpoint { return res, nil } } + +func LogRequestEnd(logger kitlog.Logger) func(context.Context, http.ResponseWriter) context.Context { + return func(ctx context.Context, w http.ResponseWriter) context.Context { + logCtx, ok := logging.FromContext(ctx) + if !ok { + return ctx + } + logCtx.Log(ctx, logger) + return ctx + } +} diff --git a/server/service/testing_client.go b/server/service/testing_client.go index fea1a46c5e..64d02f02be 100644 --- a/server/service/testing_client.go +++ b/server/service/testing_client.go @@ -26,9 +26,9 @@ import ( "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/live_query/live_query_mock" "github.com/fleetdm/fleet/v4/server/pubsub" - "github.com/fleetdm/fleet/v4/server/service/middleware/endpoint_utils" "github.com/fleetdm/fleet/v4/server/sso" "github.com/fleetdm/fleet/v4/server/test" + fleet_httptest "github.com/fleetdm/fleet/v4/server/test/httptest" "github.com/ghodss/yaml" kitlog "github.com/go-kit/log" "github.com/jmoiron/sqlx" @@ -244,47 +244,11 @@ func (ts *withServer) Do(verb, path string, params interface{}, expectedStatusCo func (ts *withServer) DoRawWithHeaders( verb string, path string, rawBytes []byte, expectedStatusCode int, headers map[string]string, queryParams ...string, ) *http.Response { - t := ts.s.T() + return fleet_httptest.DoHTTPReq(ts.s.T(), decodeJSON, verb, rawBytes, ts.server.URL+path, headers, expectedStatusCode, queryParams...) +} - requestBody := io.NopCloser(bytes.NewBuffer(rawBytes)) - req, err := http.NewRequest(verb, ts.server.URL+path, requestBody) - require.NoError(t, err) - for key, val := range headers { - req.Header.Add(key, val) - } - - opts := []fleethttp.ClientOpt{} - if expectedStatusCode >= 300 && expectedStatusCode <= 399 { - opts = append(opts, fleethttp.WithFollowRedir(false)) - } - client := fleethttp.NewClient(opts...) - - if len(queryParams)%2 != 0 { - require.Fail(t, "need even number of params: key value") - } - if len(queryParams) > 0 { - q := req.URL.Query() - for i := 0; i < len(queryParams); i += 2 { - q.Add(queryParams[i], queryParams[i+1]) - } - req.URL.RawQuery = q.Encode() - } - - resp, err := client.Do(req) - require.NoError(t, err) - - if resp.StatusCode != expectedStatusCode { - defer resp.Body.Close() - var je endpoint_utils.JsonError - err := json.NewDecoder(resp.Body).Decode(&je) - if err != nil { - t.Logf("Error trying to decode response body as Fleet jsonError: %s", err) - require.Equal(t, expectedStatusCode, resp.StatusCode, fmt.Sprintf("response: %+v", resp)) - } - require.Equal(t, expectedStatusCode, resp.StatusCode, fmt.Sprintf("Fleet jsonError: %+v", je)) - } - - return resp +func decodeJSON(r io.Reader, v interface{}) error { + return json.NewDecoder(r).Decode(v) } func (ts *withServer) DoRaw(verb string, path string, rawBytes []byte, expectedStatusCode int, queryParams ...string) *http.Response { diff --git a/server/test/httptest/README.md b/server/test/httptest/README.md new file mode 100644 index 0000000000..28f7a61526 --- /dev/null +++ b/server/test/httptest/README.md @@ -0,0 +1,2 @@ +These HTTP test functions are in a separate package to prevent circular dependencies. +The circular dependency may be caused due to dependency on "github.com/fleetdm/fleet/v4/server/service/middleware/endpoint_utils" diff --git a/server/test/httptest/http.go b/server/test/httptest/http.go new file mode 100644 index 0000000000..768121b0f5 --- /dev/null +++ b/server/test/httptest/http.go @@ -0,0 +1,55 @@ +package httptest + +import ( + "bytes" + "fmt" + "io" + "net/http" + "testing" + + "github.com/fleetdm/fleet/v4/pkg/fleethttp" + "github.com/fleetdm/fleet/v4/server/service/middleware/endpoint_utils" + "github.com/stretchr/testify/require" +) + +func DoHTTPReq(t *testing.T, jsonDecoder func(r io.Reader, v interface{}) error, verb string, rawBytes []byte, urlPath string, + headers map[string]string, expectedStatusCode int, queryParams ...string) *http.Response { + requestBody := io.NopCloser(bytes.NewBuffer(rawBytes)) + req, err := http.NewRequest(verb, urlPath, requestBody) + require.NoError(t, err) + for key, val := range headers { + req.Header.Add(key, val) + } + + opts := []fleethttp.ClientOpt{} + if expectedStatusCode >= 300 && expectedStatusCode <= 399 { + opts = append(opts, fleethttp.WithFollowRedir(false)) + } + client := fleethttp.NewClient(opts...) + + if len(queryParams)%2 != 0 { + require.Fail(t, "need even number of params: key value") + } + if len(queryParams) > 0 { + q := req.URL.Query() + for i := 0; i < len(queryParams); i += 2 { + q.Add(queryParams[i], queryParams[i+1]) + } + req.URL.RawQuery = q.Encode() + } + + resp, err := client.Do(req) + require.NoError(t, err) + + if resp.StatusCode != expectedStatusCode { + defer resp.Body.Close() + var je endpoint_utils.JsonError + err := jsonDecoder(resp.Body, &je) + if err != nil { + t.Logf("Error trying to decode response body as Fleet jsonError: %s", err) + require.Equal(t, expectedStatusCode, resp.StatusCode, fmt.Sprintf("response: %+v", resp)) + } + require.Equal(t, expectedStatusCode, resp.StatusCode, fmt.Sprintf("Fleet jsonError: %+v", je)) + } + return resp +} From e336cdebba0ec60b32564a46e77a54c9a45ee76b Mon Sep 17 00:00:00 2001 From: Mike McNeil Date: Wed, 26 Feb 2025 11:18:20 -0600 Subject: [PATCH 12/13] Website: Add Zapier webhook (#26372) relates to https://github.com/fleetdm/confidential/pull/9650 --------- Co-authored-by: Eric --- website/.eslintrc | 1 + .../webhooks/receive-from-zapier.js | 176 ++++++++++++++++++ website/api/models/AdCampaign.js | 65 +++++++ website/assets/.eslintrc | 1 + website/config/custom.js | 3 + website/config/routes.js | 1 + 6 files changed, 247 insertions(+) create mode 100644 website/api/controllers/webhooks/receive-from-zapier.js create mode 100644 website/api/models/AdCampaign.js diff --git a/website/.eslintrc b/website/.eslintrc index 468ec2e16d..86dbf767d5 100644 --- a/website/.eslintrc +++ b/website/.eslintrc @@ -49,6 +49,7 @@ "VantaConnection": true, "CertificateSigningRequest": true, "Platform": true, + "AdCampaign": true, // …and any others. // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - }, diff --git a/website/api/controllers/webhooks/receive-from-zapier.js b/website/api/controllers/webhooks/receive-from-zapier.js new file mode 100644 index 0000000000..66db756158 --- /dev/null +++ b/website/api/controllers/webhooks/receive-from-zapier.js @@ -0,0 +1,176 @@ +module.exports = { + + + friendlyName: 'Receive Zapier events', + + + description: 'Receive events from Zapier.', + + + inputs: { + eventName: { + type: 'string', + description: 'The unique identifier for this Zap.', + moreInfoUrl: 'https://zapier.com/app/assets/zaps/folders/2035513', + required: true, + }, + data: { + type: {}, + description: 'Data associated with this event.', + whereToGet: { description: 'Check out the Zap in question and see what it\'s sending via HTTP.' }, + required: true, + }, + webhookSecret: { + type: 'string', + description: 'Used to verify that requests are coming from where we think they are.', + required: true, + }, + }, + + + exits: { + success: { description: 'An event has successfully been received.' }, + unrecognizedEventName: { description: 'I do not know how to handle that kind of event.', responseType: 'ok' },// TODO: how will zapier react to receiving a bad request response? + couldNotMatchLinkedinId: { description: 'A linkedIn company could not be found using the provided linkedIn url', responseType: 'ok' } + }, + + + fn: async function ({eventName, data, webhookSecret}) { + let assert = require('assert'); + + if (!sails.config.custom.zapierWebhookSecret) { + throw new Error('No webhook secret configured! (Please set `sails.config.custom.zapierWebhookSecret`.)'); + } + + if (!sails.config.custom.iqSecret) { + throw new Error('No iqSecret configured! (Please set `sails.config.custom.iqSecret`.)'); + } + + if (sails.config.custom.zapierWebhookSecret !== webhookSecret) { + throw new Error('Received unexpected webhook request with webhookSecret set to: '+webhookSecret); + } + // Search for any campaigns that have a placeholder URN. If there are more than one records with a placeholder URN, throw an error. + let adCampaignsWithPlaceholderUrns = await AdCampaign.find({ + isLatest: true, + linkedinCampaignUrn: {startsWith: 'PLACEHOLDER-'} + }); + if(adCampaignsWithPlaceholderUrns.length > 1) { + throw new Error(`Consistency violation. When the receive-from-zapier webhook received an event from the ${eventName} zap. More than one adcampaigns with a placeholder campaign URN exist in the database.`); + } + + // Zap: https://zapier.com/editor/280954803 + if(eventName === 'update-placeholder-campaign-urn') { + assert(_.isObject(data)); + assert(_.isString(data.placeholderUrn)); + assert(_.isString(data.linkedinCampaignUrn)); + + let adCampaignWithThisPlaceholderUrn = await AdCampaign.findOne({linkedinCampaignUrn: data.placeholderUrn}); + if(!adCampaignWithThisPlaceholderUrn) { + sails.log.warn(`when the receive-from-zapier webhook received an event to update an AdCampaign record with a non-placeholder linkedinCampaignUrn value (${data.linkedinCampaignUrn}), no record could be found with the specified placeholder (${data.placeholderUrn}).`); + } + await AdCampaign.updateOne({linkedinCampaignUrn: data.placeholderUrn}).set({ + linkedinCampaignUrn: data.linkedinCampaignUrn + }); + // Zap: https://zapier.com/editor/281086063 + } else if (eventName === 'receive-new-customer-data') { + assert(_.isObject(data)); + assert(_.isString(data.newMarketingStage)); + assert(_.isString(data.name)); + assert(_.isString(data.website)); + assert(_.isString(data.linkedinCompanyPageUrl)); + assert(_.isString(data.persona) && AdCampaign.validate('persona', data.persona)); + + // Enrich to obtain linkedin company ID using provided data. + // Remove any trailing slashes from the LinkedIn URL. + let trailingSlashlessLinkedinCompanyUrl = _.trim(data.linkedinCompanyPageUrl, '/'); + // Split the LinkedIn url by slashes + let splitLinkedinCompanyUrl = trailingSlashlessLinkedinCompanyUrl.split('/'); + // Grab the last fragment of the URL, we'll use this for the coreSignal API request to + let linkedinCompanyIdOrSlug = splitLinkedinCompanyUrl[splitLinkedinCompanyUrl.length - 1]; + let matchedCompanyPageInfo = await sails.helpers.http.get('https://api.coresignal.com/cdapi/v1/linkedin/company/collect/'+linkedinCompanyIdOrSlug, {}, { + Authorization: `Bearer ${sails.config.custom.iqSecret}`, + 'content-type': 'application/json' + }).intercept((err)=>{ + sails.log.warn(`When the receive-from-zapier webhook received a request about a Salesforce record, a linkedin company could not be found using the provided linkedIn URL (${data.linkedinCompanyPageUrl})`, err); + return 'couldNotMatchLinkedinId'; + }); + + // (FUTURE: make field for this and have it already in CRM so this step isn't necessary) + let linkedinCompanyId = matchedCompanyPageInfo.id; + + // Check if we have enough space in our current active campaign. + // If so, then use it and update its inventory. Otherwise, prepare to create + // a new campaign, and use that instead, updating our set of active campaigns + // in the db, including marking the new one as the latest and greatest. Along the way, + // communicate with Campaign Manager to update or create the appropriate campaign. + let latestCampaign = await AdCampaign.findOne({ isLatest: true, persona: data.persona }); + if (latestCampaign && latestCampaign.linkedinCompanyIds.length < 100) { + // Update ad campaign in Campaign Manager + // > For help w/ Linkedin API, see https://github.com/fleetdm/confidential/tree/main/ads + let filterCriteriaForLatestCampaign = latestCampaign.linkedinCompanyIds.map((id)=>{ + return `urn:li:organization:${id}`; + }); + await sails.helpers.http.sendHttpRequest.with({ + method: 'POST', + url: `https://hooks.zapier.com/hooks/catch/3627242/2wdx23r?webhookSecret=${ encodeURIComponent(sails.config.custom.zapierWebhookSecret)}`, + body: { + campaignGroup: sails.config.custom.linkedinAbmCampaignGroupUrn, + name: latestCampaign.name, + linkedinCampaignUrn: latestCampaign.linkedinCampaignUrn, + targetingCriteria: filterCriteriaForLatestCampaign, + } + }).retry(); + + await AdCampaign.updateOne({ id: latestCampaign.id }).set({ + linkedinCompanyIds: _.uniq(latestCampaign.linkedinCompanyIds.concat(linkedinCompanyId)) + }); + } else { + + // First, mark the old campaign as no longer the latest. + // Note: Since we might not have done the first-time setup for + // this persona yet, it's possible there won't actually be a + // campaign record yet. (In that case, we'll create it momentarily.) + if (latestCampaign) { + await AdCampaign.updateOne({ id: latestCampaign.id }).set({ + isLatest: false, + }); + }//fi + + // Create a placeholder linkedinCampaignUrn value to create the record with initially + // We'll use this value in a subsequent webhook run that will save update the record with the real linkedinCampaignUrn (once it has been created). + // Note: there is a possibility that a new campaign can't be created with only one linkedInCompanyID, (There is a minimum audience size of 300) + // In this case, we will treat this new campaign as the latest campaign in the website's database, and send updates for it as new company IDs are added. + // When the campaign actually exists in LinkedIn, Zapier will send another event to update the campaign urn in the website's database. + let placeholderUrn = 'PLACEHOLDER-'+sails.helpers.strings.random(); + let nowAt = new Date(); + let newCampaignName = `${data.persona} - ${nowAt.toISOString().trim('T')[0]} @ ${nowAt.toLocaleString().split(', ')[1]}`; + // Now save an incomplete reference to the new LinkedIn campaign. + latestCampaign = await AdCampaign.create({ + isLatest: true, + persona: data.persona, + name: newCampaignName, + linkedinCampaignUrn: placeholderUrn, + linkedinCompanyIds: [ linkedinCompanyId ], + }).fetch(); + + // Then create new ad campaign in Campaign Manager + // > For help w/ Linkedin API, see https://github.com/fleetdm/confidential/tree/main/ads + await sails.helpers.http.sendHttpRequest.with({ + method: 'POST', + url: `https://hooks.zapier.com/hooks/catch/3627242/2wdx23r?webhookSecret=${ encodeURIComponent(sails.config.custom.zapierWebhookSecret)}`, + body: { + campaignGroup: sails.config.custom.linkedinAbmCampaignGroupUrn, + name: newCampaignName, + targetingCriteria: [`urn:li:organization:${linkedinCompanyId}`], + linkedinCampaignUrn: placeholderUrn, + }, + }).retry(); + } + } else { + throw 'unrecognizedEventName'; + } + + } + + +}; diff --git a/website/api/models/AdCampaign.js b/website/api/models/AdCampaign.js new file mode 100644 index 0000000000..6edfd4796e --- /dev/null +++ b/website/api/models/AdCampaign.js @@ -0,0 +1,65 @@ +/** + * AdCampaign.js + * + * @description :: A model definition represents a database table/collection. + * @docs :: https://sailsjs.com/docs/concepts/models-and-orm/models + */ + +module.exports = { + + attributes: { + + // ╔═╗╦═╗╦╔╦╗╦╔╦╗╦╦ ╦╔═╗╔═╗ + // ╠═╝╠╦╝║║║║║ ║ ║╚╗╔╝║╣ ╚═╗ + // ╩ ╩╚═╩╩ ╩╩ ╩ ╩ ╚╝ ╚═╝╚═╝ + persona: { + type: 'string', + isIn: [ + 'elf.it-major-mdm', + // 'elf.it-gap-filler-mdm', + // 'elf.it-misc', + // 'elf.security-vm', + // 'elf.security-misc', + // 'santa.it-major-mdm', + // 'santa.it-gap-filler-mdm', + // 'santa.it-misc', + // 'santa.security-vm', + // 'santa.security-misc', + ], + required: true + }, + + name: { + type: 'string', + example: 'elf.it-major-mdm - 2024-02-24 @ 6:11pm', + required: true, + }, + + linkedinCampaignUrn: { + type: 'string', + example: 'urn:li:sponsoredCampaign:379399199', + required: true + }, + + isLatest: { + type: 'boolean', + description: 'Whether this is the latest and greatest campaign for this persona.', + }, + + // ╔═╗╔╦╗╔╗ ╔═╗╔╦╗╔═╗ + // ║╣ ║║║╠╩╗║╣ ║║╚═╗ + // ╚═╝╩ ╩╚═╝╚═╝═╩╝╚═╝ + linkedinCompanyIds: { + type: 'json', + example: [ 8482494, 28328 ], + defaultsTo: [], + }, + + // ╔═╗╔═╗╔═╗╔═╗╔═╗╦╔═╗╔╦╗╦╔═╗╔╗╔╔═╗ + // ╠═╣╚═╗╚═╗║ ║║ ║╠═╣ ║ ║║ ║║║║╚═╗ + // ╩ ╩╚═╝╚═╝╚═╝╚═╝╩╩ ╩ ╩ ╩╚═╝╝╚╝╚═╝ + + }, + +}; + diff --git a/website/assets/.eslintrc b/website/assets/.eslintrc index b23198f97a..24f4a2ca1d 100644 --- a/website/assets/.eslintrc +++ b/website/assets/.eslintrc @@ -65,6 +65,7 @@ "VantaConnection": false, "CertificateSigningRequest": false, "Platform": false, + "AdCampaign": false, // ...and any other backend globals (e.g. `"Organization": false`) // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - } diff --git a/website/config/custom.js b/website/config/custom.js index b9d76f3f22..2f67a6c9b8 100644 --- a/website/config/custom.js +++ b/website/config/custom.js @@ -382,6 +382,9 @@ module.exports.custom = { 'qq.com', ], + // Zapier: + // zapierWebhookSecret: '…', + // Contact form: // slackWebhookUrlForContactForm: '…', diff --git a/website/config/routes.js b/website/config/routes.js index 504c5f613e..e07facd0f5 100644 --- a/website/config/routes.js +++ b/website/config/routes.js @@ -914,6 +914,7 @@ module.exports.routes = { 'POST /api/v1/webhooks/receive-usage-analytics': { action: 'webhooks/receive-usage-analytics', csrf: false }, '/api/v1/webhooks/github': { action: 'webhooks/receive-from-github', csrf: false }, 'POST /api/v1/webhooks/receive-from-stripe': { action: 'webhooks/receive-from-stripe', csrf: false }, + 'POST /api/v1/webhooks/receive-from-zapier': { action: 'webhooks/receive-from-zapier', csrf: false }, 'POST /api/v1/get-est-device-certificate': { action: 'get-est-device-certificate', csrf: false}, // ╔═╗╔═╗╦ ╔═╗╔╗╔╔╦╗╔═╗╔═╗╦╔╗╔╔╦╗╔═╗ From 7a95f59f4a2522dc5dda81e1cf70399f65277aff Mon Sep 17 00:00:00 2001 From: Sarah Gillespie <73313222+gillespi314@users.noreply.github.com> Date: Wed, 26 Feb 2025 12:03:56 -0600 Subject: [PATCH 13/13] Update dashboard, manage hosts, and host details UI for Android MDM feature (#26577) --- .../PlatformCompatibility.tsx | 42 ++++++++----- frontend/components/icons/Android.tsx | 30 +++++++++ frontend/components/icons/index.ts | 2 + frontend/interfaces/platform.ts | 38 +++++++++--- frontend/interfaces/script.ts | 2 +- .../pages/DashboardPage/DashboardPage.tsx | 26 +++++++- frontend/pages/DashboardPage/helpers.ts | 19 ++---- .../MetricsHostCounts/MetricsHostCounts.tsx | 3 +- .../PlatformHostCounts/PlatformHostCounts.tsx | 36 +++++++++++ .../OSUpdates/OSUpdates.tsx | 23 ++++--- .../components/PlatformTabs/PlatformTabs.tsx | 38 ++++++++++-- .../components/PlatformTabs/_styles.scss | 6 ++ .../TargetSection/TargetSection.tsx | 41 +++++++------ .../hosts/ManageHostsPage/HostTableConfig.tsx | 61 ++++++++----------- .../DeleteHostModal/DeleteHostModal.tsx | 6 +- .../HostActionsDropdown/helpers.tsx | 26 +++++--- .../HostDetailsPage/HostDetailsPage.tsx | 11 ++-- .../pages/hosts/details/cards/About/About.tsx | 16 +++-- .../details/cards/HostSummary/HostSummary.tsx | 45 ++++++++------ .../details/cards/Policies/HostPolicies.tsx | 15 +++++ .../details/cards/Queries/HostQueries.tsx | 15 +++++ .../details/cards/Software/HostSoftware.tsx | 11 ++-- .../HostSoftwareTable/HostSoftwareTable.tsx | 18 ++++++ frontend/router/index.tsx | 1 + frontend/router/paths.ts | 1 + frontend/utilities/constants.tsx | 30 ++++----- 26 files changed, 388 insertions(+), 174 deletions(-) create mode 100644 frontend/components/icons/Android.tsx diff --git a/frontend/components/PlatformCompatibility/PlatformCompatibility.tsx b/frontend/components/PlatformCompatibility/PlatformCompatibility.tsx index 05ae3619a3..25cbe7f4ea 100644 --- a/frontend/components/PlatformCompatibility/PlatformCompatibility.tsx +++ b/frontend/components/PlatformCompatibility/PlatformCompatibility.tsx @@ -48,6 +48,31 @@ const displayIncompatibilityText = (err: Error) => { } }; +// const tipContent = ( +// <> +// Estimated compatibility based on the
+// tables used in the query. Querying
+// iPhones, iPads, and Android hosts is not
+// supported. +// +// ); + +// TODO(android): replace with the above tipContent when Android feature flag is removed +const tipContent = ( + <> + Estimated compatibility based on the
+ tables used in the query. Check the
+ table documentation (schema) to verify
+ compatibility of individual columns. +
+
+ Only live queries are supported on ChromeOS. +
+
+ Querying iPhones & iPads is not supported. + +); + const PlatformCompatibility = ({ compatiblePlatforms, error, @@ -84,22 +109,7 @@ const PlatformCompatibility = ({ return (
- - Estimated compatibility based on the
- tables used in the query. Check the
- table documentation (schema) to verify
- compatibility of individual columns. -
-
- Only live queries are supported on ChromeOS. -
-
- Querying iPhones & iPads is not supported. - - } - > + Compatible with:
diff --git a/frontend/components/icons/Android.tsx b/frontend/components/icons/Android.tsx new file mode 100644 index 0000000000..6ca60464e4 --- /dev/null +++ b/frontend/components/icons/Android.tsx @@ -0,0 +1,30 @@ +import React from "react"; +import { COLORS, Colors } from "styles/var/colors"; +import { ICON_SIZES, IconSizes } from "styles/var/icon_sizes"; + +const Android = ({ + size = "medium", + color = "ui-fleet-black-75", +}: { + size: IconSizes; + color?: Colors; +}) => { + return ( + + + + ); +}; + +export default Android; diff --git a/frontend/components/icons/index.ts b/frontend/components/icons/index.ts index 810998b63e..754e683c9a 100644 --- a/frontend/components/icons/index.ts +++ b/frontend/components/icons/index.ts @@ -69,6 +69,7 @@ import AutomaticSelfService from "./AutomaticSelfService"; import User from "./User"; import InfoOutline from "./InfoOutline"; import GitOpsMode from "./GitOpsMode"; +import Android from "./Android"; // a mapping of the usable names of icons to the icon source. export const ICON_MAP = { @@ -133,6 +134,7 @@ export const ICON_MAP = { iPadOS, ios: iOS, iOS, + android: Android, "premium-feature": PremiumFeature, profile: Profile, download: Download, diff --git a/frontend/interfaces/platform.ts b/frontend/interfaces/platform.ts index 9fd2d4502c..753cfc41b8 100644 --- a/frontend/interfaces/platform.ts +++ b/frontend/interfaces/platform.ts @@ -11,24 +11,27 @@ export const PLATFORM_DISPLAY_NAMES = { windows: "Windows", linux: "Linux", chrome: "ChromeOS", + android: "Android", ...APPLE_PLATFORM_DISPLAY_NAMES, } as const; +export const QUERYABLE_PLATFORMS = [ + "darwin", + "windows", + "linux", + "chrome", +] as const; + +export const NON_QUERYABLE_PLATFORMS = ["ios", "ipados", "android"] as const; + export type Platform = keyof typeof PLATFORM_DISPLAY_NAMES; export type DisplayPlatform = typeof PLATFORM_DISPLAY_NAMES[keyof typeof PLATFORM_DISPLAY_NAMES]; export type QueryableDisplayPlatform = Exclude< DisplayPlatform, - "iOS" | "iPadOS" + typeof PLATFORM_DISPLAY_NAMES[typeof NON_QUERYABLE_PLATFORMS[number]] >; -export type QueryablePlatform = Exclude; - -export const QUERYABLE_PLATFORMS: QueryablePlatform[] = [ - "darwin", - "windows", - "linux", - "chrome", -]; +export type QueryablePlatform = typeof QUERYABLE_PLATFORMS[number]; export const isQueryablePlatform = ( platform: string | undefined @@ -111,7 +114,8 @@ export type HostPlatform = | typeof HOST_LINUX_PLATFORMS[number] | typeof HOST_APPLE_PLATFORMS[number] | "windows" - | "chrome"; + | "chrome" + | "android"; /** * Checks if the provided platform is a Linux-like OS. We can recieve many @@ -133,6 +137,14 @@ export const isAppleDevice = (platform: string) => { export const isIPadOrIPhone = (platform: string | HostPlatform) => ["ios", "ipados"].includes(platform); +export const isAndroid = ( + platform: string | HostPlatform +): platform is "android" => platform === "android"; + +/** isMobilePlatform checks if the platform is an iPad or iPhone or Android. */ +export const isMobilePlatform = (platform: string | HostPlatform) => + isIPadOrIPhone(platform) || isAndroid(platform); + export const DISK_ENCRYPTION_SUPPORTED_LINUX_PLATFORMS = [ "ubuntu", // covers Kubuntu "rhel", // *included here to support Fedora systems. Necessary to cross-check with `os_versions` as well to confrim host is Fedora and not another, non-support rhel-like platform. @@ -161,6 +173,9 @@ export const platformSupportsDiskEncryption = ( /** os_version necessary to differentiate Fedora from other rhel-like platforms */ os_version?: string ) => { + if (isAndroid(platform)) { + return false; + } if (platform === "rhel") { return !!os_version && os_version.toLowerCase().includes("fedora"); } @@ -179,6 +194,9 @@ export const isOsSettingsDisplayPlatform = ( platform: HostPlatform, os_version: string ) => { + if (isAndroid(platform)) { + return false; + } if (platform === "rhel") { return !!os_version && os_version.toLowerCase().includes("fedora"); } diff --git a/frontend/interfaces/script.ts b/frontend/interfaces/script.ts index 15497dcc90..7bbdd4dc21 100644 --- a/frontend/interfaces/script.ts +++ b/frontend/interfaces/script.ts @@ -9,7 +9,7 @@ export interface IScript { } export const isScriptSupportedPlatform = (hostPlatform: string) => - ["darwin", "windows", ...HOST_LINUX_PLATFORMS].includes(hostPlatform); // excludes chrome, ios, ipados see also https://github.com/fleetdm/fleet/blob/5a21e2cfb029053ddad0508869eb9f1f23997bf2/server/fleet/hosts.go#L775 + ["darwin", "windows", ...HOST_LINUX_PLATFORMS].includes(hostPlatform); // excludes chrome, ios, ipados, android see also https://github.com/fleetdm/fleet/blob/5a21e2cfb029053ddad0508869eb9f1f23997bf2/server/fleet/hosts.go#L775 export type IScriptExecutionStatus = "ran" | "pending" | "error"; diff --git a/frontend/pages/DashboardPage/DashboardPage.tsx b/frontend/pages/DashboardPage/DashboardPage.tsx index 8a1489250c..505a13c9c5 100644 --- a/frontend/pages/DashboardPage/DashboardPage.tsx +++ b/frontend/pages/DashboardPage/DashboardPage.tsx @@ -137,6 +137,7 @@ const DashboardPage = ({ router, location }: IDashboardProps): JSX.Element => { const [chromeCount, setChromeCount] = useState(0); const [iosCount, setIosCount] = useState(0); const [ipadosCount, setIpadosCount] = useState(0); + const [androidCount, setAndroidCount] = useState(0); const [missingCount, setMissingCount] = useState(0); const [lowDiskSpaceCount, setLowDiskSpaceCount] = useState(0); const [showActivityFeedTitle, setShowActivityFeedTitle] = useState(false); @@ -191,6 +192,14 @@ const DashboardPage = ({ router, location }: IDashboardProps): JSX.Element => { IConfig >(["config"], () => configAPI.loadAll(), { ...DEFAULT_USE_QUERY_OPTIONS }); + // TODO(android): remove this when the feature flag is removed + const platformOptions = useMemo(() => { + if (!config?.android_enabled) { + return PLATFORM_DROPDOWN_OPTIONS.filter((o) => o.value !== "android"); + } + return [...PLATFORM_DROPDOWN_OPTIONS]; + }, [config?.android_enabled]); + const { data: teams, isLoading: isLoadingTeams } = useQuery< ILoadTeamsResponse, Error, @@ -242,12 +251,17 @@ const DashboardPage = ({ router, location }: IDashboardProps): JSX.Element => { (platform: IHostSummaryPlatforms) => platform.platform === "ipados" ) || { platform: "ipados", hosts_count: 0 }; + const android = data.platforms?.find( + (platform: IHostSummaryPlatforms) => platform.platform === "android" + ) || { platform: "android", hosts_count: 0 }; + setMacCount(macHosts.hosts_count); setWindowsCount(windowsHosts.hosts_count); setLinuxCount(data.all_linux_count); setChromeCount(chromebooks.hosts_count); setIosCount(iphones.hosts_count); setIpadosCount(ipads.hosts_count); + setAndroidCount(android.hosts_count); setShowHostsUI(true); }, } @@ -540,6 +554,7 @@ const DashboardPage = ({ router, location }: IDashboardProps): JSX.Element => { const HostCountCards = ( <> { chromeCount={chromeCount} iosCount={iosCount} ipadosCount={ipadosCount} + androidCount={androidCount} builtInLabels={labels} selectedPlatform={selectedPlatform} errorHosts={!!errorHosts} @@ -764,6 +780,12 @@ const DashboardPage = ({ router, location }: IDashboardProps): JSX.Element => { ); + const androidLayout = () => ( + <> + {showMdmCard &&
{MDMCard}
} + + ); + const renderCards = () => { switch (selectedPlatform) { case "darwin": @@ -778,6 +800,8 @@ const DashboardPage = ({ router, location }: IDashboardProps): JSX.Element => { return iosLayout(); case "ipados": return ipadosLayout(); + case "android": + return androidLayout(); default: return allLayout(); } @@ -861,7 +885,7 @@ const DashboardPage = ({ router, location }: IDashboardProps): JSX.Element => { name="platform-filter" value={selectedPlatform || ""} className={`${baseClass}__platform-filter`} - options={PLATFORM_DROPDOWN_OPTIONS} + options={platformOptions} onChange={(option: SingleValue) => { const selectedPlatformOption = PLATFORM_DROPDOWN_OPTIONS.find( (platform) => platform.value === option?.value diff --git a/frontend/pages/DashboardPage/helpers.ts b/frontend/pages/DashboardPage/helpers.ts index 34d5f96dda..e01d3336f7 100644 --- a/frontend/pages/DashboardPage/helpers.ts +++ b/frontend/pages/DashboardPage/helpers.ts @@ -1,18 +1,7 @@ import paths from "router/paths"; -import { - PlatformLabelOptions, - PlatformValueOptions, -} from "utilities/constants"; - -interface IPlatformDropdownOptions { - label: PlatformLabelOptions; - value: PlatformValueOptions; - path: string; -} - /** Select platform */ -export const PLATFORM_DROPDOWN_OPTIONS: IPlatformDropdownOptions[] = [ +export const PLATFORM_DROPDOWN_OPTIONS = [ { label: "All", value: "all", path: paths.DASHBOARD }, { label: "macOS", value: "darwin", path: paths.DASHBOARD_MAC }, { label: "Windows", value: "windows", path: paths.DASHBOARD_WINDOWS }, @@ -20,7 +9,8 @@ export const PLATFORM_DROPDOWN_OPTIONS: IPlatformDropdownOptions[] = [ { label: "ChromeOS", value: "chrome", path: paths.DASHBOARD_CHROME }, { label: "iOS", value: "ios", path: paths.DASHBOARD_IOS }, { label: "iPadOS", value: "ipados", path: paths.DASHBOARD_IPADOS }, -]; + { label: "Android", value: "android", path: paths.DASHBOARD_ANDROID }, +] as const; /** Selected platform value mapped to built in label name */ export const PLATFORM_NAME_TO_LABEL_NAME = { @@ -30,7 +20,8 @@ export const PLATFORM_NAME_TO_LABEL_NAME = { chrome: "chrome", ios: "iOS", ipados: "iPadOS", -}; + android: "Android", +} as const; /** Premium feature, Gb must be set between 1-100 */ export const LOW_DISK_SPACE_GB = 32; diff --git a/frontend/pages/DashboardPage/sections/MetricsHostCounts/MetricsHostCounts.tsx b/frontend/pages/DashboardPage/sections/MetricsHostCounts/MetricsHostCounts.tsx index a44b3f9427..90d77e3562 100644 --- a/frontend/pages/DashboardPage/sections/MetricsHostCounts/MetricsHostCounts.tsx +++ b/frontend/pages/DashboardPage/sections/MetricsHostCounts/MetricsHostCounts.tsx @@ -66,7 +66,8 @@ const MetricsHostCounts = ({ {selectedPlatform === "all" && TotalHostsCard} {isPremiumTier && selectedPlatform !== "ios" && - selectedPlatform !== "ipados" && ( + selectedPlatform !== "ipados" && + selectedPlatform !== "android" && ( <> {MissingHostsCard} {LowDiskSpaceHostsCard} diff --git a/frontend/pages/DashboardPage/sections/PlatformHostCounts/PlatformHostCounts.tsx b/frontend/pages/DashboardPage/sections/PlatformHostCounts/PlatformHostCounts.tsx index 4e4133c9c4..a9676b3a1f 100644 --- a/frontend/pages/DashboardPage/sections/PlatformHostCounts/PlatformHostCounts.tsx +++ b/frontend/pages/DashboardPage/sections/PlatformHostCounts/PlatformHostCounts.tsx @@ -12,6 +12,7 @@ import HostCountCard from "../../cards/HostCountCard"; const baseClass = "platform-host-counts"; interface IPlatformHostCountsProps { + androidDevEnabled: boolean; // TODO(android): remove when feature flag is removed currentTeamId: number | undefined; macCount: number; windowsCount: number; @@ -19,6 +20,7 @@ interface IPlatformHostCountsProps { chromeCount: number; iosCount: number; ipadosCount: number; + androidCount: number; builtInLabels?: IHostSummary["builtin_labels"]; errorHosts: boolean; selectedPlatform?: PlatformValueOptions; @@ -26,6 +28,7 @@ interface IPlatformHostCountsProps { } const PlatformHostCounts = ({ + androidDevEnabled, currentTeamId, macCount, windowsCount, @@ -33,6 +36,7 @@ const PlatformHostCounts = ({ chromeCount, iosCount, ipadosCount, + androidCount, builtInLabels, errorHosts, selectedPlatform, @@ -187,6 +191,34 @@ const PlatformHostCounts = ({ ); }; + const renderAndroidCount = (teamId?: number) => { + if (!androidDevEnabled) { + // TODO(android): remove when feature flag is removed + return null; + } + + const androidLabelId = getBuiltinLabelId("android"); + + if (hidePlatformCard(androidCount)) { + return null; + } + + if (androidLabelId === undefined) { + return <>; + } + + return ( + + ); + }; + const renderCounts = (teamId?: number) => { switch (selectedPlatform) { case "darwin": @@ -201,7 +233,10 @@ const PlatformHostCounts = ({ return renderIosCount(teamId); case "ipados": return renderIpadosCount(teamId); + case "android": + return renderAndroidCount(teamId); default: + // TODO(android): responsive layout with variable column widths (see figma for 2x2x3 grid) return ( <> {renderMacCard(teamId)} @@ -210,6 +245,7 @@ const PlatformHostCounts = ({ {renderChromeCard(teamId)} {renderIosCount(teamId)} {renderIpadosCount(teamId)} + {renderAndroidCount(teamId)} ); } diff --git a/frontend/pages/ManageControlsPage/OSUpdates/OSUpdates.tsx b/frontend/pages/ManageControlsPage/OSUpdates/OSUpdates.tsx index ebbda487a9..2306b79739 100644 --- a/frontend/pages/ManageControlsPage/OSUpdates/OSUpdates.tsx +++ b/frontend/pages/ManageControlsPage/OSUpdates/OSUpdates.tsx @@ -6,7 +6,7 @@ import { AppContext } from "context/app"; import { IConfig } from "interfaces/config"; import { ITeamConfig } from "interfaces/team"; -import { ApplePlatform } from "interfaces/platform"; +import { ApplePlatform, isAndroid } from "interfaces/platform"; import configAPI from "services/entities/config"; import teamsAPI, { ILoadTeamResponse } from "services/entities/teams"; @@ -22,11 +22,13 @@ import { parseOSUpdatesCurrentVersionsQueryParams } from "./components/CurrentVe export type OSUpdatesSupportedPlatform = ApplePlatform | "windows"; +export type OSUpdatesTargetPlatform = OSUpdatesSupportedPlatform | "android"; // used for displaying "coming soon" messaging + const baseClass = "os-updates"; -const getSelectedPlatform = ( +const getDefaultSelectedPlatform = ( appConfig: IConfig | null -): OSUpdatesSupportedPlatform => { +): OSUpdatesTargetPlatform => { // We dont have the data ready yet so we default to mac. // This is usually when the users first comes to this page. if (appConfig === null) return "darwin"; @@ -34,7 +36,7 @@ const getSelectedPlatform = ( // if the mac mdm is enable and configured we check the app config to see if // the mdm for mac is enabled. If it is, it does not matter if windows is // enabled and configured and we will always return "mac". - return appConfig.mdm.enabled_and_configured ? "darwin" : "windows"; + return appConfig.mdm.enabled_and_configured ? "darwin" : "windows"; // TODO(android): adjust this when android is supported }; interface IOSUpdates { @@ -49,7 +51,7 @@ const OSUpdates = ({ router, teamIdForApi, queryParams }: IOSUpdates) => { const [ selectedPlatformTab, setSelectedPlatformTab, - ] = useState(null); + ] = useState(null); const { isError: isErrorConfig, @@ -102,7 +104,8 @@ const OSUpdates = ({ router, teamIdForApi, queryParams }: IOSUpdates) => { // If the user has not selected a platform yet, we default to the platform that // is enabled and configured. - const selectedPlatform = selectedPlatformTab || getSelectedPlatform(config); + const selectedPlatform = + selectedPlatformTab || getDefaultSelectedPlatform(config); return (
@@ -131,9 +134,11 @@ const OSUpdates = ({ router, teamIdForApi, queryParams }: IOSUpdates) => { refetchTeamConfig={refetchTeamConfig} />
-
- -
+ {!isAndroid(selectedPlatform) && ( +
+ +
+ )}
); diff --git a/frontend/pages/ManageControlsPage/OSUpdates/components/PlatformTabs/PlatformTabs.tsx b/frontend/pages/ManageControlsPage/OSUpdates/components/PlatformTabs/PlatformTabs.tsx index df5be12011..d69a9ec838 100644 --- a/frontend/pages/ManageControlsPage/OSUpdates/components/PlatformTabs/PlatformTabs.tsx +++ b/frontend/pages/ManageControlsPage/OSUpdates/components/PlatformTabs/PlatformTabs.tsx @@ -1,9 +1,11 @@ import React from "react"; import { Tab, TabList, TabPanel, Tabs } from "react-tabs"; import TabsWrapper from "components/TabsWrapper"; +import CustomLink from "components/CustomLink"; +import { SUPPORT_LINK } from "utilities/constants"; import WindowsTargetForm from "../WindowsTargetForm"; -import { OSUpdatesSupportedPlatform } from "../../OSUpdates"; +import { OSUpdatesTargetPlatform } from "../../OSUpdates"; import AppleOSTargetForm from "../AppleOSTargetForm"; const baseClass = "platform-tabs"; @@ -18,11 +20,12 @@ interface IPlatformTabsProps { defaultIPadOSDeadline: string; defaultWindowsDeadlineDays: string; defaultWindowsGracePeriodDays: string; - selectedPlatform: OSUpdatesSupportedPlatform; - onSelectPlatform: (platform: OSUpdatesSupportedPlatform) => void; + selectedPlatform: OSUpdatesTargetPlatform; + onSelectPlatform: (platform: OSUpdatesTargetPlatform) => void; refetchAppConfig: () => void; refetchTeamConfig: () => void; isWindowsMdmEnabled: boolean; + isAndroidMdmEnabled: boolean; } const PlatformTabs = ({ @@ -40,23 +43,28 @@ const PlatformTabs = ({ refetchAppConfig, refetchTeamConfig, isWindowsMdmEnabled, + isAndroidMdmEnabled, }: IPlatformTabsProps) => { // FIXME: This behaves unexpectedly when a user switches tabs or changes the teams dropdown while a form is // submitting. - const PLATFORM_BY_INDEX: OSUpdatesSupportedPlatform[] = isWindowsMdmEnabled + const platformByIndex: OSUpdatesTargetPlatform[] = isWindowsMdmEnabled ? ["darwin", "windows", "ios", "ipados"] : ["darwin", "ios", "ipados"]; + if (isAndroidMdmEnabled) { + platformByIndex.push("android"); + } + const onTabChange = (index: number) => { - onSelectPlatform(PLATFORM_BY_INDEX[index]); + onSelectPlatform(platformByIndex[index]); }; return (
@@ -76,6 +84,11 @@ const PlatformTabs = ({ iPadOS + {isAndroidMdmEnabled && ( + + Android + + )} + {isAndroidMdmEnabled && ( + +
+

+ Android updates are coming soon. +

+

+ Need to encourage installation of Android updates?{" "} + +

+
+
+ )}
diff --git a/frontend/pages/ManageControlsPage/OSUpdates/components/PlatformTabs/_styles.scss b/frontend/pages/ManageControlsPage/OSUpdates/components/PlatformTabs/_styles.scss index 2cf0a6f4ab..d38e98c5f2 100644 --- a/frontend/pages/ManageControlsPage/OSUpdates/components/PlatformTabs/_styles.scss +++ b/frontend/pages/ManageControlsPage/OSUpdates/components/PlatformTabs/_styles.scss @@ -2,4 +2,10 @@ .react-tabs__tab-list { margin-bottom: $pad-large; } + &__coming-soon { + p { + margin: 0; + padding-bottom: $pad-small; + } + } } diff --git a/frontend/pages/ManageControlsPage/OSUpdates/components/TargetSection/TargetSection.tsx b/frontend/pages/ManageControlsPage/OSUpdates/components/TargetSection/TargetSection.tsx index b0bb07e3c1..f4704e5de0 100644 --- a/frontend/pages/ManageControlsPage/OSUpdates/components/TargetSection/TargetSection.tsx +++ b/frontend/pages/ManageControlsPage/OSUpdates/components/TargetSection/TargetSection.tsx @@ -9,7 +9,7 @@ import Spinner from "components/Spinner"; import WindowsTargetForm from "../WindowsTargetForm"; import PlatformTabs from "../PlatformTabs"; -import { OSUpdatesSupportedPlatform } from "../../OSUpdates"; +import { OSUpdatesTargetPlatform } from "../../OSUpdates"; const baseClass = "os-updates-target-section"; @@ -86,9 +86,9 @@ interface ITargetSectionProps { appConfig: IConfig; currentTeamId: number; isFetching: boolean; - selectedPlatform: OSUpdatesSupportedPlatform; + selectedPlatform: OSUpdatesTargetPlatform; teamConfig?: ITeamConfig; - onSelectPlatform: (platform: OSUpdatesSupportedPlatform) => void; + onSelectPlatform: (platform: OSUpdatesTargetPlatform) => void; refetchAppConfig: () => void; refetchTeamConfig: () => void; } @@ -107,6 +107,8 @@ const TargetSection = ({ return ; } + const isAndroidMdmEnabled = appConfig.mdm.android_enabled_and_configured; + const isAppleMdmEnabled = appConfig.mdm.enabled_and_configured; const isWindowsMdmEnabled = appConfig.mdm.windows_enabled_and_configured; @@ -161,33 +163,34 @@ const TargetSection = ({ }); const renderTargetForms = () => { - if (isAppleMdmEnabled) { + if (isWindowsMdmEnabled && !isAppleMdmEnabled && !isAndroidMdmEnabled) { return ( - ); } return ( - ); }; diff --git a/frontend/pages/hosts/ManageHostsPage/HostTableConfig.tsx b/frontend/pages/hosts/ManageHostsPage/HostTableConfig.tsx index f5837eed38..633901fff4 100644 --- a/frontend/pages/hosts/ManageHostsPage/HostTableConfig.tsx +++ b/frontend/pages/hosts/ManageHostsPage/HostTableConfig.tsx @@ -6,6 +6,8 @@ import { CellProps, Column } from "react-table"; import ReactTooltip from "react-tooltip"; import { IDeviceUser, IHost } from "interfaces/host"; +import { isAndroid, isMobilePlatform } from "interfaces/platform"; + import Checkbox from "components/forms/fields/Checkbox"; import DiskSpaceIndicator from "pages/hosts/components/DiskSpaceIndicator"; import HeaderCell from "components/TableContainer/DataTable/HeaderCell/HeaderCell"; @@ -225,10 +227,7 @@ const allHostTableHeaders: IHostTableColumnConfig[] = [ accessor: "status", id: "status", Cell: (cellProps: IHostTableStringCellProps) => { - if ( - cellProps.row.original.platform === "ios" || - cellProps.row.original.platform === "ipados" - ) { + if (isMobilePlatform(cellProps.row.original.platform)) { return NotSupported; } const value = cellProps.cell.value; @@ -247,10 +246,7 @@ const allHostTableHeaders: IHostTableColumnConfig[] = [ id: "issues", sortDescFirst: true, Cell: (cellProps: IIssuesCellProps) => { - if ( - cellProps.row.original.platform === "ios" || - cellProps.row.original.platform === "ipados" - ) { + if (isMobilePlatform(cellProps.row.original.platform)) { return NotSupported; } return ( @@ -302,6 +298,7 @@ const allHostTableHeaders: IHostTableColumnConfig[] = [ accessor: "os_version", id: "os_version", Cell: (cellProps: IHostTableStringCellProps) => ( + // TODO(android): is Android supported? what about the os versions endpoint and dashboard card? ), }, @@ -316,10 +313,7 @@ const allHostTableHeaders: IHostTableColumnConfig[] = [ accessor: "osquery_version", id: "osquery_version", Cell: (cellProps: IHostTableStringCellProps) => { - if ( - cellProps.row.original.platform === "ios" || - cellProps.row.original.platform === "ipados" - ) { + if (isMobilePlatform(cellProps.row.original.platform)) { return NotSupported; } return ; @@ -332,6 +326,7 @@ const allHostTableHeaders: IHostTableColumnConfig[] = [ accessor: "device_mapping", id: "device_mapping", Cell: (cellProps: IDeviceUserCellProps) => { + // TODO(android): is android supported? const numUsers = cellProps.cell.value?.length || 0; const users = condenseDeviceUsers(cellProps.cell.value || []); if (users.length > 1) { @@ -364,10 +359,7 @@ const allHostTableHeaders: IHostTableColumnConfig[] = [ accessor: "primary_ip", id: "primary_ip", Cell: (cellProps: IHostTableStringCellProps) => { - if ( - cellProps.row.original.platform === "ios" || - cellProps.row.original.platform === "ipados" - ) { + if (isMobilePlatform(cellProps.row.original.platform)) { return NotSupported; } return ; @@ -442,10 +434,7 @@ const allHostTableHeaders: IHostTableColumnConfig[] = [ accessor: "public_ip", id: "public_ip", Cell: (cellProps: IHostTableStringCellProps) => { - if ( - cellProps.row.original.platform === "ios" || - cellProps.row.original.platform === "ipados" - ) { + if (isMobilePlatform(cellProps.row.original.platform)) { return NotSupported; } return ( @@ -478,6 +467,7 @@ const allHostTableHeaders: IHostTableColumnConfig[] = [ accessor: "detail_updated_at", id: "detail_updated_at", Cell: (cellProps: IHostTableStringCellProps) => ( + // TODO(android): android doesn't support refetch? { - if ( - cellProps.row.original.platform === "ios" || - cellProps.row.original.platform === "ipados" - ) { + if (isMobilePlatform(cellProps.row.original.platform)) { return NotSupported; } return ( @@ -546,11 +533,7 @@ const allHostTableHeaders: IHostTableColumnConfig[] = [ Cell: (cellProps: IHostTableStringCellProps) => { const { platform, last_restarted_at } = cellProps.row.original; - if ( - platform === "ios" || - platform === "ipados" || - platform === "chrome" - ) { + if (isMobilePlatform(platform) || platform === "chrome") { return NotSupported; } return ( @@ -608,9 +591,13 @@ const allHostTableHeaders: IHostTableColumnConfig[] = [ ), accessor: "primary_mac", id: "primary_mac", - Cell: (cellProps: IHostTableStringCellProps) => ( - - ), + Cell: (cellProps: IHostTableStringCellProps) => { + // TODO(android): is iOS/iPadOS supported? + if (isAndroid(cellProps.row.original.platform)) { + return NotSupported; + } + return ; + }, }, { title: "Serial number", @@ -622,9 +609,13 @@ const allHostTableHeaders: IHostTableColumnConfig[] = [ ), accessor: "hardware_serial", id: "hardware_serial", - Cell: (cellProps: IHostTableStringCellProps) => ( - - ), + Cell: (cellProps: IHostTableStringCellProps) => { + // TODO(android): is iOS/iPadOS supported? + if (isAndroid(cellProps.row.original.platform)) { + return NotSupported; + } + return ; + }, }, { title: "Hardware model", diff --git a/frontend/pages/hosts/components/DeleteHostModal/DeleteHostModal.tsx b/frontend/pages/hosts/components/DeleteHostModal/DeleteHostModal.tsx index 8f11661fa6..45a9356301 100644 --- a/frontend/pages/hosts/components/DeleteHostModal/DeleteHostModal.tsx +++ b/frontend/pages/hosts/components/DeleteHostModal/DeleteHostModal.tsx @@ -77,7 +77,11 @@ const DeleteHostModal = ({ newTab /> -
  • iOS and iPadOS hosts will re-appear unless MDM is turned off.
  • +
  • + {/* TODO(android): iOS, iPadOS, and Android hosts will re-appear unless MDM is turned + off. */} + iOS and iPadOS hosts will re-appear unless MDM is turned off. +