mirror of
https://github.com/fleetdm/fleet
synced 2026-04-21 13:37:30 +00:00
Backend: Auto rotate recovery lock passwords (#42084)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** Resolves #41670 # Checklist for submitter If some of the following don't apply, delete the relevant line. - [X] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files) for more information. - [X] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements), JS inline code is prevented especially for url redirects, and untrusted data interpolated into shell scripts/commands is validated against shell metacharacters. ## Testing - [X] Added/updated automated tests - [ ] QA'd all new/changed functionality manually ## Database migrations - [X] Checked schema for all modified table for columns that will auto-update timestamps during migration. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Automatic recovery lock password rotation for Mac devices—passwords now rotate 1 hour after being viewed or accessed via the API, enhancing security. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
a74901ea5d
commit
1aef647195
18 changed files with 777 additions and 31 deletions
1
changes/41670-auto-rotate-recovery-lock
Normal file
1
changes/41670-auto-rotate-recovery-lock
Normal file
|
|
@ -0,0 +1 @@
|
|||
- Added automatic rotation of Mac recovery lock passwords 1 hour after the password is viewed via the API.
|
||||
|
|
@ -2034,6 +2034,7 @@ func newRecoveryLockPasswordSchedule(
|
|||
ds fleet.Datastore,
|
||||
commander *apple_mdm.MDMAppleCommander,
|
||||
logger *slog.Logger,
|
||||
newActivityFn fleet.NewActivityFunc,
|
||||
) (*schedule.Schedule, error) {
|
||||
const (
|
||||
name = string(fleet.CronSendRecoveryLockCommands)
|
||||
|
|
@ -2045,7 +2046,7 @@ func newRecoveryLockPasswordSchedule(
|
|||
ctx, name, instanceID, defaultInterval, ds, ds,
|
||||
schedule.WithLogger(logger),
|
||||
schedule.WithJob("send_recovery_lock_commands", func(ctx context.Context) error {
|
||||
return apple_mdm.SendRecoveryLockCommands(ctx, ds, commander, logger)
|
||||
return apple_mdm.SendRecoveryLockCommands(ctx, ds, commander, logger, newActivityFn)
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1356,7 +1356,7 @@ func runServeCmd(cmd *cobra.Command, configManager configpkg.Manager, debug, dev
|
|||
|
||||
if err := cronSchedules.StartCronSchedule(func() (fleet.CronSchedule, error) {
|
||||
commander := apple_mdm.NewMDMAppleCommander(mdmStorage, mdmPushService)
|
||||
return newRecoveryLockPasswordSchedule(ctx, instanceID, ds, commander, logger)
|
||||
return newRecoveryLockPasswordSchedule(ctx, instanceID, ds, commander, logger, svc.NewActivity)
|
||||
}); err != nil {
|
||||
initFatal(err, "failed to register recovery lock password schedule")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7323,11 +7323,12 @@ func (ds *Datastore) SetHostsRecoveryLockPasswords(ctx context.Context, password
|
|||
}
|
||||
|
||||
func (ds *Datastore) GetHostRecoveryLockPassword(ctx context.Context, hostUUID string) (*fleet.HostRecoveryLockPassword, error) {
|
||||
const stmt = `SELECT encrypted_password, updated_at FROM host_recovery_key_passwords WHERE host_uuid = ? AND deleted = 0`
|
||||
const stmt = `SELECT encrypted_password, updated_at, auto_rotate_at FROM host_recovery_key_passwords WHERE host_uuid = ? AND deleted = 0`
|
||||
|
||||
var row struct {
|
||||
EncryptedPassword []byte `db:"encrypted_password"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
EncryptedPassword []byte `db:"encrypted_password"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
AutoRotateAt *time.Time `db:"auto_rotate_at"`
|
||||
}
|
||||
if err := sqlx.GetContext(ctx, ds.reader(ctx), &row, stmt, hostUUID); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
|
|
@ -7343,8 +7344,9 @@ func (ds *Datastore) GetHostRecoveryLockPassword(ctx context.Context, hostUUID s
|
|||
}
|
||||
|
||||
return &fleet.HostRecoveryLockPassword{
|
||||
Password: string(decrypted),
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
Password: string(decrypted),
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
AutoRotateAt: row.AutoRotateAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
|
@ -7680,10 +7682,10 @@ func (ds *Datastore) InitiateRecoveryLockRotation(ctx context.Context, hostUUID
|
|||
}
|
||||
|
||||
if dest.HasPending {
|
||||
return ctxerr.Errorf(ctx, "rotation already pending for host %s", hostUUID)
|
||||
return ctxerr.Wrap(ctx, fleet.ErrRecoveryLockRotationPending, fmt.Sprintf("host %s", hostUUID))
|
||||
}
|
||||
|
||||
return ctxerr.Errorf(ctx, "host %s not eligible for rotation (status=%v, operation_type=%v)", hostUUID, dest.Status.String, dest.OperationType.String)
|
||||
return ctxerr.Wrap(ctx, fleet.ErrRecoveryLockNotEligible, fmt.Sprintf("host %s (status=%v, operation_type=%v)", hostUUID, dest.Status.String, dest.OperationType.String))
|
||||
}
|
||||
|
||||
return nil
|
||||
|
|
@ -7691,13 +7693,15 @@ func (ds *Datastore) InitiateRecoveryLockRotation(ctx context.Context, hostUUID
|
|||
|
||||
func (ds *Datastore) CompleteRecoveryLockRotation(ctx context.Context, hostUUID string) error {
|
||||
// Move pending password to active and clear pending columns.
|
||||
// Also clear auto_rotate_at since rotation is now complete.
|
||||
stmt := fmt.Sprintf(`
|
||||
UPDATE host_recovery_key_passwords
|
||||
SET encrypted_password = pending_encrypted_password,
|
||||
pending_encrypted_password = NULL,
|
||||
pending_error_message = NULL,
|
||||
status = '%s',
|
||||
error_message = NULL
|
||||
error_message = NULL,
|
||||
auto_rotate_at = NULL
|
||||
WHERE host_uuid = ?
|
||||
AND deleted = 0
|
||||
AND pending_encrypted_password IS NOT NULL
|
||||
|
|
@ -7839,3 +7843,62 @@ func (ds *Datastore) HasPendingRecoveryLockRotation(ctx context.Context, hostUUI
|
|||
|
||||
return hasPending, nil
|
||||
}
|
||||
|
||||
func (ds *Datastore) MarkRecoveryLockPasswordViewed(ctx context.Context, hostUUID string) (time.Time, error) {
|
||||
// Set auto_rotate_at to 1 hour from now when password is viewed.
|
||||
// This always updates (even if pending rotation exists) so the API always returns a valid rotation time.
|
||||
rotateAt := time.Now().Add(1 * time.Hour)
|
||||
|
||||
stmt := fmt.Sprintf(`
|
||||
UPDATE host_recovery_key_passwords
|
||||
SET auto_rotate_at = ?
|
||||
WHERE host_uuid = ?
|
||||
AND deleted = 0
|
||||
AND operation_type = '%s'
|
||||
`, fleet.MDMOperationTypeInstall)
|
||||
|
||||
result, err := ds.writer(ctx).ExecContext(ctx, stmt, rotateAt, hostUUID)
|
||||
if err != nil {
|
||||
return time.Time{}, ctxerr.Wrap(ctx, err, "mark recovery lock password viewed")
|
||||
}
|
||||
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
return time.Time{}, ctxerr.Wrap(ctx, notFound("HostRecoveryLockPassword").
|
||||
WithMessage(fmt.Sprintf("for host %s", hostUUID)))
|
||||
}
|
||||
|
||||
return rotateAt, nil
|
||||
}
|
||||
|
||||
func (ds *Datastore) GetHostsForAutoRotation(ctx context.Context) ([]fleet.HostAutoRotationInfo, error) {
|
||||
// Return hosts where:
|
||||
// - auto_rotate_at is in the past (due for rotation)
|
||||
// - status is verified (password is confirmed working)
|
||||
// - no pending rotation (pending_encrypted_password IS NULL)
|
||||
// - operation_type is install (not in remove state)
|
||||
// - not deleted
|
||||
// Join with hosts table to get host ID and display name for activity logging.
|
||||
stmt := fmt.Sprintf(`
|
||||
SELECT
|
||||
hrkp.host_uuid,
|
||||
h.id AS host_id,
|
||||
COALESCE(NULLIF(h.computer_name, ''), h.hostname) AS display_name
|
||||
FROM host_recovery_key_passwords hrkp
|
||||
JOIN hosts h ON h.uuid = hrkp.host_uuid
|
||||
WHERE hrkp.auto_rotate_at IS NOT NULL
|
||||
AND hrkp.auto_rotate_at <= NOW(6)
|
||||
AND hrkp.status = '%s'
|
||||
AND hrkp.pending_encrypted_password IS NULL
|
||||
AND hrkp.operation_type = '%s'
|
||||
AND hrkp.deleted = 0
|
||||
LIMIT 100
|
||||
`, fleet.MDMDeliveryVerified, fleet.MDMOperationTypeInstall)
|
||||
|
||||
var hosts []fleet.HostAutoRotationInfo
|
||||
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &hosts, stmt); err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "get hosts for auto rotation")
|
||||
}
|
||||
|
||||
return hosts, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -123,6 +123,7 @@ func TestMDMApple(t *testing.T) {
|
|||
{"GetHostRecoveryLockPasswordStatus", testGetHostRecoveryLockPasswordStatus},
|
||||
{"ClaimHostsForRecoveryLockClear", testClaimHostsForRecoveryLockClear},
|
||||
{"RecoveryLockRotation", testRecoveryLockRotation},
|
||||
{"RecoveryLockAutoRotation", testRecoveryLockAutoRotation},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
|
|
@ -11207,7 +11208,7 @@ func testRecoveryLockRotation(t *testing.T, ds *Datastore) {
|
|||
// Try to initiate second rotation - should fail
|
||||
err = ds.InitiateRecoveryLockRotation(ctx, host.UUID, "another-password")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "rotation already pending")
|
||||
assert.ErrorIs(t, err, fleet.ErrRecoveryLockRotationPending)
|
||||
})
|
||||
|
||||
t.Run("InitiateRecoveryLockRotation rejects pending status", func(t *testing.T) {
|
||||
|
|
@ -11220,7 +11221,7 @@ func testRecoveryLockRotation(t *testing.T, ds *Datastore) {
|
|||
// Try to initiate rotation on pending status - should fail
|
||||
err = ds.InitiateRecoveryLockRotation(ctx, host.UUID, "new-password")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "not eligible for rotation")
|
||||
assert.ErrorIs(t, err, fleet.ErrRecoveryLockNotEligible)
|
||||
})
|
||||
|
||||
t.Run("InitiateRecoveryLockRotation allows failed status", func(t *testing.T) {
|
||||
|
|
@ -11390,3 +11391,226 @@ func testRecoveryLockRotation(t *testing.T, ds *Datastore) {
|
|||
assert.False(t, pending)
|
||||
})
|
||||
}
|
||||
|
||||
func testRecoveryLockAutoRotation(t *testing.T, ds *Datastore) {
|
||||
ctx := t.Context()
|
||||
|
||||
// Helper to set up a host with a verified recovery lock password
|
||||
setupHostWithVerifiedPassword := func(t *testing.T, name, uuid string) *fleet.Host {
|
||||
t.Helper()
|
||||
host := test.NewHost(t, ds, name, "2.3.4."+uuid[:3], name+"key", uuid, time.Now())
|
||||
pw := apple_mdm.GenerateRecoveryLockPassword()
|
||||
err := ds.SetHostsRecoveryLockPasswords(ctx, []fleet.HostRecoveryLockPasswordPayload{{HostUUID: host.UUID, Password: pw}})
|
||||
require.NoError(t, err)
|
||||
err = ds.SetRecoveryLockVerified(ctx, host.UUID)
|
||||
require.NoError(t, err)
|
||||
return host
|
||||
}
|
||||
|
||||
// Helper to get auto_rotate_at directly from DB
|
||||
getAutoRotateAt := func(t *testing.T, hostUUID string) *time.Time {
|
||||
t.Helper()
|
||||
var autoRotateAt *time.Time
|
||||
err := ds.writer(ctx).GetContext(ctx, &autoRotateAt, `
|
||||
SELECT auto_rotate_at FROM host_recovery_key_passwords
|
||||
WHERE host_uuid = ? AND deleted = 0`, hostUUID)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil
|
||||
}
|
||||
require.NoError(t, err)
|
||||
return autoRotateAt
|
||||
}
|
||||
|
||||
t.Run("MarkRecoveryLockPasswordViewed sets auto_rotate_at", func(t *testing.T) {
|
||||
host := setupHostWithVerifiedPassword(t, "view-host1", "viewuuid0001")
|
||||
|
||||
// Initially no auto_rotate_at
|
||||
autoRotateAt := getAutoRotateAt(t, host.UUID)
|
||||
assert.Nil(t, autoRotateAt)
|
||||
|
||||
// Mark as viewed
|
||||
rotateAt, err := ds.MarkRecoveryLockPasswordViewed(ctx, host.UUID)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, rotateAt.IsZero())
|
||||
|
||||
// Verify auto_rotate_at is approximately 1 hour from now
|
||||
expectedRotateAt := time.Now().Add(1 * time.Hour)
|
||||
assert.WithinDuration(t, expectedRotateAt, rotateAt, 1*time.Minute)
|
||||
|
||||
// Verify via direct DB query
|
||||
autoRotateAt = getAutoRotateAt(t, host.UUID)
|
||||
require.NotNil(t, autoRotateAt)
|
||||
assert.WithinDuration(t, expectedRotateAt, *autoRotateAt, 1*time.Minute)
|
||||
})
|
||||
|
||||
t.Run("MarkRecoveryLockPasswordViewed updates existing auto_rotate_at", func(t *testing.T) {
|
||||
host := setupHostWithVerifiedPassword(t, "view-host2", "viewuuid0002")
|
||||
|
||||
// First view
|
||||
firstRotateAt, err := ds.MarkRecoveryLockPasswordViewed(ctx, host.UUID)
|
||||
require.NoError(t, err)
|
||||
|
||||
time.Sleep(10 * time.Millisecond) // Small delay to ensure different timestamp
|
||||
|
||||
// Second view should update auto_rotate_at
|
||||
secondRotateAt, err := ds.MarkRecoveryLockPasswordViewed(ctx, host.UUID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Second rotation time should be after first
|
||||
assert.True(t, secondRotateAt.After(firstRotateAt), "second view should update auto_rotate_at")
|
||||
|
||||
// Verify the value was persisted in the database
|
||||
pw, err := ds.GetHostRecoveryLockPassword(ctx, host.UUID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, pw.AutoRotateAt, "auto_rotate_at should be persisted")
|
||||
assert.True(t, pw.AutoRotateAt.After(firstRotateAt), "persisted auto_rotate_at should be after first rotation time")
|
||||
})
|
||||
|
||||
t.Run("MarkRecoveryLockPasswordViewed fails for non-existent host", func(t *testing.T) {
|
||||
_, err := ds.MarkRecoveryLockPasswordViewed(ctx, "non-existent-uuid")
|
||||
require.Error(t, err)
|
||||
assert.True(t, fleet.IsNotFound(err))
|
||||
})
|
||||
|
||||
t.Run("MarkRecoveryLockPasswordViewed fails for remove operation", func(t *testing.T) {
|
||||
host := setupHostWithVerifiedPassword(t, "view-host3", "viewuuid0003")
|
||||
|
||||
// Change to remove operation type
|
||||
_, err := ds.writer(ctx).ExecContext(ctx, `
|
||||
UPDATE host_recovery_key_passwords
|
||||
SET operation_type = 'remove'
|
||||
WHERE host_uuid = ?`, host.UUID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should fail because operation_type is not 'install'
|
||||
_, err = ds.MarkRecoveryLockPasswordViewed(ctx, host.UUID)
|
||||
require.Error(t, err)
|
||||
assert.True(t, fleet.IsNotFound(err))
|
||||
})
|
||||
|
||||
// Helper to check if a host UUID is in the rotation info list
|
||||
containsHostUUID := func(hosts []fleet.HostAutoRotationInfo, uuid string) bool {
|
||||
for _, h := range hosts {
|
||||
if h.HostUUID == uuid {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
t.Run("GetHostsForAutoRotation returns due hosts", func(t *testing.T) {
|
||||
host := setupHostWithVerifiedPassword(t, "auto-rotate-host1", "autorotateuuid1")
|
||||
|
||||
// Set auto_rotate_at to 2 hours ago (past due)
|
||||
_, err := ds.writer(ctx).ExecContext(ctx, `
|
||||
UPDATE host_recovery_key_passwords
|
||||
SET auto_rotate_at = DATE_SUB(NOW(6), INTERVAL 2 HOUR)
|
||||
WHERE host_uuid = ?`, host.UUID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should be returned
|
||||
hosts, err := ds.GetHostsForAutoRotation(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, containsHostUUID(hosts, host.UUID), "host should be in auto-rotation list")
|
||||
})
|
||||
|
||||
t.Run("GetHostsForAutoRotation excludes future auto_rotate_at", func(t *testing.T) {
|
||||
host := setupHostWithVerifiedPassword(t, "auto-rotate-host2", "autorotateuuid2")
|
||||
|
||||
// Set auto_rotate_at to 1 hour in the future
|
||||
_, err := ds.writer(ctx).ExecContext(ctx, `
|
||||
UPDATE host_recovery_key_passwords
|
||||
SET auto_rotate_at = DATE_ADD(NOW(6), INTERVAL 1 HOUR)
|
||||
WHERE host_uuid = ?`, host.UUID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should NOT be returned
|
||||
hosts, err := ds.GetHostsForAutoRotation(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, containsHostUUID(hosts, host.UUID), "host should not be in auto-rotation list")
|
||||
})
|
||||
|
||||
t.Run("GetHostsForAutoRotation excludes hosts with pending rotation", func(t *testing.T) {
|
||||
host := setupHostWithVerifiedPassword(t, "auto-rotate-host3", "autorotateuuid3")
|
||||
|
||||
// Set auto_rotate_at to past due
|
||||
_, err := ds.writer(ctx).ExecContext(ctx, `
|
||||
UPDATE host_recovery_key_passwords
|
||||
SET auto_rotate_at = DATE_SUB(NOW(6), INTERVAL 2 HOUR)
|
||||
WHERE host_uuid = ?`, host.UUID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Initiate rotation (sets pending_encrypted_password)
|
||||
newPassword := apple_mdm.GenerateRecoveryLockPassword()
|
||||
err = ds.InitiateRecoveryLockRotation(ctx, host.UUID, newPassword)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should NOT be returned because pending rotation exists
|
||||
hosts, err := ds.GetHostsForAutoRotation(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, containsHostUUID(hosts, host.UUID), "host should not be in auto-rotation list")
|
||||
})
|
||||
|
||||
t.Run("GetHostsForAutoRotation excludes non-verified hosts", func(t *testing.T) {
|
||||
host := test.NewHost(t, ds, "auto-rotate-host4", "2.3.4.104", "autorotate4key", "autorotateuuid4", time.Now())
|
||||
pw := apple_mdm.GenerateRecoveryLockPassword()
|
||||
err := ds.SetHostsRecoveryLockPasswords(ctx, []fleet.HostRecoveryLockPasswordPayload{{HostUUID: host.UUID, Password: pw}})
|
||||
require.NoError(t, err)
|
||||
// Status is "pending" after SetHostsRecoveryLockPasswords, NOT verified
|
||||
|
||||
// Set auto_rotate_at to past due
|
||||
_, err = ds.writer(ctx).ExecContext(ctx, `
|
||||
UPDATE host_recovery_key_passwords
|
||||
SET auto_rotate_at = DATE_SUB(NOW(6), INTERVAL 2 HOUR)
|
||||
WHERE host_uuid = ?`, host.UUID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should NOT be returned because status is not verified
|
||||
hosts, err := ds.GetHostsForAutoRotation(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, containsHostUUID(hosts, host.UUID), "host should not be in auto-rotation list")
|
||||
})
|
||||
|
||||
t.Run("CompleteRecoveryLockRotation clears auto_rotate_at", func(t *testing.T) {
|
||||
host := setupHostWithVerifiedPassword(t, "complete-auto-rotate", "completeautorot")
|
||||
|
||||
// Mark as viewed to set auto_rotate_at
|
||||
_, err := ds.MarkRecoveryLockPasswordViewed(ctx, host.UUID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify auto_rotate_at is set
|
||||
autoRotateAt := getAutoRotateAt(t, host.UUID)
|
||||
require.NotNil(t, autoRotateAt)
|
||||
|
||||
// Initiate and complete rotation
|
||||
newPassword := apple_mdm.GenerateRecoveryLockPassword()
|
||||
err = ds.InitiateRecoveryLockRotation(ctx, host.UUID, newPassword)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = ds.CompleteRecoveryLockRotation(ctx, host.UUID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// auto_rotate_at should be cleared
|
||||
autoRotateAt = getAutoRotateAt(t, host.UUID)
|
||||
assert.Nil(t, autoRotateAt)
|
||||
})
|
||||
|
||||
t.Run("GetHostRecoveryLockPassword includes auto_rotate_at", func(t *testing.T) {
|
||||
host := setupHostWithVerifiedPassword(t, "get-pw-auto-rotate", "getpwautorot")
|
||||
|
||||
// Initially no auto_rotate_at
|
||||
pw, err := ds.GetHostRecoveryLockPassword(ctx, host.UUID)
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, pw.AutoRotateAt)
|
||||
|
||||
// Mark as viewed
|
||||
rotateAt, err := ds.MarkRecoveryLockPasswordViewed(ctx, host.UUID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Now auto_rotate_at should be returned
|
||||
pw, err = ds.GetHostRecoveryLockPassword(ctx, host.UUID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, pw.AutoRotateAt)
|
||||
assert.WithinDuration(t, rotateAt, *pw.AutoRotateAt, 1*time.Second)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
package tables
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func init() {
|
||||
MigrationClient.AddMigration(Up_20260326131501, Down_20260326131501)
|
||||
}
|
||||
|
||||
func Up_20260326131501(tx *sql.Tx) error {
|
||||
// Add auto_rotate_at column to track when a viewed password should be automatically rotated.
|
||||
// When a password is viewed via the API, auto_rotate_at is set to 1 hour in the future.
|
||||
// The cron job rotates passwords where auto_rotate_at <= NOW().
|
||||
if _, err := tx.Exec(`
|
||||
ALTER TABLE host_recovery_key_passwords
|
||||
ADD COLUMN auto_rotate_at TIMESTAMP(6) NULL DEFAULT NULL,
|
||||
ADD INDEX idx_auto_rotate_at (auto_rotate_at)
|
||||
`); err != nil {
|
||||
return fmt.Errorf("adding auto_rotate_at column to host_recovery_key_passwords: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func Down_20260326131501(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -1004,9 +1004,12 @@ func (a ActivityTypeWipedHost) HostIDs() []uint {
|
|||
return []uint{a.HostID}
|
||||
}
|
||||
|
||||
// ActivityTypeRotatedHostRecoveryLockPassword is for password rotation.
|
||||
// Can be user-initiated (manual) or Fleet-initiated (auto-rotation after password viewed).
|
||||
type ActivityTypeRotatedHostRecoveryLockPassword struct {
|
||||
HostID uint `json:"host_id"`
|
||||
HostDisplayName string `json:"host_display_name"`
|
||||
FleetInitiated bool `json:"-"` // True for auto-rotation, not serialized
|
||||
}
|
||||
|
||||
func (a ActivityTypeRotatedHostRecoveryLockPassword) ActivityName() string {
|
||||
|
|
@ -1017,6 +1020,10 @@ func (a ActivityTypeRotatedHostRecoveryLockPassword) HostIDs() []uint {
|
|||
return []uint{a.HostID}
|
||||
}
|
||||
|
||||
func (a ActivityTypeRotatedHostRecoveryLockPassword) WasFromAutomation() bool {
|
||||
return a.FleetInitiated
|
||||
}
|
||||
|
||||
type ActivityTypeCreatedDeclarationProfile struct {
|
||||
ProfileName string `json:"profile_name"`
|
||||
Identifier string `json:"identifier"`
|
||||
|
|
|
|||
|
|
@ -18,6 +18,16 @@ import (
|
|||
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/godep"
|
||||
)
|
||||
|
||||
// Sentinel errors for recovery lock rotation
|
||||
var (
|
||||
// ErrRecoveryLockRotationPending indicates a rotation is already in progress for the host.
|
||||
ErrRecoveryLockRotationPending = errors.New("recovery lock rotation already pending")
|
||||
|
||||
// ErrRecoveryLockNotEligible indicates the host is not eligible for rotation
|
||||
// (e.g., wrong status, operation type, or no existing password).
|
||||
ErrRecoveryLockNotEligible = errors.New("host not eligible for recovery lock rotation")
|
||||
)
|
||||
|
||||
type MDMAppleCommandIssuer interface {
|
||||
InstallProfile(ctx context.Context, hostUUIDs []string, profile mobileconfig.Mobileconfig, uuid string) error
|
||||
RemoveProfile(ctx context.Context, hostUUIDs []string, identifier string, uuid string) error
|
||||
|
|
@ -1144,8 +1154,9 @@ type HostLocationData struct {
|
|||
|
||||
// HostRecoveryLockPassword represents a recovery lock password for a host.
|
||||
type HostRecoveryLockPassword struct {
|
||||
Password string
|
||||
UpdatedAt time.Time
|
||||
Password string
|
||||
UpdatedAt time.Time
|
||||
AutoRotateAt *time.Time // When auto-rotation is scheduled (1 hour after password is viewed)
|
||||
}
|
||||
|
||||
// HostRecoveryLockPasswordPayload contains the data needed to store a recovery lock password.
|
||||
|
|
@ -1163,3 +1174,10 @@ type HostRecoveryLockRotationStatus struct {
|
|||
HasPendingRotation bool // pending_encrypted_password is not null
|
||||
PendingErrorMessage *string // error from failed rotation
|
||||
}
|
||||
|
||||
// HostAutoRotationInfo contains the minimal host data needed for auto-rotation activity logging.
|
||||
type HostAutoRotationInfo struct {
|
||||
HostUUID string `db:"host_uuid"`
|
||||
HostID uint `db:"host_id"`
|
||||
DisplayName string `db:"display_name"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1576,6 +1576,16 @@ type Datastore interface {
|
|||
// This is used when a clear command fails with a transient error (not password mismatch).
|
||||
ResetRecoveryLockForRetry(ctx context.Context, hostUUID string) error
|
||||
|
||||
// MarkRecoveryLockPasswordViewed sets auto_rotate_at to 1 hour from now.
|
||||
// Called when the password is viewed and returns the scheduled rotation time.
|
||||
MarkRecoveryLockPasswordViewed(ctx context.Context, hostUUID string) (time.Time, error)
|
||||
|
||||
// GetHostsForAutoRotation returns hosts where auto_rotate_at <= now
|
||||
// and are eligible for rotation (verified status, no pending rotation).
|
||||
// Returns host info needed for rotation and activity logging.
|
||||
// Limited to 100 hosts per batch.
|
||||
GetHostsForAutoRotation(ctx context.Context) ([]HostAutoRotationInfo, error)
|
||||
|
||||
// InsertMDMAppleBootstrapPackage insterts a new bootstrap package in the
|
||||
// database (or S3 if configured).
|
||||
InsertMDMAppleBootstrapPackage(ctx context.Context, bp *MDMAppleBootstrapPackage, pkgStore MDMBootstrapPackageStore) error
|
||||
|
|
|
|||
|
|
@ -1663,6 +1663,7 @@ func ValidateMDMSettingsAppleSupportedOSVersion[T fleet.MDM | fleet.TeamMDM](set
|
|||
type RecoveryLockCommander interface {
|
||||
SetRecoveryLock(ctx context.Context, hostUUIDs []string, cmdUUID string) error
|
||||
ClearRecoveryLock(ctx context.Context, hostUUIDs []string, cmdUUID string) error
|
||||
RotateRecoveryLock(ctx context.Context, hostUUID string, cmdUUID string) error
|
||||
}
|
||||
|
||||
// SendRecoveryLockCommands is the cron job function that sends SetRecoveryLock MDM commands
|
||||
|
|
@ -1675,8 +1676,9 @@ func SendRecoveryLockCommands(
|
|||
ds fleet.Datastore,
|
||||
commander *MDMAppleCommander,
|
||||
logger *slog.Logger,
|
||||
newActivityFn fleet.NewActivityFunc,
|
||||
) error {
|
||||
return sendRecoveryLockCommandsWithCommander(ctx, ds, commander, logger)
|
||||
return sendRecoveryLockCommandsWithCommander(ctx, ds, commander, logger, newActivityFn)
|
||||
}
|
||||
|
||||
func sendRecoveryLockCommandsWithCommander(
|
||||
|
|
@ -1684,6 +1686,7 @@ func sendRecoveryLockCommandsWithCommander(
|
|||
ds fleet.Datastore,
|
||||
commander RecoveryLockCommander,
|
||||
logger *slog.Logger,
|
||||
newActivityFn fleet.NewActivityFunc,
|
||||
) error {
|
||||
var result *multierror.Error
|
||||
|
||||
|
|
@ -1706,6 +1709,11 @@ func sendRecoveryLockCommandsWithCommander(
|
|||
result = multierror.Append(result, err)
|
||||
}
|
||||
|
||||
// Handle AUTO-ROTATION for viewed passwords (password viewed 1+ hour ago)
|
||||
if err := sendAutoRotationCommands(ctx, ds, commander, logger, newActivityFn); err != nil {
|
||||
result = multierror.Append(result, err)
|
||||
}
|
||||
|
||||
return result.ErrorOrNil()
|
||||
}
|
||||
|
||||
|
|
@ -1854,6 +1862,120 @@ func sendClearRecoveryLockCommands(
|
|||
return nil
|
||||
}
|
||||
|
||||
func sendAutoRotationCommands(
|
||||
ctx context.Context,
|
||||
ds fleet.Datastore,
|
||||
commander RecoveryLockCommander,
|
||||
logger *slog.Logger,
|
||||
newActivityFn fleet.NewActivityFunc,
|
||||
) error {
|
||||
hosts, err := ds.GetHostsForAutoRotation(ctx)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "get hosts for auto rotation")
|
||||
}
|
||||
|
||||
if len(hosts) == 0 {
|
||||
logger.DebugContext(ctx, "no hosts need auto-rotation")
|
||||
return nil
|
||||
}
|
||||
|
||||
logger.InfoContext(ctx, "performing auto-rotation for viewed passwords", "count", len(hosts))
|
||||
|
||||
var result *multierror.Error
|
||||
for _, host := range hosts {
|
||||
newPassword := GenerateRecoveryLockPassword()
|
||||
|
||||
// Initiate rotation - stores pending password and validates eligibility
|
||||
if err := ds.InitiateRecoveryLockRotation(ctx, host.HostUUID, newPassword); err != nil {
|
||||
// Check for benign race conditions where host state changed between
|
||||
// GetHostsForAutoRotation and now (e.g., manual rotation started,
|
||||
// password removed, host deleted, etc.)
|
||||
if fleet.IsNotFound(err) ||
|
||||
errors.Is(err, fleet.ErrRecoveryLockRotationPending) ||
|
||||
errors.Is(err, fleet.ErrRecoveryLockNotEligible) {
|
||||
logger.DebugContext(ctx, "host lost eligibility for auto-rotation",
|
||||
"host_uuid", host.HostUUID,
|
||||
"error", err,
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
logger.ErrorContext(ctx, "failed to initiate auto-rotation",
|
||||
"host_uuid", host.HostUUID,
|
||||
"error", err,
|
||||
)
|
||||
result = multierror.Append(result, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Enqueue RotateRecoveryLock command
|
||||
cmdUUID := uuid.NewString()
|
||||
if err := commander.RotateRecoveryLock(ctx, host.HostUUID, cmdUUID); err != nil {
|
||||
var apnsErr *APNSDeliveryError
|
||||
if errors.As(err, &apnsErr) {
|
||||
// Command was persisted but push notification failed - log activity and continue.
|
||||
// The command will be retried when the device checks in.
|
||||
logAutoRotationActivity(ctx, logger, newActivityFn, host)
|
||||
logger.WarnContext(ctx, "auto-rotation command enqueued but APNs push failed",
|
||||
"host_uuid", host.HostUUID,
|
||||
"command_uuid", cmdUUID,
|
||||
"error", err,
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
// Persistence failed - clear pending rotation so host can be retried
|
||||
logger.ErrorContext(ctx, "failed to enqueue auto-rotation command",
|
||||
"host_uuid", host.HostUUID,
|
||||
"error", err,
|
||||
)
|
||||
if clearErr := ds.ClearRecoveryLockRotation(ctx, host.HostUUID); clearErr != nil {
|
||||
logger.ErrorContext(ctx, "failed to clear pending rotation after enqueue failure",
|
||||
"host_uuid", host.HostUUID,
|
||||
"error", clearErr,
|
||||
)
|
||||
result = multierror.Append(result, clearErr)
|
||||
}
|
||||
result = multierror.Append(result, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Log activity for auto-rotation (Fleet-initiated)
|
||||
logAutoRotationActivity(ctx, logger, newActivityFn, host)
|
||||
|
||||
logger.DebugContext(ctx, "sent auto-rotation command",
|
||||
"host_uuid", host.HostUUID,
|
||||
"command_uuid", cmdUUID,
|
||||
)
|
||||
}
|
||||
|
||||
return result.ErrorOrNil()
|
||||
}
|
||||
|
||||
// logAutoRotationActivity logs the rotation activity for auto-rotations.
|
||||
// It uses the same activity type as manual rotations but marks it as Fleet-initiated.
|
||||
func logAutoRotationActivity(
|
||||
ctx context.Context,
|
||||
logger *slog.Logger,
|
||||
newActivityFn fleet.NewActivityFunc,
|
||||
host fleet.HostAutoRotationInfo,
|
||||
) {
|
||||
if newActivityFn == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err := newActivityFn(ctx, nil, fleet.ActivityTypeRotatedHostRecoveryLockPassword{
|
||||
HostID: host.HostID,
|
||||
HostDisplayName: host.DisplayName,
|
||||
FleetInitiated: true,
|
||||
}); err != nil {
|
||||
logger.WarnContext(ctx, "auto-rotation: failed to create activity",
|
||||
"host_uuid", host.HostUUID,
|
||||
"err", err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// RecoveryLockPasswordCharset excludes confusing characters (0/O, 1/I/l)
|
||||
const RecoveryLockPasswordCharset = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"
|
||||
|
||||
|
|
|
|||
|
|
@ -587,6 +587,10 @@ func TestSendRecoveryLockCommands(t *testing.T) {
|
|||
ds.ClaimHostsForRecoveryLockClearFunc = func(ctx context.Context) ([]string, error) {
|
||||
return nil, nil
|
||||
}
|
||||
// Mock auto-rotation - no hosts need auto-rotation
|
||||
ds.GetHostsForAutoRotationFunc = func(ctx context.Context) ([]fleet.HostAutoRotationInfo, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var commandSent bool
|
||||
mockCommander := &mockRecoveryLockCommander{
|
||||
|
|
@ -596,7 +600,7 @@ func TestSendRecoveryLockCommands(t *testing.T) {
|
|||
},
|
||||
}
|
||||
|
||||
err := sendRecoveryLockCommandsWithCommander(ctx, ds, mockCommander, logger)
|
||||
err := sendRecoveryLockCommandsWithCommander(ctx, ds, mockCommander, logger, nil)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, commandSent, "SetRecoveryLock should not be called when no hosts need it")
|
||||
})
|
||||
|
|
@ -616,6 +620,10 @@ func TestSendRecoveryLockCommands(t *testing.T) {
|
|||
ds.ClaimHostsForRecoveryLockClearFunc = func(ctx context.Context) ([]string, error) {
|
||||
return nil, nil
|
||||
}
|
||||
// Mock auto-rotation - no hosts need auto-rotation
|
||||
ds.GetHostsForAutoRotationFunc = func(ctx context.Context) ([]fleet.HostAutoRotationInfo, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Track call order to verify correct sequencing
|
||||
var callOrder []string
|
||||
|
|
@ -636,7 +644,7 @@ func TestSendRecoveryLockCommands(t *testing.T) {
|
|||
},
|
||||
}
|
||||
|
||||
err := sendRecoveryLockCommandsWithCommander(ctx, ds, mockCommander, logger)
|
||||
err := sendRecoveryLockCommandsWithCommander(ctx, ds, mockCommander, logger, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify call order: password must be stored BEFORE command is sent
|
||||
|
|
@ -665,6 +673,10 @@ func TestSendRecoveryLockCommands(t *testing.T) {
|
|||
ds.ClaimHostsForRecoveryLockClearFunc = func(ctx context.Context) ([]string, error) {
|
||||
return nil, nil
|
||||
}
|
||||
// Mock auto-rotation - no hosts need auto-rotation
|
||||
ds.GetHostsForAutoRotationFunc = func(ctx context.Context) ([]fleet.HostAutoRotationInfo, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Track call order to verify correct sequencing
|
||||
var callOrder []string
|
||||
|
|
@ -687,7 +699,7 @@ func TestSendRecoveryLockCommands(t *testing.T) {
|
|||
},
|
||||
}
|
||||
|
||||
err := sendRecoveryLockCommandsWithCommander(ctx, ds, mockCommander, logger)
|
||||
err := sendRecoveryLockCommandsWithCommander(ctx, ds, mockCommander, logger, nil)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "APNs push failed")
|
||||
|
||||
|
|
@ -714,6 +726,10 @@ func TestSendRecoveryLockCommands(t *testing.T) {
|
|||
ds.ClaimHostsForRecoveryLockClearFunc = func(ctx context.Context) ([]string, error) {
|
||||
return nil, nil
|
||||
}
|
||||
// Mock auto-rotation - no hosts need auto-rotation
|
||||
ds.GetHostsForAutoRotationFunc = func(ctx context.Context) ([]fleet.HostAutoRotationInfo, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Track call order to verify ClearRecoveryLockPendingStatus is NOT called
|
||||
var callOrder []string
|
||||
|
|
@ -735,7 +751,7 @@ func TestSendRecoveryLockCommands(t *testing.T) {
|
|||
},
|
||||
}
|
||||
|
||||
err := sendRecoveryLockCommandsWithCommander(ctx, ds, mockCommander, logger)
|
||||
err := sendRecoveryLockCommandsWithCommander(ctx, ds, mockCommander, logger, nil)
|
||||
// Should NOT return error - command was persisted, just push failed
|
||||
require.NoError(t, err)
|
||||
|
||||
|
|
@ -748,8 +764,9 @@ func TestSendRecoveryLockCommands(t *testing.T) {
|
|||
|
||||
// mockRecoveryLockCommander implements RecoveryLockCommander for testing.
|
||||
type mockRecoveryLockCommander struct {
|
||||
setRecoveryLockFn func(ctx context.Context, hostUUIDs []string, cmdUUID string) error
|
||||
clearRecoveryLockFn func(ctx context.Context, hostUUIDs []string, cmdUUID string) error
|
||||
setRecoveryLockFn func(ctx context.Context, hostUUIDs []string, cmdUUID string) error
|
||||
clearRecoveryLockFn func(ctx context.Context, hostUUIDs []string, cmdUUID string) error
|
||||
rotateRecoveryLockFn func(ctx context.Context, hostUUID string, cmdUUID string) error
|
||||
}
|
||||
|
||||
func (m *mockRecoveryLockCommander) SetRecoveryLock(ctx context.Context, hostUUIDs []string, cmdUUID string) error {
|
||||
|
|
@ -766,6 +783,13 @@ func (m *mockRecoveryLockCommander) ClearRecoveryLock(ctx context.Context, hostU
|
|||
return nil
|
||||
}
|
||||
|
||||
func (m *mockRecoveryLockCommander) RotateRecoveryLock(ctx context.Context, hostUUID string, cmdUUID string) error {
|
||||
if m.rotateRecoveryLockFn != nil {
|
||||
return m.rotateRecoveryLockFn(ctx, hostUUID, cmdUUID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestSendClearRecoveryLockCommands(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
logger := slog.New(slog.DiscardHandler)
|
||||
|
|
@ -787,6 +811,10 @@ func TestSendClearRecoveryLockCommands(t *testing.T) {
|
|||
ds.ClaimHostsForRecoveryLockClearFunc = func(ctx context.Context) ([]string, error) {
|
||||
return []string{hostUUID}, nil
|
||||
}
|
||||
// Mock auto-rotation - no hosts need auto-rotation
|
||||
ds.GetHostsForAutoRotationFunc = func(ctx context.Context) ([]fleet.HostAutoRotationInfo, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var clearCalled bool
|
||||
mockCommander := &mockRecoveryLockCommander{
|
||||
|
|
@ -797,7 +825,7 @@ func TestSendClearRecoveryLockCommands(t *testing.T) {
|
|||
},
|
||||
}
|
||||
|
||||
err := sendRecoveryLockCommandsWithCommander(ctx, ds, mockCommander, logger)
|
||||
err := sendRecoveryLockCommandsWithCommander(ctx, ds, mockCommander, logger, nil)
|
||||
require.NoError(t, err)
|
||||
require.True(t, clearCalled, "ClearRecoveryLock should have been called")
|
||||
})
|
||||
|
|
@ -817,6 +845,10 @@ func TestSendClearRecoveryLockCommands(t *testing.T) {
|
|||
ds.ClaimHostsForRecoveryLockClearFunc = func(ctx context.Context) ([]string, error) {
|
||||
return nil, nil
|
||||
}
|
||||
// Mock auto-rotation - no hosts need auto-rotation
|
||||
ds.GetHostsForAutoRotationFunc = func(ctx context.Context) ([]fleet.HostAutoRotationInfo, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
mockCommander := &mockRecoveryLockCommander{
|
||||
clearRecoveryLockFn: func(ctx context.Context, hostUUIDs []string, cmdUUID string) error {
|
||||
|
|
@ -825,7 +857,7 @@ func TestSendClearRecoveryLockCommands(t *testing.T) {
|
|||
},
|
||||
}
|
||||
|
||||
err := sendRecoveryLockCommandsWithCommander(ctx, ds, mockCommander, logger)
|
||||
err := sendRecoveryLockCommandsWithCommander(ctx, ds, mockCommander, logger, nil)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1063,6 +1063,10 @@ type HasPendingRecoveryLockRotationFunc func(ctx context.Context, hostUUID strin
|
|||
|
||||
type ResetRecoveryLockForRetryFunc func(ctx context.Context, hostUUID string) error
|
||||
|
||||
type MarkRecoveryLockPasswordViewedFunc func(ctx context.Context, hostUUID string) (time.Time, error)
|
||||
|
||||
type GetHostsForAutoRotationFunc func(ctx context.Context) ([]fleet.HostAutoRotationInfo, 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
|
||||
|
|
@ -3394,6 +3398,12 @@ type DataStore struct {
|
|||
ResetRecoveryLockForRetryFunc ResetRecoveryLockForRetryFunc
|
||||
ResetRecoveryLockForRetryFuncInvoked bool
|
||||
|
||||
MarkRecoveryLockPasswordViewedFunc MarkRecoveryLockPasswordViewedFunc
|
||||
MarkRecoveryLockPasswordViewedFuncInvoked bool
|
||||
|
||||
GetHostsForAutoRotationFunc GetHostsForAutoRotationFunc
|
||||
GetHostsForAutoRotationFuncInvoked bool
|
||||
|
||||
InsertMDMAppleBootstrapPackageFunc InsertMDMAppleBootstrapPackageFunc
|
||||
InsertMDMAppleBootstrapPackageFuncInvoked bool
|
||||
|
||||
|
|
@ -8192,6 +8202,20 @@ func (s *DataStore) ResetRecoveryLockForRetry(ctx context.Context, hostUUID stri
|
|||
return s.ResetRecoveryLockForRetryFunc(ctx, hostUUID)
|
||||
}
|
||||
|
||||
func (s *DataStore) MarkRecoveryLockPasswordViewed(ctx context.Context, hostUUID string) (time.Time, error) {
|
||||
s.mu.Lock()
|
||||
s.MarkRecoveryLockPasswordViewedFuncInvoked = true
|
||||
s.mu.Unlock()
|
||||
return s.MarkRecoveryLockPasswordViewedFunc(ctx, hostUUID)
|
||||
}
|
||||
|
||||
func (s *DataStore) GetHostsForAutoRotation(ctx context.Context) ([]fleet.HostAutoRotationInfo, error) {
|
||||
s.mu.Lock()
|
||||
s.GetHostsForAutoRotationFuncInvoked = true
|
||||
s.mu.Unlock()
|
||||
return s.GetHostsForAutoRotationFunc(ctx)
|
||||
}
|
||||
|
||||
func (s *DataStore) InsertMDMAppleBootstrapPackage(ctx context.Context, bp *fleet.MDMAppleBootstrapPackage, pkgStore fleet.MDMBootstrapPackageStore) error {
|
||||
s.mu.Lock()
|
||||
s.InsertMDMAppleBootstrapPackageFuncInvoked = true
|
||||
|
|
|
|||
|
|
@ -6609,6 +6609,7 @@ func NewSetRecoveryLockResultsHandler(
|
|||
if err := ds.CompleteRecoveryLockRotation(ctx, hostUUID); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "SetRecoveryLock handler: complete rotation")
|
||||
}
|
||||
|
||||
logger.InfoContext(ctx, "RotateRecoveryLock acknowledged, password rotated",
|
||||
"host_uuid", hostUUID,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -656,7 +656,9 @@ func TestSetRecoveryLockResultsHandler(t *testing.T) {
|
|||
return "", nil
|
||||
}
|
||||
|
||||
// No activity should be created for manual rotation (activity logged at initiation)
|
||||
newActivityFn := func(_ context.Context, _ *fleet.User, _ fleet.ActivityDetails) error {
|
||||
t.Fatal("Activity should not be created for manual rotation completion")
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -764,4 +766,8 @@ func TestSetRecoveryLockResultsHandler(t *testing.T) {
|
|||
assert.True(t, failRotationCalled, "FailRecoveryLockRotation should be called for command format errors")
|
||||
assert.Equal(t, "RotateRecoveryLock command failed", capturedError)
|
||||
})
|
||||
|
||||
// Note: Activity logging for auto-rotation now happens at initiation time
|
||||
// (in the cron job's sendAutoRotationCommands), not at completion time.
|
||||
// Manual rotation activity is logged at the API handler level.
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3902,8 +3902,9 @@ type getHostRecoveryLockPasswordRequest struct {
|
|||
}
|
||||
|
||||
type recoveryLockPasswordPayload struct {
|
||||
Password string `json:"password"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Password string `json:"password"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
AutoRotateAt *time.Time `json:"auto_rotate_at,omitempty"`
|
||||
}
|
||||
|
||||
type getHostRecoveryLockPasswordResponse struct {
|
||||
|
|
@ -3923,8 +3924,9 @@ func getHostRecoveryLockPasswordEndpoint(ctx context.Context, request any, svc f
|
|||
return getHostRecoveryLockPasswordResponse{
|
||||
HostID: req.ID,
|
||||
RecoveryLockPassword: &recoveryLockPasswordPayload{
|
||||
Password: password.Password,
|
||||
UpdatedAt: password.UpdatedAt,
|
||||
Password: password.Password,
|
||||
UpdatedAt: password.UpdatedAt,
|
||||
AutoRotateAt: password.AutoRotateAt,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
|
@ -3966,6 +3968,8 @@ func (svc *Service) GetHostRecoveryLockPassword(ctx context.Context, hostID uint
|
|||
return nil, ctxerr.Wrap(ctx, err, "get host recovery lock password")
|
||||
}
|
||||
|
||||
// Create activity first. If this fails, we return an error before scheduling
|
||||
// rotation, ensuring we don't rotate passwords that were never returned to the user.
|
||||
if err := svc.NewActivity(
|
||||
ctx,
|
||||
authz.UserFromContext(ctx),
|
||||
|
|
@ -3977,6 +3981,14 @@ func (svc *Service) GetHostRecoveryLockPassword(ctx context.Context, hostID uint
|
|||
return nil, ctxerr.Wrap(ctx, err, "create activity for viewed host recovery lock password")
|
||||
}
|
||||
|
||||
// Schedule auto-rotation by marking the password as viewed.
|
||||
// This sets auto_rotate_at to 1 hour from now.
|
||||
rotateAt, err := svc.ds.MarkRecoveryLockPasswordViewed(ctx, host.UUID)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "mark recovery lock password viewed")
|
||||
}
|
||||
password.AutoRotateAt = &rotateAt
|
||||
|
||||
return password, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4734,10 +4734,102 @@ func TestGetHostRecoveryLockPassword(t *testing.T) {
|
|||
Password: "test-password",
|
||||
}, nil
|
||||
}
|
||||
ds.MarkRecoveryLockPasswordViewedFunc = func(ctx context.Context, hostUUID string) (time.Time, error) {
|
||||
return time.Now().Add(1 * time.Hour), nil
|
||||
}
|
||||
opts.ActivityMock.NewActivityFunc = func(_ context.Context, _ *activity_api.User, _ activity_api.ActivityDetails) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
userCtx := test.UserContext(ctx, test.UserAdmin)
|
||||
password, err := svc.GetHostRecoveryLockPassword(userCtx, 3)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "test-password", password.Password)
|
||||
})
|
||||
|
||||
t.Run("calls MarkRecoveryLockPasswordViewed and sets auto_rotate_at", func(t *testing.T) {
|
||||
ds := new(mock.Store)
|
||||
opts := &TestServerOpts{}
|
||||
svc, ctx := newTestService(t, ds, nil, nil, opts)
|
||||
|
||||
appleSiliconHost := &fleet.Host{
|
||||
ID: 4,
|
||||
Platform: "darwin",
|
||||
UUID: "apple-silicon-uuid-4",
|
||||
CPUType: "arm64e",
|
||||
}
|
||||
ds.HostFunc = func(ctx context.Context, id uint) (*fleet.Host, error) {
|
||||
return appleSiliconHost, nil
|
||||
}
|
||||
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
|
||||
return &fleet.AppConfig{
|
||||
MDM: fleet.MDM{
|
||||
EnabledAndConfigured: true,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
ds.GetHostRecoveryLockPasswordFunc = func(ctx context.Context, hostUUID string) (*fleet.HostRecoveryLockPassword, error) {
|
||||
return &fleet.HostRecoveryLockPassword{
|
||||
Password: "test-password-4",
|
||||
}, nil
|
||||
}
|
||||
opts.ActivityMock.NewActivityFunc = func(_ context.Context, _ *activity_api.User, _ activity_api.ActivityDetails) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
expectedRotateAt := time.Now().Add(1 * time.Hour)
|
||||
markViewedCalled := false
|
||||
ds.MarkRecoveryLockPasswordViewedFunc = func(ctx context.Context, hostUUID string) (time.Time, error) {
|
||||
markViewedCalled = true
|
||||
assert.Equal(t, "apple-silicon-uuid-4", hostUUID)
|
||||
return expectedRotateAt, nil
|
||||
}
|
||||
|
||||
userCtx := test.UserContext(ctx, test.UserAdmin)
|
||||
password, err := svc.GetHostRecoveryLockPassword(userCtx, 4)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "test-password-4", password.Password)
|
||||
assert.True(t, markViewedCalled, "MarkRecoveryLockPasswordViewed should be called")
|
||||
require.NotNil(t, password.AutoRotateAt)
|
||||
assert.WithinDuration(t, expectedRotateAt, *password.AutoRotateAt, 1*time.Second)
|
||||
})
|
||||
|
||||
t.Run("fails if MarkRecoveryLockPasswordViewed fails", func(t *testing.T) {
|
||||
ds := new(mock.Store)
|
||||
opts := &TestServerOpts{}
|
||||
svc, ctx := newTestService(t, ds, nil, nil, opts)
|
||||
|
||||
appleSiliconHost := &fleet.Host{
|
||||
ID: 5,
|
||||
Platform: "darwin",
|
||||
UUID: "apple-silicon-uuid-5",
|
||||
CPUType: "arm64e",
|
||||
}
|
||||
ds.HostFunc = func(ctx context.Context, id uint) (*fleet.Host, error) {
|
||||
return appleSiliconHost, nil
|
||||
}
|
||||
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
|
||||
return &fleet.AppConfig{
|
||||
MDM: fleet.MDM{
|
||||
EnabledAndConfigured: true,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
ds.GetHostRecoveryLockPasswordFunc = func(ctx context.Context, hostUUID string) (*fleet.HostRecoveryLockPassword, error) {
|
||||
return &fleet.HostRecoveryLockPassword{
|
||||
Password: "test-password-5",
|
||||
}, nil
|
||||
}
|
||||
ds.MarkRecoveryLockPasswordViewedFunc = func(ctx context.Context, hostUUID string) (time.Time, error) {
|
||||
return time.Time{}, errors.New("database error")
|
||||
}
|
||||
|
||||
userCtx := test.UserContext(ctx, test.UserAdmin)
|
||||
// Should fail because rotation scheduling failed - password must not be
|
||||
// returned unless rotation is successfully scheduled
|
||||
password, err := svc.GetHostRecoveryLockPassword(userCtx, 5)
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, password)
|
||||
assert.Contains(t, err.Error(), "mark recovery lock password viewed")
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22355,7 +22355,7 @@ func (s *integrationMDMTestSuite) TestRecoveryLockPasswordIntegration() {
|
|||
runRecoveryLockCron := func(t *testing.T) {
|
||||
t.Helper()
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
err := apple_mdm.SendRecoveryLockCommands(t.Context(), s.ds, s.mdmCommander, logger)
|
||||
err := apple_mdm.SendRecoveryLockCommands(t.Context(), s.ds, s.mdmCommander, logger, s.fleetSvc.NewActivity)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
|
|
@ -22568,7 +22568,7 @@ func (s *integrationMDMTestSuite) TestRecoveryLockPasswordIntegration() {
|
|||
require.NotNil(t, getPasswordResp.RecoveryLockPassword)
|
||||
assert.NotEqual(t, originalPassword, getPasswordResp.RecoveryLockPassword.Password, "password should be different after rotation")
|
||||
|
||||
// Verify activity was created
|
||||
// Verify activity was created for user-triggered rotation
|
||||
s.lastActivityOfTypeMatches(fleet.ActivityTypeRotatedHostRecoveryLockPassword{}.ActivityName(),
|
||||
fmt.Sprintf(`{"host_id": %d, "host_display_name": %q}`, host.ID, host.DisplayName()), 0)
|
||||
|
||||
|
|
@ -22939,6 +22939,109 @@ func (s *integrationMDMTestSuite) TestRecoveryLockPasswordIntegration() {
|
|||
}, http.StatusOK, &appConfigResponse{})
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// Test 11: Viewing password sets auto_rotate_at
|
||||
// =========================================================================
|
||||
t.Run("Viewing password sets auto_rotate_at", func(t *testing.T) {
|
||||
// Enable recovery lock password
|
||||
s.DoJSON("PATCH", "/api/latest/fleet/config", map[string]any{
|
||||
"mdm": map[string]any{"enable_recovery_lock_password": true},
|
||||
}, http.StatusOK, &appConfigResponse{})
|
||||
|
||||
host, mdmClient := createAppleSiliconHost(t)
|
||||
|
||||
// Run cron and acknowledge command to get to verified state
|
||||
runRecoveryLockCron(t)
|
||||
cmd, err := mdmClient.Idle()
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cmd)
|
||||
_, err = mdmClient.Acknowledge(cmd.CommandUUID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// View the password
|
||||
var getPasswordResp getHostRecoveryLockPasswordResponse
|
||||
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/recovery_lock_password", host.ID), nil, http.StatusOK, &getPasswordResp)
|
||||
require.NotNil(t, getPasswordResp.RecoveryLockPassword)
|
||||
assert.NotEmpty(t, getPasswordResp.RecoveryLockPassword.Password)
|
||||
|
||||
// auto_rotate_at should be set (approximately 1 hour from now)
|
||||
require.NotNil(t, getPasswordResp.RecoveryLockPassword.AutoRotateAt, "auto_rotate_at should be set after viewing password")
|
||||
expectedRotateAt := time.Now().Add(1 * time.Hour)
|
||||
assert.WithinDuration(t, expectedRotateAt, *getPasswordResp.RecoveryLockPassword.AutoRotateAt, 2*time.Minute)
|
||||
|
||||
// Disable recovery lock password
|
||||
s.DoJSON("PATCH", "/api/latest/fleet/config", map[string]any{
|
||||
"mdm": map[string]any{"enable_recovery_lock_password": false},
|
||||
}, http.StatusOK, &appConfigResponse{})
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// Test 12: Auto-rotation triggers after password is viewed
|
||||
// =========================================================================
|
||||
t.Run("Auto-rotation triggers after password is viewed", func(t *testing.T) {
|
||||
// Enable recovery lock password
|
||||
s.DoJSON("PATCH", "/api/latest/fleet/config", map[string]any{
|
||||
"mdm": map[string]any{"enable_recovery_lock_password": true},
|
||||
}, http.StatusOK, &appConfigResponse{})
|
||||
|
||||
host, mdmClient := createAppleSiliconHost(t)
|
||||
|
||||
// Run cron and acknowledge command to get to verified state
|
||||
runRecoveryLockCron(t)
|
||||
cmd, err := mdmClient.Idle()
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cmd)
|
||||
_, err = mdmClient.Acknowledge(cmd.CommandUUID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Get the original password
|
||||
var getPasswordResp getHostRecoveryLockPasswordResponse
|
||||
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/recovery_lock_password", host.ID), nil, http.StatusOK, &getPasswordResp)
|
||||
require.NotNil(t, getPasswordResp.RecoveryLockPassword)
|
||||
originalPassword := getPasswordResp.RecoveryLockPassword.Password
|
||||
|
||||
// Manually set auto_rotate_at to the past to trigger auto-rotation
|
||||
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
|
||||
_, err := q.ExecContext(t.Context(),
|
||||
`UPDATE host_recovery_key_passwords SET auto_rotate_at = ? WHERE host_uuid = ?`,
|
||||
time.Now().Add(-1*time.Hour), host.UUID)
|
||||
return err
|
||||
})
|
||||
|
||||
// Run the cron job - should trigger auto-rotation
|
||||
runRecoveryLockCron(t)
|
||||
|
||||
// Host should receive SetRecoveryLock command for rotation
|
||||
// (rotation uses the same MDM command type as initial setup, but with a new password)
|
||||
cmd, err = mdmClient.Idle()
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cmd, "should receive rotation command")
|
||||
assert.Equal(t, "SetRecoveryLock", cmd.Command.RequestType)
|
||||
|
||||
// Acknowledge the rotation command
|
||||
_, err = mdmClient.Acknowledge(cmd.CommandUUID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Password should now be different (rotated)
|
||||
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/recovery_lock_password", host.ID), nil, http.StatusOK, &getPasswordResp)
|
||||
require.NotNil(t, getPasswordResp.RecoveryLockPassword)
|
||||
assert.NotEqual(t, originalPassword, getPasswordResp.RecoveryLockPassword.Password, "password should be different after auto-rotation")
|
||||
|
||||
// auto_rotate_at should be set again (viewing the password schedules another rotation)
|
||||
require.NotNil(t, getPasswordResp.RecoveryLockPassword.AutoRotateAt, "auto_rotate_at should be set after viewing rotated password")
|
||||
expectedRotateAt := time.Now().Add(1 * time.Hour)
|
||||
assert.WithinDuration(t, expectedRotateAt, *getPasswordResp.RecoveryLockPassword.AutoRotateAt, 2*time.Minute)
|
||||
|
||||
// Verify auto-rotation activity was created (Fleet-initiated, uses same activity type as manual rotation)
|
||||
s.lastActivityOfTypeMatches(fleet.ActivityTypeRotatedHostRecoveryLockPassword{}.ActivityName(),
|
||||
fmt.Sprintf(`{"host_id": %d, "host_display_name": %q}`, host.ID, host.DisplayName()), 0)
|
||||
|
||||
// Disable recovery lock password
|
||||
s.DoJSON("PATCH", "/api/latest/fleet/config", map[string]any{
|
||||
"mdm": map[string]any{"enable_recovery_lock_password": false},
|
||||
}, http.StatusOK, &appConfigResponse{})
|
||||
})
|
||||
|
||||
// Final cleanup: ensure recovery lock is disabled
|
||||
s.DoJSON("PATCH", "/api/latest/fleet/config", map[string]any{
|
||||
"mdm": map[string]any{"enable_recovery_lock_password": false},
|
||||
|
|
|
|||
Loading…
Reference in a new issue