From 965a78d2de594e2071b811259e4ceca3283edeec Mon Sep 17 00:00:00 2001 From: Martin Angers Date: Tue, 14 Nov 2023 08:19:29 -0500 Subject: [PATCH] Add endpoint to get or download a profile (Windows and macOS) (#15105) --- changes/issue-14363-api-windows-profiles | 1 + server/datastore/mysql/apple_mdm.go | 1 + server/fleet/mdm.go | 46 ++++++++++++++ server/fleet/service.go | 3 + server/service/handler.go | 2 + server/service/integration_mdm_test.go | 39 ++++++++++++ server/service/mdm.go | 81 +++++++++++++++++++++++- server/service/mdm_test.go | 56 ++++++++++++++-- server/service/scripts.go | 20 +++--- server/service/testing_utils.go | 1 + 10 files changed, 233 insertions(+), 17 deletions(-) diff --git a/changes/issue-14363-api-windows-profiles b/changes/issue-14363-api-windows-profiles index 05a07760e1..6915149b68 100644 --- a/changes/issue-14363-api-windows-profiles +++ b/changes/issue-14363-api-windows-profiles @@ -1 +1,2 @@ * Added endpoint `DELETE /mdm/profiles/{id}` to delete an existing MDM profile (Windows and macOS). +* Added endpoint `GET /mdm/profiles/{id}` to get or download an existing MDM profile (Windows and macOS). diff --git a/server/datastore/mysql/apple_mdm.go b/server/datastore/mysql/apple_mdm.go index 42b1e41866..f797654fad 100644 --- a/server/datastore/mysql/apple_mdm.go +++ b/server/datastore/mysql/apple_mdm.go @@ -118,6 +118,7 @@ SELECT name, identifier, mobileconfig, + checksum, created_at, updated_at FROM diff --git a/server/fleet/mdm.go b/server/fleet/mdm.go index 1950f18946..eb63cb4eb1 100644 --- a/server/fleet/mdm.go +++ b/server/fleet/mdm.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "net/url" + "strconv" "time" ) @@ -305,3 +306,48 @@ type MDMConfigProfileAuthz struct { func (m MDMConfigProfileAuthz) AuthzType() string { return "mdm_config_profile" } + +// MDMConfigProfilePayload is the platform-agnostic struct returned by +// endpoints that return MDM configuration profiles (get/list profiles). +type MDMConfigProfilePayload struct { + ProfileID string `json:"profile_id"` // is a uuid string for Windows + TeamID *uint `json:"team_id"` // null for no-team + Name string `json:"name"` + Platform string `json:"platform"` // "windows" or "darwin" + Identifier string `json:"identifier,omitempty"` // only set for macOS + Checksum []byte `json:"checksum,omitempty"` // only set for macOS + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func NewMDMConfigProfilePayloadFromWindows(cp *MDMWindowsConfigProfile) *MDMConfigProfilePayload { + var tid *uint + if cp.TeamID != nil && *cp.TeamID > 0 { + tid = cp.TeamID + } + return &MDMConfigProfilePayload{ + ProfileID: cp.ProfileUUID, + TeamID: tid, + Name: cp.Name, + Platform: "windows", + CreatedAt: cp.CreatedAt, + UpdatedAt: cp.UpdatedAt, + } +} + +func NewMDMConfigProfilePayloadFromApple(cp *MDMAppleConfigProfile) *MDMConfigProfilePayload { + var tid *uint + if cp.TeamID != nil && *cp.TeamID > 0 { + tid = cp.TeamID + } + return &MDMConfigProfilePayload{ + ProfileID: strconv.FormatUint(uint64(cp.ProfileID), 10), + TeamID: tid, + Name: cp.Name, + Identifier: cp.Identifier, + Platform: "darwin", + Checksum: cp.Checksum, + CreatedAt: cp.CreatedAt, + UpdatedAt: cp.UpdatedAt, + } +} diff --git a/server/fleet/service.go b/server/fleet/service.go index 17609832ff..b0c90b2599 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -810,6 +810,9 @@ type Service interface { // Set or update the disk encryption key for a host. SetOrUpdateDiskEncryptionKey(ctx context.Context, encryptionKey, clientError string) error + // GetMDMWindowsConfigProfile retrieves the specified configuration profile. + GetMDMWindowsConfigProfile(ctx context.Context, profileUUID string) (*MDMWindowsConfigProfile, error) + // DeleteMDMWindowsConfigProfile deletes the specified windows profile. DeleteMDMWindowsConfigProfile(ctx context.Context, profileUUID string) error diff --git a/server/service/handler.go b/server/service/handler.go index 2ee3e172a0..0dd2d91098 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -529,6 +529,8 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC mdmAnyMW.GET("/api/_version_/fleet/mdm/commands", listMDMCommandsEndpoint, listMDMCommandsRequest{}) mdmAnyMW.GET("/api/_version_/fleet/mdm/disk_encryption/summary", getMDMDiskEncryptionSummaryEndpoint, getMDMDiskEncryptionSummaryRequest{}) mdmAnyMW.GET("/api/_version_/fleet/mdm/hosts/{id:[0-9]+}/encryption_key", getHostEncryptionKey, getHostEncryptionKeyRequest{}) + + mdmAnyMW.GET("/api/_version_/fleet/mdm/profiles/{profile_id_or_uuid}", getMDMConfigProfileEndpoint, getMDMConfigProfileRequest{}) mdmAnyMW.DELETE("/api/_version_/fleet/mdm/profiles/{profile_id_or_uuid}", deleteMDMConfigProfileEndpoint, deleteMDMConfigProfileRequest{}) // the following set of mdm endpoints must always be accessible (even diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go index a106674d88..c7d8cd41c1 100644 --- a/server/service/integration_mdm_test.go +++ b/server/service/integration_mdm_test.go @@ -8032,6 +8032,45 @@ func (s *integrationMDMTestSuite) TestMDMConfigProfileCRUD() { noTeamWinProfID := createWindowsProfile("win-global-profile", 0) teamWinProfID := createWindowsProfile("win-team-profile", testTeam.ID) + // get the existing profiles work + expectedChecksum := []byte("1234\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00") // binary16 in the DB + expectedProfiles := []fleet.MDMConfigProfilePayload{ + {ProfileID: fmt.Sprint(noTeamAppleProfID), Platform: "darwin", Name: "apple-global-profile", Identifier: "test-global-ident", TeamID: nil, Checksum: expectedChecksum}, + {ProfileID: fmt.Sprint(teamAppleProfID), Platform: "darwin", Name: "apple-team-profile", Identifier: "test-team-ident", TeamID: &testTeam.ID, Checksum: expectedChecksum}, + {ProfileID: noTeamWinProfID, Platform: "windows", Name: "win-global-profile", TeamID: nil}, + {ProfileID: teamWinProfID, Platform: "windows", Name: "win-team-profile", TeamID: &testTeam.ID}, + } + for _, prof := range expectedProfiles { + var getResp getMDMConfigProfileResponse + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/mdm/profiles/%s", prof.ProfileID), nil, http.StatusOK, &getResp) + require.NotZero(t, getResp.CreatedAt) + require.NotZero(t, getResp.UpdatedAt) + getResp.CreatedAt, getResp.UpdatedAt = time.Time{}, time.Time{} + require.Equal(t, prof, *getResp.MDMConfigProfilePayload) + + resp := s.Do("GET", fmt.Sprintf("/api/latest/fleet/mdm/profiles/%s", prof.ProfileID), nil, http.StatusOK, "alt", "media") + require.NotZero(t, resp.ContentLength) + require.Contains(t, resp.Header.Get("Content-Disposition"), "attachment;") + if getResp.Platform == "darwin" { + require.Contains(t, resp.Header.Get("Content-Type"), "application/x-apple-aspen-config") + } else { + require.Contains(t, resp.Header.Get("Content-Type"), "application/octet-stream") + } + require.Contains(t, resp.Header.Get("X-Content-Type-Options"), "nosniff") + + b, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, resp.ContentLength, int64(len(b))) + } + + var getResp getMDMConfigProfileResponse + // get an unknown Apple profile + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/mdm/profiles/%d", noTeamAppleProfID+1000), nil, http.StatusNotFound, &getResp) + s.Do("GET", fmt.Sprintf("/api/latest/fleet/mdm/profiles/%d", noTeamAppleProfID+1000), nil, http.StatusNotFound, "alt", "media") + // get an unknown Windows profile + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/mdm/profiles/%s", "no-such-profile"), nil, http.StatusNotFound, &getResp) + s.Do("GET", fmt.Sprintf("/api/latest/fleet/mdm/profiles/%s", "no-such-profile"), nil, http.StatusNotFound, "alt", "media") + var deleteResp deleteMDMConfigProfileResponse // delete existing Apple profiles s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/mdm/profiles/%d", noTeamAppleProfID), nil, http.StatusOK, &deleteResp) diff --git a/server/service/mdm.go b/server/service/mdm.go index 0a9b6903e4..dced2ec20a 100644 --- a/server/service/mdm.go +++ b/server/service/mdm.go @@ -937,6 +937,85 @@ func (svc *Service) authorizeAllHostsTeams(ctx context.Context, hostUUIDs []stri return hosts, nil } +//////////////////////////////////////////////////////////////////////////////// +// GET /mdm/profiles/{id_or_uuid} +//////////////////////////////////////////////////////////////////////////////// + +type getMDMConfigProfileRequest struct { + ProfileIDOrUUID string `url:"profile_id_or_uuid"` + Alt string `query:"alt,optional"` +} + +type getMDMConfigProfileResponse struct { + *fleet.MDMConfigProfilePayload + Err error `json:"error,omitempty"` +} + +func (r getMDMConfigProfileResponse) error() error { return r.Err } + +func getMDMConfigProfileEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { + req := request.(*getMDMConfigProfileRequest) + + downloadRequested := req.Alt == "media" + appleID, isApple := isAppleProfileID(req.ProfileIDOrUUID) + var err error + if isApple { + // Apple config profile + cp, err := svc.GetMDMAppleConfigProfile(ctx, appleID) + if err != nil { + return &getMDMConfigProfileResponse{Err: err}, nil + } + + if downloadRequested { + return downloadFileResponse{ + content: cp.Mobileconfig, + contentType: "application/x-apple-aspen-config", + filename: fmt.Sprintf("%s_%s.mobileconfig", time.Now().Format("2006-01-02"), strings.ReplaceAll(cp.Name, " ", "_")), + }, nil + } + return &getMDMConfigProfileResponse{ + MDMConfigProfilePayload: fleet.NewMDMConfigProfilePayloadFromApple(cp), + }, nil + } + + // Windows config profile + cp, err := svc.GetMDMWindowsConfigProfile(ctx, req.ProfileIDOrUUID) + if err != nil { + return &getMDMConfigProfileResponse{Err: err}, nil + } + + if downloadRequested { + return downloadFileResponse{ + content: cp.SyncML, + contentType: "application/octet-stream", // not using the XML MIME type as a profile is not valid XML (a list of elements) + filename: fmt.Sprintf("%s_%s.xml", time.Now().Format("2006-01-02"), strings.ReplaceAll(cp.Name, " ", "_")), + }, nil + } + return &getMDMConfigProfileResponse{ + MDMConfigProfilePayload: fleet.NewMDMConfigProfilePayloadFromWindows(cp), + }, nil +} + +func (svc *Service) GetMDMWindowsConfigProfile(ctx context.Context, profileUUID string) (*fleet.MDMWindowsConfigProfile, error) { + // first we perform a perform basic authz check + if err := svc.authz.Authorize(ctx, &fleet.Team{}, fleet.ActionRead); err != nil { + return nil, err + } + + cp, err := svc.ds.GetMDMWindowsConfigProfile(ctx, profileUUID) + if err != nil { + return nil, ctxerr.Wrap(ctx, err) + } + + // now we can do a specific authz check based on team id of profile before we + // return the profile. + if err := svc.authz.Authorize(ctx, &fleet.MDMConfigProfileAuthz{TeamID: cp.TeamID}, fleet.ActionRead); err != nil { + return nil, err + } + + return cp, nil +} + //////////////////////////////////////////////////////////////////////////////// // DELETE /mdm/profiles/{id_or_uuid} //////////////////////////////////////////////////////////////////////////////// @@ -997,8 +1076,6 @@ func (svc *Service) DeleteMDMWindowsConfigProfile(ctx context.Context, profileUU return ctxerr.Wrap(ctx, err) } - // TODO: do we have Fleet-specific profiles for Windows that we'd want to prevent the user from deleting? - if err := svc.ds.DeleteMDMWindowsConfigProfile(ctx, profileUUID); err != nil { return ctxerr.Wrap(ctx, err) } diff --git a/server/service/mdm_test.go b/server/service/mdm_test.go index 4f9852cae4..f315b63745 100644 --- a/server/service/mdm_test.go +++ b/server/service/mdm_test.go @@ -745,34 +745,44 @@ func TestMDMWindowsConfigProfileAuthz(t *testing.T) { svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: license, SkipCreateTestUsers: true}) testCases := []struct { - name string - user *fleet.User - shouldFailGlobal bool - shouldFailTeam bool + name string + user *fleet.User + shouldFailGlobalRead bool + shouldFailTeamRead bool + shouldFailGlobalWrite bool + shouldFailTeamWrite bool }{ { "global admin", &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}, false, false, + false, + false, }, { "global maintainer", &fleet.User{GlobalRole: ptr.String(fleet.RoleMaintainer)}, false, false, + false, + false, }, { "global observer", &fleet.User{GlobalRole: ptr.String(fleet.RoleObserver)}, true, true, + true, + true, }, { "global observer+", &fleet.User{GlobalRole: ptr.String(fleet.RoleObserverPlus)}, true, true, + true, + true, }, { // this is authorized because any logged-in user can read teams (the @@ -780,6 +790,8 @@ func TestMDMWindowsConfigProfileAuthz(t *testing.T) { // profiles. "global gitops", &fleet.User{GlobalRole: ptr.String(fleet.RoleGitOps)}, + true, + true, false, false, }, @@ -788,48 +800,64 @@ func TestMDMWindowsConfigProfileAuthz(t *testing.T) { &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleAdmin}}}, true, false, + true, + false, }, { "team admin, DOES NOT belong to team", &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleAdmin}}}, true, true, + true, + true, }, { "team maintainer, belongs to team", &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleMaintainer}}}, true, false, + true, + false, }, { "team maintainer, DOES NOT belong to team", &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleMaintainer}}}, true, true, + true, + true, }, { "team observer, belongs to team", &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleObserver}}}, true, true, + true, + true, }, { "team observer, DOES NOT belong to team", &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleObserver}}}, true, true, + true, + true, }, { "team observer+, belongs to team", &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleObserverPlus}}}, true, true, + true, + true, }, { "team observer+, DOES NOT belong to team", &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleObserverPlus}}}, true, true, + true, + true, }, { // this is authorized because any logged-in user can read teams (the @@ -838,6 +866,8 @@ func TestMDMWindowsConfigProfileAuthz(t *testing.T) { "team gitops, belongs to team", &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleGitOps}}}, true, + true, + true, false, }, { @@ -845,12 +875,16 @@ func TestMDMWindowsConfigProfileAuthz(t *testing.T) { &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleGitOps}}}, true, true, + true, + true, }, { "user no roles", &fleet.User{ID: 1337}, true, true, + true, + true, }, } @@ -895,13 +929,21 @@ func TestMDMWindowsConfigProfileAuthz(t *testing.T) { t.Run(tt.name, func(t *testing.T) { ctx := viewer.NewContext(ctx, viewer.Viewer{User: tt.user}) + // test authz get config profile (no team) + _, err := svc.GetMDMWindowsConfigProfile(ctx, "global") + checkShouldFail(t, err, tt.shouldFailGlobalRead) + + // test authz get config profile (team 1) + _, err = svc.GetMDMWindowsConfigProfile(ctx, "team-1") + checkShouldFail(t, err, tt.shouldFailTeamRead) + // test authz delete config profile (no team) - err := svc.DeleteMDMWindowsConfigProfile(ctx, "global") - checkShouldFail(t, err, tt.shouldFailGlobal) + err = svc.DeleteMDMWindowsConfigProfile(ctx, "global") + checkShouldFail(t, err, tt.shouldFailGlobalWrite) // test authz delete config profile (team 1) err = svc.DeleteMDMWindowsConfigProfile(ctx, "team-1") - checkShouldFail(t, err, tt.shouldFailTeam) + checkShouldFail(t, err, tt.shouldFailTeamWrite) }) } } diff --git a/server/service/scripts.go b/server/service/scripts.go index 6e309b19c4..e25e31c64a 100644 --- a/server/service/scripts.go +++ b/server/service/scripts.go @@ -325,17 +325,21 @@ type getScriptResponse struct { func (r getScriptResponse) error() error { return r.Err } -type downloadScriptResponse struct { - Err error `json:"error,omitempty"` - filename string - content []byte +type downloadFileResponse struct { + Err error `json:"error,omitempty"` + filename string + content []byte + contentType string // optional, defaults to application/octet-stream } -func (r downloadScriptResponse) error() error { return r.Err } +func (r downloadFileResponse) error() error { return r.Err } -func (r downloadScriptResponse) hijackRender(ctx context.Context, w http.ResponseWriter) { +func (r downloadFileResponse) hijackRender(ctx context.Context, w http.ResponseWriter) { w.Header().Set("Content-Length", strconv.Itoa(len(r.content))) - w.Header().Set("Content-Type", "application/octet-stream") + if r.contentType == "" { + r.contentType = "application/octet-stream" + } + w.Header().Set("Content-Type", r.contentType) w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment;filename="%s"`, r.filename)) w.Header().Set("X-Content-Type-Options", "nosniff") @@ -358,7 +362,7 @@ func getScriptEndpoint(ctx context.Context, request interface{}, svc fleet.Servi } if downloadRequested { - return downloadScriptResponse{ + return downloadFileResponse{ content: content, filename: fmt.Sprintf("%s %s", time.Now().Format(time.DateOnly), script.Name), }, nil diff --git a/server/service/testing_utils.go b/server/service/testing_utils.go index f069f83883..b2bfb969a3 100644 --- a/server/service/testing_utils.go +++ b/server/service/testing_utils.go @@ -636,6 +636,7 @@ func mdmConfigurationRequiredEndpoints() []struct { {"GET", "/api/latest/fleet/mdm/commands", false, false}, {"POST", "/api/fleet/orbit/disk_encryption_key", false, false}, {"GET", "/api/latest/fleet/mdm/disk_encryption/summary", false, true}, + {"GET", "/api/latest/fleet/mdm/profiles/1", false, false}, {"DELETE", "/api/latest/fleet/mdm/profiles/1", false, false}, } }