Add endpoint to get or download a profile (Windows and macOS) (#15105)
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -118,6 +118,7 @@ SELECT
|
||||
name,
|
||||
identifier,
|
||||
mobileconfig,
|
||||
checksum,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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},
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user