Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
425e1e6
Add PATCH configuration_profiles endpoint stub
andymFleet Jul 2, 2026
6ec4396
Add validation test coverage for update configuration profile endpoint
andymFleet Jul 3, 2026
75900cd
Persist identifier-keyed Apple updates in PATCH configuration_profiles
andymFleet Jul 3, 2026
92675ff
Add unit tests for Apple profile updates in PATCH configuration_profiles
andymFleet Jul 6, 2026
640f212
Add MySQL integration test for UpdateMDMAppleConfigProfile
andymFleet Jul 6, 2026
6464353
Fix edited_configuration_profile activity to use the post-update name
andymFleet Jul 6, 2026
a9847d3
Add cross-team rename case to UpdateMDMAppleConfigProfile integration…
andymFleet Jul 6, 2026
318fa1d
Add Fleet variable pass-through test for UpdateMDMAppleConfigProfile
andymFleet Jul 6, 2026
414553e
Add secrets_updated_at pass-through test for UpdateMDMAppleConfigProfile
andymFleet Jul 6, 2026
a45be1d
Persist name-keyed Windows profile updates in PATCH configuration_pro…
andymFleet Jul 6, 2026
66371ab
Add unit test coverage for UpdateMDMWindowsConfigProfile
andymFleet Jul 6, 2026
62b1309
Close coverage gaps in UpdateMDMWindowsConfigProfile tests
andymFleet Jul 7, 2026
9c3eef7
Clear stale OS-update tracking when Windows profile content drops the…
andymFleet Jul 7, 2026
6f927be
Add integration test coverage for UpdateMDMWindowsConfigProfile
andymFleet Jul 7, 2026
1f7f268
Persist name-keyed Android profile updates in PATCH configuration_pro…
andymFleet Jul 7, 2026
09f528b
Add unit test coverage for UpdateMDMAndroidConfigProfile
andymFleet Jul 8, 2026
c68249a
Add integration test coverage for UpdateMDMAndroidConfigProfile
andymFleet Jul 8, 2026
8e4c82d
Persist name-keyed DDM declaration updates in PATCH configuration_pro…
andymFleet Jul 9, 2026
9b6967d
fix Reject identifier mismatch on DDM declaration update
andymFleet Jul 10, 2026
23f8546
Add unit test coverage for UpdateMDMAppleDeclaration
andymFleet Jul 10, 2026
36ad188
add change file
andymFleet Jul 10, 2026
0e00bfa
Merge branch 'main' into 48342-edit-config-profile-endpoint
JordanMontgomery Jul 13, 2026
22f8cd7
Fix Free-tier panics and edit-endpoint validation gaps
JordanMontgomery Jul 13, 2026
38a6143
Add integration test coverage for the profile edit endpoint
JordanMontgomery Jul 13, 2026
734b0ab
Merge branch '48342-edit-config-profile-endpoint' into 48342-edit-con…
JordanMontgomery Jul 13, 2026
272f925
Extend edit-endpoint fixes to Android variables and DDM scope
JordanMontgomery Jul 13, 2026
0aed94a
Retain prior Windows profile content on edit
JordanMontgomery Jul 15, 2026
ec1dde8
Merge branch '48342-edit-config-profile-endpoint' into 48342-edit-con…
JordanMontgomery Jul 15, 2026
9d90253
48342 ECP Backend with fixes (#49331)
JordanMontgomery Jul 15, 2026
5e312e2
Remove unnecessary byte conversions flagged by unconvert
JordanMontgomery Jul 17, 2026
48e9a3f
Merge remote-tracking branch 'origin/main' into 48342-edit-config-pro…
JordanMontgomery Jul 17, 2026
b615e6c
Preserve uploaded_at on no-op profile edits
JordanMontgomery Jul 17, 2026
5ad0255
Merge branch '48342-edit-config-profile-fixes' into 48342-edit-config…
JordanMontgomery Jul 17, 2026
328686c
Fixes per review/slack comments
JordanMontgomery Jul 20, 2026
2c31d49
gofmt file
JordanMontgomery Jul 20, 2026
0313a7a
Fix update tracking bug
JordanMontgomery Jul 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changes/48342-edit-config-profile-endpoint
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
* Added support for editing existing configuration profiles (Apple `.mobileconfig`, Apple DDM declarations, Windows, and Android) in place via `PATCH /api/v1/fleet/configuration_profiles/:profile_uuid`.
90 changes: 90 additions & 0 deletions server/datastore/mysql/android.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"strings"
"unicode/utf8"

"github.com/fleetdm/fleet/v4/server/contexts/ctxdb"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mdm/android"
Expand Down Expand Up @@ -730,6 +731,95 @@ func (ds *Datastore) GetMDMAndroidConfigProfile(ctx context.Context, profileUUID
return &profile, nil
}

// UpdateMDMAndroidConfigProfile updates an existing profile's contents (if
// cp.RawJSON is non-empty) and/or label targeting in place. cp.Name must
// match the existing profile's -- name is an Android profile's only
// identity, so it never changes on this path.
func (ds *Datastore) UpdateMDMAndroidConfigProfile(ctx context.Context, cp fleet.MDMAndroidConfigProfile, usesFleetVars []fleet.FleetVarName) (*fleet.MDMAndroidConfigProfile, error) {
err := ds.withTx(ctx, func(tx sqlx.ExtContext) error {
var existing struct {
Name string `db:"name"`
}
err := sqlx.GetContext(ctx, tx, &existing,
`SELECT name FROM mdm_android_configuration_profiles WHERE profile_uuid = ?`, cp.ProfileUUID)
if err != nil {
if err == sql.ErrNoRows {
return ctxerr.Wrap(ctx, notFound("MDMAndroidConfigProfile").WithName(cp.ProfileUUID))
}
return ctxerr.Wrap(ctx, err, "get existing android config profile")
}
if existing.Name != cp.Name {
return ctxerr.Wrap(ctx, &fleet.BadRequestError{
Message: "The new profile's name must match the existing profile's name.",
})
}

if len(cp.RawJSON) > 0 {
// Preserve uploaded_at on unchanged content (matching the batch
// upsert) so a no-op edit doesn't read as a fresh upload. The IF sees
// the pre-update raw_json (SET evaluates left to right), and the
// parameter must be CAST to JSON -- a json column never equals a
// bare string.
stmt := `UPDATE mdm_android_configuration_profiles SET uploaded_at = IF(raw_json = CAST(? AS JSON), uploaded_at, CURRENT_TIMESTAMP()), raw_json = ? WHERE profile_uuid = ? AND name = ?`
res, err := tx.ExecContext(ctx, stmt, cp.RawJSON, cp.RawJSON, cp.ProfileUUID, cp.Name)
if err != nil {
return ctxerr.Wrap(ctx, err, "updating android mdm config profile contents")
}
if aff, _ := res.RowsAffected(); aff == 0 {
return ctxerr.Wrap(ctx, notFound("MDMAndroidConfigProfile").WithName(cp.ProfileUUID))
}

// Reset variable associations only on a content update, but then
// unconditionally, so an edit that removes the profile's last Fleet
// variable still clears the stale association. A labels-only update
// must leave them alone or variable-driven redelivery would break.
if _, err := batchSetProfileVariableAssociationsDB(ctx, tx, []fleet.MDMProfileUUIDFleetVariables{

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Aren't we technically calling this, even if the contents didn't change? The above rowsAffected=0 check only checks for not found.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WE are but I think it's probably OK. We can't super easily check for affected rows because we set the DB to return affectedRows=matchedRows(even if not updated) in our connections. So this is a tiny bit less performant perhaps but given it's a single profile endpoint I think probably OK?

{ProfileUUID: cp.ProfileUUID, FleetVariables: usesFleetVars},
}, "android", false); err != nil {
return ctxerr.Wrap(ctx, err, "updating android profile variable associations")
}
}

labels := make([]fleet.ConfigurationProfileLabel, 0, len(cp.LabelsIncludeAll)+len(cp.LabelsIncludeAny)+len(cp.LabelsExcludeAny))
for i := range cp.LabelsIncludeAll {
cp.LabelsIncludeAll[i].ProfileUUID = cp.ProfileUUID
cp.LabelsIncludeAll[i].RequireAll = true
cp.LabelsIncludeAll[i].Exclude = false
labels = append(labels, cp.LabelsIncludeAll[i])
}
for i := range cp.LabelsIncludeAny {
cp.LabelsIncludeAny[i].ProfileUUID = cp.ProfileUUID
cp.LabelsIncludeAny[i].RequireAll = false
cp.LabelsIncludeAny[i].Exclude = false
labels = append(labels, cp.LabelsIncludeAny[i])
}
for i := range cp.LabelsExcludeAny {
cp.LabelsExcludeAny[i].ProfileUUID = cp.ProfileUUID
cp.LabelsExcludeAny[i].RequireAll = false
cp.LabelsExcludeAny[i].Exclude = true
labels = append(labels, cp.LabelsExcludeAny[i])
}
var profsWithoutLabel []string
if len(labels) == 0 {
profsWithoutLabel = append(profsWithoutLabel, cp.ProfileUUID)
}
if _, err := batchSetProfileLabelAssociationsDB(ctx, tx, labels, profsWithoutLabel, "android"); err != nil {
return ctxerr.Wrap(ctx, err, "updating android profile label associations")
}

return nil
})
if err != nil {
return nil, err
}

updated, err := ds.GetMDMAndroidConfigProfile(ctxdb.RequirePrimary(ctx, true), cp.ProfileUUID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "get updated android config profile")
}
return updated, nil
}

func (ds *Datastore) DeleteMDMAndroidConfigProfile(ctx context.Context, profileUUID string) error {
return ds.withTx(ctx, func(tx sqlx.ExtContext) error {
stmt := `DELETE FROM mdm_android_configuration_profiles WHERE profile_uuid = ?`
Expand Down
232 changes: 232 additions & 0 deletions server/datastore/mysql/android_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ func TestAndroid(t *testing.T) {
{"AndroidHostStorageData", testAndroidHostStorageData},
{"NewMDMAndroidConfigProfile", testNewMDMAndroidConfigProfile},
{"GetMDMAndroidConfigProfile", testGetMDMAndroidConfigProfile},
{"UpdateMDMAndroidConfigProfile", testUpdateMDMAndroidConfigProfile},
{"DeleteMDMAndroidConfigProfile", testDeleteMDMAndroidConfigProfile},
{"GetMDMAndroidProfilesSummary", testMDMAndroidProfilesSummary},
{"ListMDMAndroidProfilesToSend", testListMDMAndroidProfilesToSend},
Expand Down Expand Up @@ -941,6 +942,237 @@ func testDeleteMDMAndroidConfigProfile(t *testing.T, ds *Datastore) {
require.Equal(t, "testAndroid2", profile2.Name)
}

func testUpdateMDMAndroidConfigProfile(t *testing.T, ds *Datastore) {
ctx := testCtx()

// profile content update happens in place: the ProfileUUID is preserved
// (not a delete+recreate), and the new content is actually persisted --
// confirmed below by re-fetching from the DB, not just trusting the
// value UpdateMDMAndroidConfigProfile returns.
initial, err := ds.NewMDMAndroidConfigProfile(ctx, fleet.MDMAndroidConfigProfile{
Name: "Update Test Profile",
RawJSON: []byte(`{"original": true}`),
}, nil)
require.NoError(t, err)

newRawJSON := []byte(`{"updated": true}`)
updated, err := ds.UpdateMDMAndroidConfigProfile(ctx, fleet.MDMAndroidConfigProfile{
ProfileUUID: initial.ProfileUUID,
Name: initial.Name,
RawJSON: newRawJSON,
}, nil)
require.NoError(t, err)
require.Equal(t, initial.ProfileUUID, updated.ProfileUUID)
require.JSONEq(t, string(newRawJSON), string(updated.RawJSON))

// confirms values actually stored in the DB match what was returned from the update call
stored, err := ds.GetMDMAndroidConfigProfile(ctx, initial.ProfileUUID)
require.NoError(t, err)
require.JSONEq(t, string(newRawJSON), string(stored.RawJSON))

// mismatched name is rejected -- Android profiles have no separate
// identifier field, so name is the only identity a profile has. This is
// the only layer this can be tested at: the service layer never exposes
// a way for a client to submit a different name on an edit.
_, err = ds.UpdateMDMAndroidConfigProfile(ctx, fleet.MDMAndroidConfigProfile{
ProfileUUID: initial.ProfileUUID,
Name: "A Different Name",
RawJSON: newRawJSON,
}, nil)
require.ErrorContains(t, err, "must match the existing profile's name")

// updating a nonexistent profile returns a not-found error
_, err = ds.UpdateMDMAndroidConfigProfile(ctx, fleet.MDMAndroidConfigProfile{
ProfileUUID: "g" + uuid.NewString(),
Name: "Does Not Exist",
RawJSON: newRawJSON,
}, nil)
require.True(t, fleet.IsNotFound(err))

// labels replace the previous set entirely rather than merging with it
label1, err := ds.NewLabel(ctx, &fleet.Label{Name: "android-update-label-1", Query: "select 1"})
require.NoError(t, err)
label2, err := ds.NewLabel(ctx, &fleet.Label{Name: "android-update-label-2", Query: "select 1"})
require.NoError(t, err)
_, err = ds.UpdateMDMAndroidConfigProfile(ctx, fleet.MDMAndroidConfigProfile{
ProfileUUID: initial.ProfileUUID,
Name: initial.Name,
LabelsIncludeAll: []fleet.ConfigurationProfileLabel{
{LabelName: label1.Name, LabelID: label1.ID},
},
}, nil)
require.NoError(t, err)
stored, err = ds.GetMDMAndroidConfigProfile(ctx, initial.ProfileUUID)
require.NoError(t, err)
require.Len(t, stored.LabelsIncludeAll, 1)
require.Equal(t, label1.Name, stored.LabelsIncludeAll[0].LabelName)

_, err = ds.UpdateMDMAndroidConfigProfile(ctx, fleet.MDMAndroidConfigProfile{
ProfileUUID: initial.ProfileUUID,
Name: initial.Name,
LabelsIncludeAll: []fleet.ConfigurationProfileLabel{
{LabelName: label2.Name, LabelID: label2.ID},
},
}, nil)
require.NoError(t, err)
stored, err = ds.GetMDMAndroidConfigProfile(ctx, initial.ProfileUUID)
require.NoError(t, err)
require.Len(t, stored.LabelsIncludeAll, 1)
require.Equal(t, label2.Name, stored.LabelsIncludeAll[0].LabelName, "the previous label must be replaced, not merged with")

// labels can be cleared entirely, not just replaced with a different set --
// exercises the profsWithoutLabel branch, a distinct code path from "has
// labels".
_, err = ds.UpdateMDMAndroidConfigProfile(ctx, fleet.MDMAndroidConfigProfile{
ProfileUUID: initial.ProfileUUID,
Name: initial.Name,
}, nil)
require.NoError(t, err)
stored, err = ds.GetMDMAndroidConfigProfile(ctx, initial.ProfileUUID)
require.NoError(t, err)
require.Empty(t, stored.LabelsIncludeAll)
require.Empty(t, stored.LabelsIncludeAny)
require.Empty(t, stored.LabelsExcludeAny)

// LabelsIncludeAny and LabelsExcludeAny replace the same way LabelsIncludeAll
// does above -- each is a separate label list on the profile.
anyExcludeProfile, err := ds.NewMDMAndroidConfigProfile(ctx, fleet.MDMAndroidConfigProfile{
Name: "Any Exclude Labels Profile",
RawJSON: []byte(`{"anyExclude": true}`),
}, nil)
require.NoError(t, err)
includeAnyLabel, err := ds.NewLabel(ctx, &fleet.Label{Name: "android-update-include-any", Query: "select 1"})
require.NoError(t, err)
excludeAnyLabel, err := ds.NewLabel(ctx, &fleet.Label{Name: "android-update-exclude-any", Query: "select 1"})
require.NoError(t, err)

_, err = ds.UpdateMDMAndroidConfigProfile(ctx, fleet.MDMAndroidConfigProfile{
ProfileUUID: anyExcludeProfile.ProfileUUID,
Name: anyExcludeProfile.Name,
LabelsIncludeAny: []fleet.ConfigurationProfileLabel{
{LabelName: includeAnyLabel.Name, LabelID: includeAnyLabel.ID},
},
}, nil)
require.NoError(t, err)
stored, err = ds.GetMDMAndroidConfigProfile(ctx, anyExcludeProfile.ProfileUUID)
require.NoError(t, err)
require.Len(t, stored.LabelsIncludeAny, 1)
require.Equal(t, includeAnyLabel.Name, stored.LabelsIncludeAny[0].LabelName)
require.Empty(t, stored.LabelsExcludeAny)

_, err = ds.UpdateMDMAndroidConfigProfile(ctx, fleet.MDMAndroidConfigProfile{
ProfileUUID: anyExcludeProfile.ProfileUUID,
Name: anyExcludeProfile.Name,
LabelsExcludeAny: []fleet.ConfigurationProfileLabel{
{LabelName: excludeAnyLabel.Name, LabelID: excludeAnyLabel.ID},
},
}, nil)
require.NoError(t, err)
stored, err = ds.GetMDMAndroidConfigProfile(ctx, anyExcludeProfile.ProfileUUID)
require.NoError(t, err)
require.Empty(t, stored.LabelsIncludeAny, "the previous IncludeAny label must be replaced, not kept alongside ExcludeAny")
require.Len(t, stored.LabelsExcludeAny, 1)
require.Equal(t, excludeAnyLabel.Name, stored.LabelsExcludeAny[0].LabelName)

// content and labels updated together in a single call -- proves the two
// transactional steps (content UPDATE, label rebuild) compose correctly,
// not just each dimension on its own.
combined, err := ds.NewMDMAndroidConfigProfile(ctx, fleet.MDMAndroidConfigProfile{
Name: "Combined Update Profile",
RawJSON: []byte(`{"combinedOriginal": true}`),
}, nil)
require.NoError(t, err)
combinedLabel, err := ds.NewLabel(ctx, &fleet.Label{Name: "android-combined-label", Query: "select 1"})
require.NoError(t, err)

combinedRawJSON := []byte(`{"combinedUpdated": true}`)
_, err = ds.UpdateMDMAndroidConfigProfile(ctx, fleet.MDMAndroidConfigProfile{
ProfileUUID: combined.ProfileUUID,
Name: combined.Name,
RawJSON: combinedRawJSON,
LabelsIncludeAll: []fleet.ConfigurationProfileLabel{
{LabelName: combinedLabel.Name, LabelID: combinedLabel.ID},
},
}, nil)
require.NoError(t, err)

stored, err = ds.GetMDMAndroidConfigProfile(ctx, combined.ProfileUUID)
require.NoError(t, err)
require.JSONEq(t, string(combinedRawJSON), string(stored.RawJSON))
require.Len(t, stored.LabelsIncludeAll, 1)
require.Equal(t, combinedLabel.Name, stored.LabelsIncludeAll[0].LabelName)

// Fleet variables used in the new content are persisted, a labels-only
// edit (no new content) leaves them untouched, and a content edit that
// drops the last variable clears the stale association.
varNamesStmt := `
SELECT fv.name
FROM mdm_configuration_profile_variables mcpv
JOIN fleet_variables fv ON mcpv.fleet_variable_id = fv.id
WHERE mcpv.android_profile_uuid = ?
ORDER BY fv.name
`
varProfile, err := ds.NewMDMAndroidConfigProfile(ctx, fleet.MDMAndroidConfigProfile{
Name: "Android Fleet Vars Profile",
RawJSON: []byte(`{"managedConfiguration": {"platform": "$FLEET_VAR_HOST_PLATFORM"}}`),
}, []fleet.FleetVarName{fleet.FleetVarHostPlatform})
require.NoError(t, err)
varLabel, err := ds.NewLabel(ctx, &fleet.Label{Name: "android-labels-only-vars-label", Query: "select 1"})
require.NoError(t, err)
_, err = ds.UpdateMDMAndroidConfigProfile(ctx, fleet.MDMAndroidConfigProfile{
ProfileUUID: varProfile.ProfileUUID,
Name: varProfile.Name,
LabelsIncludeAll: []fleet.ConfigurationProfileLabel{
{LabelName: varLabel.Name, LabelID: varLabel.ID},
},
}, nil)
require.NoError(t, err)
var varNames []string
err = ds.writer(ctx).SelectContext(ctx, &varNames, varNamesStmt, varProfile.ProfileUUID)
require.NoError(t, err)
require.Equal(t, []string{"FLEET_VAR_" + string(fleet.FleetVarHostPlatform)}, varNames,
"a labels-only edit must preserve the profile's variable associations")

_, err = ds.UpdateMDMAndroidConfigProfile(ctx, fleet.MDMAndroidConfigProfile{
ProfileUUID: varProfile.ProfileUUID,
Name: varProfile.Name,
RawJSON: []byte(`{"managedConfiguration": {"platform": "static"}}`),
}, nil)
require.NoError(t, err)
err = ds.writer(ctx).SelectContext(ctx, &varNames, varNamesStmt, varProfile.ProfileUUID)
require.NoError(t, err)
require.Empty(t, varNames, "a content edit that drops the last Fleet variable must clear the stale association")

// uploaded_at is preserved on a no-op edit (identical content) and bumped
// on a real content change, matching the batch upsert's convention
uploadedAtRawJSON := []byte(`{"uploadedAt": true}`)
uploadedAtProfile, err := ds.NewMDMAndroidConfigProfile(ctx, fleet.MDMAndroidConfigProfile{
Name: "Uploaded At Profile",
RawJSON: uploadedAtRawJSON,
}, nil)
require.NoError(t, err)
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, `UPDATE mdm_android_configuration_profiles SET uploaded_at = '2020-01-01 00:00:00' WHERE profile_uuid = ?`, uploadedAtProfile.ProfileUUID)
return err
})

noOp, err := ds.UpdateMDMAndroidConfigProfile(ctx, fleet.MDMAndroidConfigProfile{
ProfileUUID: uploadedAtProfile.ProfileUUID,
Name: uploadedAtProfile.Name,
RawJSON: uploadedAtRawJSON,
}, nil)
require.NoError(t, err)
require.Equal(t, 2020, noOp.UploadedAt.Year(), "a no-op edit must not bump uploaded_at")

contentChangedProf, err := ds.UpdateMDMAndroidConfigProfile(ctx, fleet.MDMAndroidConfigProfile{
ProfileUUID: uploadedAtProfile.ProfileUUID,
Name: uploadedAtProfile.Name,
RawJSON: []byte(`{"uploadedAt": false}`),
}, nil)
require.NoError(t, err)
require.Greater(t, contentChangedProf.UploadedAt.Year(), 2020, "a content change must bump uploaded_at")
}

func testMDMAndroidProfilesSummary(t *testing.T, ds *Datastore) {
test.AddBuiltinLabels(t, ds)

Expand Down
Loading
Loading