diff --git a/changes/43222-support-fleet-variables-in-ddm b/changes/43222-support-fleet-variables-in-ddm new file mode 100644 index 0000000000..8fcb2ee2d3 --- /dev/null +++ b/changes/43222-support-fleet-variables-in-ddm @@ -0,0 +1 @@ +- Added support for Fleet variables in Apple's declaration profiles (DDM). diff --git a/cmd/fleetctl/fleetctl/apply_deprecated_test.go b/cmd/fleetctl/fleetctl/apply_deprecated_test.go index 1fd59f44ec..b28dd3f9aa 100644 --- a/cmd/fleetctl/fleetctl/apply_deprecated_test.go +++ b/cmd/fleetctl/fleetctl/apply_deprecated_test.go @@ -202,7 +202,7 @@ func TestApplyAsGitOpsDeprecatedKeys(t *testing.T) { ds.SetAsideLabelsFunc = func(ctx context.Context, notOnTeamID *uint, names []string, user fleet.User) error { return nil } - ds.SetOrUpdateMDMAppleDeclarationFunc = func(ctx context.Context, declaration *fleet.MDMAppleDeclaration) (*fleet.MDMAppleDeclaration, error) { + ds.SetOrUpdateMDMAppleDeclarationFunc = func(ctx context.Context, declaration *fleet.MDMAppleDeclaration, usesFleetVars []fleet.FleetVarName) (*fleet.MDMAppleDeclaration, error) { declaration.DeclarationUUID = uuid.NewString() return declaration, nil } diff --git a/cmd/fleetctl/fleetctl/apply_test.go b/cmd/fleetctl/fleetctl/apply_test.go index 42b39dbbbb..186f4eb3f5 100644 --- a/cmd/fleetctl/fleetctl/apply_test.go +++ b/cmd/fleetctl/fleetctl/apply_test.go @@ -298,7 +298,7 @@ func TestApplyTeamSpecs(t *testing.T) { } } - ds.SetOrUpdateMDMAppleDeclarationFunc = func(ctx context.Context, declaration *fleet.MDMAppleDeclaration) (*fleet.MDMAppleDeclaration, error) { + ds.SetOrUpdateMDMAppleDeclarationFunc = func(ctx context.Context, declaration *fleet.MDMAppleDeclaration, usesFleetVars []fleet.FleetVarName) (*fleet.MDMAppleDeclaration, error) { declaration.DeclarationUUID = uuid.NewString() return declaration, nil } @@ -755,7 +755,7 @@ func TestApplyAppConfig(t *testing.T) { return map[string]uint{fleet.BuiltinLabelMacOS14Plus: 1}, nil } - ds.SetOrUpdateMDMAppleDeclarationFunc = func(ctx context.Context, declaration *fleet.MDMAppleDeclaration) (*fleet.MDMAppleDeclaration, error) { + ds.SetOrUpdateMDMAppleDeclarationFunc = func(ctx context.Context, declaration *fleet.MDMAppleDeclaration, usesFleetVars []fleet.FleetVarName) (*fleet.MDMAppleDeclaration, error) { declaration.DeclarationUUID = uuid.NewString() return declaration, nil } @@ -1486,7 +1486,7 @@ func TestApplyAsGitOps(t *testing.T) { ds.SetAsideLabelsFunc = func(ctx context.Context, notOnTeamID *uint, names []string, user fleet.User) error { return nil } - ds.SetOrUpdateMDMAppleDeclarationFunc = func(ctx context.Context, declaration *fleet.MDMAppleDeclaration) (*fleet.MDMAppleDeclaration, error) { + ds.SetOrUpdateMDMAppleDeclarationFunc = func(ctx context.Context, declaration *fleet.MDMAppleDeclaration, usesFleetVars []fleet.FleetVarName) (*fleet.MDMAppleDeclaration, error) { declaration.DeclarationUUID = uuid.NewString() return declaration, nil } diff --git a/cmd/fleetctl/fleetctl/get_test.go b/cmd/fleetctl/fleetctl/get_test.go index 361838b8f8..b4381e08e2 100644 --- a/cmd/fleetctl/fleetctl/get_test.go +++ b/cmd/fleetctl/fleetctl/get_test.go @@ -2610,7 +2610,7 @@ func TestGetTeamsYAMLAndApply(t *testing.T) { require.ElementsMatch(t, names, []string{fleet.BuiltinLabelMacOS14Plus}) return map[string]uint{fleet.BuiltinLabelMacOS14Plus: 1}, nil } - ds.SetOrUpdateMDMAppleDeclarationFunc = func(ctx context.Context, declaration *fleet.MDMAppleDeclaration) (*fleet.MDMAppleDeclaration, error) { + ds.SetOrUpdateMDMAppleDeclarationFunc = func(ctx context.Context, declaration *fleet.MDMAppleDeclaration, usesFleetVars []fleet.FleetVarName) (*fleet.MDMAppleDeclaration, error) { declaration.DeclarationUUID = uuid.NewString() return declaration, nil } diff --git a/cmd/fleetctl/fleetctl/gitops_test.go b/cmd/fleetctl/fleetctl/gitops_test.go index 24f92bcf0a..12fe4596c3 100644 --- a/cmd/fleetctl/fleetctl/gitops_test.go +++ b/cmd/fleetctl/fleetctl/gitops_test.go @@ -1637,13 +1637,13 @@ func TestGitOpsFullTeam(t *testing.T) { ds.NewMDMAppleConfigProfileFunc = func(ctx context.Context, profile fleet.MDMAppleConfigProfile, vars []fleet.FleetVarName) (*fleet.MDMAppleConfigProfile, error) { return &profile, nil } - ds.NewMDMAppleDeclarationFunc = func(ctx context.Context, declaration *fleet.MDMAppleDeclaration) (*fleet.MDMAppleDeclaration, error) { + ds.NewMDMAppleDeclarationFunc = func(ctx context.Context, declaration *fleet.MDMAppleDeclaration, usesFleetVars []fleet.FleetVarName) (*fleet.MDMAppleDeclaration, error) { return declaration, nil } ds.LabelIDsByNameFunc = func(ctx context.Context, names []string, filter fleet.TeamFilter) (map[string]uint, error) { return map[string]uint{fleet.BuiltinLabelMacOS14Plus: 1}, nil } - ds.SetOrUpdateMDMAppleDeclarationFunc = func(ctx context.Context, declaration *fleet.MDMAppleDeclaration) (*fleet.MDMAppleDeclaration, error) { + ds.SetOrUpdateMDMAppleDeclarationFunc = func(ctx context.Context, declaration *fleet.MDMAppleDeclaration, usesFleetVars []fleet.FleetVarName) (*fleet.MDMAppleDeclaration, error) { declaration.DeclarationUUID = uuid.NewString() return declaration, nil } @@ -5869,7 +5869,7 @@ func TestGitOpsAppleOSUpdates(t *testing.T) { defaultTeamConfig = config return nil } - ds.SetOrUpdateMDMAppleDeclarationFunc = func(ctx context.Context, declaration *fleet.MDMAppleDeclaration) (*fleet.MDMAppleDeclaration, error) { + ds.SetOrUpdateMDMAppleDeclarationFunc = func(ctx context.Context, declaration *fleet.MDMAppleDeclaration, usesFleetVars []fleet.FleetVarName) (*fleet.MDMAppleDeclaration, error) { return &fleet.MDMAppleDeclaration{DeclarationUUID: "test-uuid"}, nil } ds.LabelIDsByNameFunc = func(ctx context.Context, names []string, filter fleet.TeamFilter) (map[string]uint, error) { @@ -6154,7 +6154,7 @@ func TestGitOpsWindowsOSUpdates(t *testing.T) { defaultTeamConfig = config return nil } - ds.SetOrUpdateMDMAppleDeclarationFunc = func(ctx context.Context, declaration *fleet.MDMAppleDeclaration) (*fleet.MDMAppleDeclaration, error) { + ds.SetOrUpdateMDMAppleDeclarationFunc = func(ctx context.Context, declaration *fleet.MDMAppleDeclaration, usesFleetVars []fleet.FleetVarName) (*fleet.MDMAppleDeclaration, error) { return &fleet.MDMAppleDeclaration{DeclarationUUID: "test-uuid"}, nil } diff --git a/cmd/fleetctl/fleetctl/testing_utils.go b/cmd/fleetctl/fleetctl/testing_utils.go index ff0dfa5dd0..6b2cd14475 100644 --- a/cmd/fleetctl/fleetctl/testing_utils.go +++ b/cmd/fleetctl/fleetctl/testing_utils.go @@ -100,7 +100,7 @@ func setupEmptyGitOpsMocks(ds *mock.Store) { ) (fleet.MDMProfilesUpdates, error) { return fleet.MDMProfilesUpdates{}, nil } - ds.SetOrUpdateMDMAppleDeclarationFunc = func(ctx context.Context, declaration *fleet.MDMAppleDeclaration) (*fleet.MDMAppleDeclaration, error) { + ds.SetOrUpdateMDMAppleDeclarationFunc = func(ctx context.Context, declaration *fleet.MDMAppleDeclaration, usesFleetVars []fleet.FleetVarName) (*fleet.MDMAppleDeclaration, error) { return &fleet.MDMAppleDeclaration{}, nil } ds.DeleteMDMAppleDeclarationByNameFunc = func(ctx context.Context, teamID *uint, name string) error { diff --git a/cmd/fleetctl/fleetctl/testing_utils/testing_utils.go b/cmd/fleetctl/fleetctl/testing_utils/testing_utils.go index eee6ef3f14..d1e21550fe 100644 --- a/cmd/fleetctl/fleetctl/testing_utils/testing_utils.go +++ b/cmd/fleetctl/fleetctl/testing_utils/testing_utils.go @@ -450,7 +450,7 @@ func SetupFullGitOpsPremiumServer(t *testing.T) (*mock.Store, **fleet.AppConfig, savedTeams[team.Name] = &team return team, nil } - ds.SetOrUpdateMDMAppleDeclarationFunc = func(ctx context.Context, declaration *fleet.MDMAppleDeclaration) ( + ds.SetOrUpdateMDMAppleDeclarationFunc = func(ctx context.Context, declaration *fleet.MDMAppleDeclaration, usesFleetVars []fleet.FleetVarName) ( *fleet.MDMAppleDeclaration, error, ) { declaration.DeclarationUUID = uuid.NewString() diff --git a/cmd/fleetctl/integrationtest/gitops/gitops_enterprise_integration_test.go b/cmd/fleetctl/integrationtest/gitops/gitops_enterprise_integration_test.go index e4efa0336e..1df3389956 100644 --- a/cmd/fleetctl/integrationtest/gitops/gitops_enterprise_integration_test.go +++ b/cmd/fleetctl/integrationtest/gitops/gitops_enterprise_integration_test.go @@ -4222,7 +4222,7 @@ labels: - name: Test Fleet Label label_membership_type: dynamic query: SELECT 1 - + `, fleetName) fullFleetFile, err := os.CreateTemp(t.TempDir(), "*.yml") @@ -4518,3 +4518,44 @@ settings: require.Empty(t, teamMeta.LabelsExcludeAny) require.Empty(t, teamMeta.LabelsIncludeAll) } + +func (s *enterpriseIntegrationGitopsTestSuite) TestFleetGitopsDDMUnsupportedFleetVariable() { + t := s.T() + user := s.createGitOpsUser(t) + fleetctlConfig := s.createFleetctlConfig(t, user) + + // Create a DDM declaration with an unsupported Fleet variable + declDir := t.TempDir() + declFile := path.Join(declDir, "decl-unsupported-var.json") + err := os.WriteFile(declFile, []byte(`{ + "Type": "com.apple.configuration.management.test", + "Identifier": "com.example.unsupported-var", + "Payload": {"Value": "$FLEET_VAR_BOZO"} + }`), 0o644) + require.NoError(t, err) + + globalFile, err := os.CreateTemp(t.TempDir(), "*.yml") + require.NoError(t, err) + _, err = globalFile.WriteString(fmt.Sprintf(` +agent_options: +controls: + macos_settings: + custom_settings: + - path: %s +org_settings: + server_settings: + server_url: $FLEET_URL + org_info: + org_name: Fleet + secrets: +policies: +queries: +`, declFile)) + require.NoError(t, err) + + t.Setenv("FLEET_URL", s.Server.URL) + + // Applying a DDM declaration with an unsupported Fleet variable should fail + _, err = fleetctl.RunAppNoChecks([]string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name()}) + require.ErrorContains(t, err, "Fleet variable $FLEET_VAR_BOZO is not supported in DDM profiles") +} diff --git a/cmd/fleetctl/integrationtest/gitops/gitops_integration_test.go b/cmd/fleetctl/integrationtest/gitops/gitops_integration_test.go index 70b70620d2..b58575c3f8 100644 --- a/cmd/fleetctl/integrationtest/gitops/gitops_integration_test.go +++ b/cmd/fleetctl/integrationtest/gitops/gitops_integration_test.go @@ -222,3 +222,43 @@ func (s *integrationGitopsTestSuite) TestFleetGitopsWithFleetSecrets() { require.NoError(t, err) assert.Contains(t, string(winProfile.SyncML), "${FLEET_SECRET_"+secretName2+"}") } + +func (s *integrationGitopsTestSuite) TestFleetGitopsDDMFleetVarsRequiresPremium() { + t := s.T() + fleetctlConfig := s.createFleetctlConfig() + + // Create a DDM declaration with a Fleet variable + declDir := t.TempDir() + declFile := path.Join(declDir, "decl-fleetvar.json") + err := os.WriteFile(declFile, []byte(`{ + "Type": "com.apple.configuration.management.test", + "Identifier": "com.example.fleetvar-test", + "Payload": {"Value": "$FLEET_VAR_HOST_HARDWARE_SERIAL"} + }`), 0o644) + require.NoError(t, err) + + globalFile, err := os.CreateTemp(t.TempDir(), "*.yml") + require.NoError(t, err) + _, err = globalFile.WriteString(fmt.Sprintf(` +agent_options: +controls: + macos_settings: + custom_settings: + - path: %s +org_settings: + server_settings: + server_url: $FLEET_URL + org_info: + org_name: Fleet + secrets: +policies: +queries: +`, declFile)) + require.NoError(t, err) + + t.Setenv("FLEET_URL", s.Server.URL) + + // Applying a DDM declaration with Fleet variables should fail without a premium license + _, err = fleetctl.RunAppNoChecks([]string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name()}) + require.ErrorContains(t, err, "missing or invalid license") +} diff --git a/ee/server/service/mdm.go b/ee/server/service/mdm.go index eae1bf7a76..4a9975663c 100644 --- a/ee/server/service/mdm.go +++ b/ee/server/service/mdm.go @@ -1338,7 +1338,7 @@ func (svc *Service) mdmAppleEditedAppleOSUpdates(ctx context.Context, teamID *ui {LabelName: labelName, LabelID: lblIDs[labelName]}, } - decl, err := svc.ds.SetOrUpdateMDMAppleDeclaration(ctx, d) + decl, err := svc.ds.SetOrUpdateMDMAppleDeclaration(ctx, d, nil) if err != nil { return err } diff --git a/ee/server/service/mdm_external_test.go b/ee/server/service/mdm_external_test.go index bdfd3bdc04..10996dbc48 100644 --- a/ee/server/service/mdm_external_test.go +++ b/ee/server/service/mdm_external_test.go @@ -235,7 +235,7 @@ func TestGetOrCreatePreassignTeam(t *testing.T) { require.ElementsMatch(t, names, []string{fleet.BuiltinLabelMacOS14Plus}) return map[string]uint{names[0]: 1}, nil } - ds.SetOrUpdateMDMAppleDeclarationFunc = func(ctx context.Context, declaration *fleet.MDMAppleDeclaration) (*fleet.MDMAppleDeclaration, error) { + ds.SetOrUpdateMDMAppleDeclarationFunc = func(ctx context.Context, declaration *fleet.MDMAppleDeclaration, usesFleetVars []fleet.FleetVarName) (*fleet.MDMAppleDeclaration, error) { declaration.DeclarationUUID = uuid.NewString() return declaration, nil } diff --git a/server/datastore/mysql/apple_mdm.go b/server/datastore/mysql/apple_mdm.go index 8b654574d6..5d8c15d936 100644 --- a/server/datastore/mysql/apple_mdm.go +++ b/server/datastore/mysql/apple_mdm.go @@ -265,7 +265,7 @@ INSERT INTO } if _, err := batchSetProfileVariableAssociationsDB(ctx, tx, []fleet.MDMProfileUUIDFleetVariables{ {ProfileUUID: profUUID, FleetVariables: usesFleetVars}, - }, "darwin"); err != nil { + }, "darwin", false); err != nil { return ctxerr.Wrap(ctx, err, "inserting darwin profile variable associations") } @@ -5103,7 +5103,7 @@ WHERE h.uuid = ? AND ( } func (ds *Datastore) batchSetMDMAppleDeclarations(ctx context.Context, tx sqlx.ExtContext, tmID *uint, - incomingDeclarations []*fleet.MDMAppleDeclaration, + incomingDeclarations []*fleet.MDMAppleDeclaration, profilesVariablesByIdentifier []fleet.MDMProfileIdentifierFleetVariables, ) (updatedDB bool, err error) { // First, build a list of names (which are usually filenames) for the incoming declarations. // We will keep the existing ones if there's a match and no change. @@ -5151,7 +5151,12 @@ func (ds *Datastore) batchSetMDMAppleDeclarations(ctx context.Context, tx sqlx.E return false, ctxerr.Wrap(ctx, err, "update declaration label associations") } - return deletedDeclarations || insertedOrUpdatedDeclarations || updatedLabels, nil + updatedVars, err := ds.updateDeclarationsVariableAssociations(ctx, tx, incomingDeclarationsMap, teamIDOrZero, profilesVariablesByIdentifier) + if err != nil { + return false, ctxerr.Wrap(ctx, err, "update declaration variable associations") + } + + return deletedDeclarations || insertedOrUpdatedDeclarations || updatedLabels || updatedVars, nil } func (ds *Datastore) updateDeclarationsLabelAssociations(ctx context.Context, tx sqlx.ExtContext, @@ -5219,6 +5224,53 @@ func (ds *Datastore) updateDeclarationsLabelAssociations(ctx context.Context, tx return updatedDB, err } +func (ds *Datastore) updateDeclarationsVariableAssociations(ctx context.Context, tx sqlx.ExtContext, + incomingDeclarationsMap map[string]*fleet.MDMAppleDeclaration, teamID uint, + profilesVariablesByIdentifier []fleet.MDMProfileIdentifierFleetVariables, +) (updatedDB bool, err error) { + if len(incomingDeclarationsMap) == 0 { + return false, nil + } + + incomingNames := make([]string, 0, len(incomingDeclarationsMap)) + for _, p := range incomingDeclarationsMap { + incomingNames = append(incomingNames, p.Name) + } + + // Load declaration UUIDs by name (same approach as updateDeclarationsLabelAssociations) + currentDecls, err := ds.getExistingDeclarations(ctx, tx, incomingNames, teamID) + if err != nil { + return false, ctxerr.Wrap(ctx, err, "load declarations for variable associations") + } + + // Build a map from identifier to variables, filtering for declaration entries only + varsByIdentifier := make(map[string][]fleet.FleetVarName, len(profilesVariablesByIdentifier)) + for _, pv := range profilesVariablesByIdentifier { + if name, ok := strings.CutPrefix(pv.Identifier, fleet.MDMAppleDeclarationUUIDPrefix); ok { + varsByIdentifier[name] = pv.FleetVariables + } + } + + // Map declaration UUIDs to their variables, including declarations without + // variables so stale associations get cleared. + var profilesVarsToUpsert []fleet.MDMProfileUUIDFleetVariables + for _, decl := range currentDecls { + vars := varsByIdentifier[decl.Identifier] + profilesVarsToUpsert = append(profilesVarsToUpsert, fleet.MDMProfileUUIDFleetVariables{ + ProfileUUID: decl.DeclarationUUID, + FleetVariables: vars, + }) + } + + if len(profilesVarsToUpsert) > 0 { + if updatedDB, err = batchSetProfileVariableAssociationsDB(ctx, tx, profilesVarsToUpsert, "darwin", true); err != nil { + return false, ctxerr.Wrap(ctx, err, "inserting declaration variable associations") + } + } + + return updatedDB, nil +} + func (ds *Datastore) insertOrUpdateDeclarations(ctx context.Context, tx sqlx.ExtContext, incomingDeclarations []*fleet.MDMAppleDeclaration, teamID uint, ) (updatedDB bool, err error) { @@ -5310,6 +5362,7 @@ func (ds *Datastore) getExistingDeclarations(ctx context.Context, tx sqlx.ExtCon const loadExistingDecls = ` SELECT name, + identifier, declaration_uuid, raw_json FROM @@ -5340,7 +5393,7 @@ func (ds *Datastore) teamIDPtrToUint(tmID *uint) uint { return 0 } -func (ds *Datastore) NewMDMAppleDeclaration(ctx context.Context, declaration *fleet.MDMAppleDeclaration) (*fleet.MDMAppleDeclaration, error) { +func (ds *Datastore) NewMDMAppleDeclaration(ctx context.Context, declaration *fleet.MDMAppleDeclaration, usesFleetVars []fleet.FleetVarName) (*fleet.MDMAppleDeclaration, error) { const stmt = ` INSERT INTO mdm_apple_declarations ( declaration_uuid, @@ -5360,10 +5413,10 @@ INSERT INTO mdm_apple_declarations ( ) )` - return ds.insertOrUpsertMDMAppleDeclaration(ctx, stmt, declaration) + return ds.insertOrUpsertMDMAppleDeclaration(ctx, stmt, declaration, usesFleetVars) } -func (ds *Datastore) SetOrUpdateMDMAppleDeclaration(ctx context.Context, declaration *fleet.MDMAppleDeclaration) (*fleet.MDMAppleDeclaration, error) { +func (ds *Datastore) SetOrUpdateMDMAppleDeclaration(ctx context.Context, declaration *fleet.MDMAppleDeclaration, usesFleetVars []fleet.FleetVarName) (*fleet.MDMAppleDeclaration, error) { const stmt = ` INSERT INTO mdm_apple_declarations ( declaration_uuid, @@ -5387,10 +5440,10 @@ ON DUPLICATE KEY UPDATE uploaded_at = IF(raw_json = VALUES(raw_json) AND name = VALUES(name) AND IFNULL(secrets_updated_at = VALUES(secrets_updated_at), TRUE), uploaded_at, NOW(6)), raw_json = VALUES(raw_json)` - return ds.insertOrUpsertMDMAppleDeclaration(ctx, stmt, declaration) + return ds.insertOrUpsertMDMAppleDeclaration(ctx, stmt, declaration, usesFleetVars) } -func (ds *Datastore) insertOrUpsertMDMAppleDeclaration(ctx context.Context, insOrUpsertStmt string, declaration *fleet.MDMAppleDeclaration) (*fleet.MDMAppleDeclaration, error) { +func (ds *Datastore) insertOrUpsertMDMAppleDeclaration(ctx context.Context, insOrUpsertStmt string, declaration *fleet.MDMAppleDeclaration, usesFleetVars []fleet.FleetVarName) (*fleet.MDMAppleDeclaration, error) { declUUID := fleet.MDMAppleDeclarationUUIDPrefix + uuid.NewString() var tmID uint @@ -5455,6 +5508,12 @@ func (ds *Datastore) insertOrUpsertMDMAppleDeclaration(ctx context.Context, insO return ctxerr.Wrap(ctx, err, "inserting mdm declaration label associations") } + if _, err := batchSetProfileVariableAssociationsDB(ctx, tx, []fleet.MDMProfileUUIDFleetVariables{ + {ProfileUUID: declUUID, FleetVariables: usesFleetVars}, + }, "darwin", true); err != nil { + return ctxerr.Wrap(ctx, err, "inserting declaration variable associations") + } + return nil }) if err != nil { @@ -5613,7 +5672,7 @@ func batchSetDeclarationLabelAssociationsDB(ctx context.Context, tx sqlx.ExtCont func (ds *Datastore) MDMAppleDDMDeclarationsToken(ctx context.Context, hostUUID string) (*fleet.MDMAppleDDMDeclarationsToken, error) { const stmt = ` SELECT - COALESCE(MD5(CONCAT(COUNT(0), GROUP_CONCAT(HEX(mad.token) + COALESCE(MD5(CONCAT(COUNT(0), GROUP_CONCAT(CONCAT(HEX(mad.token), IFNULL(hmad.variables_updated_at, '')) ORDER BY mad.uploaded_at DESC, mad.declaration_uuid ASC separator ''))), '') AS token, COALESCE(MAX(mad.created_at), NOW()) AS latest_created_timestamp @@ -5643,7 +5702,8 @@ func (ds *Datastore) MDMAppleDDMDeclarationItems(ctx context.Context, hostUUID s const stmt = ` SELECT HEX(mad.token) as token, - mad.identifier, mad.declaration_uuid, status, operation_type, mad.uploaded_at + mad.identifier, mad.declaration_uuid, status, operation_type, mad.uploaded_at, + hmad.variables_updated_at FROM host_mdm_apple_declarations hmad JOIN mdm_apple_declarations mad ON mad.declaration_uuid = hmad.declaration_uuid @@ -5665,7 +5725,7 @@ func (ds *Datastore) MDMAppleDDMDeclarationsResponse(ctx context.Context, identi // declarations are removed, but the join would provide an extra layer of safety. const stmt = ` SELECT - mad.raw_json, HEX(mad.token) as token + mad.declaration_uuid, mad.raw_json, HEX(mad.token) as token, hmad.variables_updated_at FROM host_mdm_apple_declarations hmad JOIN mdm_apple_declarations mad ON hmad.declaration_uuid = mad.declaration_uuid @@ -5783,14 +5843,15 @@ func mdmAppleBatchSetPendingHostDeclarationsDB( ) (updatedDB bool, err error) { baseStmt := ` INSERT INTO host_mdm_apple_declarations - (host_uuid, status, operation_type, token, secrets_updated_at, declaration_uuid, declaration_identifier, declaration_name) + (host_uuid, status, operation_type, token, secrets_updated_at, variables_updated_at, declaration_uuid, declaration_identifier, declaration_name) VALUES %s ON DUPLICATE KEY UPDATE status = VALUES(status), operation_type = VALUES(operation_type), token = VALUES(token), - secrets_updated_at = VALUES(secrets_updated_at) + secrets_updated_at = VALUES(secrets_updated_at), + variables_updated_at = VALUES(variables_updated_at) ` profilesToInsert := make(map[string]*fleet.MDMAppleHostDeclaration) @@ -5806,6 +5867,7 @@ func mdmAppleBatchSetPendingHostDeclarationsDB( COALESCE(detail, '') AS detail, token, secrets_updated_at, + variables_updated_at, declaration_uuid, declaration_identifier, declaration_name @@ -5859,18 +5921,19 @@ func mdmAppleBatchSetPendingHostDeclarationsDB( generateValueArgs := func(d *fleet.MDMAppleHostDeclaration) (string, []any) { profilesToInsert[fmt.Sprintf("%s\n%s", d.HostUUID, d.DeclarationUUID)] = &fleet.MDMAppleHostDeclaration{ - HostUUID: d.HostUUID, - DeclarationUUID: d.DeclarationUUID, - Name: d.Name, - Identifier: d.Identifier, - Status: status, - OperationType: d.OperationType, - Detail: d.Detail, - Token: d.Token, - SecretsUpdatedAt: d.SecretsUpdatedAt, + HostUUID: d.HostUUID, + DeclarationUUID: d.DeclarationUUID, + Name: d.Name, + Identifier: d.Identifier, + Status: status, + OperationType: d.OperationType, + Detail: d.Detail, + Token: d.Token, + SecretsUpdatedAt: d.SecretsUpdatedAt, + VariablesUpdatedAt: d.VariablesUpdatedAt, } - valuePart := "(?, ?, ?, ?, ?, ?, ?, ?)," - args := []any{d.HostUUID, status, d.OperationType, d.Token, d.SecretsUpdatedAt, d.DeclarationUUID, d.Identifier, d.Name} + valuePart := "(?, ?, ?, ?, ?, ?, ?, ?, ?)," + args := []any{d.HostUUID, status, d.OperationType, d.Token, d.SecretsUpdatedAt, d.VariablesUpdatedAt, d.DeclarationUUID, d.Identifier, d.Name} return valuePart, args } @@ -6000,13 +6063,65 @@ func mdmAppleGetHostsWithChangedDeclarationsDB(ctx context.Context, tx sqlx.ExtC if err := sqlx.SelectContext(ctx, tx, &decls, stmt, args...); err != nil { return nil, ctxerr.Wrap(ctx, err, "running sql statement") } + + // For declarations that use Fleet variables and are being installed, set + // VariablesUpdatedAt so the host row tracks when variable values were last + // computed. This allows re-delivery when variable values change. + if err := setVariablesUpdatedAtForDeclarations(ctx, tx, decls); err != nil { + return nil, ctxerr.Wrap(ctx, err, "setting variables_updated_at for declarations") + } + return decls, nil } +// setVariablesUpdatedAtForDeclarations looks up which declarations have Fleet +// variables and sets VariablesUpdatedAt on the install entries. +func setVariablesUpdatedAtForDeclarations(ctx context.Context, tx sqlx.ExtContext, decls []*fleet.MDMAppleHostDeclaration) error { + var installDeclUUIDs []string + for _, d := range decls { + if d.OperationType == fleet.MDMOperationTypeInstall { + installDeclUUIDs = append(installDeclUUIDs, d.DeclarationUUID) + } + } + if len(installDeclUUIDs) == 0 { + return nil + } + + stmt, args, err := sqlx.In( + `SELECT DISTINCT apple_declaration_uuid FROM mdm_configuration_profile_variables WHERE apple_declaration_uuid IN (?)`, + installDeclUUIDs, + ) + if err != nil { + return ctxerr.Wrap(ctx, err, "sqlx.In declaration variables lookup") + } + + var declsWithVars []string + if err := sqlx.SelectContext(ctx, tx, &declsWithVars, stmt, args...); err != nil { + return ctxerr.Wrap(ctx, err, "selecting declarations with variables") + } + + if len(declsWithVars) == 0 { + return nil + } + + hasVars := make(map[string]struct{}, len(declsWithVars)) + for _, uuid := range declsWithVars { + hasVars[uuid] = struct{}{} + } + + now := time.Now().UTC() + for _, d := range decls { + if _, ok := hasVars[d.DeclarationUUID]; ok && d.OperationType == fleet.MDMOperationTypeInstall { + d.VariablesUpdatedAt = &now + } + } + return nil +} + // MDMAppleStoreDDMStatusReport updates the status of the host's declarations. func (ds *Datastore) MDMAppleStoreDDMStatusReport(ctx context.Context, hostUUID string, updates []*fleet.MDMAppleHostDeclaration) error { getHostDeclarationsStmt := ` - SELECT host_uuid, status, operation_type, HEX(token) as token, secrets_updated_at, declaration_uuid, declaration_identifier, declaration_name + SELECT host_uuid, status, operation_type, HEX(token) as token, secrets_updated_at, variables_updated_at, declaration_uuid, declaration_identifier, declaration_name FROM host_mdm_apple_declarations WHERE host_uuid = ? ` @@ -6042,7 +6157,7 @@ ON DUPLICATE KEY UPDATE for _, c := range current { // Skip updates for 'remove' operations because it is possible that IT admin removed a profile and then re-added it. // Pending removes are cleaned up after we update status of installs. - if u, ok := updatesByToken[c.Token]; ok && u.OperationType != fleet.MDMOperationTypeRemove { + if u, ok := updatesByToken[fleet.EffectiveDDMToken(c.Token, c.VariablesUpdatedAt)]; ok && u.OperationType != fleet.MDMOperationTypeRemove { insertVals.WriteString("(?, ?, ?, ?, ?, ?, ?, UNHEX(?), ?),") args = append(args, hostUUID, c.DeclarationUUID, u.Status, u.OperationType, u.Detail, c.Identifier, c.Name, c.Token, c.SecretsUpdatedAt) @@ -6069,6 +6184,12 @@ ON DUPLICATE KEY UPDATE return ctxerr.Wrap(ctx, err, "updating host declarations") } +func (ds *Datastore) SetHostMDMAppleDeclarationStatus(ctx context.Context, hostUUID string, declarationUUID string, status *fleet.MDMDeliveryStatus, detail string, variablesUpdatedAt *time.Time) error { + stmt := `UPDATE host_mdm_apple_declarations SET status = ?, detail = ?, variables_updated_at = COALESCE(?, variables_updated_at) WHERE host_uuid = ? AND declaration_uuid = ?` + _, err := ds.writer(ctx).ExecContext(ctx, stmt, status, detail, variablesUpdatedAt, hostUUID, declarationUUID) + return ctxerr.Wrap(ctx, err, "set host declaration status") +} + func (ds *Datastore) MDMAppleSetPendingDeclarationsAs(ctx context.Context, hostUUID string, status *fleet.MDMDeliveryStatus, detail string) error { stmt := ` UPDATE host_mdm_apple_declarations diff --git a/server/datastore/mysql/apple_mdm_ddm_test.go b/server/datastore/mysql/apple_mdm_ddm_test.go index 0a412635fc..06d215dd3a 100644 --- a/server/datastore/mysql/apple_mdm_ddm_test.go +++ b/server/datastore/mysql/apple_mdm_ddm_test.go @@ -105,7 +105,7 @@ func testMDMAppleBatchSetHostDeclarationState(t *testing.T, ds *Datastore) { Name: "Test Declaration " + string(rune('A'+i)), Identifier: "com.example.test.declaration." + string(rune('A'+i)), RawJSON: []byte(`{"Type":"com.apple.test.declaration","Identifier":"com.example.test.declaration.` + string(rune('A'+i)) + `"}`), - }) + }, nil) require.NoError(t, err) } removeDeclarations := make([]*fleet.MDMAppleDeclaration, 3) @@ -189,7 +189,7 @@ func testMDMAppleBatchSetHostDeclarationState(t *testing.T, ds *Datastore) { Name: "Test Install Declaration " + string(rune('A'+i)), Identifier: "com.example.test.install." + string(rune('A'+i)), RawJSON: []byte(`{"Type":"com.apple.test.declaration","Identifier":"com.example.test.install.` + string(rune('A'+i)) + `"}`), - }) + }, nil) require.NoError(t, err) } diff --git a/server/datastore/mysql/apple_mdm_test.go b/server/datastore/mysql/apple_mdm_test.go index fae7c5e080..1d1a4bea6c 100644 --- a/server/datastore/mysql/apple_mdm_test.go +++ b/server/datastore/mysql/apple_mdm_test.go @@ -410,7 +410,7 @@ func testDeleteMDMAppleConfigProfile(t *testing.T, ds *Datastore) { require.NoError(t, err) testDecl := declForTest("D1", "D1", "{}") - dbDecl, err := ds.NewMDMAppleDeclaration(ctx, testDecl) + dbDecl, err := ds.NewMDMAppleDeclaration(ctx, testDecl, nil) require.NoError(t, err) // delete for a non-existing team does nothing @@ -5966,7 +5966,7 @@ func testMDMAppleDDMDeclarationsToken(t *testing.T, ds *Datastore) { Identifier: "decl-1", Name: "decl-1", RawJSON: json.RawMessage(`{"Identifier": "decl-1"}`), - }) + }, nil) require.NoError(t, err) updates, err := ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, []string{decl.DeclarationUUID}, nil) require.NoError(t, err) @@ -6005,7 +6005,7 @@ func testMDMAppleDDMDeclarationsToken(t *testing.T, ds *Datastore) { Identifier: "decl-2", Name: "decl-2", RawJSON: json.RawMessage(`{"Identifier": "decl-2"}`), - }) + }, nil) require.NoError(t, err) updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, []string{decl2.DeclarationUUID}, nil) require.NoError(t, err) @@ -6043,7 +6043,7 @@ func testMDMAppleSetPendingDeclarationsAs(t *testing.T, ds *Datastore) { Identifier: fmt.Sprintf("decl-%d", i), Name: fmt.Sprintf("decl-%d", i), RawJSON: json.RawMessage(fmt.Sprintf(`{"Identifier": "decl-%d"}`, i)), - }) + }, nil) require.NoError(t, err) } @@ -6094,7 +6094,7 @@ func testSetOrUpdateMDMAppleDDMDeclaration(t *testing.T, ds *Datastore) { Identifier: "i1", Name: "d1", RawJSON: json.RawMessage(`{"Identifier": "i1"}`), - }) + }, nil) require.NoError(t, err) // try to create same name, different identifier fails @@ -6102,7 +6102,7 @@ func testSetOrUpdateMDMAppleDDMDeclaration(t *testing.T, ds *Datastore) { Identifier: "i1b", Name: "d1", RawJSON: json.RawMessage(`{"Identifier": "i1b"}`), - }) + }, nil) require.Error(t, err) var existsErr *existsError require.ErrorAs(t, err, &existsErr) @@ -6112,7 +6112,7 @@ func testSetOrUpdateMDMAppleDDMDeclaration(t *testing.T, ds *Datastore) { Identifier: "i1", Name: "d1b", RawJSON: json.RawMessage(`{"Identifier": "i1"}`), - }) + }, nil) require.Error(t, err) require.ErrorAs(t, err, &existsErr) @@ -6122,7 +6122,7 @@ func testSetOrUpdateMDMAppleDDMDeclaration(t *testing.T, ds *Datastore) { Name: "d1", TeamID: &tm1.ID, RawJSON: json.RawMessage(`{"Identifier": "i1"}`), - }) + }, nil) require.NoError(t, err) require.NotEqual(t, d1.DeclarationUUID, d1tm1.DeclarationUUID) @@ -6136,7 +6136,7 @@ func testSetOrUpdateMDMAppleDDMDeclaration(t *testing.T, ds *Datastore) { Name: "d1", RawJSON: json.RawMessage(`{"Identifier": "i1b"}`), LabelsIncludeAll: []fleet.ConfigurationProfileLabel{{LabelName: l1.Name, LabelID: l1.ID}}, - }) + }, nil) require.NoError(t, err) require.Equal(t, d1.DeclarationUUID, d1Ori.DeclarationUUID) require.NotEqual(t, d1.DeclarationUUID, d1tm1.DeclarationUUID) @@ -6152,7 +6152,7 @@ func testSetOrUpdateMDMAppleDDMDeclaration(t *testing.T, ds *Datastore) { Name: "d1", RawJSON: json.RawMessage(`{"Identifier": "i1b"}`), LabelsIncludeAll: []fleet.ConfigurationProfileLabel{{LabelName: l2.Name, LabelID: l2.ID}}, - }) + }, nil) require.NoError(t, err) require.Equal(t, d1.DeclarationUUID, d1Ori.DeclarationUUID) @@ -6168,7 +6168,7 @@ func testSetOrUpdateMDMAppleDDMDeclaration(t *testing.T, ds *Datastore) { TeamID: &tm1.ID, RawJSON: json.RawMessage(`{"Identifier": "i1b"}`), LabelsIncludeAll: []fleet.ConfigurationProfileLabel{{LabelName: l1.Name, LabelID: l1.ID}}, - }) + }, nil) require.NoError(t, err) require.Equal(t, d1tm1B.DeclarationUUID, d1tm1.DeclarationUUID) @@ -6197,7 +6197,7 @@ func testDeleteMDMAppleDeclarationWithPendingInstalls(t *testing.T, ds *Datastor Identifier: "decl-1", Name: "decl-1", RawJSON: json.RawMessage(`{"Identifier": "decl-1"}`), - }) + }, nil) require.NoError(t, err) host, err := ds.NewHost(ctx, &fleet.Host{ @@ -9709,8 +9709,8 @@ func testSetMDMAppleProfilesWithVariables(t *testing.T, ds *Datastore) { &profA, &profB, }, nil, nil, nil, []fleet.MDMProfileIdentifierFleetVariables{ - {Identifier: profA.Identifier, FleetVariables: []fleet.FleetVarName{fleet.FleetVarHostEndUserIDPUsernameLocalPart}}, - {Identifier: profB.Identifier, FleetVariables: []fleet.FleetVarName{fleet.FleetVarHostEndUserIDPUsername}}, + {Identifier: fleet.MDMAppleProfileUUIDPrefix + profA.Identifier, FleetVariables: []fleet.FleetVarName{fleet.FleetVarHostEndUserIDPUsernameLocalPart}}, + {Identifier: fleet.MDMAppleProfileUUIDPrefix + profB.Identifier, FleetVariables: []fleet.FleetVarName{fleet.FleetVarHostEndUserIDPUsername}}, }) require.NoError(t, err) require.Equal(t, fleet.MDMProfilesUpdates{AppleConfigProfile: true}, updates) @@ -9727,9 +9727,9 @@ func testSetMDMAppleProfilesWithVariables(t *testing.T, ds *Datastore) { &profB, &profD, }, nil, nil, nil, []fleet.MDMProfileIdentifierFleetVariables{ - {Identifier: profA.Identifier, FleetVariables: nil}, - {Identifier: profB.Identifier, FleetVariables: []fleet.FleetVarName{fleet.FleetVarHostEndUserIDPGroups}}, - {Identifier: profD.Identifier, FleetVariables: []fleet.FleetVarName{fleet.FleetVarHostEndUserIDPUsername, fleet.FleetVarName(string(fleet.FleetVarDigiCertDataPrefix) + "ZZZ")}}, + {Identifier: fleet.MDMAppleProfileUUIDPrefix + profA.Identifier, FleetVariables: nil}, + {Identifier: fleet.MDMAppleProfileUUIDPrefix + profB.Identifier, FleetVariables: []fleet.FleetVarName{fleet.FleetVarHostEndUserIDPGroups}}, + {Identifier: fleet.MDMAppleProfileUUIDPrefix + profD.Identifier, FleetVariables: []fleet.FleetVarName{fleet.FleetVarHostEndUserIDPUsername, fleet.FleetVarName(string(fleet.FleetVarDigiCertDataPrefix) + "ZZZ")}}, }) require.NoError(t, err) require.Equal(t, fleet.MDMProfilesUpdates{AppleConfigProfile: true}, updates) @@ -9754,9 +9754,9 @@ func testSetMDMAppleProfilesWithVariables(t *testing.T, ds *Datastore) { }, nil, []fleet.MDMProfileIdentifierFleetVariables{ - {Identifier: profA.Identifier, FleetVariables: nil}, - {Identifier: profB.Identifier, FleetVariables: []fleet.FleetVarName{fleet.FleetVarHostEndUserIDPGroups}}, - {Identifier: profD.Identifier, FleetVariables: []fleet.FleetVarName{fleet.FleetVarHostEndUserIDPUsername, fleet.FleetVarName(string(fleet.FleetVarDigiCertDataPrefix) + "ZZZ")}}, + {Identifier: fleet.MDMAppleProfileUUIDPrefix + profA.Identifier, FleetVariables: nil}, + {Identifier: fleet.MDMAppleProfileUUIDPrefix + profB.Identifier, FleetVariables: []fleet.FleetVarName{fleet.FleetVarHostEndUserIDPGroups}}, + {Identifier: fleet.MDMAppleProfileUUIDPrefix + profD.Identifier, FleetVariables: []fleet.FleetVarName{fleet.FleetVarHostEndUserIDPUsername, fleet.FleetVarName(string(fleet.FleetVarDigiCertDataPrefix) + "ZZZ")}}, }) require.NoError(t, err) require.Equal(t, fleet.MDMProfilesUpdates{AppleConfigProfile: true, AppleDeclaration: true, WindowsConfigProfile: true}, updates) @@ -9769,7 +9769,7 @@ func testSetMDMAppleProfilesWithVariables(t *testing.T, ds *Datastore) { updates, err = ds.BatchSetMDMProfiles(ctx, &tm1.ID, []*fleet.MDMAppleConfigProfile{ &profE, }, nil, nil, nil, []fleet.MDMProfileIdentifierFleetVariables{ - {Identifier: profE.Identifier, FleetVariables: []fleet.FleetVarName{fleet.FleetVarHostEndUserIDPGroups, fleet.FleetVarName(string(fleet.FleetVarDigiCertDataPrefix) + "ZZZ")}}, + {Identifier: fleet.MDMAppleProfileUUIDPrefix + profE.Identifier, FleetVariables: []fleet.FleetVarName{fleet.FleetVarHostEndUserIDPGroups, fleet.FleetVarName(string(fleet.FleetVarDigiCertDataPrefix) + "ZZZ")}}, }) require.NoError(t, err) require.Equal(t, fleet.MDMProfilesUpdates{AppleConfigProfile: true}, updates) diff --git a/server/datastore/mysql/hosts_test.go b/server/datastore/mysql/hosts_test.go index d4cccb640c..6dc3e6804d 100644 --- a/server/datastore/mysql/hosts_test.go +++ b/server/datastore/mysql/hosts_test.go @@ -4974,7 +4974,7 @@ func testListHostsProfileUUIDAndStatus(t *testing.T, ds *Datastore) { // no team Apple declaration profile // ///////////////////////////////////// - noTeamDeclaration, err := ds.NewMDMAppleDeclaration(ctx, declForTest("test-decleration", "com.fleetdm.fleet.mdm.test-decl", "{}")) + noTeamDeclaration, err := ds.NewMDMAppleDeclaration(ctx, declForTest("test-decleration", "com.fleetdm.fleet.mdm.test-decl", "{}"), nil) require.NoError(t, err) // verified status diff --git a/server/datastore/mysql/mdm.go b/server/datastore/mysql/mdm.go index a6363261d1..3cfdc9ae8a 100644 --- a/server/datastore/mysql/mdm.go +++ b/server/datastore/mysql/mdm.go @@ -452,7 +452,7 @@ func (ds *Datastore) BatchSetMDMProfiles(ctx context.Context, tmID *uint, macPro return ctxerr.Wrap(ctx, err, "batch set apple profiles") } - if updates.AppleDeclaration, err = ds.batchSetMDMAppleDeclarations(ctx, tx, tmID, macDeclarations); err != nil { + if updates.AppleDeclaration, err = ds.batchSetMDMAppleDeclarations(ctx, tx, tmID, macDeclarations, profilesVariablesByIdentifier); err != nil { return ctxerr.Wrap(ctx, err, "batch set apple declarations") } @@ -2021,18 +2021,21 @@ func batchSetProfileVariableAssociationsDB( tx sqlx.ExtContext, profileVariablesByUUID []fleet.MDMProfileUUIDFleetVariables, platform string, + forAppleDeclarations bool, ) (didUpdate bool, err error) { if len(profileVariablesByUUID) == 0 { return false, nil } - var platformPrefix string - switch platform { - case "darwin": - platformPrefix = "apple" - case "windows": - platformPrefix = "windows" - case "android": + var columnName string + switch { + case platform == "darwin" && forAppleDeclarations: + columnName = "apple_declaration_uuid" + case platform == "darwin": + columnName = "apple_profile_uuid" + case platform == "windows": + columnName = "windows_profile_uuid" + case platform == "android": return false, nil // Early return here, to avoid failing but still utilizing the shared batchSet method. default: return false, fmt.Errorf("unsupported platform %s", platform) @@ -2050,7 +2053,7 @@ func batchSetProfileVariableAssociationsDB( } // delete variables associated with those profiles - clearVarsForProfilesStmt := fmt.Sprintf(`DELETE FROM mdm_configuration_profile_variables WHERE %s_profile_uuid IN (?)`, platformPrefix) + clearVarsForProfilesStmt := fmt.Sprintf(`DELETE FROM mdm_configuration_profile_variables WHERE %s IN (?)`, columnName) clearVarsForProfilesStmt, args, err := sqlx.In(clearVarsForProfilesStmt, profileUUIDsToDelete) if err != nil { return false, ctxerr.Wrap(ctx, err, "sqlx.In delete variables for profiles") @@ -2117,13 +2120,13 @@ func batchSetProfileVariableAssociationsDB( executeUpsertBatch := func(valuePart string, args []any) error { stmt := fmt.Sprintf(` INSERT INTO mdm_configuration_profile_variables ( - %s_profile_uuid, + %s, fleet_variable_id ) VALUES %s ON DUPLICATE KEY UPDATE fleet_variable_id = VALUES(fleet_variable_id) - `, platformPrefix, strings.TrimSuffix(valuePart, ",")) + `, columnName, strings.TrimSuffix(valuePart, ",")) _, err := tx.ExecContext(ctx, stmt, args...) return err @@ -2574,12 +2577,23 @@ func (ds *Datastore) batchSetLabelAndVariableAssociations(ctx context.Context, t return false, ctxerr.Wrap(ctx, err, fmt.Sprintf("inserting %s profile label associations", platform)) } - // save fleet variables associated with Windows profiles (both new and updated) + // save fleet variables associated with profiles (both new and updated) // Note: currentProfiles contains all incoming profiles (new AND updated), not just new ones // Process ALL profiles to ensure stale variable associations are cleared for profiles that no longer have variables + var varPrefix string + switch platform { + case "darwin": + varPrefix = fleet.MDMAppleProfileUUIDPrefix + case "windows": + varPrefix = fleet.MDMWindowsProfileUUIDPrefix + case "android": + varPrefix = fleet.MDMAndroidProfileUUIDPrefix + } profileVariablesByName := make(map[string][]fleet.FleetVarName, len(profilesVariablesByIdentifier)) for _, pv := range profilesVariablesByIdentifier { - profileVariablesByName[pv.Identifier] = pv.FleetVariables + if name, ok := strings.CutPrefix(pv.Identifier, varPrefix); ok { + profileVariablesByName[name] = pv.FleetVariables + } } // collect ALL profile UUIDs, including those without variables (to clear stale associations) @@ -2595,7 +2609,7 @@ func (ds *Datastore) batchSetLabelAndVariableAssociations(ctx context.Context, t if len(profilesVarsToUpsert) > 0 { var didUpdateVariableAssociations bool - if didUpdateVariableAssociations, err = batchSetProfileVariableAssociationsDB(ctx, tx, profilesVarsToUpsert, platform); err != nil { + if didUpdateVariableAssociations, err = batchSetProfileVariableAssociationsDB(ctx, tx, profilesVarsToUpsert, platform, false); err != nil { return false, ctxerr.Wrap(ctx, err, fmt.Sprintf("inserting %s profile variable associations", platform)) } diff --git a/server/datastore/mysql/microsoft_mdm.go b/server/datastore/mysql/microsoft_mdm.go index a3e1d03bf8..b74c6c1240 100644 --- a/server/datastore/mysql/microsoft_mdm.go +++ b/server/datastore/mysql/microsoft_mdm.go @@ -2655,7 +2655,7 @@ INSERT INTO FleetVariables: usesFleetVars, }, } - if _, err := batchSetProfileVariableAssociationsDB(ctx, tx, profilesVarsToUpsert, "windows"); err != nil { + if _, err := batchSetProfileVariableAssociationsDB(ctx, tx, profilesVarsToUpsert, "windows", false); err != nil { return ctxerr.Wrap(ctx, err, "inserting windows profile variable associations") } } diff --git a/server/datastore/mysql/microsoft_mdm_test.go b/server/datastore/mysql/microsoft_mdm_test.go index b613dfd2d7..a715050572 100644 --- a/server/datastore/mysql/microsoft_mdm_test.go +++ b/server/datastore/mysql/microsoft_mdm_test.go @@ -2648,7 +2648,7 @@ func testMDMWindowsConfigProfilesWithFleetVars(t *testing.T, ds *Datastore) { // Mock the profilesVariablesByIdentifier that would be passed from service layer profilesVars := []fleet.MDMProfileIdentifierFleetVariables{ - {Identifier: "team_profile_1", FleetVariables: []fleet.FleetVarName{fleet.FleetVarHostUUID}}, + {Identifier: fleet.MDMWindowsProfileUUIDPrefix + "team_profile_1", FleetVariables: []fleet.FleetVarName{fleet.FleetVarHostUUID}}, } _, err = ds.BatchSetMDMProfiles(ctx, ptr.Uint(1), nil, []*fleet.MDMWindowsConfigProfile{teamProf1WithVarsAgain, teamProf2NoChange}, nil, nil, profilesVars) @@ -3680,7 +3680,7 @@ func testSetMDMWindowsProfilesWithVariables(t *testing.T, ds *Datastore) { _, err := batchSetProfileVariableAssociationsDB(ctx, ds.writer(ctx), []fleet.MDMProfileUUIDFleetVariables{ {ProfileUUID: globalProfiles[0], FleetVariables: nil}, {ProfileUUID: globalProfiles[1], FleetVariables: nil}, - }, "windows") + }, "windows", false) require.NoError(t, err) checkProfileVariables(globalProfiles[0], 0, nil) @@ -3690,7 +3690,7 @@ func testSetMDMWindowsProfilesWithVariables(t *testing.T, ds *Datastore) { _, err = batchSetProfileVariableAssociationsDB(ctx, ds.writer(ctx), []fleet.MDMProfileUUIDFleetVariables{ {ProfileUUID: globalProfiles[0], FleetVariables: []fleet.FleetVarName{fleet.FleetVarHostEndUserIDPUsername, fleet.FleetVarName(string(fleet.FleetVarDigiCertDataPrefix) + "ZZZ")}}, {ProfileUUID: globalProfiles[1], FleetVariables: []fleet.FleetVarName{fleet.FleetVarHostEndUserIDPGroups}}, - }, "windows") + }, "windows", false) require.NoError(t, err) checkProfileVariables(globalProfiles[0], 0, []fleet.FleetVarName{fleet.FleetVarHostEndUserIDPUsername, fleet.FleetVarDigiCertDataPrefix}) diff --git a/server/datastore/mysql/scim.go b/server/datastore/mysql/scim.go index cf3fa52509..ec9b075513 100644 --- a/server/datastore/mysql/scim.go +++ b/server/datastore/mysql/scim.go @@ -1317,12 +1317,33 @@ func triggerResendProfilesUsingVariables(ctx context.Context, tx sqlx.ExtContext fv.name IN (:affected_vars) ` + const declarationUpdateStatusQuery = ` + UPDATE + host_mdm_apple_declarations hmad + JOIN hosts h + ON h.uuid = hmad.host_uuid + JOIN mdm_apple_declarations mad + ON (mad.team_id = h.team_id OR (COALESCE(mad.team_id, 0) = 0 AND h.team_id IS NULL)) AND + mad.declaration_uuid = hmad.declaration_uuid + JOIN mdm_configuration_profile_variables mcpv + ON mcpv.apple_declaration_uuid = mad.declaration_uuid + JOIN fleet_variables fv + ON mcpv.fleet_variable_id = fv.id + SET + hmad.status = NULL + WHERE + h.id IN (:host_ids) AND + hmad.operation_type = :operation_type_install AND + hmad.status IS NOT NULL AND + fv.name IN (:affected_vars) +` + vars := make([]any, len(affectedVars)) for i, v := range affectedVars { vars[i] = "FLEET_VAR_" + string(v) } - for _, query := range []string{appleUpdateStatusQuery, windowsUpdateStatusQuery} { + for _, query := range []string{appleUpdateStatusQuery, windowsUpdateStatusQuery, declarationUpdateStatusQuery} { updateStmt, args, err := sqlx.Named(query, map[string]any{ "host_ids": hostIDs, "operation_type_install": fleet.MDMOperationTypeInstall, diff --git a/server/datastore/mysql/secret_variables_test.go b/server/datastore/mysql/secret_variables_test.go index 2e98b416a2..ceec56ab90 100644 --- a/server/datastore/mysql/secret_variables_test.go +++ b/server/datastore/mysql/secret_variables_test.go @@ -618,7 +618,7 @@ func testDeleteUsedSecretVariable(t *testing.T, ds *Datastore) { Identifier: "decl-1", Name: "decl-1", RawJSON: json.RawMessage(`{"Identifier": "${FLEET_SECRET_FOOBAR}"}`), - }) + }, nil) require.NoError(t, err) // Attempt to delete the variable, should fail. @@ -640,7 +640,7 @@ func testDeleteUsedSecretVariable(t *testing.T, ds *Datastore) { Name: "decl-1", RawJSON: json.RawMessage(`{"Identifier": "${FLEET_SECRET_FOOBAR}"}`), TeamID: &foobarTeam.ID, - }) + }, nil) require.NoError(t, err) // Attempt to delete the variable, should fail. diff --git a/server/datastore/mysql/teams_test.go b/server/datastore/mysql/teams_test.go index 5cfeaaccd0..8e4a3e9f7e 100644 --- a/server/datastore/mysql/teams_test.go +++ b/server/datastore/mysql/teams_test.go @@ -122,7 +122,7 @@ func testTeamsGetSetDelete(t *testing.T, ds *Datastore) { Name: "decl-1", TeamID: &team.ID, RawJSON: json.RawMessage(`{"Type": "com.apple.configuration.test", "Identifier": "decl-1"}`), - }) + }, nil) require.NoError(t, err) teamLabel, err := ds.NewLabel(t.Context(), &fleet.Label{ diff --git a/server/fleet/apple_mdm.go b/server/fleet/apple_mdm.go index 94228eb467..9fde6c607f 100644 --- a/server/fleet/apple_mdm.go +++ b/server/fleet/apple_mdm.go @@ -720,9 +720,25 @@ type MDMAppleDeclaration struct { LabelsIncludeAny []ConfigurationProfileLabel `db:"-" json:"labels_include_any,omitempty"` LabelsExcludeAny []ConfigurationProfileLabel `db:"-" json:"labels_exclude_any,omitempty"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - UploadedAt time.Time `db:"uploaded_at" json:"uploaded_at"` - SecretsUpdatedAt *time.Time `db:"secrets_updated_at" json:"-"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UploadedAt time.Time `db:"uploaded_at" json:"uploaded_at"` + SecretsUpdatedAt *time.Time `db:"secrets_updated_at" json:"-"` + VariablesUpdatedAt *time.Time `db:"variables_updated_at" json:"-"` +} + +// EffectiveDDMToken computes the per-declaration token that incorporates both +// the static content hash and the host-specific variables_updated_at timestamp. +// When variablesUpdatedAt is nil (declaration has no Fleet variables), the +// effective token equals the static token unchanged. +func EffectiveDDMToken(staticToken string, variablesUpdatedAt *time.Time) string { + if variablesUpdatedAt == nil { + return staticToken + } + // Must match MySQL's DATETIME(6) string representation used in + // MDMAppleDDMDeclarationsToken's IFNULL(hmad.variables_updated_at, ''). + hasher := md5.New() // nolint:gosec // used for declarative management token + hasher.Write([]byte(staticToken + variablesUpdatedAt.Format("2006-01-02 15:04:05.000000"))) + return hex.EncodeToString(hasher.Sum(nil)) } type MDMAppleRawDeclaration struct { @@ -815,11 +831,16 @@ type MDMAppleHostDeclaration struct { // SecretsUpdatedAt is the timestamp when the secrets were last updated or when this declaration was uploaded. SecretsUpdatedAt *time.Time `db:"secrets_updated_at" json:"-"` + + // VariablesUpdatedAt tracks when the Fleet variable values for this host + // were last computed. Non-null only for declarations that use Fleet variables. + VariablesUpdatedAt *time.Time `db:"variables_updated_at" json:"-"` } func (p MDMAppleHostDeclaration) Equal(other MDMAppleHostDeclaration) bool { statusEqual := p.Status == nil && other.Status == nil || p.Status != nil && other.Status != nil && *p.Status == *other.Status secretsEqual := p.SecretsUpdatedAt == nil && other.SecretsUpdatedAt == nil || p.SecretsUpdatedAt != nil && other.SecretsUpdatedAt != nil && p.SecretsUpdatedAt.Equal(*other.SecretsUpdatedAt) + varsEqual := p.VariablesUpdatedAt == nil && other.VariablesUpdatedAt == nil || p.VariablesUpdatedAt != nil && other.VariablesUpdatedAt != nil && p.VariablesUpdatedAt.Equal(*other.VariablesUpdatedAt) return statusEqual && p.HostUUID == other.HostUUID && p.DeclarationUUID == other.DeclarationUUID && @@ -828,7 +849,8 @@ func (p MDMAppleHostDeclaration) Equal(other MDMAppleHostDeclaration) bool { p.OperationType == other.OperationType && p.Detail == other.Detail && p.Token == other.Token && - secretsEqual + secretsEqual && + varsEqual } func NewMDMAppleDeclaration(raw []byte, teamID *uint, name string, declType, ident string) *MDMAppleDeclaration { @@ -896,6 +918,10 @@ type MDMAppleDDMDeclarationItem struct { Status *string `db:"status"` OperationType *string `db:"operation_type"` UploadedAt time.Time `db:"uploaded_at"` + // VariablesUpdatedAt is not part of the DDM profile, but part of the host-ddm tuple, as the variables' + // values depend on the host. It is used to compute the token for the DDM for a specific host, as the + // ServerToken field is just for the static token of the DDM. + VariablesUpdatedAt *time.Time `db:"variables_updated_at"` } // MDMAppleDDMDeclarationResponse represents a declaration in the datastore. It is used for the DDM diff --git a/server/fleet/apple_mdm_test.go b/server/fleet/apple_mdm_test.go index 96588adb8f..029370d23d 100644 --- a/server/fleet/apple_mdm_test.go +++ b/server/fleet/apple_mdm_test.go @@ -555,6 +555,8 @@ func TestMDMAppleHostDeclarationEqual(t *testing.T) { fieldsInEqualMethod++ items[1].SecretsUpdatedAt = items[0].SecretsUpdatedAt fieldsInEqualMethod++ + items[1].VariablesUpdatedAt = items[0].VariablesUpdatedAt + fieldsInEqualMethod++ assert.Equal(t, fieldsInEqualMethod, numberOfFields, "MDMAppleHostDeclaration.Equal needs to be updated for new/updated field(s)") assert.True(t, items[0].Equal(items[1])) diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index 83577e75ef..8b59be03df 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -1764,6 +1764,10 @@ type Datastore interface { // It also takes care of cleaning up all host declarations that are // pending removal. MDMAppleStoreDDMStatusReport(ctx context.Context, hostUUID string, updates []*MDMAppleHostDeclaration) error + // SetHostMDMAppleDeclarationStatus updates the status and detail of a + // single declaration for a host. If variablesUpdatedAt is non-nil, it also + // sets the variables_updated_at timestamp. + SetHostMDMAppleDeclarationStatus(ctx context.Context, hostUUID string, declarationUUID string, status *MDMDeliveryStatus, detail string, variablesUpdatedAt *time.Time) error // MDMAppleSetPendingDeclarationsAs updates all ("pending", "install") // declarations for a host to be ("verifying", status), where status is // the provided value. @@ -2013,10 +2017,10 @@ type Datastore interface { macDeclarations []*MDMAppleDeclaration, androidProfiles []*MDMAndroidConfigProfile, profilesVariables []MDMProfileIdentifierFleetVariables) (updates MDMProfilesUpdates, err error) // NewMDMAppleDeclaration creates and returns a new MDM Apple declaration. - NewMDMAppleDeclaration(ctx context.Context, declaration *MDMAppleDeclaration) (*MDMAppleDeclaration, error) + NewMDMAppleDeclaration(ctx context.Context, declaration *MDMAppleDeclaration, usesFleetVars []FleetVarName) (*MDMAppleDeclaration, error) // SetOrUpdateMDMAppleDeclaration upserts the MDM Apple declaration. - SetOrUpdateMDMAppleDeclaration(ctx context.Context, declaration *MDMAppleDeclaration) (*MDMAppleDeclaration, error) + SetOrUpdateMDMAppleDeclaration(ctx context.Context, declaration *MDMAppleDeclaration, usesFleetVars []FleetVarName) (*MDMAppleDeclaration, error) /////////////////////////////////////////////////////////////////////////////// // Host Script Results diff --git a/server/fleet/mdm.go b/server/fleet/mdm.go index a8084437da..ba7e2d8dda 100644 --- a/server/fleet/mdm.go +++ b/server/fleet/mdm.go @@ -1231,7 +1231,7 @@ type HostMDMCommand struct { // MDMProfileUUIDFleetVariables represents the Fleet variables used by a // profile identified by its UUID. type MDMProfileUUIDFleetVariables struct { - // ProfileUUID is the UUID of the profile. + // ProfileUUID is the UUID of the profile or declaration. ProfileUUID string // FleetVariables is the (deduplicated) list of Fleet variables used by the // profile, without the "FLEET_VAR_" prefix (as returned by @@ -1242,8 +1242,10 @@ type MDMProfileUUIDFleetVariables struct { // MDMProfileIdentifierFleetVariables represents the Fleet variables used by a // profile identified by its identifier. type MDMProfileIdentifierFleetVariables struct { - // Identifier is the identifier of the profile (which is unique by team for - // Apple profiles). + // Identifier is the identifier of the profile. Because the profile identifier is not guaranteed + // to be unique across platforms and types of profiles (e.g. Apple profiles vs declarations vs Windows + // profiles), it must be prefixed with the same letter used for the UUID prefix of the profile type. + // E.g. fleet.MDMAppleDeclarationUUIDPrefix. Identifier string // FleetVariables is the (deduplicated) list of Fleet variables used by the // profile, without the "FLEET_VAR_" prefix (as returned by diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index 371fe691b3..d3047a509a 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -1159,6 +1159,8 @@ type MDMAppleHostDeclarationsGetAndClearResyncFunc func(ctx context.Context) (ho type MDMAppleStoreDDMStatusReportFunc func(ctx context.Context, hostUUID string, updates []*fleet.MDMAppleHostDeclaration) error +type SetHostMDMAppleDeclarationStatusFunc func(ctx context.Context, hostUUID string, declarationUUID string, status *fleet.MDMDeliveryStatus, detail string, variablesUpdatedAt *time.Time) error + type MDMAppleSetPendingDeclarationsAsFunc func(ctx context.Context, hostUUID string, status *fleet.MDMDeliveryStatus, detail string) error type MDMAppleSetRemoveDeclarationsAsPendingFunc func(ctx context.Context, hostUUID string, declarationUUIDs []string) error @@ -1297,9 +1299,9 @@ type SetOrUpdateMDMWindowsConfigProfileFunc func(ctx context.Context, cp fleet.M type BatchSetMDMProfilesFunc func(ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDeclarations []*fleet.MDMAppleDeclaration, androidProfiles []*fleet.MDMAndroidConfigProfile, profilesVariables []fleet.MDMProfileIdentifierFleetVariables) (updates fleet.MDMProfilesUpdates, err error) -type NewMDMAppleDeclarationFunc func(ctx context.Context, declaration *fleet.MDMAppleDeclaration) (*fleet.MDMAppleDeclaration, error) +type NewMDMAppleDeclarationFunc func(ctx context.Context, declaration *fleet.MDMAppleDeclaration, usesFleetVars []fleet.FleetVarName) (*fleet.MDMAppleDeclaration, error) -type SetOrUpdateMDMAppleDeclarationFunc func(ctx context.Context, declaration *fleet.MDMAppleDeclaration) (*fleet.MDMAppleDeclaration, error) +type SetOrUpdateMDMAppleDeclarationFunc func(ctx context.Context, declaration *fleet.MDMAppleDeclaration, usesFleetVars []fleet.FleetVarName) (*fleet.MDMAppleDeclaration, error) type NewHostScriptExecutionRequestFunc func(ctx context.Context, request *fleet.HostScriptRequestPayload) (*fleet.HostScriptResult, error) @@ -3568,6 +3570,9 @@ type DataStore struct { MDMAppleStoreDDMStatusReportFunc MDMAppleStoreDDMStatusReportFunc MDMAppleStoreDDMStatusReportFuncInvoked bool + SetHostMDMAppleDeclarationStatusFunc SetHostMDMAppleDeclarationStatusFunc + SetHostMDMAppleDeclarationStatusFuncInvoked bool + MDMAppleSetPendingDeclarationsAsFunc MDMAppleSetPendingDeclarationsAsFunc MDMAppleSetPendingDeclarationsAsFuncInvoked bool @@ -8603,6 +8608,13 @@ func (s *DataStore) MDMAppleStoreDDMStatusReport(ctx context.Context, hostUUID s return s.MDMAppleStoreDDMStatusReportFunc(ctx, hostUUID, updates) } +func (s *DataStore) SetHostMDMAppleDeclarationStatus(ctx context.Context, hostUUID string, declarationUUID string, status *fleet.MDMDeliveryStatus, detail string, variablesUpdatedAt *time.Time) error { + s.mu.Lock() + s.SetHostMDMAppleDeclarationStatusFuncInvoked = true + s.mu.Unlock() + return s.SetHostMDMAppleDeclarationStatusFunc(ctx, hostUUID, declarationUUID, status, detail, variablesUpdatedAt) +} + func (s *DataStore) MDMAppleSetPendingDeclarationsAs(ctx context.Context, hostUUID string, status *fleet.MDMDeliveryStatus, detail string) error { s.mu.Lock() s.MDMAppleSetPendingDeclarationsAsFuncInvoked = true @@ -9086,18 +9098,18 @@ func (s *DataStore) BatchSetMDMProfiles(ctx context.Context, tmID *uint, macProf return s.BatchSetMDMProfilesFunc(ctx, tmID, macProfiles, winProfiles, macDeclarations, androidProfiles, profilesVariables) } -func (s *DataStore) NewMDMAppleDeclaration(ctx context.Context, declaration *fleet.MDMAppleDeclaration) (*fleet.MDMAppleDeclaration, error) { +func (s *DataStore) NewMDMAppleDeclaration(ctx context.Context, declaration *fleet.MDMAppleDeclaration, usesFleetVars []fleet.FleetVarName) (*fleet.MDMAppleDeclaration, error) { s.mu.Lock() s.NewMDMAppleDeclarationFuncInvoked = true s.mu.Unlock() - return s.NewMDMAppleDeclarationFunc(ctx, declaration) + return s.NewMDMAppleDeclarationFunc(ctx, declaration, usesFleetVars) } -func (s *DataStore) SetOrUpdateMDMAppleDeclaration(ctx context.Context, declaration *fleet.MDMAppleDeclaration) (*fleet.MDMAppleDeclaration, error) { +func (s *DataStore) SetOrUpdateMDMAppleDeclaration(ctx context.Context, declaration *fleet.MDMAppleDeclaration, usesFleetVars []fleet.FleetVarName) (*fleet.MDMAppleDeclaration, error) { s.mu.Lock() s.SetOrUpdateMDMAppleDeclarationFuncInvoked = true s.mu.Unlock() - return s.SetOrUpdateMDMAppleDeclarationFunc(ctx, declaration) + return s.SetOrUpdateMDMAppleDeclarationFunc(ctx, declaration, usesFleetVars) } func (s *DataStore) NewHostScriptExecutionRequest(ctx context.Context, request *fleet.HostScriptRequestPayload) (*fleet.HostScriptResult, error) { diff --git a/server/service/apple_mdm.go b/server/service/apple_mdm.go index 44e70ac891..7db640770d 100644 --- a/server/service/apple_mdm.go +++ b/server/service/apple_mdm.go @@ -49,6 +49,7 @@ import ( "github.com/fleetdm/fleet/v4/server/mdm/nanodep/storage" "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/cryptoutil" "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm" + "github.com/fleetdm/fleet/v4/server/mdm/profiles" nano_service "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/service" "github.com/fleetdm/fleet/v4/server/platform/endpointer" @@ -74,6 +75,19 @@ var fleetVarsSupportedInAppleConfigProfiles = []fleet.FleetVarName{ fleet.FleetVarHostUUID, fleet.FleetVarHostPlatform, } +// fleetVarsSupportedInDDMDeclarations is the list of Fleet variables +// supported in Apple DDM declarations. +var fleetVarsSupportedInDDMDeclarations = []fleet.FleetVarName{ + fleet.FleetVarHostHardwareSerial, + fleet.FleetVarHostEndUserIDPUsername, + fleet.FleetVarHostEndUserIDPUsernameLocalPart, + fleet.FleetVarHostEndUserIDPGroups, + fleet.FleetVarHostEndUserIDPDepartment, + fleet.FleetVarHostEndUserIDPFullname, + fleet.FleetVarHostUUID, + fleet.FleetVarHostPlatform, +} + type getMDMAppleCommandResultsRequest struct { CommandUUID string `query:"command_uuid,optional"` } @@ -860,9 +874,11 @@ func (svc *Service) NewMDMAppleDeclaration(ctx context.Context, teamID uint, dat return nil, err } + // Get license for team lookup and variable validation + lic, _ := license.FromContext(ctx) + var teamName string if teamID > 0 { - lic, _ := license.FromContext(ctx) if lic == nil || !lic.IsPremium() { return nil, ctxerr.Wrap(ctx, fleet.ErrMissingLicense) } @@ -874,7 +890,7 @@ func (svc *Service) NewMDMAppleDeclaration(ctx context.Context, teamID uint, dat } var tmID *uint - if teamID >= 1 { + if teamID > 0 { tmID = &teamID } @@ -888,8 +904,19 @@ func (svc *Service) NewMDMAppleDeclaration(ctx context.Context, teamID uint, dat return nil, fleet.NewInvalidArgumentError("profile", err.Error()) } - if err := validateDeclarationFleetVariables(dataWithSecrets); err != nil { - return nil, err + declVars, err := validateDeclarationFleetVariables(dataWithSecrets, lic) + if err != nil { + var badReqErr *fleet.BadRequestError + if errors.As(err, &badReqErr) { + badReqErr.Message = "Couldn't upload profile. " + badReqErr.Message + err = badReqErr + } + return nil, ctxerr.Wrap(ctx, err, "validating declaration Fleet variables") + } + + varNames := make([]fleet.FleetVarName, 0, len(declVars)) + for _, v := range declVars { + varNames = append(varNames, fleet.FleetVarName(v)) } // TODO(roberto): Maybe GetRawDeclarationValues belongs inside NewMDMAppleDeclaration? We can refactor this in a follow up. @@ -918,7 +945,7 @@ func (svc *Service) NewMDMAppleDeclaration(ctx context.Context, teamID uint, dat d.LabelsIncludeAll = validatedLabels } - decl, err := svc.ds.NewMDMAppleDeclaration(ctx, d) + decl, err := svc.ds.NewMDMAppleDeclaration(ctx, d, varNames) if err != nil { return nil, err } @@ -948,11 +975,177 @@ func (svc *Service) NewMDMAppleDeclaration(ctx context.Context, teamID uint, dat return decl, nil } -func validateDeclarationFleetVariables(contents string) error { - if variables.Contains(contents) { - return &fleet.BadRequestError{Message: "Fleet variables ($FLEET_VAR_*) are not currently supported in DDM profiles"} +func validateDeclarationFleetVariables(contents string, lic license.LicenseChecker) ([]string, error) { + fleetVars := variables.Find(contents) + if len(fleetVars) == 0 { + return nil, nil } - return nil + + // Check premium license + if lic == nil || !lic.IsPremium() { + return nil, fleet.ErrMissingLicense + } + + // Validate against allowed list + for _, fleetVar := range fleetVars { + if !slices.Contains(fleetVarsSupportedInDDMDeclarations, fleet.FleetVarName(fleetVar)) { + return nil, &fleet.BadRequestError{ + Message: fmt.Sprintf("Fleet variable $FLEET_VAR_%s is not supported in DDM profiles.", fleetVar), + } + } + } + + return fleetVars, nil +} + +// jsonEscapeString returns the JSON-escaped interior of a string value +// (without surrounding quotes), suitable for embedding inside a JSON string. +func jsonEscapeString(s string) string { + b, err := json.Marshal(s) + if err != nil { + // json.Marshal on a string should never fail, but return the + // original string as a fallback. + return s + } + // strip surrounding quotes + return string(b[1 : len(b)-1]) +} + +// replaceDeclarationFleetVariables replaces $FLEET_VAR_* placeholders in a +// DDM declaration with host-specific values. Values are JSON-string-escaped +// so they are safe inside JSON string fields. +func (svc *MDMAppleDDMService) replaceDeclarationFleetVariables( + ctx context.Context, contents string, hostUUID string, +) (string, error) { + fleetVars := variables.Find(contents) + if len(fleetVars) == 0 { + return contents, nil + } + + var hostLite fleet.Host + hostLite.UUID = hostUUID + hostHydrated := false + + hydrateHost := func() error { + if hostHydrated { + return nil + } + h, ok, err := profiles.HydrateHost(ctx, svc.ds, hostLite, func(n int) error { + return fmt.Errorf("unexpected number of hosts (%d) for UUID %s", n, hostUUID) + }) + if err != nil { + return err + } + if !ok { + return fmt.Errorf("host not found for UUID %s", hostUUID) + } + hostLite = h + hostHydrated = true + return nil + } + + var idpUser *fleet.HostEndUser + resolveIDPUser := func(varName string) (*fleet.HostEndUser, error) { + if idpUser != nil { + return idpUser, nil + } + if err := hydrateHost(); err != nil { + return nil, err + } + users, err := fleet.GetEndUsers(ctx, svc.ds, hostLite.ID) + if err != nil { + return nil, fmt.Errorf("get end users for host: %w", err) + } + if len(users) == 0 || users[0].IdpUserName == "" { + return nil, fmt.Errorf("There is no IdP username for this host. Fleet couldn't populate $FLEET_VAR_%s.", varName) + } + idpUser = &users[0] + return idpUser, nil + } + + for _, fleetVar := range fleetVars { + var value string + switch fleet.FleetVarName(fleetVar) { + case fleet.FleetVarHostUUID: + value = hostUUID + + case fleet.FleetVarHostHardwareSerial: + if err := hydrateHost(); err != nil { + return "", err + } + if strings.TrimSpace(hostLite.HardwareSerial) == "" { + return "", fmt.Errorf("There is no serial number for this host. Fleet couldn't populate $FLEET_VAR_%s.", fleetVar) + } + value = hostLite.HardwareSerial + + case fleet.FleetVarHostPlatform: + if err := hydrateHost(); err != nil { + return "", err + } + value = hostLite.Platform + if value == "darwin" { + value = "macos" + } + + case fleet.FleetVarHostEndUserIDPUsername: + user, err := resolveIDPUser(fleetVar) + if err != nil { + return "", err + } + value = user.IdpUserName + + case fleet.FleetVarHostEndUserIDPUsernameLocalPart: + user, err := resolveIDPUser(fleetVar) + if err != nil { + return "", err + } + local, _, _ := strings.Cut(user.IdpUserName, "@") + value = local + + case fleet.FleetVarHostEndUserIDPGroups: + user, err := resolveIDPUser(fleetVar) + if err != nil { + return "", err + } + if len(user.IdpGroups) == 0 { + return "", fmt.Errorf("There are no IdP groups for this host. Fleet couldn't populate $FLEET_VAR_%s.", fleetVar) + } + value = strings.Join(user.IdpGroups, ",") + + case fleet.FleetVarHostEndUserIDPDepartment: + user, err := resolveIDPUser(fleetVar) + if err != nil { + return "", err + } + if user.Department == "" { + return "", fmt.Errorf("There is no IdP department for this host. Fleet couldn't populate $FLEET_VAR_%s.", fleetVar) + } + value = user.Department + + case fleet.FleetVarHostEndUserIDPFullname: + user, err := resolveIDPUser(fleetVar) + if err != nil { + return "", err + } + if strings.TrimSpace(user.IdpFullName) == "" { + return "", fmt.Errorf("There is no IdP full name for this host. Fleet couldn't populate $FLEET_VAR_%s.", fleetVar) + } + value = strings.TrimSpace(user.IdpFullName) + + default: + return "", fmt.Errorf("Fleet variable $FLEET_VAR_%s is not supported in DDM declarations.", fleetVar) + } + + contents = variables.Replace(contents, fleetVar, jsonEscapeString(value)) + } + + return contents, nil +} + +// markDeclarationFailed marks a DDM declaration as failed for a specific host. +func (svc *MDMAppleDDMService) markDeclarationFailed(ctx context.Context, hostUUID string, d *fleet.MDMAppleDeclaration, detail string) error { + status := fleet.MDMDeliveryFailed + return svc.ds.SetHostMDMAppleDeclarationStatus(ctx, hostUUID, d.DeclarationUUID, &status, detail, nil) } func (svc *Service) batchValidateDeclarationLabels(ctx context.Context, labelNames []string, teamID uint) (map[string]fleet.ConfigurationProfileLabel, error) { @@ -5967,31 +6160,34 @@ func (svc *MDMAppleDDMService) handleDeclarationItems(ctx context.Context, hostU } continue } + effectiveToken := fleet.EffectiveDDMToken(d.ServerToken, d.VariablesUpdatedAt) configurations = append(configurations, fleet.MDMAppleDDMManifest{ Identifier: d.Identifier, - ServerToken: d.ServerToken, + ServerToken: effectiveToken, }) activations = append(activations, fleet.MDMAppleDDMManifest{ Identifier: fmt.Sprintf("%s.activation", d.Identifier), - ServerToken: d.ServerToken, + ServerToken: effectiveToken, }) } // Calculate token based on count and concatenated tokens for install items var count int type tokenSorting struct { - token string - uploadedAt time.Time - declarationUUID string + token string + variablesUpdatedAt *time.Time + uploadedAt time.Time + declarationUUID string } var tokens []tokenSorting for _, d := range di { if d.OperationType != nil && *d.OperationType == string(fleet.MDMOperationTypeInstall) { // Extract d.ServerToken and order by d.UploadedAt descending and then by d.DeclarationUUID ascending sorting := tokenSorting{ - token: d.ServerToken, - uploadedAt: d.UploadedAt, - declarationUUID: d.DeclarationUUID, + token: d.ServerToken, + variablesUpdatedAt: d.VariablesUpdatedAt, + uploadedAt: d.UploadedAt, + declarationUUID: d.DeclarationUUID, } tokens = append(tokens, sorting) count++ @@ -6007,6 +6203,11 @@ func (svc *MDMAppleDDMService) handleDeclarationItems(ctx context.Context, hostU var tokenBuilder strings.Builder for _, t := range tokens { tokenBuilder.WriteString(t.token) + if t.variablesUpdatedAt != nil { + // Must match MySQL's DATETIME(6) string representation used in + // MDMAppleDDMDeclarationsToken's IFNULL(hmad.variables_updated_at, ''). + tokenBuilder.WriteString(t.variablesUpdatedAt.Format("2006-01-02 15:04:05.000000")) + } } var token string @@ -6080,7 +6281,7 @@ func (svc *MDMAppleDDMService) handleActivationDeclaration(ctx context.Context, }, "ServerToken": "%s", "Type": "com.apple.activation.simple" -}`, parts[2], references, d.Token) +}`, parts[2], references, fleet.EffectiveDDMToken(d.Token, d.VariablesUpdatedAt)) return []byte(response), nil } @@ -6099,11 +6300,21 @@ func (svc *MDMAppleDDMService) handleConfigurationDeclaration(ctx context.Contex return nil, ctxerr.Wrap(ctx, err, fmt.Sprintf("expanding embedded secrets for identifier:%s hostUUID:%s", parts[2], hostUUID)) } + // Replace Fleet variables with host-specific values + expanded, err = svc.replaceDeclarationFleetVariables(ctx, expanded, hostUUID) + if err != nil { + // Mark this declaration as failed for this host, return empty 200 + if err := svc.markDeclarationFailed(ctx, hostUUID, d, err.Error()); err != nil { + return nil, ctxerr.Wrap(ctx, err, "mark declaration as failed") + } + return nil, nil + } + var tempd map[string]any if err := json.Unmarshal([]byte(expanded), &tempd); err != nil { return nil, ctxerr.Wrap(ctx, err, "unmarshaling stored declaration") } - tempd["ServerToken"] = d.Token + tempd["ServerToken"] = fleet.EffectiveDDMToken(d.Token, d.VariablesUpdatedAt) //nolint:nilaway // tempd is non-nil after successful json.Unmarshal b, err := json.Marshal(tempd) if err != nil { diff --git a/server/service/apple_mdm_ddm_test.go b/server/service/apple_mdm_ddm_test.go index 6a337c293d..210ca149e1 100644 --- a/server/service/apple_mdm_ddm_test.go +++ b/server/service/apple_mdm_ddm_test.go @@ -49,7 +49,7 @@ func TestDeclarativeManagement_DeclarationItems(t *testing.T) { TeamID: nil, RawJSON: []byte(fmt.Sprintf(`{"Type":"com.apple.test.declaration","Identifier":"%s"}`, identifier)), } - declaration, err := ds.NewMDMAppleDeclaration(context.Background(), declaration) + declaration, err := ds.NewMDMAppleDeclaration(context.Background(), declaration, nil) require.NoError(t, err) return declaration } diff --git a/server/service/apple_mdm_test.go b/server/service/apple_mdm_test.go index 9810805a28..4184327659 100644 --- a/server/service/apple_mdm_test.go +++ b/server/service/apple_mdm_test.go @@ -887,7 +887,7 @@ func TestNewMDMAppleDeclaration(t *testing.T) { _, err = svc.NewMDMAppleDeclaration(ctx, 0, b, nil, "name", fleet.LabelsIncludeAll) assert.ErrorContains(t, err, "Only configuration declarations (com.apple.configuration.) are supported") - ds.NewMDMAppleDeclarationFunc = func(ctx context.Context, d *fleet.MDMAppleDeclaration) (*fleet.MDMAppleDeclaration, error) { + ds.NewMDMAppleDeclarationFunc = func(ctx context.Context, d *fleet.MDMAppleDeclaration, usesFleetVars []fleet.FleetVarName) (*fleet.MDMAppleDeclaration, error) { return d, nil } ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hids, tids []uint, puuids, uuids []string, @@ -973,7 +973,7 @@ func TestNewMDMAppleDeclarationSkipValidation(t *testing.T) { ds.ExpandEmbeddedSecretsAndUpdatedAtFunc = func(ctx context.Context, s string) (string, *time.Time, error) { return s, nil, nil } - ds.NewMDMAppleDeclarationFunc = func(ctx context.Context, d *fleet.MDMAppleDeclaration) (*fleet.MDMAppleDeclaration, error) { + ds.NewMDMAppleDeclarationFunc = func(ctx context.Context, d *fleet.MDMAppleDeclaration, usesFleetVars []fleet.FleetVarName) (*fleet.MDMAppleDeclaration, error) { return d, nil } ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hids, tids []uint, puuids, uuids []string, @@ -1016,7 +1016,7 @@ func TestNewMDMAppleDeclarationSkipValidation(t *testing.T) { ds.ExpandEmbeddedSecretsAndUpdatedAtFunc = func(ctx context.Context, s string) (string, *time.Time, error) { return s, nil, nil } - ds.NewMDMAppleDeclarationFunc = func(ctx context.Context, d *fleet.MDMAppleDeclaration) (*fleet.MDMAppleDeclaration, error) { + ds.NewMDMAppleDeclarationFunc = func(ctx context.Context, d *fleet.MDMAppleDeclaration, usesFleetVars []fleet.FleetVarName) (*fleet.MDMAppleDeclaration, error) { return d, nil } ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hids, tids []uint, puuids, uuids []string, @@ -1058,7 +1058,7 @@ func TestNewMDMAppleDeclarationSkipValidation(t *testing.T) { ds.ExpandEmbeddedSecretsAndUpdatedAtFunc = func(ctx context.Context, s string) (string, *time.Time, error) { return s, nil, nil } - ds.NewMDMAppleDeclarationFunc = func(ctx context.Context, d *fleet.MDMAppleDeclaration) (*fleet.MDMAppleDeclaration, error) { + ds.NewMDMAppleDeclarationFunc = func(ctx context.Context, d *fleet.MDMAppleDeclaration, usesFleetVars []fleet.FleetVarName) (*fleet.MDMAppleDeclaration, error) { return d, nil } ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hids, tids []uint, puuids, uuids []string, @@ -6338,6 +6338,103 @@ func TestValidateConfigProfileFleetVariables(t *testing.T) { } } +func TestValidateDeclarationFleetVariables(t *testing.T) { + t.Parallel() + + premiumLic := &fleet.LicenseInfo{Tier: fleet.TierPremium} + freeLic := &fleet.LicenseInfo{Tier: fleet.TierFree} + + // helper to create a simple DDM declaration JSON with a value field + makeDecl := func(value string) string { + return fmt.Sprintf(`{"Type": "com.apple.configuration.management.test", "Identifier": "com.example.test", "Payload": {"Value": %q}}`, value) + } + + t.Run("no variables, free license", func(t *testing.T) { + vars, err := validateDeclarationFleetVariables(makeDecl("static-value"), freeLic) + require.NoError(t, err) + require.Nil(t, vars) + }) + + t.Run("supported variable with premium license", func(t *testing.T) { + vars, err := validateDeclarationFleetVariables(makeDecl("$FLEET_VAR_HOST_HARDWARE_SERIAL"), premiumLic) + require.NoError(t, err) + require.Equal(t, []string{"HOST_HARDWARE_SERIAL"}, vars) + }) + + t.Run("supported variable with braces", func(t *testing.T) { + vars, err := validateDeclarationFleetVariables(makeDecl("${FLEET_VAR_HOST_UUID}"), premiumLic) + require.NoError(t, err) + require.Equal(t, []string{"HOST_UUID"}, vars) + }) + + t.Run("multiple supported variables", func(t *testing.T) { + vars, err := validateDeclarationFleetVariables( + makeDecl(`["$FLEET_VAR_HOST_HARDWARE_SERIAL", "$FLEET_VAR_HOST_END_USER_IDP_USERNAME"]`), premiumLic) + require.NoError(t, err) + require.ElementsMatch(t, []string{"HOST_HARDWARE_SERIAL", "HOST_END_USER_IDP_USERNAME"}, vars) + }) + + t.Run("all supported variables", func(t *testing.T) { + // Build the declaration content and expected results from the allowed list + var jsonVars, expectedVars []string + for _, v := range fleetVarsSupportedInDDMDeclarations { + jsonVars = append(jsonVars, fmt.Sprintf(`"$FLEET_VAR_%s"`, v)) + expectedVars = append(expectedVars, string(v)) + } + vars, err := validateDeclarationFleetVariables( + makeDecl("["+strings.Join(jsonVars, ", ")+"]"), premiumLic) + require.NoError(t, err) + require.ElementsMatch(t, expectedVars, vars) + }) + + t.Run("supported variable without premium license", func(t *testing.T) { + _, err := validateDeclarationFleetVariables(makeDecl("$FLEET_VAR_HOST_HARDWARE_SERIAL"), freeLic) + require.ErrorIs(t, err, fleet.ErrMissingLicense) + }) + + t.Run("supported variable with nil license", func(t *testing.T) { + _, err := validateDeclarationFleetVariables(makeDecl("$FLEET_VAR_HOST_UUID"), nil) + require.ErrorIs(t, err, fleet.ErrMissingLicense) + }) + + t.Run("unsupported variable", func(t *testing.T) { + _, err := validateDeclarationFleetVariables(makeDecl("$FLEET_VAR_NDES_SCEP_CHALLENGE"), premiumLic) + require.Error(t, err) + require.ErrorContains(t, err, "Fleet variable $FLEET_VAR_NDES_SCEP_CHALLENGE is not supported in DDM profiles") + }) + + t.Run("supported and unsupported variables", func(t *testing.T) { + _, err := validateDeclarationFleetVariables( + makeDecl(`["$FLEET_VAR_HOST_UUID", "$FLEET_VAR_DIGICERT_DATA_myCA"]`), premiumLic) + require.Error(t, err) + require.ErrorContains(t, err, "Fleet variable $FLEET_VAR_DIGICERT_DATA_myCA is not supported in DDM profiles") + }) +} + +func TestJSONEscapeString(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + input string + expected string + }{ + {"plain string", "hello", "hello"}, + {"with double quotes", `say "hi"`, `say \"hi\"`}, + {"with backslash", `path\to\file`, `path\\to\\file`}, + {"with newline", "line1\nline2", `line1\nline2`}, + {"with tab", "col1\tcol2", `col1\tcol2`}, + {"empty string", "", ""}, + {"unicode", "café ☕", "café ☕"}, + {"control chars", "a\x00b", `a\u0000b`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.expected, jsonEscapeString(tc.input)) + }) + } +} + //go:embed testdata/profiles/digicert-validation.mobileconfig var digiCertValidationMobileconfig string diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index 7b36da0be2..8158d6b5ed 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -7723,6 +7723,26 @@ func (s *integrationTestSuite) TestPremiumEndpointsWithoutLicense() { createHostAndDeviceToken(t, s.ds, "some-token") s.Do("POST", fmt.Sprintf("/api/v1/fleet/device/%s/migrate_mdm", "some-token"), nil, http.StatusPaymentRequired) + // uploading a DDM declaration with a Fleet variable returns a license error + // (single profile upload endpoint) + ddmWithFleetVar := []byte(`{ + "Type": "com.apple.configuration.management.test", + "Identifier": "com.example.fleetvar-test", + "Payload": {"Value": "$FLEET_VAR_HOST_HARDWARE_SERIAL"} + }`) + body, headers := generateNewProfileMultipartRequest(t, "fleetvar-test.json", ddmWithFleetVar, s.token, nil) + res = s.DoRawWithHeaders("POST", "/api/latest/fleet/configuration_profiles", body.Bytes(), http.StatusPaymentRequired, headers) + errMsg = extractServerErrorText(res.Body) + require.Contains(t, errMsg, "Requires Fleet Premium license") + + // uploading a DDM declaration with a Fleet variable returns a license error + // (batch profiles endpoint) + res = s.Do("POST", "/api/latest/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{ + {Name: "N1", Contents: ddmWithFleetVar}, + }}, http.StatusPaymentRequired) + errMsg = extractServerErrorText(res.Body) + require.Contains(t, errMsg, "Requires Fleet Premium license") + // software titles // a normal request works fine var resp listSoftwareTitlesResponse @@ -15052,7 +15072,7 @@ func (s *integrationTestSuite) TestSecretVariablesInUse() { Name: "decl-1", RawJSON: json.RawMessage(`{"Identifier": "${FLEET_SECRET_NAME1}"}`), TeamID: &foobarTeam.ID, - }) + }, nil) require.NoError(t, err) res = s.DoRaw("DELETE", fmt.Sprintf("/api/latest/fleet/custom_variables/%d", firstVariableID), nil, http.StatusConflict) diff --git a/server/service/integration_mdm_ddm_test.go b/server/service/integration_mdm_ddm_test.go index deef5c5002..32bd63ab61 100644 --- a/server/service/integration_mdm_ddm_test.go +++ b/server/service/integration_mdm_ddm_test.go @@ -1302,6 +1302,569 @@ func (s *integrationMDMTestSuite) TestDDMTransactionRecording() { }) } +func (s *integrationMDMTestSuite) TestAppleDDMFleetVariables() { + t := s.T() + ctx := t.Context() + + // === Setup === + + // Create two MDM-enrolled hosts + host1, mdmDevice1 := createHostThenEnrollMDM(s.ds, s.server.URL, t) + _, mdmDevice2 := createHostThenEnrollMDM(s.ds, s.server.URL, t) + + // Set host1's serial to a value with characters that need JSON escaping, + // to verify that variable substitution produces valid JSON. + host1.HardwareSerial = `SER"IAL\123` + err := s.ds.UpdateHost(ctx, host1) + require.NoError(t, err) + + // Create a team and transfer host1 into it; host2 stays global (control) + team := &fleet.Team{Name: t.Name() + "team1"} + var createTeamResp teamResponse + s.DoJSON("POST", "/api/latest/fleet/teams", team, http.StatusOK, &createTeamResp) + require.NotZero(t, createTeamResp.Team.ID) + team = createTeamResp.Team + + s.Do("POST", "/api/v1/fleet/hosts/transfer", + addHostsToTeamRequest{TeamID: &team.ID, HostIDs: []uint{host1.ID}}, http.StatusOK) + + // Helper: read declaration from DB by name + getDeclaration := func(t *testing.T, name string) fleet.MDMAppleDeclaration { + stmt := ` +SELECT + declaration_uuid, + team_id, + identifier, + name, + raw_json, + HEX(token) as token, + created_at, + uploaded_at +FROM mdm_apple_declarations +WHERE name = ?` + + var decl fleet.MDMAppleDeclaration + mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + return sqlx.GetContext(ctx, q, &decl, stmt, name) + }) + return decl + } + + // Helper: read variables_updated_at for a host/declaration pair + getHostDeclVarsUpdatedAt := func(t *testing.T, hostUUID, declUUID string) *time.Time { + var result []time.Time + mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + return sqlx.SelectContext(ctx, q, &result, + `SELECT variables_updated_at FROM host_mdm_apple_declarations WHERE host_uuid = ? AND declaration_uuid = ? AND variables_updated_at IS NOT NULL`, + hostUUID, declUUID) + }) + if len(result) == 0 { + return nil + } + return &result[0] + } + + checkNoCommands := func(d *mdmtest.TestAppleMDMClient) { + cmd, err := d.Idle() + require.NoError(t, err) + require.Nil(t, cmd) + } + + checkDDMSync := func(d *mdmtest.TestAppleMDMClient) { + cmd, err := d.Idle() + require.NoError(t, err) + require.NotNil(t, cmd) + require.Equal(t, "DeclarativeManagement", cmd.Command.RequestType) + cmd, err = d.Acknowledge(cmd.CommandUUID) + require.NoError(t, err) + require.Nil(t, cmd) + _, err = d.DeclarativeManagement("tokens") + require.NoError(t, err) + } + + checkDeclarationItemsResp := func(t *testing.T, r fleet.MDMAppleDDMDeclarationItemsResponse, expectedDeclTok string, + expectedDeclsByToken map[string]fleet.MDMAppleDeclaration, + ) { + require.Equal(t, expectedDeclTok, r.DeclarationsToken) + require.NotEmpty(t, r.Declarations.Activations) + require.Empty(t, r.Declarations.Assets) + require.Empty(t, r.Declarations.Management) + require.Len(t, r.Declarations.Configurations, len(expectedDeclsByToken)) + for _, m := range r.Declarations.Configurations { + d, ok := expectedDeclsByToken[m.ServerToken] + require.True(t, ok, "server token %x not found for %s", m.ServerToken, m.Identifier) + require.Equal(t, d.Identifier, m.Identifier) + } + } + + teamIDStr := fmt.Sprintf("%d", team.ID) + + // Declaration payloads + declWithUUID := []byte(`{ + "Type": "com.apple.configuration.management.test", + "Payload": {"Echo": "$FLEET_VAR_HOST_UUID"}, + "Identifier": "com.fleet.var.uuid" +}`) + declWithSerial := []byte(`{ + "Type": "com.apple.configuration.management.test", + "Payload": {"Echo": "$FLEET_VAR_HOST_HARDWARE_SERIAL"}, + "Identifier": "com.fleet.var.serial" +}`) + declPlain := []byte(`{ + "Type": "com.apple.configuration.management.test", + "Payload": {"Echo": "static-value"}, + "Identifier": "com.fleet.plain" +}`) + + // === Failing upload (unsupported variable) === + + badDecl := []byte(`{ + "Type": "com.apple.configuration.management.test", + "Payload": {"Echo": "$FLEET_VAR_NDES_SCEP_CHALLENGE"}, + "Identifier": "com.fleet.bad" +}`) + badReq := batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{ + {Name: "BadDecl.json", Contents: badDecl}, + }} + badRes := s.Do("POST", "/api/latest/fleet/mdm/profiles/batch", badReq, http.StatusBadRequest, + "team_id", teamIDStr) + errMsg := extractServerErrorText(badRes.Body) + require.Contains(t, errMsg, "$FLEET_VAR_NDES_SCEP_CHALLENGE is not supported in DDM") + + // === Upload declarations with and without variables === + + profilesReq := batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{ + {Name: "VarUUID.json", Contents: declWithUUID}, + {Name: "VarSerial.json", Contents: declWithSerial}, + {Name: "Plain.json", Contents: declPlain}, + }} + s.Do("POST", "/api/latest/fleet/mdm/profiles/batch", profilesReq, http.StatusNoContent, + "team_id", teamIDStr) + + // Verify raw JSON stored as-is (variables not expanded in storage) + dbDeclUUID := getDeclaration(t, "VarUUID.json") + assert.Contains(t, string(dbDeclUUID.RawJSON), "$FLEET_VAR_HOST_UUID") + dbDeclSerial := getDeclaration(t, "VarSerial.json") + assert.Contains(t, string(dbDeclSerial.RawJSON), "$FLEET_VAR_HOST_HARDWARE_SERIAL") + dbDeclPlain := getDeclaration(t, "Plain.json") + assert.Contains(t, string(dbDeclPlain.RawJSON), "static-value") + + // === First sync — verify variable substitution === + + s.awaitTriggerProfileSchedule(t) + + checkDDMSync(mdmDevice1) + checkNoCommands(mdmDevice2) + + // Host1 fetches tokens + r, err := mdmDevice1.DeclarativeManagement("tokens") + require.NoError(t, err) + tokens := parseTokensResp(t, r) + lastSyncDeclToken := tokens.SyncTokens.DeclarationsToken + require.NotEmpty(t, lastSyncDeclToken) + + // Fetch individual declarations and verify substitution + var gotParsed fleet.MDMAppleDDMDeclarationResponse + + r, err = mdmDevice1.DeclarativeManagement("declaration/configuration/com.fleet.var.uuid") + require.NoError(t, err) + require.NoError(t, json.NewDecoder(r.Body).Decode(&gotParsed)) + assert.Contains(t, string(gotParsed.Payload), host1.UUID) + assert.NotContains(t, string(gotParsed.Payload), "$FLEET_VAR") + + r, err = mdmDevice1.DeclarativeManagement("declaration/configuration/com.fleet.var.serial") + require.NoError(t, err) + require.NoError(t, json.NewDecoder(r.Body).Decode(&gotParsed)) + assert.NotContains(t, string(gotParsed.Payload), "$FLEET_VAR") + // Verify the serial (which contains " and \) is properly JSON-escaped: + // the payload must be valid JSON and unmarshal to the original value. + var serialPayload struct{ Echo string } + require.NoError(t, json.Unmarshal(gotParsed.Payload, &serialPayload)) + assert.Equal(t, host1.HardwareSerial, serialPayload.Echo) + + r, err = mdmDevice1.DeclarativeManagement("declaration/configuration/com.fleet.plain") + require.NoError(t, err) + require.NoError(t, json.NewDecoder(r.Body).Decode(&gotParsed)) + assert.Contains(t, string(gotParsed.Payload), "static-value") + + // Verify variables_updated_at: set for variable decls, nil for plain + varsUpdatedUUID := getHostDeclVarsUpdatedAt(t, host1.UUID, dbDeclUUID.DeclarationUUID) + require.NotNil(t, varsUpdatedUUID) + varsUpdatedSerial := getHostDeclVarsUpdatedAt(t, host1.UUID, dbDeclSerial.DeclarationUUID) + require.NotNil(t, varsUpdatedSerial) + varsUpdatedPlain := getHostDeclVarsUpdatedAt(t, host1.UUID, dbDeclPlain.DeclarationUUID) + require.Nil(t, varsUpdatedPlain) + + // Build expected declaration-items map with effective tokens (incorporating variables_updated_at) + declsByToken := map[string]fleet.MDMAppleDeclaration{ + fleet.EffectiveDDMToken(dbDeclUUID.Token, varsUpdatedUUID): {Identifier: "com.fleet.var.uuid"}, + fleet.EffectiveDDMToken(dbDeclSerial.Token, varsUpdatedSerial): {Identifier: "com.fleet.var.serial"}, + dbDeclPlain.Token: {Identifier: "com.fleet.plain"}, + } + + // Host1 fetches declaration items + r, err = mdmDevice1.DeclarativeManagement("declaration-items") + require.NoError(t, err) + itemsResp := parseDeclarationItemsResp(t, r) + checkDeclarationItemsResp(t, itemsResp, lastSyncDeclToken, declsByToken) + + // === No resend when unrelated declaration added === + + newDecl := []byte(`{ + "Type": "com.apple.configuration.management.test", + "Payload": {"Echo": "new-stuff"}, + "Identifier": "com.fleet.new" +}`) + profilesReqWithNew := batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{ + {Name: "VarUUID.json", Contents: declWithUUID}, + {Name: "VarSerial.json", Contents: declWithSerial}, + {Name: "Plain.json", Contents: declPlain}, + {Name: "NewDecl.json", Contents: newDecl}, + }} + s.Do("POST", "/api/latest/fleet/mdm/profiles/batch", profilesReqWithNew, http.StatusNoContent, + "team_id", teamIDStr) + + dbNewDecl := getDeclaration(t, "NewDecl.json") + assert.Contains(t, string(dbNewDecl.RawJSON), "new-stuff") + + s.awaitTriggerProfileSchedule(t) + + // Host1 gets DDM sync (declaration set changed), host2 nothing + checkDDMSync(mdmDevice1) + checkNoCommands(mdmDevice2) + + r, err = mdmDevice1.DeclarativeManagement("tokens") + require.NoError(t, err) + tokens = parseTokensResp(t, r) + lastSyncDeclToken = tokens.SyncTokens.DeclarationsToken + require.NotEmpty(t, lastSyncDeclToken) + + declsByToken = map[string]fleet.MDMAppleDeclaration{ + fleet.EffectiveDDMToken(dbDeclUUID.Token, varsUpdatedUUID): {Identifier: "com.fleet.var.uuid"}, + fleet.EffectiveDDMToken(dbDeclSerial.Token, varsUpdatedSerial): {Identifier: "com.fleet.var.serial"}, + dbDeclPlain.Token: {Identifier: "com.fleet.plain"}, + dbNewDecl.Token: {Identifier: "com.fleet.new"}, + } + + r, err = mdmDevice1.DeclarativeManagement("declaration-items") + require.NoError(t, err) + itemsResp = parseDeclarationItemsResp(t, r) + checkDeclarationItemsResp(t, itemsResp, lastSyncDeclToken, declsByToken) + + // variables_updated_at did NOT change for existing variable declarations + varsUpdatedUUIDAfterAdd := getHostDeclVarsUpdatedAt(t, host1.UUID, dbDeclUUID.DeclarationUUID) + require.NotNil(t, varsUpdatedUUIDAfterAdd) + assert.Equal(t, *varsUpdatedUUID, *varsUpdatedUUIDAfterAdd) + varsUpdatedSerialAfterAdd := getHostDeclVarsUpdatedAt(t, host1.UUID, dbDeclSerial.DeclarationUUID) + require.NotNil(t, varsUpdatedSerialAfterAdd) + assert.Equal(t, *varsUpdatedSerial, *varsUpdatedSerialAfterAdd) + + // === No resend when unrelated declaration deleted === + + // new decl is not in profilesReq, so it will be deleted + s.Do("POST", "/api/latest/fleet/mdm/profiles/batch", profilesReq, http.StatusNoContent, + "team_id", teamIDStr) + + s.awaitTriggerProfileSchedule(t) + + // Host1 gets DDM sync (declaration set changed), host2 nothing + checkDDMSync(mdmDevice1) + checkNoCommands(mdmDevice2) + + declsByToken = map[string]fleet.MDMAppleDeclaration{ + fleet.EffectiveDDMToken(dbDeclUUID.Token, varsUpdatedUUID): {Identifier: "com.fleet.var.uuid"}, + fleet.EffectiveDDMToken(dbDeclSerial.Token, varsUpdatedSerial): {Identifier: "com.fleet.var.serial"}, + dbDeclPlain.Token: {Identifier: "com.fleet.plain"}, + } + + r, err = mdmDevice1.DeclarativeManagement("tokens") + require.NoError(t, err) + tokens = parseTokensResp(t, r) + lastSyncDeclToken = tokens.SyncTokens.DeclarationsToken + require.NotEmpty(t, lastSyncDeclToken) + + r, err = mdmDevice1.DeclarativeManagement("declaration-items") + require.NoError(t, err) + itemsResp = parseDeclarationItemsResp(t, r) + checkDeclarationItemsResp(t, itemsResp, lastSyncDeclToken, declsByToken) + + // variables_updated_at still unchanged + varsUpdatedUUIDAfterDel := getHostDeclVarsUpdatedAt(t, host1.UUID, dbDeclUUID.DeclarationUUID) + require.NotNil(t, varsUpdatedUUIDAfterDel) + assert.Equal(t, *varsUpdatedUUID, *varsUpdatedUUIDAfterDel) + varsUpdatedSerialAfterDel := getHostDeclVarsUpdatedAt(t, host1.UUID, dbDeclSerial.DeclarationUUID) + require.NotNil(t, varsUpdatedSerialAfterDel) + assert.Equal(t, *varsUpdatedSerial, *varsUpdatedSerialAfterDel) + + // === No resend on no-op GitOps batch upload === + + s.Do("POST", "/api/latest/fleet/mdm/profiles/batch", profilesReq, http.StatusNoContent, + "team_id", teamIDStr) + + s.awaitTriggerProfileSchedule(t) + + // No commands for either host — nothing changed + checkNoCommands(mdmDevice1) + checkNoCommands(mdmDevice2) + + // Token unchanged + r, err = mdmDevice1.DeclarativeManagement("tokens") + require.NoError(t, err) + tokens = parseTokensResp(t, r) + assert.Equal(t, lastSyncDeclToken, tokens.SyncTokens.DeclarationsToken) + + // === Resend when variable values change === + + // Simulate variable value change: set status = NULL on variable declarations. + // This is the same operation triggerResendProfilesUsingVariables performs. + mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + _, err := q.ExecContext(ctx, + `UPDATE host_mdm_apple_declarations SET status = NULL + WHERE host_uuid = ? AND declaration_uuid IN (?, ?)`, + host1.UUID, dbDeclUUID.DeclarationUUID, dbDeclSerial.DeclarationUUID, + ) + return err + }) + + s.awaitTriggerProfileSchedule(t) + + // Host1 gets DDM sync, host2 nothing + checkDDMSync(mdmDevice1) + checkNoCommands(mdmDevice2) + + // variables_updated_at for variable declarations was updated (newer) + varsUpdatedUUIDAfterChange := getHostDeclVarsUpdatedAt(t, host1.UUID, dbDeclUUID.DeclarationUUID) + require.NotNil(t, varsUpdatedUUIDAfterChange) + assert.True(t, varsUpdatedUUIDAfterChange.After(*varsUpdatedUUID), + "variables_updated_at should be newer after variable change, got %v vs original %v", varsUpdatedUUIDAfterChange, varsUpdatedUUID) + varsUpdatedSerialAfterChange := getHostDeclVarsUpdatedAt(t, host1.UUID, dbDeclSerial.DeclarationUUID) + require.NotNil(t, varsUpdatedSerialAfterChange) + assert.True(t, varsUpdatedSerialAfterChange.After(*varsUpdatedSerial), + "variables_updated_at should be newer after variable change, got %v vs original %v", varsUpdatedSerialAfterChange, varsUpdatedSerial) + + // Plain declaration's variables_updated_at is still nil + varsUpdatedPlainAfterChange := getHostDeclVarsUpdatedAt(t, host1.UUID, dbDeclPlain.DeclarationUUID) + require.Nil(t, varsUpdatedPlainAfterChange) + + // Token changed + r, err = mdmDevice1.DeclarativeManagement("tokens") + require.NoError(t, err) + tokens = parseTokensResp(t, r) + assert.NotEqual(t, lastSyncDeclToken, tokens.SyncTokens.DeclarationsToken) + + // Variables still substituted correctly + r, err = mdmDevice1.DeclarativeManagement("declaration/configuration/com.fleet.var.uuid") + require.NoError(t, err) + require.NoError(t, json.NewDecoder(r.Body).Decode(&gotParsed)) + assert.Contains(t, string(gotParsed.Payload), host1.UUID) + + // === Variable change on one host does not resend to teammate === + + // Create a third host on the same team as host1 + host3, mdmDevice3 := createHostThenEnrollMDM(s.ds, s.server.URL, t) + s.Do("POST", "/api/v1/fleet/hosts/transfer", + addHostsToTeamRequest{TeamID: &team.ID, HostIDs: []uint{host3.ID}}, http.StatusOK) + + // Let host3 complete its initial DDM sync + s.awaitTriggerProfileSchedule(t) + + checkDDMSync(mdmDevice3) + checkNoCommands(mdmDevice1) + checkNoCommands(mdmDevice2) + + // Record host3's variables_updated_at after initial sync + host3InitVarsUpdatedUUID := getHostDeclVarsUpdatedAt(t, host3.UUID, dbDeclUUID.DeclarationUUID) + require.NotNil(t, host3InitVarsUpdatedUUID) + host3InitVarsUpdatedSerial := getHostDeclVarsUpdatedAt(t, host3.UUID, dbDeclSerial.DeclarationUUID) + require.NotNil(t, host3InitVarsUpdatedSerial) + + // Verify stable state: no-op batch upload triggers no commands for anyone + s.Do("POST", "/api/latest/fleet/mdm/profiles/batch", profilesReq, http.StatusNoContent, + "team_id", teamIDStr) + s.awaitTriggerProfileSchedule(t) + checkNoCommands(mdmDevice1) + checkNoCommands(mdmDevice2) + checkNoCommands(mdmDevice3) + + // Simulate variable change for host1 only + mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + _, err := q.ExecContext(ctx, + `UPDATE host_mdm_apple_declarations SET status = NULL + WHERE host_uuid = ? AND declaration_uuid IN (?, ?)`, + host1.UUID, dbDeclUUID.DeclarationUUID, dbDeclSerial.DeclarationUUID, + ) + return err + }) + + s.awaitTriggerProfileSchedule(t) + + // Only host1 gets DDM sync; host3 (same team) and host2 (global) do not + checkDDMSync(mdmDevice1) + checkNoCommands(mdmDevice2) + checkNoCommands(mdmDevice3) + + // Verify host3's variables_updated_at was not changed by host1's resend + varsUpdatedUUIDHost3 := getHostDeclVarsUpdatedAt(t, host3.UUID, dbDeclUUID.DeclarationUUID) + require.NotNil(t, varsUpdatedUUIDHost3) + assert.Equal(t, *host3InitVarsUpdatedUUID, *varsUpdatedUUIDHost3) + varsUpdatedSerialHost3 := getHostDeclVarsUpdatedAt(t, host3.UUID, dbDeclSerial.DeclarationUUID) + require.NotNil(t, varsUpdatedSerialHost3) + assert.Equal(t, *host3InitVarsUpdatedSerial, *varsUpdatedSerialHost3) + + // host3 fetches its own declarations — variables are correctly substituted + // with host3's own values + r, err = mdmDevice3.DeclarativeManagement("declaration/configuration/com.fleet.var.uuid") + require.NoError(t, err) + require.NoError(t, json.NewDecoder(r.Body).Decode(&gotParsed)) + assert.Contains(t, string(gotParsed.Payload), host3.UUID) + assert.NotContains(t, string(gotParsed.Payload), host1.UUID) + + r, err = mdmDevice3.DeclarativeManagement("declaration/configuration/com.fleet.var.serial") + require.NoError(t, err) + require.NoError(t, json.NewDecoder(r.Body).Decode(&gotParsed)) + assert.Contains(t, string(gotParsed.Payload), host3.HardwareSerial) + assert.NotContains(t, string(gotParsed.Payload), host1.HardwareSerial) + + // === Failed variable resolution (no IdP user for host) === + + declWithIdpUsername := []byte(`{ + "Type": "com.apple.configuration.management.test", + "Payload": {"Echo": "$FLEET_VAR_HOST_END_USER_IDP_USERNAME"}, + "Identifier": "com.fleet.var.idpusername" +}`) + profilesReqWithIdp := batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{ + {Name: "VarUUID.json", Contents: declWithUUID}, + {Name: "VarSerial.json", Contents: declWithSerial}, + {Name: "Plain.json", Contents: declPlain}, + {Name: "VarIdpUsername.json", Contents: declWithIdpUsername}, + }} + s.Do("POST", "/api/latest/fleet/mdm/profiles/batch", profilesReqWithIdp, http.StatusNoContent, + "team_id", teamIDStr) + + dbDeclIdpUsername := getDeclaration(t, "VarIdpUsername.json") + + s.awaitTriggerProfileSchedule(t) + + // Host1 gets DDM sync (declaration set changed) + checkDDMSync(mdmDevice1) + checkNoCommands(mdmDevice2) + + r, err = mdmDevice1.DeclarativeManagement("tokens") + require.NoError(t, err) + tokens = parseTokensResp(t, r) + lastSyncDeclToken = tokens.SyncTokens.DeclarationsToken + require.NotEmpty(t, lastSyncDeclToken) + + // Get current variables_updated_at for host1's declarations (may have changed since earlier captures) + latestVarsUpdatedUUID := getHostDeclVarsUpdatedAt(t, host1.UUID, dbDeclUUID.DeclarationUUID) + latestVarsUpdatedSerial := getHostDeclVarsUpdatedAt(t, host1.UUID, dbDeclSerial.DeclarationUUID) + latestVarsUpdatedIdp := getHostDeclVarsUpdatedAt(t, host1.UUID, dbDeclIdpUsername.DeclarationUUID) + + declsByToken = map[string]fleet.MDMAppleDeclaration{ + fleet.EffectiveDDMToken(dbDeclUUID.Token, latestVarsUpdatedUUID): {Identifier: "com.fleet.var.uuid"}, + fleet.EffectiveDDMToken(dbDeclSerial.Token, latestVarsUpdatedSerial): {Identifier: "com.fleet.var.serial"}, + dbDeclPlain.Token: {Identifier: "com.fleet.plain"}, + fleet.EffectiveDDMToken(dbDeclIdpUsername.Token, latestVarsUpdatedIdp): {Identifier: "com.fleet.var.idpusername"}, + } + + r, err = mdmDevice1.DeclarativeManagement("declaration-items") + require.NoError(t, err) + itemsResp = parseDeclarationItemsResp(t, r) + checkDeclarationItemsResp(t, itemsResp, lastSyncDeclToken, declsByToken) + + // Host1 fetches the IdP username declaration — variable resolution fails + // because no IdP user exists for the host. The server returns an empty 200 + // and marks the declaration as failed. + _, err = mdmDevice1.DeclarativeManagement("declaration/configuration/com.fleet.var.idpusername") + require.NoError(t, err) + + // Verify the declaration is marked as failed with the expected detail message + var hostDecl fleet.MDMAppleHostDeclaration + mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + return sqlx.GetContext(ctx, q, &hostDecl, + `SELECT status, detail FROM host_mdm_apple_declarations WHERE host_uuid = ? AND declaration_uuid = ?`, + host1.UUID, dbDeclIdpUsername.DeclarationUUID) + }) + require.NotNil(t, hostDecl.Status) + assert.Equal(t, fleet.MDMDeliveryFailed, *hostDecl.Status) + assert.Contains(t, hostDecl.Detail, "There is no IdP username for this host") + assert.Contains(t, hostDecl.Detail, "$FLEET_VAR_HOST_END_USER_IDP_USERNAME") + + // === Updating variable declaration to non-variable clears variables_updated_at === + + // Drain host3's pending DDM sync from the IdP batch upload above + checkDDMSync(mdmDevice3) + + // Capture current token for comparison + r, err = mdmDevice1.DeclarativeManagement("tokens") + require.NoError(t, err) + tokens = parseTokensResp(t, r) + lastSyncDeclToken = tokens.SyncTokens.DeclarationsToken + require.NotEmpty(t, lastSyncDeclToken) + + // Verify variables_updated_at is non-nil for VarUUID and VarSerial on both team hosts + preVarsUpdatedUUIDHost1 := getHostDeclVarsUpdatedAt(t, host1.UUID, dbDeclUUID.DeclarationUUID) + require.NotNil(t, preVarsUpdatedUUIDHost1) + preVarsUpdatedUUIDHost3 := getHostDeclVarsUpdatedAt(t, host3.UUID, dbDeclUUID.DeclarationUUID) + require.NotNil(t, preVarsUpdatedUUIDHost3) + preVarsUpdatedSerialHost1 := getHostDeclVarsUpdatedAt(t, host1.UUID, dbDeclSerial.DeclarationUUID) + require.NotNil(t, preVarsUpdatedSerialHost1) + preVarsUpdatedSerialHost3 := getHostDeclVarsUpdatedAt(t, host3.UUID, dbDeclSerial.DeclarationUUID) + require.NotNil(t, preVarsUpdatedSerialHost3) + + // Update VarUUID.json to remove the variable (same name/identifier, static content) + declUUIDNowStatic := []byte(`{ + "Type": "com.apple.configuration.management.test", + "Payload": {"Echo": "static-uuid-replacement"}, + "Identifier": "com.fleet.var.uuid" +}`) + profilesReqVarRemoved := batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{ + {Name: "VarUUID.json", Contents: declUUIDNowStatic}, + {Name: "VarSerial.json", Contents: declWithSerial}, + {Name: "Plain.json", Contents: declPlain}, + {Name: "VarIdpUsername.json", Contents: declWithIdpUsername}, + }} + s.Do("POST", "/api/latest/fleet/mdm/profiles/batch", profilesReqVarRemoved, http.StatusNoContent, + "team_id", teamIDStr) + + // Re-read the declaration from DB — content and token should have changed + dbDeclUUIDUpdated := getDeclaration(t, "VarUUID.json") + assert.Contains(t, string(dbDeclUUIDUpdated.RawJSON), "static-uuid-replacement") + assert.NotContains(t, string(dbDeclUUIDUpdated.RawJSON), "$FLEET_VAR") + assert.NotEqual(t, dbDeclUUID.Token, dbDeclUUIDUpdated.Token) + // Declaration UUID stays the same (updated in place) + assert.Equal(t, dbDeclUUID.DeclarationUUID, dbDeclUUIDUpdated.DeclarationUUID) + + s.awaitTriggerProfileSchedule(t) + + // Both team hosts get DDM sync (declaration content changed) + checkDDMSync(mdmDevice1) + checkDDMSync(mdmDevice3) + // Global host gets nothing + checkNoCommands(mdmDevice2) + + // Token changed (declaration requires re-delivery) + r, err = mdmDevice1.DeclarativeManagement("tokens") + require.NoError(t, err) + tokens = parseTokensResp(t, r) + assert.NotEqual(t, lastSyncDeclToken, tokens.SyncTokens.DeclarationsToken) + + // variables_updated_at for VarUUID.json is now NULL (no more variables) + varsUpdatedUUIDAfterRemoval := getHostDeclVarsUpdatedAt(t, host1.UUID, dbDeclUUIDUpdated.DeclarationUUID) + assert.Nil(t, varsUpdatedUUIDAfterRemoval, "variables_updated_at should be NULL after removing variable from declaration (host1)") + + varsUpdatedUUIDAfterRemovalHost3 := getHostDeclVarsUpdatedAt(t, host3.UUID, dbDeclUUIDUpdated.DeclarationUUID) + assert.Nil(t, varsUpdatedUUIDAfterRemovalHost3, "variables_updated_at should be NULL after removing variable from declaration (host3)") + + // VarSerial.json still has variables — variables_updated_at unchanged on both hosts + varsUpdatedSerialAfterRemoval := getHostDeclVarsUpdatedAt(t, host1.UUID, dbDeclSerial.DeclarationUUID) + require.NotNil(t, varsUpdatedSerialAfterRemoval) + assert.Equal(t, *preVarsUpdatedSerialHost1, *varsUpdatedSerialAfterRemoval, "VarSerial variables_updated_at should be unchanged on host1") + varsUpdatedSerialAfterRemovalHost3 := getHostDeclVarsUpdatedAt(t, host3.UUID, dbDeclSerial.DeclarationUUID) + require.NotNil(t, varsUpdatedSerialAfterRemovalHost3) + assert.Equal(t, *preVarsUpdatedSerialHost3, *varsUpdatedSerialAfterRemovalHost3, "VarSerial variables_updated_at should be unchanged on host3") +} + func declarationForTest(identifier string) []byte { return []byte(fmt.Sprintf(` { diff --git a/server/service/mdm.go b/server/service/mdm.go index 6c37a4da54..177a04d240 100644 --- a/server/service/mdm.go +++ b/server/service/mdm.go @@ -2293,23 +2293,30 @@ func validateFleetVariables(ctx context.Context, ds fleet.Datastore, appConfig * if err != nil { return nil, ctxerr.Wrap(ctx, err, "validating config profile Fleet variables") } - profileVarsByProfIdentifier[p.Identifier] = profileVars + profileVarsByProfIdentifier[fleet.MDMAppleProfileUUIDPrefix+p.Identifier] = profileVars } for _, p := range windowsProfiles { windowsVars, err := validateWindowsProfileFleetVariables(string(p.SyncML), lic, groupedCAs) if err != nil { return nil, ctxerr.Wrap(ctx, err, "validating Windows profile Fleet variables") } - // Collect Fleet variables for Windows profiles (use unique Name as identifier for Windows) if len(windowsVars) > 0 { - profileVarsByProfIdentifier[p.Name] = windowsVars + profileVarsByProfIdentifier[fleet.MDMWindowsProfileUUIDPrefix+p.Name] = windowsVars } } for _, p := range appleDecls { - err = validateDeclarationFleetVariables(string(p.RawJSON)) + declVars, err := validateDeclarationFleetVariables(string(p.RawJSON), lic) if err != nil { + var badReqErr *fleet.BadRequestError + if errors.As(err, &badReqErr) { + badReqErr.Message = "Couldn't set profile. " + badReqErr.Message + err = badReqErr + } return nil, ctxerr.Wrap(ctx, err, "validating declaration Fleet variables") } + if len(declVars) > 0 { + profileVarsByProfIdentifier[fleet.MDMAppleDeclarationUUIDPrefix+p.Identifier] = declVars + } } return profileVarsByProfIdentifier, nil }