Add endpoint to get or download a profile (Windows and macOS) (#15105)

This commit is contained in:
Martin Angers
2023-11-14 08:19:29 -05:00
committed by GitHub
parent 809cc5e2d3
commit 965a78d2de
10 changed files with 233 additions and 17 deletions
+1
View File
@@ -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).
+1
View File
@@ -118,6 +118,7 @@ SELECT
name,
identifier,
mobileconfig,
checksum,
created_at,
updated_at
FROM
+46
View File
@@ -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,
}
}
+3
View File
@@ -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
+2
View File
@@ -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
+39
View File
@@ -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)
+79 -2
View File
@@ -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 <Replace> 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)
}
+49 -7
View File
@@ -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)
})
}
}
+12 -8
View File
@@ -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
+1
View File
@@ -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},
}
}