fleet/server/datastore/mysql/nanomdm_storage.go

394 lines
12 KiB
Go
Raw Normal View History

package mysql
import (
"context"
"crypto/tls"
"database/sql"
"errors"
"fmt"
"log/slog"
"strings"
"time"
feat: enable multiple ABM and VPP tokens (#21693) > Related issue: #9956 # Checklist for submitter If some of the following don't apply, delete the relevant line. <!-- Note that API documentation changes are now addressed by the product design team. --> - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/Committing-Changes.md#changes-files) for more information. - [x] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements) - [x] Added/updated tests - [x] If paths of existing endpoints are modified without backwards compatibility, checked the frontend/CLI for any necessary changes - [x] If database migrations are included, checked table schema to confirm autoupdate - For database migrations: - [x] Checked schema for all modified table for columns that will auto-update timestamps during migration. - [x] Confirmed that updating the timestamps is acceptable, and will not cause unwanted side effects. - [x] Ensured the correct collation is explicitly set for character columns (`COLLATE utf8mb4_unicode_ci`). - [x] Manual QA for all new/changed functionality --------- Co-authored-by: Martin Angers <martin.n.angers@gmail.com> Co-authored-by: Gabriel Hernandez <ghernandez345@gmail.com> Co-authored-by: Roberto Dip <rroperzh@gmail.com> Co-authored-by: Sarah Gillespie <73313222+gillespi314@users.noreply.github.com> Co-authored-by: Dante Catalfamo <43040593+dantecatalfamo@users.noreply.github.com> Co-authored-by: Roberto Dip <dip.jesusr@gmail.com>
2024-08-29 22:51:46 +00:00
abmctx "github.com/fleetdm/fleet/v4/server/contexts/apple_bm"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mdm/assets"
nanodep_client "github.com/fleetdm/fleet/v4/server/mdm/nanodep/client"
nanodep_mysql "github.com/fleetdm/fleet/v4/server/mdm/nanodep/storage/mysql"
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
nanomdm_mysql "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/storage/mysql"
common_mysql "github.com/fleetdm/fleet/v4/server/platform/mysql"
"github.com/jmoiron/sqlx"
)
// lockConflictError indicates a lock command already exists for the host
type lockConflictError struct {
hostUUID string
}
func (e lockConflictError) Error() string {
return "host already has a pending lock command"
}
func (e lockConflictError) IsConflict() bool {
return true
}
func (e lockConflictError) IsClientError() bool {
return true
}
// isConflict checks if an error implements the IsConflict() interface
func isConflict(err error) bool {
type conflictInterface interface {
IsConflict() bool
}
if c, ok := err.(conflictInterface); ok {
return c.IsConflict()
}
return false
}
// NanoMDMStorage wraps a *nanomdm_mysql.MySQLStorage and overrides further functionality.
type NanoMDMStorage struct {
*nanomdm_mysql.MySQLStorage
db *sqlx.DB
logger *slog.Logger
ds fleet.Datastore
}
// NewMDMAppleMDMStorage returns a MySQL nanomdm storage that uses the Datastore
// underlying MySQL writer *sql.DB.
func (ds *Datastore) NewMDMAppleMDMStorage() (*NanoMDMStorage, error) {
s, err := nanomdm_mysql.New(
nanomdm_mysql.WithDB(ds.primary.DB),
nanomdm_mysql.WithLogger(ds.logger),
nanomdm_mysql.WithReaderFunc(ds.reader),
)
if err != nil {
return nil, err
}
return &NanoMDMStorage{
MySQLStorage: s,
db: ds.primary,
logger: ds.logger,
ds: ds,
}, nil
}
// NewTestMDMAppleMDMStorage returns a test MySQL nanomdm storage that uses the
// Datastore underlying MySQL writer *sql.DB. It allows configuring the async
// last seen time's capacity and interval and should only be used in tests.
func (ds *Datastore) NewTestMDMAppleMDMStorage(asyncCap int, asyncInterval time.Duration) (*NanoMDMStorage, error) {
s, err := nanomdm_mysql.New(
nanomdm_mysql.WithDB(ds.primary.DB),
nanomdm_mysql.WithLogger(ds.logger),
nanomdm_mysql.WithReaderFunc(ds.reader),
nanomdm_mysql.WithAsyncLastSeen(asyncCap, asyncInterval),
)
if err != nil {
return nil, err
}
return &NanoMDMStorage{
MySQLStorage: s,
db: ds.primary,
logger: ds.logger,
ds: ds,
}, nil
}
// RetrievePushCert partially implements nanomdm_storage.PushCertStore.
//
// Always returns "0" as stale token because fleet.Datastore always returns a valid push certificate.
func (s *NanoMDMStorage) RetrievePushCert(
ctx context.Context, topic string,
) (*tls.Certificate, string, error) {
cert, err := assets.APNSKeyPair(ctx, s.ds)
if err != nil {
return nil, "", ctxerr.Wrap(ctx, err, "loading push certificate")
}
return cert, "0", nil
}
// IsPushCertStale partially implements nanomdm_storage.PushCertStore.
//
// Always returns `false` because the underlying datastore implementation makes sure that the token is always fresh.
func (s *NanoMDMStorage) IsPushCertStale(ctx context.Context, topic, staleToken string) (bool, error) {
return false, nil
}
// StorePushCert partially implements nanomdm_storage.PushCertStore.
func (s *NanoMDMStorage) StorePushCert(ctx context.Context, pemCert, pemKey []byte) error {
return errors.New("please use fleet.Datastore to manage MDM assets")
}
// GetPendingLockCommand returns the most recent unacknowledged DeviceLock command
// for the given host, along with its unlock PIN.
// Returns nil, "", nil if no pending lock command exists.
func (s *NanoMDMStorage) GetPendingLockCommand(ctx context.Context, hostUUID string) (*mdm.Command, string, error) {
query := `
SELECT nc.command_uuid, nc.request_type, nc.command, hma.unlock_pin
FROM nano_commands nc
INNER JOIN host_mdm_actions hma ON hma.lock_ref = nc.command_uuid
LEFT JOIN nano_command_results ncr ON ncr.command_uuid = nc.command_uuid
INNER JOIN nano_enrollment_queue neq ON neq.command_uuid = nc.command_uuid
WHERE neq.id = ?
AND nc.request_type = 'DeviceLock'
AND ncr.command_uuid IS NULL
ORDER BY nc.created_at DESC
LIMIT 1`
var result struct {
CommandUUID string `db:"command_uuid"`
RequestType string `db:"request_type"`
Command []byte `db:"command"`
UnlockPIN string `db:"unlock_pin"`
}
err := sqlx.GetContext(ctx, s.db, &result, query, hostUUID)
if err == sql.ErrNoRows {
return nil, "", nil
}
if err != nil {
return nil, "", ctxerr.Wrap(ctx, err, "getting pending lock command")
}
cmd := &mdm.Command{
CommandUUID: result.CommandUUID,
Command: struct {
RequestType string
}{
RequestType: result.RequestType,
},
Raw: result.Command,
}
return cmd, result.UnlockPIN, nil
}
// EnqueueDeviceLockCommand enqueues a DeviceLock command for the given host.
//
// A few implementation details:
// - It can only be called for a single hosts, to ensure we don't use the same
// pin for multiple hosts.
// - The method performs fleet-specific actions after the command is enqueued.
// - It will fail with a ConflictError if a lock command already exists.
func (s *NanoMDMStorage) EnqueueDeviceLockCommand(
ctx context.Context,
host *fleet.Host,
cmd *mdm.Command,
pin string,
) error {
return common_mysql.WithRetryTxx(ctx, s.db, func(tx sqlx.ExtContext) error {
// check if a lock already exists using SELECT FOR UPDATE to prevent a race
var existingLockRef *string
err := sqlx.GetContext(ctx, tx, &existingLockRef,
`SELECT lock_ref FROM host_mdm_actions WHERE host_id = ? FOR UPDATE`,
host.ID)
// If we got a row and it has a lock_ref, fail with conflict
if err == nil && existingLockRef != nil && *existingLockRef != "" {
// A lock command already exists, don't overwrite
return lockConflictError{hostUUID: host.UUID}
}
// If the row doesn't exist, that's OK, we'll insert it
if err != nil && err != sql.ErrNoRows {
return ctxerr.Wrap(ctx, err, "checking for existing lock")
}
// Now enqueue the command
if err := enqueueCommandDB(ctx, tx, []string{host.UUID}, cmd); err != nil {
return err
}
// Insert or update the host_mdm_actions row
stmt := `
INSERT INTO host_mdm_actions (
host_id,
lock_ref,
unlock_pin,
fleet_platform
)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
wipe_ref = NULL,
unlock_ref = NULL,
unlock_pin = VALUES(unlock_pin),
lock_ref = VALUES(lock_ref)`
if _, err := tx.ExecContext(ctx, stmt, host.ID, cmd.CommandUUID, pin, host.FleetPlatform()); err != nil {
return ctxerr.Wrap(ctx, err, "modifying host_mdm_actions for DeviceLock")
}
return nil
}, s.logger)
}
Add lost mode behaviour for iOS/iPadOS (#33805) <!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** Resolves #33416 It's been decided to ship the feature and in the guide mention the apple bug, that we are currently tracking. [Slack 🧵](https://fleetdm.slack.com/archives/C03C41L5YEL/p1760448150025089?thread_ts=1760433366.092499&cid=C03C41L5YEL) # 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. ## Testing - [x] Added/updated automated tests - [x] QA'd all new/changed functionality manually <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * New Features * Added Lost Mode support to lock iOS and iPadOS devices. * Added ability to disable Lost Mode to unlock iOS/iPadOS devices. * Improvements * More consistent lock/unlock experience across macOS, iOS/iPadOS, Windows, and Linux, with clearer status and activity updates. * iOS/iPadOS now shows pending unlock status while Lost Mode disable is in progress. * Tests * Added comprehensive end-to-end tests covering lock/unlock/wipe across Apple, Windows, and Linux devices. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-14 14:30:05 +00:00
func (s *NanoMDMStorage) EnqueueDeviceUnlockCommand(ctx context.Context, host *fleet.Host, cmd *mdm.Command) error {
return common_mysql.WithRetryTxx(ctx, s.db, func(tx sqlx.ExtContext) error {
if err := enqueueCommandDB(ctx, tx, []string{host.UUID}, cmd); err != nil {
return err
}
stmt := `
INSERT INTO host_mdm_actions (
host_id,
unlock_ref,
fleet_platform
)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE
unlock_ref = VALUES(unlock_ref),
unlock_pin = NULL`
if _, err := tx.ExecContext(ctx, stmt, host.ID, cmd.CommandUUID, host.FleetPlatform()); err != nil {
return ctxerr.Wrap(ctx, err, "modifying host_mdm_actions for DeviceUnlock")
}
return nil
}, s.logger)
}
// EnqueueDeviceWipeCommand enqueues a EraseDevice command for the given host.
func (s *NanoMDMStorage) EnqueueDeviceWipeCommand(ctx context.Context, host *fleet.Host, cmd *mdm.Command) error {
return common_mysql.WithRetryTxx(ctx, s.db, func(tx sqlx.ExtContext) error {
if err := enqueueCommandDB(ctx, tx, []string{host.UUID}, cmd); err != nil {
return err
}
stmt := `
INSERT INTO host_mdm_actions (
host_id,
wipe_ref,
fleet_platform
)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE
wipe_ref = VALUES(wipe_ref)`
if _, err := tx.ExecContext(ctx, stmt, host.ID, cmd.CommandUUID, host.FleetPlatform()); err != nil {
return ctxerr.Wrap(ctx, err, "modifying host_mdm_actions for DeviceWipe")
}
return nil
}, s.logger)
}
func (s *NanoMDMStorage) GetAllMDMConfigAssetsByName(ctx context.Context, assetNames []fleet.MDMAssetName,
Add lost mode behaviour for iOS/iPadOS (#33805) <!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** Resolves #33416 It's been decided to ship the feature and in the guide mention the apple bug, that we are currently tracking. [Slack 🧵](https://fleetdm.slack.com/archives/C03C41L5YEL/p1760448150025089?thread_ts=1760433366.092499&cid=C03C41L5YEL) # 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. ## Testing - [x] Added/updated automated tests - [x] QA'd all new/changed functionality manually <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * New Features * Added Lost Mode support to lock iOS and iPadOS devices. * Added ability to disable Lost Mode to unlock iOS/iPadOS devices. * Improvements * More consistent lock/unlock experience across macOS, iOS/iPadOS, Windows, and Linux, with clearer status and activity updates. * iOS/iPadOS now shows pending unlock status while Lost Mode disable is in progress. * Tests * Added comprehensive end-to-end tests covering lock/unlock/wipe across Apple, Windows, and Linux devices. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-14 14:30:05 +00:00
queryerContext sqlx.QueryerContext,
) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) {
return s.ds.GetAllMDMConfigAssetsByName(ctx, assetNames, queryerContext)
}
feat: enable multiple ABM and VPP tokens (#21693) > Related issue: #9956 # Checklist for submitter If some of the following don't apply, delete the relevant line. <!-- Note that API documentation changes are now addressed by the product design team. --> - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/Committing-Changes.md#changes-files) for more information. - [x] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements) - [x] Added/updated tests - [x] If paths of existing endpoints are modified without backwards compatibility, checked the frontend/CLI for any necessary changes - [x] If database migrations are included, checked table schema to confirm autoupdate - For database migrations: - [x] Checked schema for all modified table for columns that will auto-update timestamps during migration. - [x] Confirmed that updating the timestamps is acceptable, and will not cause unwanted side effects. - [x] Ensured the correct collation is explicitly set for character columns (`COLLATE utf8mb4_unicode_ci`). - [x] Manual QA for all new/changed functionality --------- Co-authored-by: Martin Angers <martin.n.angers@gmail.com> Co-authored-by: Gabriel Hernandez <ghernandez345@gmail.com> Co-authored-by: Roberto Dip <rroperzh@gmail.com> Co-authored-by: Sarah Gillespie <73313222+gillespi314@users.noreply.github.com> Co-authored-by: Dante Catalfamo <43040593+dantecatalfamo@users.noreply.github.com> Co-authored-by: Roberto Dip <dip.jesusr@gmail.com>
2024-08-29 22:51:46 +00:00
func (s *NanoMDMStorage) GetABMTokenByOrgName(ctx context.Context, orgName string) (*fleet.ABMToken, error) {
return s.ds.GetABMTokenByOrgName(ctx, orgName)
}
// ExpandEmbeddedSecrets in NanoMDMStorage overrides the implementation in nanomdm_mysql.MySQLStorage.
func (s *NanoMDMStorage) ExpandEmbeddedSecrets(ctx context.Context, document string) (string, error) {
return s.ds.ExpandEmbeddedSecrets(ctx, document)
}
// ExpandHostSecrets expands host-scoped secrets in the document using the enrollment ID.
func (s *NanoMDMStorage) ExpandHostSecrets(ctx context.Context, document string, enrollmentID string) (string, error) {
return s.ds.ExpandHostSecrets(ctx, document, enrollmentID)
}
func (s *NanoMDMStorage) SetRecoveryLockFailed(ctx context.Context, hostUUID string, errorMsg string) error {
return s.ds.SetRecoveryLockFailed(ctx, hostUUID, errorMsg)
}
// ClearQueue in NanoMDMStorage overrides the implementation in
// nanomdm_mysql.MySQLStorage. It does call
// nanomdm_mysql.MySQLStorage.ClearQueue, but expands on its behavior.
func (s *NanoMDMStorage) ClearQueue(r *mdm.Request) error {
err := common_mysql.WithRetryTxx(r.Context, s.db, func(tx sqlx.ExtContext) error {
if err := s.ds.ClearMDMUpcomingActivitiesDB(r.Context, tx, r.ID); err != nil {
return err
}
return nil
}, s.logger)
if err != nil {
return err
}
return s.MySQLStorage.ClearQueue(r)
}
// NewMDMAppleDEPStorage returns a MySQL nanodep storage that uses the Datastore
// underlying MySQL writer *sql.DB.
func (ds *Datastore) NewMDMAppleDEPStorage() (*NanoDEPStorage, error) {
s, err := nanodep_mysql.New(nanodep_mysql.WithDB(ds.primary.DB))
if err != nil {
return nil, err
}
return &NanoDEPStorage{
MySQLStorage: s,
ds: ds,
}, nil
}
// NanoDEPStorage wraps a *nanodep_mysql.MySQLStorage and overrides functionality to load
// DEP auth tokens from the tables managed by Fleet.
type NanoDEPStorage struct {
*nanodep_mysql.MySQLStorage
ds fleet.Datastore
}
feat: enable multiple ABM and VPP tokens (#21693) > Related issue: #9956 # Checklist for submitter If some of the following don't apply, delete the relevant line. <!-- Note that API documentation changes are now addressed by the product design team. --> - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/Committing-Changes.md#changes-files) for more information. - [x] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements) - [x] Added/updated tests - [x] If paths of existing endpoints are modified without backwards compatibility, checked the frontend/CLI for any necessary changes - [x] If database migrations are included, checked table schema to confirm autoupdate - For database migrations: - [x] Checked schema for all modified table for columns that will auto-update timestamps during migration. - [x] Confirmed that updating the timestamps is acceptable, and will not cause unwanted side effects. - [x] Ensured the correct collation is explicitly set for character columns (`COLLATE utf8mb4_unicode_ci`). - [x] Manual QA for all new/changed functionality --------- Co-authored-by: Martin Angers <martin.n.angers@gmail.com> Co-authored-by: Gabriel Hernandez <ghernandez345@gmail.com> Co-authored-by: Roberto Dip <rroperzh@gmail.com> Co-authored-by: Sarah Gillespie <73313222+gillespi314@users.noreply.github.com> Co-authored-by: Dante Catalfamo <43040593+dantecatalfamo@users.noreply.github.com> Co-authored-by: Roberto Dip <dip.jesusr@gmail.com>
2024-08-29 22:51:46 +00:00
// RetrieveAuthTokens partially implements nanodep.AuthTokensRetriever. NOTE: this method will first
// check the context for an ABM token; if it doesn't find one, it will fall back to checking the DB.
// This is so we can use the existing DEP client machinery without major changes. See
// https://github.com/fleetdm/fleet/issues/21177 for more details.
func (s *NanoDEPStorage) RetrieveAuthTokens(ctx context.Context, name string) (*nanodep_client.OAuth1Tokens, error) {
feat: enable multiple ABM and VPP tokens (#21693) > Related issue: #9956 # Checklist for submitter If some of the following don't apply, delete the relevant line. <!-- Note that API documentation changes are now addressed by the product design team. --> - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/Committing-Changes.md#changes-files) for more information. - [x] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements) - [x] Added/updated tests - [x] If paths of existing endpoints are modified without backwards compatibility, checked the frontend/CLI for any necessary changes - [x] If database migrations are included, checked table schema to confirm autoupdate - For database migrations: - [x] Checked schema for all modified table for columns that will auto-update timestamps during migration. - [x] Confirmed that updating the timestamps is acceptable, and will not cause unwanted side effects. - [x] Ensured the correct collation is explicitly set for character columns (`COLLATE utf8mb4_unicode_ci`). - [x] Manual QA for all new/changed functionality --------- Co-authored-by: Martin Angers <martin.n.angers@gmail.com> Co-authored-by: Gabriel Hernandez <ghernandez345@gmail.com> Co-authored-by: Roberto Dip <rroperzh@gmail.com> Co-authored-by: Sarah Gillespie <73313222+gillespi314@users.noreply.github.com> Co-authored-by: Dante Catalfamo <43040593+dantecatalfamo@users.noreply.github.com> Co-authored-by: Roberto Dip <dip.jesusr@gmail.com>
2024-08-29 22:51:46 +00:00
if ctxTok, ok := abmctx.FromContext(ctx); ok {
return ctxTok, nil
}
token, err := assets.ABMToken(ctx, s.ds, name)
if err != nil {
return nil, fmt.Errorf("retrieving token in nano dep storage: %w", err)
}
return token, nil
}
// StoreAuthTokens partially implements nanodep.AuthTokensStorer.
func (s *NanoDEPStorage) StoreAuthTokens(ctx context.Context, name string, tokens *nanodep_client.OAuth1Tokens) error {
return errors.New("please use fleet.Datastore to manage MDM assets")
}
func enqueueCommandDB(ctx context.Context, tx sqlx.ExtContext, ids []string, cmd *mdm.Command) error {
// NOTE: the code to insert into nano_commands and
// nano_enrollment_queue was copied verbatim from the nanomdm
// implementation. Ideally we modify some of the interfaces to not
// duplicate the code here, but that needs more careful planning
// (which we lack right now)
if len(ids) < 1 {
return errors.New("no id(s) supplied to queue command to")
}
_, err := tx.ExecContext(
ctx,
`INSERT INTO nano_commands (command_uuid, request_type, command) VALUES (?, ?, ?);`,
cmd.CommandUUID, cmd.Command.RequestType, cmd.Raw,
)
if err != nil {
return err
}
query := `INSERT INTO nano_enrollment_queue (id, command_uuid) VALUES (?, ?)`
query += strings.Repeat(", (?, ?)", len(ids)-1)
args := make([]interface{}, len(ids)*2)
for i, id := range ids {
args[i*2] = id
args[i*2+1] = cmd.CommandUUID
}
if _, err = tx.ExecContext(ctx, query+";", args...); err != nil {
return err
}
return nil
}