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