diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx
index a3941ac7a..999827869 100644
--- a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx
+++ b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx
@@ -799,10 +799,8 @@ const TAGGED_TEMPLATES = {
return (
<>
{" "}
- edited declaration (DDM) profile
- {activity.details?.profile_name}
- {" "}
- for{" "}
+ edited declaration (DDM) profiles{" "}
+ {activity.details?.profile_name} for{" "}
{getProfileMessageSuffix(
isPremiumTier,
"darwin",
diff --git a/server/fleet/activities.go b/server/fleet/activities.go
index abd4835a4..f299222c2 100644
--- a/server/fleet/activities.go
+++ b/server/fleet/activities.go
@@ -87,6 +87,7 @@ var ActivityDetailsList = []ActivityDetails{
ActivityTypeCreatedDeclarationProfile{},
ActivityTypeDeletedDeclarationProfile{},
+ ActivityTypeEditedDeclarationProfile{},
}
type ActivityDetails interface {
@@ -1370,6 +1371,25 @@ func (a ActivityTypeDeletedDeclarationProfile) Documentation() (activity string,
}`
}
+type ActivityTypeEditedDeclarationProfile struct {
+ TeamID *uint `json:"team_id"`
+ TeamName *string `json:"team_name"`
+}
+
+func (a ActivityTypeEditedDeclarationProfile) ActivityName() string {
+ return "edited_declaration_profile"
+}
+
+func (a ActivityTypeEditedDeclarationProfile) Documentation() (activity string, details string, detailsExample string) {
+ return `Generated when a user edits the macOS declarations of a team (or no team) via the fleetctl CLI.`,
+ `This activity contains the following fields:
+- "team_id": The ID of the team that the declarations apply to, ` + "`null`" + ` if they apply to devices that are not in a team.
+- "team_name": The name of the team that the declarations apply to, ` + "`null`" + ` if they apply to devices that are not in a team.`, `{
+ "team_id": 123,
+ "team_name": "Workstations"
+}`
+}
+
// LogRoleChangeActivities logs activities for each role change, globally and one for each change in teams.
func LogRoleChangeActivities(ctx context.Context, ds Datastore, adminUser *User, oldGlobalRole *string, oldTeamRoles []UserTeam, user *User) error {
if user.GlobalRole != nil && (oldGlobalRole == nil || *oldGlobalRole != *user.GlobalRole) {
diff --git a/server/mdm/mdm.go b/server/mdm/mdm.go
index 5a15dd8c0..e2888fbc5 100644
--- a/server/mdm/mdm.go
+++ b/server/mdm/mdm.go
@@ -30,6 +30,11 @@ func DecryptBase64CMS(p7Base64 string, cert *x509.Certificate, key crypto.Privat
return p7.Decrypt(cert, key)
}
+func prefixMatches(val []byte, prefix string) bool {
+ return len(val) >= len(prefix) &&
+ bytes.EqualFold([]byte(prefix), val[:len(prefix)])
+}
+
// GetRawProfilePlatform identifies the platform type of a profile bytes by
// examining its initial content:
//
@@ -45,22 +50,37 @@ func GetRawProfilePlatform(profile []byte) string {
return ""
}
- prefixMatches := func(prefix []byte) bool {
- return len(trimmedProfile) >= len(prefix) &&
- bytes.EqualFold(prefix, trimmedProfile[:len(prefix)])
- }
-
- if prefixMatches([]byte(""),
+ expected: "xml",
+ },
+ {
+ name: "XML with "),
+ expected: "xml",
+ },
+ {
+ name: "XML with "),
+ expected: "xml",
+ },
+ {
+ name: "JSON with { prefix",
+ profile: []byte("{ \"key\": \"value\" }"),
+ expected: "json",
+ },
+ {
+ name: "Empty string",
+ profile: []byte(""),
+ expected: "",
+ },
+ {
+ name: "Text with no recognizable prefix",
+ profile: []byte("This is just some text."),
+ expected: "",
+ },
+ {
+ name: "XML with spaces before prefix",
+ profile: []byte(" "),
+ expected: "xml",
+ },
+ {
+ name: "JSON with spaces before prefix",
+ profile: []byte(" { \"key\": \"value\" }"),
+ expected: "json",
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ result := GuessProfileExtension(tc.profile)
+ require.Equal(t, tc.expected, result, "Expected result does not match actual result")
+ })
+ }
+}
diff --git a/server/service/integration_ddm_test.go b/server/service/integration_ddm_test.go
index a2c5425ca..617976281 100644
--- a/server/service/integration_ddm_test.go
+++ b/server/service/integration_ddm_test.go
@@ -1018,3 +1018,14 @@ func declarationForTest(identifier string) []byte {
"Identifier": "%s"
}`, identifier))
}
+
+func declarationForTestWithType(identifier string, dType string) []byte {
+ return []byte(fmt.Sprintf(`
+{
+ "Type": "%s",
+ "Payload": {
+ "Echo": "foo"
+ },
+ "Identifier": "%s"
+}`, dType, identifier))
+}
diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go
index d026d849d..2ec8f3ca3 100644
--- a/server/service/integration_mdm_test.go
+++ b/server/service/integration_mdm_test.go
@@ -10846,6 +10846,11 @@ func (s *integrationMDMTestSuite) TestBatchSetMDMProfiles() {
`{"team_id": null, "team_name": null}`,
0,
)
+ s.lastActivityOfTypeMatches(
+ fleet.ActivityTypeEditedDeclarationProfile{}.ActivityName(),
+ `{"team_id": null, "team_name": null}`,
+ 0,
+ )
// apply to both team id and name
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: nil},
@@ -10860,6 +10865,7 @@ func (s *integrationMDMTestSuite) TestBatchSetMDMProfiles() {
{Name: "N1", Contents: mobileconfigForTest("N1", "I1")},
{Name: "N2", Contents: mobileconfigForTest("N1", "I2")},
{Name: "N3", Contents: syncMLForTest("./Foo/Bar")},
+ {Name: "N4", Contents: declarationForTest("D1")},
}}, http.StatusUnprocessableEntity, "team_id", strconv.Itoa(int(tm.ID)))
// profiles with reserved macOS identifiers
@@ -10868,6 +10874,7 @@ func (s *integrationMDMTestSuite) TestBatchSetMDMProfiles() {
{Name: "N1", Contents: mobileconfigForTest("N1", "I1")},
{Name: p, Contents: mobileconfigForTest(p, p)},
{Name: "N3", Contents: syncMLForTest("./Foo/Bar")},
+ {Name: "N4", Contents: declarationForTest("D1")},
}}, http.StatusUnprocessableEntity, "team_id", strconv.Itoa(int(tm.ID)))
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, fmt.Sprintf("Validation Failed: payload identifier %s is not allowed", p))
@@ -10878,6 +10885,7 @@ func (s *integrationMDMTestSuite) TestBatchSetMDMProfiles() {
res := s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "N1", Contents: mobileconfigForTestWithContent("N1", "I1", "II1", p, "")},
{Name: "N3", Contents: syncMLForTest("./Foo/Bar")},
+ {Name: "N4", Contents: declarationForTest("D1")},
}}, http.StatusUnprocessableEntity, "team_id", strconv.Itoa(int(tm.ID)))
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, fmt.Sprintf("Validation Failed: unsupported PayloadType(s): %s", p))
@@ -10888,19 +10896,48 @@ func (s *integrationMDMTestSuite) TestBatchSetMDMProfiles() {
res := s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "N1", Contents: mobileconfigForTestWithContent("N1", "I1", p, "random", "")},
{Name: "N3", Contents: syncMLForTest("./Foo/Bar")},
+ {Name: "N4", Contents: declarationForTest("D1")},
}}, http.StatusUnprocessableEntity, "team_id", strconv.Itoa(int(tm.ID)))
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, fmt.Sprintf("Validation Failed: unsupported PayloadIdentifier(s): %s", p))
}
+ // profiles with forbidden declaration types
+ for dt := range fleet.ForbiddenDeclTypes {
+ res := s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
+ {Name: "N1", Contents: mobileconfigForTest("N1", "I1")},
+ {Name: "N3", Contents: syncMLForTest("./Foo/Bar")},
+ {Name: "N4", Contents: declarationForTestWithType("D1", dt)},
+ }}, http.StatusUnprocessableEntity, "team_id", strconv.Itoa(int(tm.ID)))
+ errMsg := extractServerErrorText(res.Body)
+ require.Contains(t, errMsg, "Only configuration declarations that don’t require an asset reference are supported", dt)
+ }
+ // and one more for the software update declaration
+ res := s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
+ {Name: "N1", Contents: mobileconfigForTest("N1", "I1")},
+ {Name: "N3", Contents: syncMLForTest("./Foo/Bar")},
+ {Name: "N4", Contents: declarationForTestWithType("D1", "com.apple.configuration.softwareupdate.enforcement.specific")},
+ }}, http.StatusUnprocessableEntity, "team_id", strconv.Itoa(int(tm.ID)))
+ errMsg := extractServerErrorText(res.Body)
+ require.Contains(t, errMsg, "Declaration profile can’t include OS updates settings. To control these settings, go to OS updates.")
+
+ // invalid JSON
+ res = s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
+ {Name: "N1", Contents: mobileconfigForTest("N1", "I1")},
+ {Name: "N3", Contents: syncMLForTest("./Foo/Bar")},
+ {Name: "N4", Contents: []byte(`{"foo":}`)},
+ }}, http.StatusBadRequest, "team_id", strconv.Itoa(int(tm.ID)))
+ errMsg = extractServerErrorText(res.Body)
+ require.Contains(t, errMsg, "The file should include valid JSON")
+
// profiles with reserved Windows location URIs
// bitlocker
- res := s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
+ res = s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "N1", Contents: mobileconfigForTest("N1", "I1")},
{Name: syncml.FleetBitLockerTargetLocURI, Contents: syncMLForTest(fmt.Sprintf("%s/Foo", syncml.FleetBitLockerTargetLocURI))},
{Name: "N3", Contents: syncMLForTest("./Foo/Bar")},
}}, http.StatusUnprocessableEntity, "team_id", strconv.Itoa(int(tm.ID)))
- errMsg := extractServerErrorText(res.Body)
+ errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Custom configuration profiles can't include BitLocker settings. To control these settings, use the mdm.enable_disk_encryption option.")
// os updates
@@ -10930,6 +10967,7 @@ func (s *integrationMDMTestSuite) TestBatchSetMDMProfiles() {
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "N1", Contents: mobileconfigForTest("N1", "I1")},
{Name: "N2", Contents: syncMLForTest("./Foo/Bar")},
+ {Name: "N4", Contents: declarationForTest("D1")},
}}, http.StatusNoContent, "team_id", strconv.Itoa(int(tm.ID)), "dry_run", "true")
s.assertConfigProfilesByIdentifier(&tm.ID, "I1", false)
s.assertWindowsConfigProfilesByName(&tm.ID, "N1", false)
@@ -10938,6 +10976,7 @@ func (s *integrationMDMTestSuite) TestBatchSetMDMProfiles() {
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "N1", Contents: mobileconfigForTest("N1", "I1")},
{Name: "N2", Contents: syncMLForTest("./Foo/Bar")},
+ {Name: "N4", Contents: declarationForTest("D1")},
}}, http.StatusNoContent, "team_id", strconv.Itoa(int(tm.ID)))
s.assertConfigProfilesByIdentifier(&tm.ID, "I1", true)
s.assertWindowsConfigProfilesByName(&tm.ID, "N2", true)
@@ -10951,6 +10990,11 @@ func (s *integrationMDMTestSuite) TestBatchSetMDMProfiles() {
fmt.Sprintf(`{"team_id": %d, "team_name": %q}`, tm.ID, tm.Name),
0,
)
+ s.lastActivityOfTypeMatches(
+ fleet.ActivityTypeEditedDeclarationProfile{}.ActivityName(),
+ fmt.Sprintf(`{"team_id": %d, "team_name": %q}`, tm.ID, tm.Name),
+ 0,
+ )
}
func (s *integrationMDMTestSuite) TestBatchSetMDMProfilesBackwardsCompat() {
diff --git a/server/service/mdm.go b/server/service/mdm.go
index 63c71ba28..b6c932437 100644
--- a/server/service/mdm.go
+++ b/server/service/mdm.go
@@ -1568,6 +1568,12 @@ func (svc *Service) BatchSetMDMProfiles(
}); err != nil {
return ctxerr.Wrap(ctx, err, "logging activity for edited windows profile")
}
+ if err := svc.ds.NewActivity(ctx, authz.UserFromContext(ctx), &fleet.ActivityTypeEditedDeclarationProfile{
+ TeamID: tmID,
+ TeamName: tmName,
+ }); err != nil {
+ return ctxerr.Wrap(ctx, err, "logging activity for edited macos declarations")
+ }
return nil
}
@@ -1631,14 +1637,7 @@ func getAppleProfiles(
}
// Check for DDM files
-
- isJSON := func(b []byte) bool {
- var js json.RawMessage
- return json.Unmarshal(b, &js) == nil
- }
-
- // TODO(roberto): As a mini optimization, GetRawDeclarationValues could replace isJSON.
- if isJSON(prof.Contents) {
+ if mdm.GuessProfileExtension(prof.Contents) == "json" {
rawDecl, err := fleet.GetRawDeclarationValues(prof.Contents)
if err != nil {
return nil, nil, err