diff --git a/cmd/fleetctl/fleetctl/testdata/expectedGetConfigAppConfigJson.json b/cmd/fleetctl/fleetctl/testdata/expectedGetConfigAppConfigJson.json index 87c98a88faf..0872553d831 100644 --- a/cmd/fleetctl/fleetctl/testdata/expectedGetConfigAppConfigJson.json +++ b/cmd/fleetctl/fleetctl/testdata/expectedGetConfigAppConfigJson.json @@ -163,7 +163,9 @@ "software": null, "manual_agent_install": null, "require_all_software_macos": false, - "require_all_software_windows": false + "require_all_software_windows": false, + "enable_managed_local_account": false, + "end_user_local_account_type": "" }, "setup_experience": { "macos_bootstrap_package": null, @@ -175,7 +177,9 @@ "software": null, "macos_manual_agent_install": null, "require_all_software_macos": false, - "require_all_software_windows": false + "require_all_software_windows": false, + "enable_managed_local_account": false, + "end_user_local_account_type": "" }, "windows_settings": { "custom_settings": null, diff --git a/cmd/fleetctl/fleetctl/testdata/expectedGetConfigAppConfigTeamMaintainerJson.json b/cmd/fleetctl/fleetctl/testdata/expectedGetConfigAppConfigTeamMaintainerJson.json index 8b542ea98ad..be45782bfe5 100644 --- a/cmd/fleetctl/fleetctl/testdata/expectedGetConfigAppConfigTeamMaintainerJson.json +++ b/cmd/fleetctl/fleetctl/testdata/expectedGetConfigAppConfigTeamMaintainerJson.json @@ -135,7 +135,9 @@ "software": null, "manual_agent_install": null, "require_all_software_macos": false, - "require_all_software_windows": false + "require_all_software_windows": false, + "enable_managed_local_account": false, + "end_user_local_account_type": "" }, "setup_experience": { "macos_bootstrap_package": null, @@ -147,7 +149,9 @@ "software": null, "macos_manual_agent_install": null, "require_all_software_macos": false, - "require_all_software_windows": false + "require_all_software_windows": false, + "enable_managed_local_account": false, + "end_user_local_account_type": "" }, "windows_settings": { "custom_settings": null, diff --git a/cmd/fleetctl/fleetctl/testdata/expectedGetConfigAppConfigTeamMaintainerYaml.yml b/cmd/fleetctl/fleetctl/testdata/expectedGetConfigAppConfigTeamMaintainerYaml.yml index e8c575aade8..cedc8a9462f 100644 --- a/cmd/fleetctl/fleetctl/testdata/expectedGetConfigAppConfigTeamMaintainerYaml.yml +++ b/cmd/fleetctl/fleetctl/testdata/expectedGetConfigAppConfigTeamMaintainerYaml.yml @@ -75,6 +75,8 @@ spec: manual_agent_install: require_all_software_macos: false require_all_software_windows: false + enable_managed_local_account: false + end_user_local_account_type: "" script: software: setup_experience: @@ -86,6 +88,8 @@ spec: macos_manual_agent_install: require_all_software_macos: false require_all_software_windows: false + enable_managed_local_account: false + end_user_local_account_type: "" macos_script: software: windows_settings: diff --git a/cmd/fleetctl/fleetctl/testdata/expectedGetConfigAppConfigYaml.yml b/cmd/fleetctl/fleetctl/testdata/expectedGetConfigAppConfigYaml.yml index f213630f5d2..97af3eeabd4 100644 --- a/cmd/fleetctl/fleetctl/testdata/expectedGetConfigAppConfigYaml.yml +++ b/cmd/fleetctl/fleetctl/testdata/expectedGetConfigAppConfigYaml.yml @@ -75,6 +75,8 @@ spec: manual_agent_install: require_all_software_macos: false require_all_software_windows: false + enable_managed_local_account: false + end_user_local_account_type: "" script: software: setup_experience: @@ -86,6 +88,8 @@ spec: macos_manual_agent_install: require_all_software_macos: false require_all_software_windows: false + enable_managed_local_account: false + end_user_local_account_type: "" macos_script: software: windows_settings: diff --git a/cmd/fleetctl/fleetctl/testdata/expectedGetConfigIncludeServerConfigJson.json b/cmd/fleetctl/fleetctl/testdata/expectedGetConfigIncludeServerConfigJson.json index b9eeaab2c99..61ae23080b4 100644 --- a/cmd/fleetctl/fleetctl/testdata/expectedGetConfigIncludeServerConfigJson.json +++ b/cmd/fleetctl/fleetctl/testdata/expectedGetConfigIncludeServerConfigJson.json @@ -113,7 +113,9 @@ "software": null, "manual_agent_install": null, "require_all_software_macos": false, - "require_all_software_windows": false + "require_all_software_windows": false, + "enable_managed_local_account": false, + "end_user_local_account_type": "" }, "setup_experience": { "macos_bootstrap_package": null, @@ -125,7 +127,9 @@ "software": null, "macos_manual_agent_install": null, "require_all_software_macos": false, - "require_all_software_windows": false + "require_all_software_windows": false, + "enable_managed_local_account": false, + "end_user_local_account_type": "" }, "windows_settings": { "custom_settings": null, diff --git a/cmd/fleetctl/fleetctl/testdata/expectedGetConfigIncludeServerConfigYaml.yml b/cmd/fleetctl/fleetctl/testdata/expectedGetConfigIncludeServerConfigYaml.yml index e97e583db66..66379b72065 100644 --- a/cmd/fleetctl/fleetctl/testdata/expectedGetConfigIncludeServerConfigYaml.yml +++ b/cmd/fleetctl/fleetctl/testdata/expectedGetConfigIncludeServerConfigYaml.yml @@ -75,6 +75,8 @@ spec: manual_agent_install: require_all_software_macos: false require_all_software_windows: false + enable_managed_local_account: false + end_user_local_account_type: "" script: software: setup_experience: @@ -86,6 +88,8 @@ spec: macos_manual_agent_install: require_all_software_macos: false require_all_software_windows: false + enable_managed_local_account: false + end_user_local_account_type: "" macos_script: software: windows_settings: diff --git a/cmd/fleetctl/fleetctl/testdata/expectedGetTeamsJson.json b/cmd/fleetctl/fleetctl/testdata/expectedGetTeamsJson.json index 333658d36c7..463126a731a 100644 --- a/cmd/fleetctl/fleetctl/testdata/expectedGetTeamsJson.json +++ b/cmd/fleetctl/fleetctl/testdata/expectedGetTeamsJson.json @@ -50,6 +50,8 @@ "apple_enable_release_device_manually": false, "apple_setup_assistant": null, "enable_end_user_authentication": false, + "enable_managed_local_account": false, + "end_user_local_account_type": "", "lock_end_user_info": false, "macos_bootstrap_package": null, "macos_manual_agent_install": null, @@ -122,7 +124,9 @@ "macos_setup": { "bootstrap_package": null, "enable_end_user_authentication": false, + "enable_managed_local_account": false, "enable_release_device_manually": false, + "end_user_local_account_type": "", "lock_end_user_info": false, "macos_setup_assistant": null, "manual_agent_install": null, @@ -227,6 +231,8 @@ "apple_enable_release_device_manually": false, "apple_setup_assistant": null, "enable_end_user_authentication": false, + "enable_managed_local_account": false, + "end_user_local_account_type": "", "lock_end_user_info": false, "macos_bootstrap_package": null, "macos_manual_agent_install": null, @@ -314,7 +320,9 @@ "macos_setup": { "bootstrap_package": null, "enable_end_user_authentication": false, + "enable_managed_local_account": false, "enable_release_device_manually": false, + "end_user_local_account_type": "", "lock_end_user_info": false, "macos_setup_assistant": null, "manual_agent_install": null, diff --git a/cmd/fleetctl/fleetctl/testdata/expectedGetTeamsYaml.yml b/cmd/fleetctl/fleetctl/testdata/expectedGetTeamsYaml.yml index a472e8e5e13..74fa0aeb9f2 100644 --- a/cmd/fleetctl/fleetctl/testdata/expectedGetTeamsYaml.yml +++ b/cmd/fleetctl/fleetctl/testdata/expectedGetTeamsYaml.yml @@ -47,6 +47,8 @@ spec: macos_manual_agent_install: require_all_software_macos: false require_all_software_windows: false + enable_managed_local_account: false + end_user_local_account_type: "" macos_script: software: scripts: null @@ -100,6 +102,8 @@ spec: manual_agent_install: require_all_software_macos: false require_all_software_windows: false + enable_managed_local_account: false + end_user_local_account_type: "" script: software: scripts: null @@ -160,6 +164,8 @@ spec: macos_manual_agent_install: require_all_software_macos: false require_all_software_windows: false + enable_managed_local_account: false + end_user_local_account_type: "" macos_script: software: scripts: null @@ -214,6 +220,8 @@ spec: manual_agent_install: require_all_software_macos: false require_all_software_windows: false + enable_managed_local_account: false + end_user_local_account_type: "" script: software: scripts: null diff --git a/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedAppConfigEmpty.yml b/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedAppConfigEmpty.yml index c9906fd8754..21f68d36edc 100644 --- a/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedAppConfigEmpty.yml +++ b/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedAppConfigEmpty.yml @@ -66,6 +66,8 @@ spec: manual_agent_install: null require_all_software_macos: false require_all_software_windows: false + enable_managed_local_account: false + end_user_local_account_type: "" enable_release_device_manually: false script: null software: null @@ -77,6 +79,8 @@ spec: macos_manual_agent_install: null require_all_software_macos: false require_all_software_windows: false + enable_managed_local_account: false + end_user_local_account_type: "" apple_enable_release_device_manually: false macos_script: null software: null diff --git a/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml b/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml index 4c54455d085..5b33fcdbff8 100644 --- a/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml +++ b/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml @@ -66,6 +66,8 @@ spec: manual_agent_install: null require_all_software_macos: false require_all_software_windows: false + enable_managed_local_account: false + end_user_local_account_type: "" enable_release_device_manually: false script: null software: null @@ -77,6 +79,8 @@ spec: macos_manual_agent_install: null require_all_software_macos: false require_all_software_windows: false + enable_managed_local_account: false + end_user_local_account_type: "" apple_enable_release_device_manually: false macos_script: null software: null diff --git a/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedTeam1And2Empty.yml b/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedTeam1And2Empty.yml index 7b3d1032d5b..6b764dac116 100644 --- a/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedTeam1And2Empty.yml +++ b/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedTeam1And2Empty.yml @@ -31,6 +31,8 @@ spec: macos_manual_agent_install: null require_all_software_macos: false require_all_software_windows: false + enable_managed_local_account: false + end_user_local_account_type: "" apple_enable_release_device_manually: false macos_script: null software: null @@ -84,6 +86,8 @@ spec: manual_agent_install: null require_all_software_macos: false require_all_software_windows: false + enable_managed_local_account: false + end_user_local_account_type: "" enable_release_device_manually: false script: null software: null diff --git a/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedTeam1And2Set.yml b/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedTeam1And2Set.yml index a86a21c4582..824ff166785 100644 --- a/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedTeam1And2Set.yml +++ b/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedTeam1And2Set.yml @@ -32,6 +32,8 @@ spec: apple_enable_release_device_manually: false require_all_software_macos: false require_all_software_windows: false + enable_managed_local_account: false + end_user_local_account_type: "" macos_script: null software: null macos_updates: @@ -85,6 +87,8 @@ spec: enable_release_device_manually: false require_all_software_macos: false require_all_software_windows: false + enable_managed_local_account: false + end_user_local_account_type: "" script: null software: null macos_updates: diff --git a/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedTeam1Empty.yml b/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedTeam1Empty.yml index 903eb3ff3af..7b5f7d3fd2f 100644 --- a/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedTeam1Empty.yml +++ b/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedTeam1Empty.yml @@ -29,6 +29,8 @@ spec: macos_manual_agent_install: null require_all_software_macos: false require_all_software_windows: false + enable_managed_local_account: false + end_user_local_account_type: "" apple_enable_release_device_manually: false macos_script: null software: null @@ -82,6 +84,8 @@ spec: manual_agent_install: null require_all_software_macos: false require_all_software_windows: false + enable_managed_local_account: false + end_user_local_account_type: "" enable_release_device_manually: false script: null software: null diff --git a/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedTeam1Set.yml b/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedTeam1Set.yml index e88d3a338a7..1fa24ef266e 100644 --- a/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedTeam1Set.yml +++ b/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedTeam1Set.yml @@ -30,6 +30,8 @@ spec: macos_manual_agent_install: null require_all_software_macos: false require_all_software_windows: false + enable_managed_local_account: false + end_user_local_account_type: "" apple_enable_release_device_manually: false macos_script: null software: null @@ -83,6 +85,8 @@ spec: manual_agent_install: null require_all_software_macos: false require_all_software_windows: false + enable_managed_local_account: false + end_user_local_account_type: "" enable_release_device_manually: false script: null software: null diff --git a/server/datastore/mysql/managed_local_account.go b/server/datastore/mysql/managed_local_account.go new file mode 100644 index 00000000000..2254446e5ab --- /dev/null +++ b/server/datastore/mysql/managed_local_account.go @@ -0,0 +1,108 @@ +package mysql + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/jmoiron/sqlx" +) + +const managedLocalAccountUsername = "_fleetadmin" + +func (ds *Datastore) SaveHostManagedLocalAccount(ctx context.Context, hostUUID, plaintextPassword, commandUUID string) error { + encrypted, err := encrypt([]byte(plaintextPassword), ds.serverPrivateKey) + if err != nil { + return ctxerr.Wrap(ctx, err, "encrypting managed local account password") + } + + const stmt = ` + INSERT INTO host_managed_local_account_passwords + (host_uuid, encrypted_password, command_uuid) + VALUES (?, ?, ?) + ON DUPLICATE KEY UPDATE + encrypted_password = VALUES(encrypted_password), + command_uuid = VALUES(command_uuid), + status = NULL + ` + if _, err := ds.writer(ctx).ExecContext(ctx, stmt, hostUUID, encrypted, commandUUID); err != nil { + return ctxerr.Wrap(ctx, err, "save host managed local account") + } + return nil +} + +func (ds *Datastore) GetHostManagedLocalAccountPassword(ctx context.Context, hostUUID string) (*fleet.HostManagedLocalAccountPassword, error) { + const stmt = `SELECT encrypted_password, updated_at FROM host_managed_local_account_passwords WHERE host_uuid = ?` + + var row struct { + EncryptedPassword []byte `db:"encrypted_password"` + UpdatedAt time.Time `db:"updated_at"` + } + if err := sqlx.GetContext(ctx, ds.reader(ctx), &row, stmt, hostUUID); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ctxerr.Wrap(ctx, notFound("HostManagedLocalAccountPassword"). + WithMessage(fmt.Sprintf("for host %s", hostUUID))) + } + return nil, ctxerr.Wrap(ctx, err, "getting managed local account password") + } + + decrypted, err := decrypt(row.EncryptedPassword, ds.serverPrivateKey) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "decrypting managed local account password") + } + + return &fleet.HostManagedLocalAccountPassword{ + Username: managedLocalAccountUsername, + Password: string(decrypted), + UpdatedAt: row.UpdatedAt, + }, nil +} + +func (ds *Datastore) GetHostManagedLocalAccountStatus(ctx context.Context, hostUUID string) (*fleet.HostMDMManagedLocalAccount, error) { + const stmt = `SELECT status FROM host_managed_local_account_passwords WHERE host_uuid = ?` + + var dbStatus *string + if err := sqlx.GetContext(ctx, ds.reader(ctx), &dbStatus, stmt, hostUUID); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ctxerr.Wrap(ctx, notFound("HostManagedLocalAccount"). + WithMessage(fmt.Sprintf("for host %s", hostUUID))) + } + return nil, ctxerr.Wrap(ctx, err, "getting managed local account status") + } + + // NULL in DB means the command is pending (not yet acknowledged). + status := "pending" + if dbStatus != nil { + status = *dbStatus + } + return &fleet.HostMDMManagedLocalAccount{ + Status: &status, + PasswordAvailable: status == string(fleet.MDMDeliveryVerified), + }, nil +} + +func (ds *Datastore) SetHostManagedLocalAccountStatus(ctx context.Context, hostUUID string, status fleet.MDMDeliveryStatus) error { + const stmt = `UPDATE host_managed_local_account_passwords SET status = ? WHERE host_uuid = ?` + if _, err := ds.writer(ctx).ExecContext(ctx, stmt, status, hostUUID); err != nil { + return ctxerr.Wrap(ctx, err, "set managed local account status") + } + return nil +} + +func (ds *Datastore) GetManagedLocalAccountByCommandUUID(ctx context.Context, commandUUID string) (string, error) { + const stmt = `SELECT host_uuid FROM host_managed_local_account_passwords WHERE command_uuid = ?` + + var hostUUID string + if err := sqlx.GetContext(ctx, ds.reader(ctx), &hostUUID, stmt, commandUUID); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return "", ctxerr.Wrap(ctx, notFound("ManagedLocalAccount"). + WithMessage(fmt.Sprintf("for command %s", commandUUID))) + } + return "", ctxerr.Wrap(ctx, err, "getting managed local account by command uuid") + } + return hostUUID, nil +} diff --git a/server/datastore/mysql/managed_local_account_test.go b/server/datastore/mysql/managed_local_account_test.go new file mode 100644 index 00000000000..b082dcf6e67 --- /dev/null +++ b/server/datastore/mysql/managed_local_account_test.go @@ -0,0 +1,150 @@ +package mysql + +import ( + "testing" + + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestManagedLocalAccount(t *testing.T) { + ds := CreateMySQLDS(t) + + cases := []struct { + name string + fn func(t *testing.T, ds *Datastore) + }{ + {"SaveAndGetPassword", testManagedLocalAccountSaveAndGetPassword}, + {"GetStatus", testManagedLocalAccountGetStatus}, + {"SetStatus", testManagedLocalAccountSetStatus}, + {"GetByCommandUUID", testManagedLocalAccountGetByCommandUUID}, + {"UpsertOverwrites", testManagedLocalAccountUpsertOverwrites}, + {"NotFound", testManagedLocalAccountNotFound}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + c.fn(t, ds) + }) + } +} + +func testManagedLocalAccountSaveAndGetPassword(t *testing.T, ds *Datastore) { + ctx := t.Context() + hostUUID := "host-uuid-1" + password := "TEST-PASS-WORD1" + cmdUUID := "cmd-uuid-1" + + err := ds.SaveHostManagedLocalAccount(ctx, hostUUID, password, cmdUUID) + require.NoError(t, err) + + got, err := ds.GetHostManagedLocalAccountPassword(ctx, hostUUID) + require.NoError(t, err) + assert.Equal(t, "_fleetadmin", got.Username) + assert.Equal(t, password, got.Password) + assert.False(t, got.UpdatedAt.IsZero()) +} + +func testManagedLocalAccountGetStatus(t *testing.T, ds *Datastore) { + ctx := t.Context() + hostUUID := "host-uuid-status" + err := ds.SaveHostManagedLocalAccount(ctx, hostUUID, "pass", "cmd-status") + require.NoError(t, err) + + // Initially status is NULL in DB → should return "pending". + status, err := ds.GetHostManagedLocalAccountStatus(ctx, hostUUID) + require.NoError(t, err) + require.NotNil(t, status.Status) + assert.Equal(t, "pending", *status.Status) + assert.False(t, status.PasswordAvailable) + + // After setting to verified, password should be available. + err = ds.SetHostManagedLocalAccountStatus(ctx, hostUUID, fleet.MDMDeliveryVerified) + require.NoError(t, err) + + status, err = ds.GetHostManagedLocalAccountStatus(ctx, hostUUID) + require.NoError(t, err) + require.NotNil(t, status.Status) + assert.Equal(t, string(fleet.MDMDeliveryVerified), *status.Status) + assert.True(t, status.PasswordAvailable) +} + +func testManagedLocalAccountSetStatus(t *testing.T, ds *Datastore) { + ctx := t.Context() + hostUUID := "host-uuid-set-status" + err := ds.SaveHostManagedLocalAccount(ctx, hostUUID, "pass", "cmd-set-status") + require.NoError(t, err) + + // Set to failed. + err = ds.SetHostManagedLocalAccountStatus(ctx, hostUUID, fleet.MDMDeliveryFailed) + require.NoError(t, err) + + status, err := ds.GetHostManagedLocalAccountStatus(ctx, hostUUID) + require.NoError(t, err) + require.NotNil(t, status.Status) + assert.Equal(t, string(fleet.MDMDeliveryFailed), *status.Status) + assert.False(t, status.PasswordAvailable) +} + +func testManagedLocalAccountGetByCommandUUID(t *testing.T, ds *Datastore) { + ctx := t.Context() + hostUUID := "host-uuid-cmd" + cmdUUID := "cmd-uuid-lookup" + err := ds.SaveHostManagedLocalAccount(ctx, hostUUID, "pass", cmdUUID) + require.NoError(t, err) + + got, err := ds.GetManagedLocalAccountByCommandUUID(ctx, cmdUUID) + require.NoError(t, err) + assert.Equal(t, hostUUID, got) +} + +func testManagedLocalAccountUpsertOverwrites(t *testing.T, ds *Datastore) { + ctx := t.Context() + hostUUID := "host-uuid-upsert" + + // First save. + err := ds.SaveHostManagedLocalAccount(ctx, hostUUID, "old-pass", "cmd-old") + require.NoError(t, err) + err = ds.SetHostManagedLocalAccountStatus(ctx, hostUUID, fleet.MDMDeliveryVerified) + require.NoError(t, err) + + // Upsert with new password and command UUID should reset status to NULL (pending). + err = ds.SaveHostManagedLocalAccount(ctx, hostUUID, "new-pass", "cmd-new") + require.NoError(t, err) + + got, err := ds.GetHostManagedLocalAccountPassword(ctx, hostUUID) + require.NoError(t, err) + assert.Equal(t, "new-pass", got.Password) + + status, err := ds.GetHostManagedLocalAccountStatus(ctx, hostUUID) + require.NoError(t, err) + require.NotNil(t, status.Status) + assert.Equal(t, "pending", *status.Status) + + // Command UUID should be the new one. + foundHost, err := ds.GetManagedLocalAccountByCommandUUID(ctx, "cmd-new") + require.NoError(t, err) + assert.Equal(t, hostUUID, foundHost) + + // Old command UUID should no longer match. + _, err = ds.GetManagedLocalAccountByCommandUUID(ctx, "cmd-old") + require.Error(t, err) + assert.True(t, fleet.IsNotFound(err)) +} + +func testManagedLocalAccountNotFound(t *testing.T, ds *Datastore) { + ctx := t.Context() + + _, err := ds.GetHostManagedLocalAccountPassword(ctx, "nonexistent") + require.Error(t, err) + assert.True(t, fleet.IsNotFound(err)) + + _, err = ds.GetHostManagedLocalAccountStatus(ctx, "nonexistent") + require.Error(t, err) + assert.True(t, fleet.IsNotFound(err)) + + _, err = ds.GetManagedLocalAccountByCommandUUID(ctx, "nonexistent") + require.Error(t, err) + assert.True(t, fleet.IsNotFound(err)) +} diff --git a/server/datastore/mysql/schema.sql b/server/datastore/mysql/schema.sql index 46bbf7760a7..177f71e6278 100644 --- a/server/datastore/mysql/schema.sql +++ b/server/datastore/mysql/schema.sql @@ -221,7 +221,7 @@ CREATE TABLE `app_config_json` ( PRIMARY KEY (`id`) ) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; -INSERT INTO `app_config_json` VALUES (1,'{\"mdm\": {\"ios_updates\": {\"deadline\": null, \"minimum_version\": null, \"update_new_hosts\": null}, \"macos_setup\": {\"script\": null, \"software\": null, \"bootstrap_package\": null, \"lock_end_user_info\": false, \"manual_agent_install\": null, \"macos_setup_assistant\": null, \"require_all_software_macos\": false, \"require_all_software_windows\": false, \"enable_end_user_authentication\": false, \"enable_release_device_manually\": false}, \"macos_updates\": {\"deadline\": null, \"minimum_version\": null, \"update_new_hosts\": false}, \"ipados_updates\": {\"deadline\": null, \"minimum_version\": null, \"update_new_hosts\": null}, \"macos_settings\": {\"custom_settings\": null}, \"macos_migration\": {\"mode\": \"\", \"enable\": false, \"webhook_url\": \"\"}, \"windows_updates\": {\"deadline_days\": null, \"grace_period_days\": null}, \"android_settings\": {\"certificates\": null, \"custom_settings\": null}, \"apple_server_url\": \"\", \"windows_settings\": {\"custom_settings\": null}, \"apple_bm_terms_expired\": false, \"apple_business_manager\": null, \"enable_disk_encryption\": false, \"enabled_and_configured\": false, \"end_user_authentication\": {\"idp_name\": \"\", \"metadata\": \"\", \"entity_id\": \"\", \"issuer_uri\": \"\", \"metadata_url\": \"\"}, \"windows_entra_tenant_ids\": [], \"volume_purchasing_program\": null, \"windows_migration_enabled\": false, \"enable_recovery_lock_password\": false, \"windows_require_bitlocker_pin\": null, \"android_enabled_and_configured\": false, \"windows_enabled_and_configured\": false, \"apple_bm_enabled_and_configured\": false, \"apple_require_hardware_attestation\": false, \"enable_turn_on_windows_mdm_manually\": false}, \"gitops\": {\"exceptions\": {\"labels\": true, \"secrets\": true, \"software\": false}, \"repository_url\": \"\", \"gitops_mode_enabled\": false}, \"scripts\": null, \"features\": {\"enable_host_users\": true, \"enable_software_inventory\": false}, \"org_info\": {\"org_name\": \"\", \"contact_url\": \"\", \"org_logo_url\": \"\", \"org_logo_url_light_background\": \"\"}, \"integrations\": {\"jira\": null, \"zendesk\": null, \"google_calendar\": null, \"conditional_access_enabled\": null}, \"sso_settings\": {\"idp_name\": \"\", \"metadata\": \"\", \"entity_id\": \"\", \"enable_sso\": false, \"issuer_uri\": \"\", \"metadata_url\": \"\", \"idp_image_url\": \"\", \"sso_server_url\": \"\", \"enable_jit_role_sync\": false, \"enable_sso_idp_login\": false, \"enable_jit_provisioning\": false}, \"agent_options\": {\"config\": {\"options\": {\"logger_plugin\": \"tls\", \"pack_delimiter\": \"/\", \"logger_tls_period\": 10, \"distributed_plugin\": \"tls\", \"disable_distributed\": false, \"logger_tls_endpoint\": \"/api/osquery/log\", \"distributed_interval\": 10, \"distributed_tls_max_attempts\": 3}, \"decorators\": {\"load\": [\"SELECT uuid AS host_uuid FROM system_info;\", \"SELECT hostname AS hostname FROM system_info;\"]}}, \"overrides\": {}}, \"fleet_desktop\": {\"transparency_url\": \"\", \"alternative_browser_host\": \"\"}, \"smtp_settings\": {\"port\": 587, \"domain\": \"\", \"server\": \"\", \"password\": \"\", \"user_name\": \"\", \"configured\": false, \"enable_smtp\": false, \"enable_ssl_tls\": true, \"sender_address\": \"\", \"enable_start_tls\": true, \"verify_ssl_certs\": true, \"authentication_type\": \"0\", \"authentication_method\": \"0\"}, \"server_settings\": {\"server_url\": \"\", \"enable_analytics\": false, \"query_report_cap\": 0, \"scripts_disabled\": false, \"deferred_save_host\": false, \"live_query_disabled\": false, \"ai_features_disabled\": false, \"query_reports_disabled\": false}, \"webhook_settings\": {\"interval\": \"0s\", \"activities_webhook\": {\"destination_url\": \"\", \"enable_activities_webhook\": false}, \"host_status_webhook\": {\"days_count\": 0, \"destination_url\": \"\", \"host_percentage\": 0, \"enable_host_status_webhook\": false}, \"vulnerabilities_webhook\": {\"destination_url\": \"\", \"host_batch_size\": 0, \"enable_vulnerabilities_webhook\": false}, \"failing_policies_webhook\": {\"policy_ids\": null, \"destination_url\": \"\", \"host_batch_size\": 0, \"enable_failing_policies_webhook\": false}}, \"host_expiry_settings\": {\"host_expiry_window\": 0, \"host_expiry_enabled\": false}, \"vulnerability_settings\": {\"databases_path\": \"\"}, \"activity_expiry_settings\": {\"activity_expiry_window\": 0, \"activity_expiry_enabled\": false}}','2020-01-01 01:01:01','2020-01-01 01:01:01'); +INSERT INTO `app_config_json` VALUES (1,'{\"mdm\": {\"ios_updates\": {\"deadline\": null, \"minimum_version\": null, \"update_new_hosts\": null}, \"macos_setup\": {\"script\": null, \"software\": null, \"bootstrap_package\": null, \"lock_end_user_info\": false, \"manual_agent_install\": null, \"macos_setup_assistant\": null, \"require_all_software_macos\": false, \"end_user_local_account_type\": \"\", \"enable_managed_local_account\": false, \"require_all_software_windows\": false, \"enable_end_user_authentication\": false, \"enable_release_device_manually\": false}, \"macos_updates\": {\"deadline\": null, \"minimum_version\": null, \"update_new_hosts\": false}, \"ipados_updates\": {\"deadline\": null, \"minimum_version\": null, \"update_new_hosts\": null}, \"macos_settings\": {\"custom_settings\": null}, \"macos_migration\": {\"mode\": \"\", \"enable\": false, \"webhook_url\": \"\"}, \"windows_updates\": {\"deadline_days\": null, \"grace_period_days\": null}, \"android_settings\": {\"certificates\": null, \"custom_settings\": null}, \"apple_server_url\": \"\", \"windows_settings\": {\"custom_settings\": null}, \"apple_bm_terms_expired\": false, \"apple_business_manager\": null, \"enable_disk_encryption\": false, \"enabled_and_configured\": false, \"end_user_authentication\": {\"idp_name\": \"\", \"metadata\": \"\", \"entity_id\": \"\", \"issuer_uri\": \"\", \"metadata_url\": \"\"}, \"windows_entra_tenant_ids\": [], \"volume_purchasing_program\": null, \"windows_migration_enabled\": false, \"enable_recovery_lock_password\": false, \"windows_require_bitlocker_pin\": null, \"android_enabled_and_configured\": false, \"windows_enabled_and_configured\": false, \"apple_bm_enabled_and_configured\": false, \"apple_require_hardware_attestation\": false, \"enable_turn_on_windows_mdm_manually\": false}, \"gitops\": {\"exceptions\": {\"labels\": true, \"secrets\": true, \"software\": false}, \"repository_url\": \"\", \"gitops_mode_enabled\": false}, \"scripts\": null, \"features\": {\"enable_host_users\": true, \"enable_software_inventory\": false}, \"org_info\": {\"org_name\": \"\", \"contact_url\": \"\", \"org_logo_url\": \"\", \"org_logo_url_light_background\": \"\"}, \"integrations\": {\"jira\": null, \"zendesk\": null, \"google_calendar\": null, \"conditional_access_enabled\": null}, \"sso_settings\": {\"idp_name\": \"\", \"metadata\": \"\", \"entity_id\": \"\", \"enable_sso\": false, \"issuer_uri\": \"\", \"metadata_url\": \"\", \"idp_image_url\": \"\", \"sso_server_url\": \"\", \"enable_jit_role_sync\": false, \"enable_sso_idp_login\": false, \"enable_jit_provisioning\": false}, \"agent_options\": {\"config\": {\"options\": {\"logger_plugin\": \"tls\", \"pack_delimiter\": \"/\", \"logger_tls_period\": 10, \"distributed_plugin\": \"tls\", \"disable_distributed\": false, \"logger_tls_endpoint\": \"/api/osquery/log\", \"distributed_interval\": 10, \"distributed_tls_max_attempts\": 3}, \"decorators\": {\"load\": [\"SELECT uuid AS host_uuid FROM system_info;\", \"SELECT hostname AS hostname FROM system_info;\"]}}, \"overrides\": {}}, \"fleet_desktop\": {\"transparency_url\": \"\", \"alternative_browser_host\": \"\"}, \"smtp_settings\": {\"port\": 587, \"domain\": \"\", \"server\": \"\", \"password\": \"\", \"user_name\": \"\", \"configured\": false, \"enable_smtp\": false, \"enable_ssl_tls\": true, \"sender_address\": \"\", \"enable_start_tls\": true, \"verify_ssl_certs\": true, \"authentication_type\": \"0\", \"authentication_method\": \"0\"}, \"server_settings\": {\"server_url\": \"\", \"enable_analytics\": false, \"query_report_cap\": 0, \"scripts_disabled\": false, \"deferred_save_host\": false, \"live_query_disabled\": false, \"ai_features_disabled\": false, \"query_reports_disabled\": false}, \"webhook_settings\": {\"interval\": \"0s\", \"activities_webhook\": {\"destination_url\": \"\", \"enable_activities_webhook\": false}, \"host_status_webhook\": {\"days_count\": 0, \"destination_url\": \"\", \"host_percentage\": 0, \"enable_host_status_webhook\": false}, \"vulnerabilities_webhook\": {\"destination_url\": \"\", \"host_batch_size\": 0, \"enable_vulnerabilities_webhook\": false}, \"failing_policies_webhook\": {\"policy_ids\": null, \"destination_url\": \"\", \"host_batch_size\": 0, \"enable_failing_policies_webhook\": false}}, \"host_expiry_settings\": {\"host_expiry_window\": 0, \"host_expiry_enabled\": false}, \"vulnerability_settings\": {\"databases_path\": \"\"}, \"activity_expiry_settings\": {\"activity_expiry_window\": 0, \"activity_expiry_enabled\": false}}','2020-01-01 01:01:01','2020-01-01 01:01:01'); /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `batch_activities` ( diff --git a/server/fleet/activities.go b/server/fleet/activities.go index 38a7e62542d..6e8f0c534be 100644 --- a/server/fleet/activities.go +++ b/server/fleet/activities.go @@ -233,6 +233,11 @@ var ActivityDetailsList = []ActivityDetails{ ActivityTypeEditedHostIdpData{}, ActivityTypeEditedEnrollSecrets{}, + + ActivityTypeCreatedManagedLocalAccount{}, + ActivityTypeViewedManagedLocalAccount{}, + ActivityTypeEnabledManagedLocalAccount{}, + ActivityTypeDisabledManagedLocalAccount{}, } // ActivityDetails is an alias for the canonical ActivityDetails interface defined in server/activity/api. @@ -794,6 +799,54 @@ func (a ActivityTypeDisabledRecoveryLockPasswords) ActivityName() string { return "disabled_recovery_lock_passwords" } +type ActivityTypeCreatedManagedLocalAccount struct { + HostID uint `json:"host_id"` + HostDisplayName string `json:"host_display_name"` +} + +func (a ActivityTypeCreatedManagedLocalAccount) ActivityName() string { + return "created_managed_local_account" +} + +func (a ActivityTypeCreatedManagedLocalAccount) HostIDs() []uint { + return []uint{a.HostID} +} + +func (a ActivityTypeCreatedManagedLocalAccount) WasFromAutomation() bool { + return true +} + +type ActivityTypeViewedManagedLocalAccount struct { + HostID uint `json:"host_id"` + HostDisplayName string `json:"host_display_name"` +} + +func (a ActivityTypeViewedManagedLocalAccount) ActivityName() string { + return "viewed_managed_local_account" +} + +func (a ActivityTypeViewedManagedLocalAccount) HostIDs() []uint { + return []uint{a.HostID} +} + +type ActivityTypeEnabledManagedLocalAccount struct { + TeamID *uint `json:"team_id" renameto:"fleet_id"` + TeamName *string `json:"team_name" renameto:"fleet_name"` +} + +func (a ActivityTypeEnabledManagedLocalAccount) ActivityName() string { + return "enabled_managed_local_account" +} + +type ActivityTypeDisabledManagedLocalAccount struct { + TeamID *uint `json:"team_id" renameto:"fleet_id"` + TeamName *string `json:"team_name" renameto:"fleet_name"` +} + +func (a ActivityTypeDisabledManagedLocalAccount) ActivityName() string { + return "disabled_managed_local_account" +} + type ActivityTypeEnabledGitOpsMode struct{} func (a ActivityTypeEnabledGitOpsMode) ActivityName() string { diff --git a/server/fleet/app.go b/server/fleet/app.go index 2afc6a76fed..549beedaeaa 100644 --- a/server/fleet/app.go +++ b/server/fleet/app.go @@ -553,6 +553,8 @@ type MacOSSetup struct { ManualAgentInstall optjson.Bool `json:"manual_agent_install" renameto:"macos_manual_agent_install"` RequireAllSoftware bool `json:"require_all_software_macos"` RequireAllSoftwareWindows bool `json:"require_all_software_windows"` + EnableManagedLocalAccount bool `json:"enable_managed_local_account" renameto:"enable_create_local_admin_account"` + EndUserLocalAccountType string `json:"end_user_local_account_type"` } func (mos *MacOSSetup) Validate() error { @@ -592,6 +594,9 @@ func (mos *MacOSSetup) SetDefaultsIfNeeded() { if !mos.ManualAgentInstall.Valid { mos.ManualAgentInstall = optjson.SetBool(false) } + if mos.EndUserLocalAccountType == "" { + mos.EndUserLocalAccountType = "admin" + } } func NewMacOSSetupWithDefaults() *MacOSSetup { diff --git a/server/fleet/apple_mdm.go b/server/fleet/apple_mdm.go index 94228eb4671..595dc551a6a 100644 --- a/server/fleet/apple_mdm.go +++ b/server/fleet/apple_mdm.go @@ -522,13 +522,15 @@ func (p MDMAppleSettingsPayload) AuthzType() string { // MDMAppleSetupPayload describes the payload accepted by the endpoint to // update specific MDM macos setup values for a team (or no team). type MDMAppleSetupPayload struct { - TeamID *uint `json:"team_id" renameto:"fleet_id"` - EnableEndUserAuthentication *bool `json:"enable_end_user_authentication"` - EnableReleaseDeviceManually *bool `json:"enable_release_device_manually" renameto:"apple_enable_release_device_manually"` - ManualAgentInstall *bool `json:"manual_agent_install" renameto:"macos_manual_agent_install"` - RequireAllSoftware *bool `json:"require_all_software_macos"` - RequireAllSoftwareWindows *bool `json:"require_all_software_windows"` - LockEndUserInfo *bool `json:"lock_end_user_info"` + TeamID *uint `json:"team_id" renameto:"fleet_id"` + EnableEndUserAuthentication *bool `json:"enable_end_user_authentication"` + EnableReleaseDeviceManually *bool `json:"enable_release_device_manually" renameto:"apple_enable_release_device_manually"` + ManualAgentInstall *bool `json:"manual_agent_install" renameto:"macos_manual_agent_install"` + RequireAllSoftware *bool `json:"require_all_software_macos"` + RequireAllSoftwareWindows *bool `json:"require_all_software_windows"` + LockEndUserInfo *bool `json:"lock_end_user_info"` + EnableManagedLocalAccount *bool `json:"enable_managed_local_account"` + EndUserLocalAccountType *string `json:"end_user_local_account_type"` } // AuthzType implements authz.AuthzTyper. diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index a1ea05b1b7e..1c9c35b5569 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -1625,6 +1625,29 @@ type Datastore interface { // Limited to 100 hosts per batch. GetHostsForAutoRotation(ctx context.Context) ([]HostAutoRotationInfo, error) + /////////////////////////////////////////////////////////////////////////////// + // Managed local account + + // SaveHostManagedLocalAccount encrypts and stores the managed local account password + // for a host. Uses INSERT ... ON DUPLICATE KEY UPDATE. + SaveHostManagedLocalAccount(ctx context.Context, hostUUID, plaintextPassword, commandUUID string) error + + // GetHostManagedLocalAccountPassword retrieves and decrypts the managed local account + // password for the given host UUID. Returns notFoundError if no record exists. + GetHostManagedLocalAccountPassword(ctx context.Context, hostUUID string) (*HostManagedLocalAccountPassword, error) + + // GetHostManagedLocalAccountStatus returns the managed local account status for a host. + // Translates DB NULL status to "pending". Returns notFoundError if no record exists. + GetHostManagedLocalAccountStatus(ctx context.Context, hostUUID string) (*HostMDMManagedLocalAccount, error) + + // SetHostManagedLocalAccountStatus updates the status of the managed local account for a host. + SetHostManagedLocalAccountStatus(ctx context.Context, hostUUID string, status MDMDeliveryStatus) error + + // GetManagedLocalAccountByCommandUUID looks up the host UUID associated with a managed + // local account command UUID. Returns notFoundError if no matching record (i.e. SSO-only + // AccountConfiguration). + GetManagedLocalAccountByCommandUUID(ctx context.Context, commandUUID string) (hostUUID string, err error) + // InsertMDMAppleBootstrapPackage insterts a new bootstrap package in the // database (or S3 if configured). InsertMDMAppleBootstrapPackage(ctx context.Context, bp *MDMAppleBootstrapPackage, pkgStore MDMBootstrapPackageStore) error diff --git a/server/fleet/hosts.go b/server/fleet/hosts.go index c40344a73ce..053e7bc6083 100644 --- a/server/fleet/hosts.go +++ b/server/fleet/hosts.go @@ -603,6 +603,7 @@ type MDMHostData struct { type HostMDMOSSettings struct { DiskEncryption HostMDMDiskEncryption `json:"disk_encryption" db:"-" csv:"-"` RecoveryLockPassword HostMDMRecoveryLockPassword `json:"recovery_lock_password" db:"-" csv:"-"` + ManagedLocalAccount HostMDMManagedLocalAccount `json:"managed_local_account" db:"-" csv:"-"` } type HostMDMDiskEncryption struct { @@ -619,6 +620,19 @@ type HostMDMRecoveryLockPassword struct { operationType MDMOperationType `json:"-" db:"-" csv:"-"` } +// HostMDMManagedLocalAccount represents the managed local account status for a host. +type HostMDMManagedLocalAccount struct { + Status *string `json:"status" db:"-" csv:"-"` // nil (no record), "pending", "verified", "failed" + PasswordAvailable bool `json:"password_available" db:"-" csv:"-"` // true only when status is "verified" +} + +// HostManagedLocalAccountPassword is the API response for the managed local account password. +type HostManagedLocalAccountPassword struct { + Username string `json:"username"` + Password string `json:"password"` + UpdatedAt time.Time `json:"updated_at"` +} + // RecoveryLockStatus represents the status of recovery lock password enforcement. type RecoveryLockStatus string diff --git a/server/mdm/apple/commander.go b/server/mdm/apple/commander.go index eed349e0dca..d27cd415a85 100644 --- a/server/mdm/apple/commander.go +++ b/server/mdm/apple/commander.go @@ -332,7 +332,38 @@ func (svc *MDMAppleCommander) InstallEnterpriseApplicationWithEmbeddedManifest( return svc.EnqueueCommand(ctx, hostUUIDs, string(raw)) } -func (svc *MDMAppleCommander) AccountConfiguration(ctx context.Context, hostUUIDs []string, uuid, fullName, userName string, lockPrimaryAccountInfo bool) error { +// AdminAccount holds the parameters for an AutoSetupAdminAccounts entry in +// an AccountConfiguration MDM command. +type AdminAccount struct { + ShortName string // e.g. "_fleetadmin" + FullName string // e.g. "Fleet Admin" + PasswordHash []byte // SALTED-SHA512-PBKDF2 plist from GenerateSaltedSHA512PBKDF2Hash + Hidden bool // true → hidden from login window (UID ≤ 499) +} + +func (svc *MDMAppleCommander) AccountConfiguration(ctx context.Context, hostUUIDs []string, uuid, fullName, userName string, lockPrimaryAccountInfo bool, adminAccount *AdminAccount) error { + var autoSetupAdminAccounts string + if adminAccount != nil { + hiddenStr := "false" + if adminAccount.Hidden { + hiddenStr = "true" + } + autoSetupAdminAccounts = fmt.Sprintf(` + AutoSetupAdminAccounts + + + shortName + %s + fullName + %s + hidden + <%s /> + passwordHash + %s + + `, adminAccount.ShortName, adminAccount.FullName, hiddenStr, base64.StdEncoding.EncodeToString(adminAccount.PasswordHash)) + } + raw := fmt.Sprintf(` @@ -344,7 +375,7 @@ func (svc *MDMAppleCommander) AccountConfiguration(ctx context.Context, hostUUID PrimaryAccountUserName %s LockPrimaryAccountInfo - <%t /> + <%t />%s RequestType AccountConfiguration @@ -352,7 +383,7 @@ func (svc *MDMAppleCommander) AccountConfiguration(ctx context.Context, hostUUID CommandUUID %s -`, fullName, userName, lockPrimaryAccountInfo, uuid) +`, fullName, userName, lockPrimaryAccountInfo, autoSetupAdminAccounts, uuid) return svc.EnqueueCommand(ctx, hostUUIDs, raw) } diff --git a/server/mdm/apple/commander_test.go b/server/mdm/apple/commander_test.go index 22dcce2e3ce..496ab4c653c 100644 --- a/server/mdm/apple/commander_test.go +++ b/server/mdm/apple/commander_test.go @@ -674,6 +674,89 @@ func TestMDMAppleCommanderClearPasscode(t *testing.T) { require.True(t, mdmStorage.RetrievePushInfoFuncInvoked) } +func TestAccountConfigurationWithAdminAccount(t *testing.T) { + ctx := context.Background() + mdmStorage := &mdmmock.MDMAppleStore{} + pushFactory, _ := newMockAPNSPushProviderFactory() + pusher := nanomdm_pushsvc.New( + mdmStorage, + mdmStorage, + pushFactory, + stdlogfmt.New(), + ) + cmdr := NewMDMAppleCommander(mdmStorage, pusher) + + hostUUIDs := []string{"ABC"} + cmdUUID := uuid.New().String() + + mdmStorage.RetrievePushInfoFunc = func(ctx context.Context, targets []string) (map[string]*mdm.Push, error) { + pushes := make(map[string]*mdm.Push, len(targets)) + for _, uuid := range targets { + pushes[uuid] = &mdm.Push{ + PushMagic: "magic", + Token: []byte("token"), + Topic: "topic", + } + } + return pushes, nil + } + mdmStorage.RetrievePushCertFunc = func(ctx context.Context, topic string) (*tls.Certificate, string, error) { + cert, err := tls.LoadX509KeyPair("../../service/testdata/server.pem", "../../service/testdata/server.key") + return &cert, "", err + } + mdmStorage.IsPushCertStaleFunc = func(ctx context.Context, topic string, staleToken string) (bool, error) { + return false, nil + } + + t.Run("nil admin account produces standard plist", func(t *testing.T) { + mdmStorage.EnqueueCommandFunc = func(ctx context.Context, id []string, cmd *mdm.CommandWithSubtype) (map[string]error, error) { + raw := string(cmd.Raw) + require.Contains(t, raw, "AccountConfiguration") + require.Contains(t, raw, "PrimaryAccountFullName") + require.Contains(t, raw, "Test User") + require.Contains(t, raw, "PrimaryAccountUserName") + require.Contains(t, raw, "testuser") + require.NotContains(t, raw, "AutoSetupAdminAccounts") + return nil, nil + } + mdmStorage.EnqueueCommandFuncInvoked = false + + err := cmdr.AccountConfiguration(ctx, hostUUIDs, cmdUUID, "Test User", "testuser", true, nil) + require.NoError(t, err) + require.True(t, mdmStorage.EnqueueCommandFuncInvoked) + }) + + t.Run("admin account adds AutoSetupAdminAccounts", func(t *testing.T) { + admin := &AdminAccount{ + ShortName: "_fleetadmin", + FullName: "Fleet Admin", + PasswordHash: []byte("fake-hash-data"), + Hidden: true, + } + + mdmStorage.EnqueueCommandFunc = func(ctx context.Context, id []string, cmd *mdm.CommandWithSubtype) (map[string]error, error) { + raw := string(cmd.Raw) + require.Contains(t, raw, "AccountConfiguration") + require.Contains(t, raw, "PrimaryAccountFullName") + require.Contains(t, raw, "AutoSetupAdminAccounts") + require.Contains(t, raw, "shortName") + require.Contains(t, raw, "_fleetadmin") + require.Contains(t, raw, "fullName") + require.Contains(t, raw, "Fleet Admin") + require.Contains(t, raw, "hidden") + require.Contains(t, raw, "") + require.Contains(t, raw, "passwordHash") + require.Contains(t, raw, "") + return nil, nil + } + mdmStorage.EnqueueCommandFuncInvoked = false + + err := cmdr.AccountConfiguration(ctx, hostUUIDs, cmdUUID, "Test User", "testuser", false, admin) + require.NoError(t, err) + require.True(t, mdmStorage.EnqueueCommandFuncInvoked) + }) +} + func TestMDMAppleCommanderPassesCommandName(t *testing.T) { ctx := context.Background() mdmStorage := &mdmmock.MDMAppleStore{} diff --git a/server/mdm/apple/util.go b/server/mdm/apple/util.go index 47ff83e7cc2..a4493ea2cee 100644 --- a/server/mdm/apple/util.go +++ b/server/mdm/apple/util.go @@ -4,6 +4,7 @@ import ( "crypto/rand" "crypto/rsa" "crypto/sha256" + "crypto/sha512" "crypto/x509" "encoding/pem" "fmt" @@ -14,6 +15,8 @@ import ( "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm" + "golang.org/x/crypto/pbkdf2" + "howett.net/plist" ) // Note Apple rejects CSRs if the key size is not 2048. @@ -141,3 +144,76 @@ func IsLessThanVersion(current string, target string) (bool, error) { return cv.LessThan(tv), nil } + +const ( + // ManagedAccountPasswordGroupCount is the number of character groups in a managed account password. + ManagedAccountPasswordGroupCount = 6 + // ManagedAccountPasswordGroupLen is the number of characters per group. + ManagedAccountPasswordGroupLen = 4 + // pbkdf2Iterations is the number of PBKDF2 iterations for the managed account password hash. + pbkdf2Iterations = 40000 + // pbkdf2KeyLen is the derived key length in bytes (128 bytes as required by Apple). + pbkdf2KeyLen = 128 + // pbkdf2SaltLen is the salt length in bytes. + pbkdf2SaltLen = 32 +) + +// GenerateManagedAccountPassword generates a cryptographically random password +// in the same format as recovery lock passwords (e.g., "5ADZ-HTZ8-LJJ4-B2F8-JWH3-YPBT"). +func GenerateManagedAccountPassword() string { + groups := make([]string, ManagedAccountPasswordGroupCount) + charsetLen := len(RecoveryLockPasswordCharset) + + for i := range ManagedAccountPasswordGroupCount { + randBytes := make([]byte, ManagedAccountPasswordGroupLen) + _, _ = rand.Read(randBytes) // rand.Read never returns an error; it panics on failure + + group := make([]byte, ManagedAccountPasswordGroupLen) + for j := range ManagedAccountPasswordGroupLen { + group[j] = RecoveryLockPasswordCharset[int(randBytes[j])%charsetLen] + } + groups[i] = string(group) + } + + return strings.Join(groups, "-") +} + +// saltedSHA512PBKDF2 is the plist structure expected by Apple's AutoSetupAdminAccountItem.passwordHash. +type saltedSHA512PBKDF2 struct { + PBKDF2 pbkdf2Dict `plist:"SALTED-SHA512-PBKDF2"` +} + +type pbkdf2Dict struct { + Entropy []byte `plist:"entropy"` + Salt []byte `plist:"salt"` + Iterations int `plist:"iterations"` +} + +// GenerateSaltedSHA512PBKDF2Hash generates the password hash structure required by +// Apple's AutoSetupAdminAccountItem.passwordHash field. +// +// Returns a plist-encoded byte slice containing a SALTED-SHA512-PBKDF2 dictionary +// with 32-byte salt, 128-byte derived key (entropy), and 40,000 iterations. +// The caller should base64-encode this into the field of the AccountConfiguration plist. +func GenerateSaltedSHA512PBKDF2Hash(password string) ([]byte, error) { + salt := make([]byte, pbkdf2SaltLen) + if _, err := rand.Read(salt); err != nil { + return nil, fmt.Errorf("generating salt: %w", err) + } + + entropy := pbkdf2.Key([]byte(password), salt, pbkdf2Iterations, pbkdf2KeyLen, sha512.New) + + hashPlist := saltedSHA512PBKDF2{ + PBKDF2: pbkdf2Dict{ + Entropy: entropy, + Salt: salt, + Iterations: pbkdf2Iterations, + }, + } + + data, err := plist.Marshal(hashPlist, plist.XMLFormat) + if err != nil { + return nil, fmt.Errorf("marshaling PBKDF2 hash plist: %w", err) + } + return data, nil +} diff --git a/server/mdm/apple/util_test.go b/server/mdm/apple/util_test.go index cb7257ed9b5..61d0d325061 100644 --- a/server/mdm/apple/util_test.go +++ b/server/mdm/apple/util_test.go @@ -1,11 +1,14 @@ package apple_mdm import ( + "strings" "testing" "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "howett.net/plist" ) func TestMDMAppleEnrollURL(t *testing.T) { @@ -159,3 +162,41 @@ func TestIsRecoveryLockPasswordMismatchError(t *testing.T) { }) } } + +func TestGenerateManagedAccountPassword(t *testing.T) { + pw := GenerateManagedAccountPassword() + + // Format: XXXX-XXXX-XXXX-XXXX-XXXX-XXXX (6 groups of 4 chars separated by dashes) + groups := strings.Split(pw, "-") + require.Len(t, groups, ManagedAccountPasswordGroupCount) + for _, g := range groups { + require.Len(t, g, ManagedAccountPasswordGroupLen) + for _, c := range g { + assert.Contains(t, RecoveryLockPasswordCharset, string(c)) + } + } + + // Two calls should produce different passwords (with overwhelming probability). + pw2 := GenerateManagedAccountPassword() + require.NotEqual(t, pw, pw2) +} + +func TestGenerateSaltedSHA512PBKDF2Hash(t *testing.T) { + data, err := GenerateSaltedSHA512PBKDF2Hash("test-password") + require.NoError(t, err) + require.NotEmpty(t, data) + + // Parse the plist and verify the structure. + var result saltedSHA512PBKDF2 + _, err = plist.Unmarshal(data, &result) + require.NoError(t, err) + + assert.Len(t, result.PBKDF2.Salt, pbkdf2SaltLen, "salt should be %d bytes", pbkdf2SaltLen) + assert.Len(t, result.PBKDF2.Entropy, pbkdf2KeyLen, "entropy should be %d bytes", pbkdf2KeyLen) + assert.Equal(t, pbkdf2Iterations, result.PBKDF2.Iterations) + + // Two calls with the same password should produce different outputs (different random salts). + data2, err := GenerateSaltedSHA512PBKDF2Hash("test-password") + require.NoError(t, err) + require.NotEqual(t, data, data2) +} diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index 842ab2cb476..9374987add2 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -1083,6 +1083,16 @@ type MarkRecoveryLockPasswordViewedFunc func(ctx context.Context, hostUUID strin type GetHostsForAutoRotationFunc func(ctx context.Context) ([]fleet.HostAutoRotationInfo, error) +type SaveHostManagedLocalAccountFunc func(ctx context.Context, hostUUID string, plaintextPassword string, commandUUID string) error + +type GetHostManagedLocalAccountPasswordFunc func(ctx context.Context, hostUUID string) (*fleet.HostManagedLocalAccountPassword, error) + +type GetHostManagedLocalAccountStatusFunc func(ctx context.Context, hostUUID string) (*fleet.HostMDMManagedLocalAccount, error) + +type SetHostManagedLocalAccountStatusFunc func(ctx context.Context, hostUUID string, status fleet.MDMDeliveryStatus) error + +type GetManagedLocalAccountByCommandUUIDFunc func(ctx context.Context, commandUUID string) (hostUUID string, err error) + type InsertMDMAppleBootstrapPackageFunc func(ctx context.Context, bp *fleet.MDMAppleBootstrapPackage, pkgStore fleet.MDMBootstrapPackageStore) error type CopyDefaultMDMAppleBootstrapPackageFunc func(ctx context.Context, ac *fleet.AppConfig, toTeamID uint) error @@ -3450,6 +3460,21 @@ type DataStore struct { GetHostsForAutoRotationFunc GetHostsForAutoRotationFunc GetHostsForAutoRotationFuncInvoked bool + SaveHostManagedLocalAccountFunc SaveHostManagedLocalAccountFunc + SaveHostManagedLocalAccountFuncInvoked bool + + GetHostManagedLocalAccountPasswordFunc GetHostManagedLocalAccountPasswordFunc + GetHostManagedLocalAccountPasswordFuncInvoked bool + + GetHostManagedLocalAccountStatusFunc GetHostManagedLocalAccountStatusFunc + GetHostManagedLocalAccountStatusFuncInvoked bool + + SetHostManagedLocalAccountStatusFunc SetHostManagedLocalAccountStatusFunc + SetHostManagedLocalAccountStatusFuncInvoked bool + + GetManagedLocalAccountByCommandUUIDFunc GetManagedLocalAccountByCommandUUIDFunc + GetManagedLocalAccountByCommandUUIDFuncInvoked bool + InsertMDMAppleBootstrapPackageFunc InsertMDMAppleBootstrapPackageFunc InsertMDMAppleBootstrapPackageFuncInvoked bool @@ -8327,6 +8352,41 @@ func (s *DataStore) GetHostsForAutoRotation(ctx context.Context) ([]fleet.HostAu return s.GetHostsForAutoRotationFunc(ctx) } +func (s *DataStore) SaveHostManagedLocalAccount(ctx context.Context, hostUUID string, plaintextPassword string, commandUUID string) error { + s.mu.Lock() + s.SaveHostManagedLocalAccountFuncInvoked = true + s.mu.Unlock() + return s.SaveHostManagedLocalAccountFunc(ctx, hostUUID, plaintextPassword, commandUUID) +} + +func (s *DataStore) GetHostManagedLocalAccountPassword(ctx context.Context, hostUUID string) (*fleet.HostManagedLocalAccountPassword, error) { + s.mu.Lock() + s.GetHostManagedLocalAccountPasswordFuncInvoked = true + s.mu.Unlock() + return s.GetHostManagedLocalAccountPasswordFunc(ctx, hostUUID) +} + +func (s *DataStore) GetHostManagedLocalAccountStatus(ctx context.Context, hostUUID string) (*fleet.HostMDMManagedLocalAccount, error) { + s.mu.Lock() + s.GetHostManagedLocalAccountStatusFuncInvoked = true + s.mu.Unlock() + return s.GetHostManagedLocalAccountStatusFunc(ctx, hostUUID) +} + +func (s *DataStore) SetHostManagedLocalAccountStatus(ctx context.Context, hostUUID string, status fleet.MDMDeliveryStatus) error { + s.mu.Lock() + s.SetHostManagedLocalAccountStatusFuncInvoked = true + s.mu.Unlock() + return s.SetHostManagedLocalAccountStatusFunc(ctx, hostUUID, status) +} + +func (s *DataStore) GetManagedLocalAccountByCommandUUID(ctx context.Context, commandUUID string) (hostUUID string, err error) { + s.mu.Lock() + s.GetManagedLocalAccountByCommandUUIDFuncInvoked = true + s.mu.Unlock() + return s.GetManagedLocalAccountByCommandUUIDFunc(ctx, commandUUID) +} + func (s *DataStore) InsertMDMAppleBootstrapPackage(ctx context.Context, bp *fleet.MDMAppleBootstrapPackage, pkgStore fleet.MDMBootstrapPackageStore) error { s.mu.Lock() s.InsertMDMAppleBootstrapPackageFuncInvoked = true diff --git a/server/worker/apple_mdm.go b/server/worker/apple_mdm.go index 5bc9dbf04be..7c9534e134a 100644 --- a/server/worker/apple_mdm.go +++ b/server/worker/apple_mdm.go @@ -241,6 +241,7 @@ func (a *AppleMDM) runPostDEPEnrollment(ctx context.Context, args appleMDMArgs) fullName, acct.Username, lockPrimaryAccountInfo, + nil, // no managed local account admin (handled separately in sub-issue 2) ); err != nil { return ctxerr.Wrap(ctx, err, "sending AccountConfiguration command") } diff --git a/tools/cloner-check/generated_files/appconfig.txt b/tools/cloner-check/generated_files/appconfig.txt index e2c759d9661..f5f4a9b7601 100644 --- a/tools/cloner-check/generated_files/appconfig.txt +++ b/tools/cloner-check/generated_files/appconfig.txt @@ -157,6 +157,8 @@ github.com/fleetdm/fleet/v4/server/fleet/MacOSSetupSoftware PackagePath string github.com/fleetdm/fleet/v4/server/fleet/MacOSSetup ManualAgentInstall optjson.Bool github.com/fleetdm/fleet/v4/server/fleet/MacOSSetup RequireAllSoftware bool github.com/fleetdm/fleet/v4/server/fleet/MacOSSetup RequireAllSoftwareWindows bool +github.com/fleetdm/fleet/v4/server/fleet/MacOSSetup EnableManagedLocalAccount bool +github.com/fleetdm/fleet/v4/server/fleet/MacOSSetup EndUserLocalAccountType string github.com/fleetdm/fleet/v4/server/fleet/MDM MacOSMigration fleet.MacOSMigration github.com/fleetdm/fleet/v4/server/fleet/MacOSMigration Enable bool github.com/fleetdm/fleet/v4/server/fleet/MacOSMigration Mode fleet.MacOSMigrationMode string diff --git a/tools/cloner-check/generated_files/teamconfig.txt b/tools/cloner-check/generated_files/teamconfig.txt index c773a2a395f..4b1b88713a4 100644 --- a/tools/cloner-check/generated_files/teamconfig.txt +++ b/tools/cloner-check/generated_files/teamconfig.txt @@ -73,6 +73,8 @@ github.com/fleetdm/fleet/v4/server/fleet/MacOSSetupSoftware PackagePath string github.com/fleetdm/fleet/v4/server/fleet/MacOSSetup ManualAgentInstall optjson.Bool github.com/fleetdm/fleet/v4/server/fleet/MacOSSetup RequireAllSoftware bool github.com/fleetdm/fleet/v4/server/fleet/MacOSSetup RequireAllSoftwareWindows bool +github.com/fleetdm/fleet/v4/server/fleet/MacOSSetup EnableManagedLocalAccount bool +github.com/fleetdm/fleet/v4/server/fleet/MacOSSetup EndUserLocalAccountType string github.com/fleetdm/fleet/v4/server/fleet/TeamMDM WindowsSettings fleet.WindowsSettings github.com/fleetdm/fleet/v4/server/fleet/WindowsSettings CustomSettings optjson.Slice[github.com/fleetdm/fleet/v4/server/fleet.MDMProfileSpec] github.com/fleetdm/fleet/v4/pkg/optjson/Slice[github.com/fleetdm/fleet/v4/server/fleet.MDMProfileSpec] Set bool diff --git a/tools/cloner-check/generated_files/teammdm.txt b/tools/cloner-check/generated_files/teammdm.txt index 390c34da116..d64a470e5a3 100644 --- a/tools/cloner-check/generated_files/teammdm.txt +++ b/tools/cloner-check/generated_files/teammdm.txt @@ -44,6 +44,8 @@ github.com/fleetdm/fleet/v4/server/fleet/MacOSSetupSoftware PackagePath string github.com/fleetdm/fleet/v4/server/fleet/MacOSSetup ManualAgentInstall optjson.Bool github.com/fleetdm/fleet/v4/server/fleet/MacOSSetup RequireAllSoftware bool github.com/fleetdm/fleet/v4/server/fleet/MacOSSetup RequireAllSoftwareWindows bool +github.com/fleetdm/fleet/v4/server/fleet/MacOSSetup EnableManagedLocalAccount bool +github.com/fleetdm/fleet/v4/server/fleet/MacOSSetup EndUserLocalAccountType string github.com/fleetdm/fleet/v4/server/fleet/TeamMDM WindowsSettings fleet.WindowsSettings github.com/fleetdm/fleet/v4/server/fleet/WindowsSettings CustomSettings optjson.Slice[github.com/fleetdm/fleet/v4/server/fleet.MDMProfileSpec] github.com/fleetdm/fleet/v4/pkg/optjson/Slice[github.com/fleetdm/fleet/v4/server/fleet.MDMProfileSpec] Set bool