mirror of
https://github.com/fleetdm/fleet
synced 2026-05-22 16:39:01 +00:00
For #24862 Unreleased bug. Made disk encryption errors different between `configuration_profiles` and `batch` endpoints. # Checklist for submitter - [x] Added/updated automated tests - [x] A detailed QA plan exists on the associated ticket (if it isn't there, work with the product group's QA engineer to add it) - [x] Manual QA for all new/changed functionality - [x] For unreleased bug fixes in a release candidate, confirmed that the fix is not expected to adversely impact load test results or alerted the release DRI if additional load testing is needed.
2767 lines
94 KiB
Go
2767 lines
94 KiB
Go
package service
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"crypto"
|
||
"crypto/tls"
|
||
"encoding/json"
|
||
"encoding/pem"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"mime/multipart"
|
||
"net/http"
|
||
"path/filepath"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/VividCortex/mysqlerr"
|
||
"github.com/docker/go-units"
|
||
"github.com/fleetdm/fleet/v4/pkg/fleethttp"
|
||
"github.com/fleetdm/fleet/v4/server"
|
||
"github.com/fleetdm/fleet/v4/server/authz"
|
||
"github.com/fleetdm/fleet/v4/server/contexts/ctxdb"
|
||
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
|
||
"github.com/fleetdm/fleet/v4/server/contexts/license"
|
||
"github.com/fleetdm/fleet/v4/server/contexts/logging"
|
||
"github.com/fleetdm/fleet/v4/server/contexts/viewer"
|
||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||
"github.com/fleetdm/fleet/v4/server/mdm"
|
||
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
|
||
"github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig"
|
||
"github.com/fleetdm/fleet/v4/server/mdm/assets"
|
||
"github.com/fleetdm/fleet/v4/server/mdm/cryptoutil"
|
||
"github.com/fleetdm/fleet/v4/server/mdm/microsoft/syncml"
|
||
nanomdm "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
|
||
"github.com/fleetdm/fleet/v4/server/ptr"
|
||
"github.com/fleetdm/fleet/v4/server/service/middleware/endpoint_utils"
|
||
"github.com/go-kit/log/level"
|
||
"github.com/go-sql-driver/mysql"
|
||
)
|
||
|
||
////////////////////////////////////////////////////////////////////////////////
|
||
// GET /mdm/apple
|
||
////////////////////////////////////////////////////////////////////////////////
|
||
|
||
type getAppleMDMResponse struct {
|
||
*fleet.AppleMDM
|
||
Err error `json:"error,omitempty"`
|
||
}
|
||
|
||
func (r getAppleMDMResponse) Error() error { return r.Err }
|
||
|
||
func getAppleMDMEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
||
appleMDM, err := svc.GetAppleMDM(ctx)
|
||
if err != nil {
|
||
return getAppleMDMResponse{Err: err}, nil
|
||
}
|
||
|
||
return getAppleMDMResponse{AppleMDM: appleMDM}, nil
|
||
}
|
||
|
||
func (svc *Service) GetAppleMDM(ctx context.Context) (*fleet.AppleMDM, error) {
|
||
if err := svc.authz.Authorize(ctx, &fleet.AppleMDM{}, fleet.ActionRead); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
apns, err := assets.X509Cert(ctx, svc.ds, fleet.MDMAssetAPNSCert)
|
||
if err != nil {
|
||
return nil, ctxerr.Wrap(ctx, err, "parse certificate")
|
||
}
|
||
|
||
appleMDM := &fleet.AppleMDM{
|
||
CommonName: apns.Subject.CommonName,
|
||
Issuer: apns.Issuer.CommonName,
|
||
RenewDate: apns.NotAfter,
|
||
}
|
||
if apns.SerialNumber != nil {
|
||
appleMDM.SerialNumber = apns.SerialNumber.String()
|
||
}
|
||
|
||
return appleMDM, nil
|
||
}
|
||
|
||
////////////////////////////////////////////////////////////////////////////////
|
||
// GET /mdm/apple_bm
|
||
////////////////////////////////////////////////////////////////////////////////
|
||
|
||
type getAppleBMResponse struct {
|
||
*fleet.AppleBM
|
||
Err error `json:"error,omitempty"`
|
||
}
|
||
|
||
func (r getAppleBMResponse) Error() error { return r.Err }
|
||
|
||
func getAppleBMEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
||
appleBM, err := svc.GetAppleBM(ctx)
|
||
if err != nil {
|
||
return getAppleBMResponse{Err: err}, nil
|
||
}
|
||
|
||
return getAppleBMResponse{AppleBM: appleBM}, nil
|
||
}
|
||
|
||
func (svc *Service) GetAppleBM(ctx context.Context) (*fleet.AppleBM, error) {
|
||
// skipauth: No authorization check needed due to implementation returning
|
||
// only license error.
|
||
svc.authz.SkipAuthorization(ctx)
|
||
|
||
return nil, fleet.ErrMissingLicense
|
||
}
|
||
|
||
////////////////////////////////////////////////////////////////////////////////
|
||
// POST /mdm/apple/request_csr
|
||
////////////////////////////////////////////////////////////////////////////////
|
||
|
||
type requestMDMAppleCSRRequest struct {
|
||
EmailAddress string `json:"email_address"`
|
||
Organization string `json:"organization"`
|
||
}
|
||
|
||
type requestMDMAppleCSRResponse struct {
|
||
*fleet.AppleCSR
|
||
Err error `json:"error,omitempty"`
|
||
}
|
||
|
||
func (r requestMDMAppleCSRResponse) Error() error { return r.Err }
|
||
|
||
func requestMDMAppleCSREndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
||
req := request.(*requestMDMAppleCSRRequest)
|
||
|
||
csr, err := svc.RequestMDMAppleCSR(ctx, req.EmailAddress, req.Organization)
|
||
if err != nil {
|
||
return requestMDMAppleCSRResponse{Err: err}, nil
|
||
}
|
||
return requestMDMAppleCSRResponse{
|
||
AppleCSR: csr,
|
||
}, nil
|
||
}
|
||
|
||
func (svc *Service) RequestMDMAppleCSR(ctx context.Context, email, org string) (*fleet.AppleCSR, error) {
|
||
if err := svc.authz.Authorize(ctx, &fleet.AppleCSR{}, fleet.ActionWrite); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
if err := fleet.ValidateEmail(email); err != nil {
|
||
if strings.TrimSpace(email) == "" {
|
||
return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("email_address", "missing email address"))
|
||
}
|
||
return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("email_address", fmt.Sprintf("invalid email address: %v", err)))
|
||
}
|
||
if strings.TrimSpace(org) == "" {
|
||
return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("organization", "missing organization"))
|
||
}
|
||
|
||
// create the raw SCEP CA cert and key (creating before the CSR signing
|
||
// request so that nothing can fail after the request is made, except for the
|
||
// network during the response of course)
|
||
scepCACert, scepCAKey, err := apple_mdm.NewSCEPCACertKey()
|
||
if err != nil {
|
||
return nil, ctxerr.Wrap(ctx, err, "generate SCEP CA cert and key")
|
||
}
|
||
|
||
// create the APNs CSR
|
||
apnsCSR, apnsKey, err := apple_mdm.GenerateAPNSCSRKey(email, org)
|
||
if err != nil {
|
||
return nil, ctxerr.Wrap(ctx, err, "generate APNs CSR")
|
||
}
|
||
|
||
// request the signed APNs CSR from fleetdm.com
|
||
client := fleethttp.NewClient(fleethttp.WithTimeout(10 * time.Second))
|
||
if err := apple_mdm.GetSignedAPNSCSR(client, apnsCSR); err != nil {
|
||
if ferr, ok := err.(apple_mdm.FleetWebsiteError); ok {
|
||
status := http.StatusBadGateway
|
||
if ferr.Status >= 400 && ferr.Status <= 499 {
|
||
// TODO: fleetdm.com returns a genereric "Bad
|
||
// Request" message, we should coordinate and
|
||
// stablish a response schema from which we can get
|
||
// the invalid field and use
|
||
// fleet.NewInvalidArgumentError instead
|
||
//
|
||
// For now, since we have already validated
|
||
// everything else, we assume that a 4xx
|
||
// response is an email with an invalid domain
|
||
return nil, ctxerr.Wrap(
|
||
ctx,
|
||
fleet.NewInvalidArgumentError(
|
||
"email_address",
|
||
fmt.Sprintf("this email address is not valid: %v", err),
|
||
),
|
||
)
|
||
}
|
||
return nil, ctxerr.Wrap(
|
||
ctx,
|
||
fleet.NewUserMessageError(
|
||
fmt.Errorf("FleetDM CSR request failed: %w", err),
|
||
status,
|
||
),
|
||
)
|
||
}
|
||
|
||
return nil, ctxerr.Wrap(ctx, err, "get signed CSR")
|
||
}
|
||
|
||
// PEM-encode the cert and keys
|
||
scepCACertPEM := apple_mdm.EncodeCertPEM(scepCACert)
|
||
scepCAKeyPEM := apple_mdm.EncodePrivateKeyPEM(scepCAKey)
|
||
apnsKeyPEM := apple_mdm.EncodePrivateKeyPEM(apnsKey)
|
||
|
||
return &fleet.AppleCSR{
|
||
APNsKey: apnsKeyPEM,
|
||
SCEPCert: scepCACertPEM,
|
||
SCEPKey: scepCAKeyPEM,
|
||
}, nil
|
||
}
|
||
|
||
func (svc *Service) VerifyMDMAppleConfigured(ctx context.Context) error {
|
||
appCfg, err := svc.ds.AppConfig(ctx)
|
||
if err != nil {
|
||
// skipauth: Authorization is currently for user endpoints only.
|
||
svc.authz.SkipAuthorization(ctx)
|
||
return err
|
||
}
|
||
if !appCfg.MDM.EnabledAndConfigured {
|
||
// skipauth: Authorization is currently for user endpoints only.
|
||
svc.authz.SkipAuthorization(ctx)
|
||
return fleet.ErrMDMNotConfigured
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
////////////////////////////////////////////////////////////////////////////////
|
||
// POST /mdm/setup/eula
|
||
////////////////////////////////////////////////////////////////////////////////
|
||
|
||
type createMDMEULARequest struct {
|
||
EULA *multipart.FileHeader
|
||
}
|
||
|
||
// TODO: We parse the whole body before running svc.authz.Authorize.
|
||
// An authenticated but unauthorized user could abuse this.
|
||
func (createMDMEULARequest) DecodeRequest(ctx context.Context, r *http.Request) (interface{}, error) {
|
||
err := r.ParseMultipartForm(512 * units.MiB)
|
||
if err != nil {
|
||
return nil, &fleet.BadRequestError{
|
||
Message: "failed to parse multipart form",
|
||
InternalErr: err,
|
||
}
|
||
}
|
||
|
||
if r.MultipartForm.File["eula"] == nil {
|
||
return nil, &fleet.BadRequestError{
|
||
Message: "eula multipart field is required",
|
||
InternalErr: err,
|
||
}
|
||
}
|
||
|
||
return &createMDMEULARequest{
|
||
EULA: r.MultipartForm.File["eula"][0],
|
||
}, nil
|
||
}
|
||
|
||
type createMDMEULAResponse struct {
|
||
Err error `json:"error,omitempty"`
|
||
}
|
||
|
||
func (r createMDMEULAResponse) Error() error { return r.Err }
|
||
|
||
func createMDMEULAEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
||
req := request.(*createMDMEULARequest)
|
||
ff, err := req.EULA.Open()
|
||
if err != nil {
|
||
return createMDMEULAResponse{Err: err}, nil
|
||
}
|
||
defer ff.Close()
|
||
|
||
if err := svc.MDMCreateEULA(ctx, req.EULA.Filename, ff); err != nil {
|
||
return createMDMEULAResponse{Err: err}, nil
|
||
}
|
||
|
||
return createMDMEULAResponse{}, nil
|
||
}
|
||
|
||
func (svc *Service) MDMCreateEULA(ctx context.Context, name string, file io.ReadSeeker) error {
|
||
// skipauth: No authorization check needed due to implementation returning
|
||
// only license error.
|
||
svc.authz.SkipAuthorization(ctx)
|
||
|
||
return fleet.ErrMissingLicense
|
||
}
|
||
|
||
////////////////////////////////////////////////////////////////////////////////
|
||
// GET /mdm/setup/eula?token={token}
|
||
////////////////////////////////////////////////////////////////////////////////
|
||
|
||
type getMDMEULARequest struct {
|
||
Token string `url:"token"`
|
||
}
|
||
|
||
type getMDMEULAResponse struct {
|
||
Err error `json:"error,omitempty"`
|
||
|
||
// fields used in hijackRender to build the response
|
||
eula *fleet.MDMEULA
|
||
}
|
||
|
||
func (r getMDMEULAResponse) Error() error { return r.Err }
|
||
|
||
func (r getMDMEULAResponse) HijackRender(ctx context.Context, w http.ResponseWriter) {
|
||
w.Header().Set("Content-Length", strconv.Itoa(len(r.eula.Bytes)))
|
||
w.Header().Set("Content-Type", "application/pdf")
|
||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||
|
||
// OK to just log the error here as writing anything on
|
||
// `http.ResponseWriter` sets the status code to 200 (and it can't be
|
||
// changed.) Clients should rely on matching content-length with the
|
||
// header provided
|
||
if n, err := w.Write(r.eula.Bytes); err != nil {
|
||
logging.WithExtras(ctx, "err", err, "bytes_copied", n)
|
||
}
|
||
}
|
||
|
||
func getMDMEULAEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
||
req := request.(*getMDMEULARequest)
|
||
|
||
eula, err := svc.MDMGetEULABytes(ctx, req.Token)
|
||
if err != nil {
|
||
return getMDMEULAResponse{Err: err}, nil
|
||
}
|
||
|
||
return getMDMEULAResponse{eula: eula}, nil
|
||
}
|
||
|
||
func (svc *Service) MDMGetEULABytes(ctx context.Context, token string) (*fleet.MDMEULA, error) {
|
||
// skipauth: No authorization check needed due to implementation returning
|
||
// only license error.
|
||
svc.authz.SkipAuthorization(ctx)
|
||
|
||
return nil, fleet.ErrMissingLicense
|
||
}
|
||
|
||
////////////////////////////////////////////////////////////////////////////////
|
||
// GET /mdm/setup/eula/{token}/metadata
|
||
////////////////////////////////////////////////////////////////////////////////
|
||
|
||
type getMDMEULAMetadataRequest struct{}
|
||
|
||
type getMDMEULAMetadataResponse struct {
|
||
*fleet.MDMEULA
|
||
Err error `json:"error,omitempty"`
|
||
}
|
||
|
||
func (r getMDMEULAMetadataResponse) Error() error { return r.Err }
|
||
|
||
func getMDMEULAMetadataEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
||
eula, err := svc.MDMGetEULAMetadata(ctx)
|
||
if err != nil {
|
||
return getMDMEULAMetadataResponse{Err: err}, nil
|
||
}
|
||
|
||
return getMDMEULAMetadataResponse{MDMEULA: eula}, nil
|
||
}
|
||
|
||
func (svc *Service) MDMGetEULAMetadata(ctx context.Context) (*fleet.MDMEULA, error) {
|
||
// skipauth: No authorization check needed due to implementation returning
|
||
// only license error.
|
||
svc.authz.SkipAuthorization(ctx)
|
||
|
||
return nil, fleet.ErrMissingLicense
|
||
}
|
||
|
||
////////////////////////////////////////////////////////////////////////////////
|
||
// DELETE /mdm/setup/eula
|
||
////////////////////////////////////////////////////////////////////////////////
|
||
|
||
type deleteMDMEULARequest struct {
|
||
Token string `url:"token"`
|
||
}
|
||
|
||
type deleteMDMEULAResponse struct {
|
||
Err error `json:"error,omitempty"`
|
||
}
|
||
|
||
func (r deleteMDMEULAResponse) Error() error { return r.Err }
|
||
|
||
func deleteMDMEULAEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
||
req := request.(*deleteMDMEULARequest)
|
||
if err := svc.MDMDeleteEULA(ctx, req.Token); err != nil {
|
||
return deleteMDMEULAResponse{Err: err}, nil
|
||
}
|
||
return deleteMDMEULAResponse{}, nil
|
||
}
|
||
|
||
func (svc *Service) MDMDeleteEULA(ctx context.Context, token string) error {
|
||
// skipauth: No authorization check needed due to implementation returning
|
||
// only license error.
|
||
svc.authz.SkipAuthorization(ctx)
|
||
|
||
return fleet.ErrMissingLicense
|
||
}
|
||
|
||
////////////////////////////////////////////////////////////////////////////////
|
||
// Windows MDM Middleware
|
||
////////////////////////////////////////////////////////////////////////////////
|
||
|
||
func (svc *Service) VerifyMDMWindowsConfigured(ctx context.Context) error {
|
||
appCfg, err := svc.ds.AppConfig(ctx)
|
||
if err != nil {
|
||
// skipauth: Authorization is currently for user endpoints only.
|
||
svc.authz.SkipAuthorization(ctx)
|
||
return err
|
||
}
|
||
|
||
// Windows MDM configuration setting
|
||
if !appCfg.MDM.WindowsEnabledAndConfigured {
|
||
// skipauth: Authorization is currently for user endpoints only.
|
||
svc.authz.SkipAuthorization(ctx)
|
||
return fleet.ErrMDMNotConfigured
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
////////////////////////////////////////////////////////////////////////////////
|
||
// Apple or Windows MDM Middleware
|
||
////////////////////////////////////////////////////////////////////////////////
|
||
|
||
func (svc *Service) VerifyMDMAppleOrWindowsConfigured(ctx context.Context) error {
|
||
appCfg, err := svc.ds.AppConfig(ctx)
|
||
if err != nil {
|
||
// skipauth: Authorization is currently for user endpoints only.
|
||
svc.authz.SkipAuthorization(ctx)
|
||
return err
|
||
}
|
||
|
||
// Apple or Windows MDM configuration setting
|
||
if !appCfg.MDM.EnabledAndConfigured && !appCfg.MDM.WindowsEnabledAndConfigured {
|
||
// skipauth: Authorization is currently for user endpoints only.
|
||
svc.authz.SkipAuthorization(ctx)
|
||
return fleet.ErrMDMNotConfigured
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
////////////////////////////////////////////////////////////////////////////////
|
||
// Run Apple or Windows MDM Command
|
||
////////////////////////////////////////////////////////////////////////////////
|
||
|
||
type runMDMCommandRequest struct {
|
||
Command string `json:"command"`
|
||
HostUUIDs []string `json:"host_uuids"`
|
||
}
|
||
|
||
type runMDMCommandResponse struct {
|
||
*fleet.CommandEnqueueResult
|
||
Err error `json:"error,omitempty"`
|
||
}
|
||
|
||
func (r runMDMCommandResponse) Error() error { return r.Err }
|
||
|
||
func runMDMCommandEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
||
req := request.(*runMDMCommandRequest)
|
||
result, err := svc.RunMDMCommand(ctx, req.Command, req.HostUUIDs)
|
||
if err != nil {
|
||
return runMDMCommandResponse{Err: err}, nil
|
||
}
|
||
return runMDMCommandResponse{
|
||
CommandEnqueueResult: result,
|
||
}, nil
|
||
}
|
||
|
||
func (svc *Service) RunMDMCommand(ctx context.Context, rawBase64Cmd string, hostUUIDs []string) (result *fleet.CommandEnqueueResult, err error) {
|
||
hosts, err := svc.authorizeAllHostsTeams(ctx, hostUUIDs, fleet.ActionWrite, &fleet.MDMCommandAuthz{})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if len(hosts) == 0 {
|
||
err := fleet.NewInvalidArgumentError("host_uuids", "No hosts targeted. Make sure you provide a valid UUID.").WithStatus(http.StatusNotFound)
|
||
return nil, ctxerr.Wrap(ctx, err, "no host received")
|
||
}
|
||
|
||
connectedMap, err := svc.ds.AreHostsConnectedToFleetMDM(ctx, hosts)
|
||
if err != nil {
|
||
return nil, ctxerr.Wrap(ctx, err, "checking if hosts are connected to Fleet")
|
||
}
|
||
|
||
platforms := make(map[string]bool)
|
||
for _, h := range hosts {
|
||
if !connectedMap[h.UUID] {
|
||
err := fleet.NewInvalidArgumentError("host_uuids", "Can't run the MDM command because one or more hosts have MDM turned off. Run the following command to see a list of hosts with MDM on: fleetctl get hosts --mdm.").WithStatus(http.StatusPreconditionFailed)
|
||
return nil, ctxerr.Wrap(ctx, err, "check host mdm enrollment")
|
||
}
|
||
platforms[h.FleetPlatform()] = true
|
||
}
|
||
if len(platforms) != 1 {
|
||
err := fleet.NewInvalidArgumentError("host_uuids", "All hosts must be on the same platform.")
|
||
return nil, ctxerr.Wrap(ctx, err, "check host platform")
|
||
}
|
||
|
||
// it's a for loop but at this point it's guaranteed that the map has a single value.
|
||
var commandPlatform string
|
||
for platform := range platforms {
|
||
commandPlatform = platform
|
||
}
|
||
if !fleet.MDMSupported(commandPlatform) {
|
||
err := fleet.NewInvalidArgumentError("host_uuids", "Invalid platform. You can only run MDM commands on Windows or Apple hosts.")
|
||
return nil, ctxerr.Wrap(ctx, err, "check host platform")
|
||
}
|
||
|
||
// check that the platform-specific MDM is enabled (not sure this check can
|
||
// ever happen, since we verify that the hosts are enrolled, but just to be
|
||
// safe)
|
||
switch commandPlatform {
|
||
case "windows":
|
||
if err := svc.VerifyMDMWindowsConfigured(ctx); err != nil {
|
||
err := fleet.NewInvalidArgumentError("host_uuids", fleet.WindowsMDMNotConfiguredMessage).WithStatus(http.StatusBadRequest)
|
||
return nil, ctxerr.Wrap(ctx, err, "check windows MDM enabled")
|
||
}
|
||
default:
|
||
if err := svc.VerifyMDMAppleConfigured(ctx); err != nil {
|
||
err := fleet.NewInvalidArgumentError("host_uuids", fleet.AppleMDMNotConfiguredMessage).WithStatus(http.StatusBadRequest)
|
||
return nil, ctxerr.Wrap(ctx, err, "check macOS MDM enabled")
|
||
}
|
||
}
|
||
|
||
// We're supporting both padded and unpadded base64.
|
||
rawXMLCmd, err := server.Base64DecodePaddingAgnostic(rawBase64Cmd)
|
||
if err != nil {
|
||
err = fleet.NewInvalidArgumentError("command", "unable to decode base64 command").WithStatus(http.StatusBadRequest)
|
||
return nil, ctxerr.Wrap(ctx, err, "decode base64 command")
|
||
}
|
||
|
||
// the rest is platform-specific (validation of command payload, enqueueing, etc.)
|
||
switch commandPlatform {
|
||
case "windows":
|
||
return svc.enqueueMicrosoftMDMCommand(ctx, rawXMLCmd, hostUUIDs)
|
||
default:
|
||
return svc.enqueueAppleMDMCommand(ctx, rawXMLCmd, hostUUIDs)
|
||
}
|
||
}
|
||
|
||
var appleMDMPremiumCommands = map[string]bool{
|
||
"EraseDevice": true,
|
||
"DeviceLock": true,
|
||
}
|
||
|
||
func (svc *Service) enqueueAppleMDMCommand(ctx context.Context, rawXMLCmd []byte, deviceIDs []string) (result *fleet.CommandEnqueueResult, err error) {
|
||
cmd, err := nanomdm.DecodeCommand(rawXMLCmd)
|
||
if err != nil {
|
||
err = fleet.NewInvalidArgumentError("command", "unable to decode plist command").WithStatus(http.StatusUnsupportedMediaType)
|
||
return nil, ctxerr.Wrap(ctx, err, "decode plist command")
|
||
}
|
||
|
||
if appleMDMPremiumCommands[strings.TrimSpace(cmd.Command.RequestType)] {
|
||
lic, err := svc.License(ctx)
|
||
if err != nil {
|
||
return nil, ctxerr.Wrap(ctx, err, "get license")
|
||
}
|
||
if !lic.IsPremium() {
|
||
return nil, fleet.ErrMissingLicense
|
||
}
|
||
}
|
||
|
||
if err := svc.mdmAppleCommander.EnqueueCommand(ctx, deviceIDs, string(rawXMLCmd)); err != nil {
|
||
// if at least one UUID enqueued properly, return success, otherwise return
|
||
// error
|
||
var apnsErr *apple_mdm.APNSDeliveryError
|
||
var mysqlErr *mysql.MySQLError
|
||
if errors.As(err, &apnsErr) {
|
||
failedUUIDs := apnsErr.FailedUUIDs()
|
||
if len(failedUUIDs) < len(deviceIDs) {
|
||
// some hosts properly received the command, so return success, with the list
|
||
// of failed uuids.
|
||
return &fleet.CommandEnqueueResult{
|
||
CommandUUID: cmd.CommandUUID,
|
||
RequestType: cmd.Command.RequestType,
|
||
FailedUUIDs: failedUUIDs,
|
||
}, nil
|
||
}
|
||
// push failed for all hosts
|
||
err := fleet.NewBadGatewayError("Apple push notificiation service", err)
|
||
return nil, ctxerr.Wrap(ctx, err, "enqueue command")
|
||
|
||
} else if errors.As(err, &mysqlErr) {
|
||
// enqueue may fail with a foreign key constraint error 1452 when one of
|
||
// the hosts provided is not enrolled in nano_enrollments. Detect when
|
||
// that's the case and add information to the error.
|
||
if mysqlErr.Number == mysqlerr.ER_NO_REFERENCED_ROW_2 {
|
||
err := fleet.NewInvalidArgumentError(
|
||
"device_ids",
|
||
fmt.Sprintf("at least one of the hosts is not enrolled in MDM or is not an elegible device: %v", err),
|
||
).WithStatus(http.StatusBadRequest)
|
||
return nil, ctxerr.Wrap(ctx, err, "enqueue command")
|
||
}
|
||
}
|
||
|
||
return nil, ctxerr.Wrap(ctx, err, "enqueue command")
|
||
}
|
||
return &fleet.CommandEnqueueResult{
|
||
CommandUUID: cmd.CommandUUID,
|
||
RequestType: cmd.Command.RequestType,
|
||
Platform: "darwin",
|
||
}, nil
|
||
}
|
||
|
||
func (svc *Service) enqueueMicrosoftMDMCommand(ctx context.Context, rawXMLCmd []byte, deviceIDs []string) (result *fleet.CommandEnqueueResult, err error) {
|
||
cmdMsg, err := fleet.ParseWindowsMDMCommand(rawXMLCmd)
|
||
if err != nil {
|
||
err = fleet.NewInvalidArgumentError("command", err.Error())
|
||
return nil, ctxerr.Wrap(ctx, err, "decode SyncML command")
|
||
}
|
||
|
||
if cmdMsg.IsPremium() {
|
||
lic, err := svc.License(ctx)
|
||
if err != nil {
|
||
return nil, ctxerr.Wrap(ctx, err, "get license")
|
||
}
|
||
if !lic.IsPremium() {
|
||
return nil, fleet.ErrMissingLicense
|
||
}
|
||
}
|
||
|
||
winCmd := &fleet.MDMWindowsCommand{
|
||
// TODO: using the provided ID to mimic Apple, but seems better if
|
||
// we're full in control of it, what we should do?
|
||
CommandUUID: cmdMsg.CmdID.Value,
|
||
RawCommand: rawXMLCmd,
|
||
TargetLocURI: cmdMsg.GetTargetURI(),
|
||
}
|
||
if err := svc.ds.MDMWindowsInsertCommandForHosts(ctx, deviceIDs, winCmd); err != nil {
|
||
return nil, ctxerr.Wrap(ctx, err, "insert pending windows mdm command")
|
||
}
|
||
|
||
return &fleet.CommandEnqueueResult{
|
||
CommandUUID: winCmd.CommandUUID,
|
||
RequestType: winCmd.TargetLocURI,
|
||
Platform: "windows",
|
||
}, nil
|
||
}
|
||
|
||
////////////////////////////////////////////////////////////////////////////////
|
||
// GET /mdm/commandresults
|
||
////////////////////////////////////////////////////////////////////////////////
|
||
|
||
type getMDMCommandResultsRequest struct {
|
||
CommandUUID string `query:"command_uuid,optional"`
|
||
}
|
||
|
||
type getMDMCommandResultsResponse struct {
|
||
Results []*fleet.MDMCommandResult `json:"results,omitempty"`
|
||
Err error `json:"error,omitempty"`
|
||
}
|
||
|
||
func (r getMDMCommandResultsResponse) Error() error { return r.Err }
|
||
|
||
func getMDMCommandResultsEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
||
req := request.(*getMDMCommandResultsRequest)
|
||
results, err := svc.GetMDMCommandResults(ctx, req.CommandUUID)
|
||
if err != nil {
|
||
return getMDMCommandResultsResponse{
|
||
Err: err,
|
||
}, nil
|
||
}
|
||
|
||
return getMDMCommandResultsResponse{
|
||
Results: results,
|
||
}, nil
|
||
}
|
||
|
||
func (svc *Service) GetMDMCommandResults(ctx context.Context, commandUUID string) ([]*fleet.MDMCommandResult, error) {
|
||
// first, authorize that the user has the right to list hosts
|
||
if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil {
|
||
return nil, ctxerr.Wrap(ctx, err)
|
||
}
|
||
|
||
vc, ok := viewer.FromContext(ctx)
|
||
if !ok {
|
||
return nil, fleet.ErrNoContext
|
||
}
|
||
|
||
// check that command exists first, to return 404 on invalid commands
|
||
// (the command may exist but have no results yet).
|
||
p, err := svc.ds.GetMDMCommandPlatform(ctx, commandUUID)
|
||
if err != nil {
|
||
return nil, ctxerr.Wrap(ctx, err)
|
||
}
|
||
|
||
var results []*fleet.MDMCommandResult
|
||
switch p {
|
||
case "darwin":
|
||
results, err = svc.ds.GetMDMAppleCommandResults(ctx, commandUUID)
|
||
case "windows":
|
||
results, err = svc.ds.GetMDMWindowsCommandResults(ctx, commandUUID)
|
||
default:
|
||
// this should never happen, but just in case
|
||
level.Debug(svc.logger).Log("msg", "unknown MDM command platform", "platform", p)
|
||
}
|
||
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// now we can load the hosts (lite) corresponding to those command results,
|
||
// and do the final authorization check with the proper team(s). Include observers,
|
||
// as they are able to view command results for their teams' hosts.
|
||
filter := fleet.TeamFilter{User: vc.User, IncludeObserver: true}
|
||
hostUUIDs := make([]string, len(results))
|
||
for i, res := range results {
|
||
hostUUIDs[i] = res.HostUUID
|
||
}
|
||
hosts, err := svc.ds.ListHostsLiteByUUIDs(ctx, filter, hostUUIDs)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if len(hosts) == 0 {
|
||
// do not return 404 here, as it's possible for a command to not have
|
||
// results yet
|
||
return nil, nil
|
||
}
|
||
|
||
// collect the team IDs and verify that the user has access to view commands
|
||
// on all affected teams. Index the hosts by uuid for easly lookup as
|
||
// afterwards we'll want to store the hostname on the returned results.
|
||
hostsByUUID := make(map[string]*fleet.Host, len(hosts))
|
||
teamIDs := make(map[uint]bool)
|
||
for _, h := range hosts {
|
||
var id uint
|
||
if h.TeamID != nil {
|
||
id = *h.TeamID
|
||
}
|
||
teamIDs[id] = true
|
||
hostsByUUID[h.UUID] = h
|
||
}
|
||
|
||
var commandAuthz fleet.MDMCommandAuthz
|
||
for tmID := range teamIDs {
|
||
commandAuthz.TeamID = &tmID
|
||
if tmID == 0 {
|
||
commandAuthz.TeamID = nil
|
||
}
|
||
|
||
if err := svc.authz.Authorize(ctx, commandAuthz, fleet.ActionRead); err != nil {
|
||
return nil, ctxerr.Wrap(ctx, err)
|
||
}
|
||
}
|
||
|
||
// add the hostnames to the results
|
||
for _, res := range results {
|
||
if h := hostsByUUID[res.HostUUID]; h != nil {
|
||
res.Hostname = hostsByUUID[res.HostUUID].Hostname
|
||
}
|
||
}
|
||
return results, nil
|
||
}
|
||
|
||
////////////////////////////////////////////////////////////////////////////////
|
||
// GET /mdm/commands
|
||
////////////////////////////////////////////////////////////////////////////////
|
||
|
||
type listMDMCommandsRequest struct {
|
||
ListOptions fleet.ListOptions `url:"list_options"`
|
||
HostIdentifier string `query:"host_identifier,optional"`
|
||
RequestType string `query:"request_type,optional"`
|
||
}
|
||
|
||
type listMDMCommandsResponse struct {
|
||
Results []*fleet.MDMCommand `json:"results"`
|
||
Err error `json:"error,omitempty"`
|
||
}
|
||
|
||
func (r listMDMCommandsResponse) Error() error { return r.Err }
|
||
|
||
func listMDMCommandsEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
||
req := request.(*listMDMCommandsRequest)
|
||
results, err := svc.ListMDMCommands(ctx, &fleet.MDMCommandListOptions{
|
||
ListOptions: req.ListOptions,
|
||
Filters: fleet.MDMCommandFilters{HostIdentifier: req.HostIdentifier, RequestType: req.RequestType},
|
||
})
|
||
if err != nil {
|
||
return listMDMCommandsResponse{
|
||
Err: err,
|
||
}, nil
|
||
}
|
||
|
||
return listMDMCommandsResponse{
|
||
Results: results,
|
||
}, nil
|
||
}
|
||
|
||
func (svc *Service) ListMDMCommands(ctx context.Context, opts *fleet.MDMCommandListOptions) ([]*fleet.MDMCommand, error) {
|
||
// first, authorize that the user has the right to list hosts
|
||
if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil {
|
||
return nil, ctxerr.Wrap(ctx, err)
|
||
}
|
||
|
||
vc, ok := viewer.FromContext(ctx)
|
||
if !ok {
|
||
return nil, fleet.ErrNoContext
|
||
}
|
||
|
||
// get the list of commands so we know what hosts (and therefore what teams)
|
||
// we're dealing with. Including the observers as they are allowed to view
|
||
// MDM Apple commands.
|
||
results, err := svc.ds.ListMDMCommands(ctx, fleet.TeamFilter{
|
||
User: vc.User,
|
||
IncludeObserver: true,
|
||
}, opts)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// collect the different team IDs and verify that the user has access to view
|
||
// commands on all affected teams, do not assume that ListMDMCommands
|
||
// only returned hosts that the user is authorized to view the command
|
||
// results of (that is, always verify with our rego authz policy).
|
||
teamIDs := make(map[uint]bool)
|
||
for _, res := range results {
|
||
var id uint
|
||
if res.TeamID != nil {
|
||
id = *res.TeamID
|
||
}
|
||
teamIDs[id] = true
|
||
}
|
||
|
||
// instead of returning an authz error if the user is not authorized for a
|
||
// team, we remove those commands from the results (as we want to return
|
||
// whatever the user is allowed to see). Since this can only be done after
|
||
// retrieving the list of commands, this may result in returning less results
|
||
// than requested, but it's ok - it's expected that the results retrieved
|
||
// from the datastore will all be authorized for the user.
|
||
var commandAuthz fleet.MDMCommandAuthz
|
||
var authzErr error
|
||
for tmID := range teamIDs {
|
||
commandAuthz.TeamID = &tmID
|
||
if tmID == 0 {
|
||
commandAuthz.TeamID = nil
|
||
}
|
||
if err := svc.authz.Authorize(ctx, commandAuthz, fleet.ActionRead); err != nil {
|
||
if authzErr == nil {
|
||
authzErr = err
|
||
}
|
||
teamIDs[tmID] = false
|
||
}
|
||
}
|
||
|
||
if authzErr != nil {
|
||
level.Error(svc.logger).Log("err", "unauthorized to view some team commands", "details", authzErr)
|
||
|
||
// filter-out the teams that the user is not allowed to view
|
||
allowedResults := make([]*fleet.MDMCommand, 0, len(results))
|
||
for _, res := range results {
|
||
var id uint
|
||
if res.TeamID != nil {
|
||
id = *res.TeamID
|
||
}
|
||
if teamIDs[id] {
|
||
allowedResults = append(allowedResults, res)
|
||
}
|
||
}
|
||
results = allowedResults
|
||
}
|
||
|
||
if len(results) == 0 && opts.Filters.HostIdentifier != "" {
|
||
_, err := svc.ds.HostLiteByIdentifier(ctx, opts.Filters.HostIdentifier)
|
||
var nve fleet.NotFoundError
|
||
if errors.As(err, &nve) {
|
||
return nil, fleet.NewInvalidArgumentError("Invalid Host", fleet.HostIdentiferNotFound).WithStatus(http.StatusNotFound)
|
||
}
|
||
}
|
||
|
||
return results, nil
|
||
}
|
||
|
||
////////////////////////////////////////////////////////////////////////////////
|
||
// GET /mdm/disk_encryption/summary
|
||
////////////////////////////////////////////////////////////////////////////////
|
||
|
||
type getMDMDiskEncryptionSummaryRequest struct {
|
||
TeamID *uint `query:"team_id,optional"`
|
||
}
|
||
|
||
type getMDMDiskEncryptionSummaryResponse struct {
|
||
*fleet.MDMDiskEncryptionSummary
|
||
Err error `json:"error,omitempty"`
|
||
}
|
||
|
||
func (r getMDMDiskEncryptionSummaryResponse) Error() error { return r.Err }
|
||
|
||
func getMDMDiskEncryptionSummaryEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
||
req := request.(*getMDMDiskEncryptionSummaryRequest)
|
||
|
||
des, err := svc.GetMDMDiskEncryptionSummary(ctx, req.TeamID)
|
||
if err != nil {
|
||
return getMDMDiskEncryptionSummaryResponse{Err: err}, nil
|
||
}
|
||
|
||
return &getMDMDiskEncryptionSummaryResponse{
|
||
MDMDiskEncryptionSummary: des,
|
||
}, nil
|
||
}
|
||
|
||
func (svc *Service) GetMDMDiskEncryptionSummary(ctx context.Context, teamID *uint) (*fleet.MDMDiskEncryptionSummary, error) {
|
||
// skipauth: No authorization check needed due to implementation returning
|
||
// only license error.
|
||
svc.authz.SkipAuthorization(ctx)
|
||
|
||
return nil, fleet.ErrMissingLicense
|
||
}
|
||
|
||
////////////////////////////////////////////////////////////////////////////////
|
||
// GET /mdm/profiles/summary (deprecated)
|
||
// GET /configuration_profiles/summary
|
||
////////////////////////////////////////////////////////////////////////////////
|
||
|
||
type getMDMProfilesSummaryRequest struct {
|
||
TeamID *uint `query:"team_id,optional"`
|
||
}
|
||
|
||
type getMDMProfilesSummaryResponse struct {
|
||
fleet.MDMProfilesSummary
|
||
Err error `json:"error,omitempty"`
|
||
}
|
||
|
||
func (r getMDMProfilesSummaryResponse) Error() error { return r.Err }
|
||
|
||
func getMDMProfilesSummaryEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
||
req := request.(*getMDMProfilesSummaryRequest)
|
||
res := getMDMProfilesSummaryResponse{}
|
||
|
||
as, err := svc.GetMDMAppleProfilesSummary(ctx, req.TeamID)
|
||
if err != nil {
|
||
return &getMDMAppleProfilesSummaryResponse{Err: err}, nil
|
||
}
|
||
|
||
ws, err := svc.GetMDMWindowsProfilesSummary(ctx, req.TeamID)
|
||
if err != nil {
|
||
return &getMDMProfilesSummaryResponse{Err: err}, nil
|
||
}
|
||
|
||
ls, err := svc.GetMDMLinuxProfilesSummary(ctx, req.TeamID)
|
||
if err != nil {
|
||
return &getMDMProfilesSummaryResponse{Err: err}, nil
|
||
}
|
||
|
||
res.Verified = as.Verified + ws.Verified + ls.Verified
|
||
res.Verifying = as.Verifying + ws.Verifying
|
||
res.Failed = as.Failed + ws.Failed + ls.Failed
|
||
res.Pending = as.Pending + ws.Pending + ls.Pending
|
||
|
||
return &res, nil
|
||
}
|
||
|
||
// authorizeAllHostsTeams is a helper function that loads the hosts
|
||
// corresponding to the hostUUIDs and authorizes the context user to execute
|
||
// the specified authzAction (e.g. fleet.ActionWrite) for all the hosts' teams
|
||
// with the specified authorizer, which is typically a struct that can set a
|
||
// TeamID field and defines an authorization subject, such as
|
||
// fleet.MDMCommandAuthz.
|
||
//
|
||
// On success, the list of hosts is returned (which may be empty, it is up to
|
||
// the caller to return an error if needed when no hosts are found).
|
||
func (svc *Service) authorizeAllHostsTeams(ctx context.Context, hostUUIDs []string, authzAction any, authorizer fleet.TeamIDSetter) ([]*fleet.Host, error) {
|
||
// load hosts (lite) by uuids, check that the user has the rights to run
|
||
// commands for every affected team.
|
||
if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionSelectiveList); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// here we use a global admin as filter because we want to get all hosts that
|
||
// correspond to those uuids. Only after we get those hosts will we check
|
||
// authorization for the current user, for all teams affected by that host.
|
||
// Without this, only hosts that the user can view would be returned and the
|
||
// actual authorization check might only be done on a subset of the requsted
|
||
// hosts.
|
||
filter := fleet.TeamFilter{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}}
|
||
hosts, err := svc.ds.ListHostsLiteByUUIDs(ctx, filter, hostUUIDs)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// collect the team IDs and verify that the user has access to run commands
|
||
// on all affected teams.
|
||
teamIDs := make(map[uint]bool, len(hosts))
|
||
for _, h := range hosts {
|
||
var id uint
|
||
if h.TeamID != nil {
|
||
id = *h.TeamID
|
||
}
|
||
teamIDs[id] = true
|
||
}
|
||
|
||
for tmID := range teamIDs {
|
||
authzTeamID := &tmID
|
||
if tmID == 0 {
|
||
authzTeamID = nil
|
||
}
|
||
authorizer.SetTeamID(authzTeamID)
|
||
|
||
if err := svc.authz.Authorize(ctx, authorizer, authzAction); err != nil {
|
||
return nil, ctxerr.Wrap(ctx, err)
|
||
}
|
||
}
|
||
return hosts, nil
|
||
}
|
||
|
||
////////////////////////////////////////////////////////////////////////////////
|
||
// GET /mdm/profiles/{uuid}
|
||
////////////////////////////////////////////////////////////////////////////////
|
||
|
||
type getMDMConfigProfileRequest struct {
|
||
ProfileUUID string `url:"profile_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) (fleet.Errorer, error) {
|
||
req := request.(*getMDMConfigProfileRequest)
|
||
|
||
downloadRequested := req.Alt == "media"
|
||
var err error
|
||
if isAppleProfileUUID(req.ProfileUUID) {
|
||
// Apple config profile
|
||
cp, err := svc.GetMDMAppleConfigProfile(ctx, req.ProfileUUID)
|
||
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
|
||
}
|
||
|
||
if isAppleDeclarationUUID(req.ProfileUUID) {
|
||
// TODO: we could potentially combined with the other service methods
|
||
decl, err := svc.GetMDMAppleDeclaration(ctx, req.ProfileUUID)
|
||
if err != nil {
|
||
return &getMDMConfigProfileResponse{Err: err}, nil
|
||
}
|
||
|
||
if downloadRequested {
|
||
return downloadFileResponse{
|
||
content: decl.RawJSON,
|
||
contentType: "application/json",
|
||
filename: fmt.Sprintf("%s_%s.json", time.Now().Format("2006-01-02"), strings.ReplaceAll(decl.Name, " ", "_")),
|
||
}, nil
|
||
}
|
||
return &getMDMConfigProfileResponse{
|
||
MDMConfigProfilePayload: fleet.NewMDMConfigProfilePayloadFromAppleDDM(decl),
|
||
}, nil
|
||
}
|
||
|
||
// Windows config profile
|
||
cp, err := svc.GetMDMWindowsConfigProfile(ctx, req.ProfileUUID)
|
||
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/{uuid}
|
||
////////////////////////////////////////////////////////////////////////////////
|
||
|
||
type deleteMDMConfigProfileRequest struct {
|
||
ProfileUUID string `url:"profile_uuid"`
|
||
}
|
||
|
||
type deleteMDMConfigProfileResponse struct {
|
||
Err error `json:"error,omitempty"`
|
||
}
|
||
|
||
func (r deleteMDMConfigProfileResponse) Error() error { return r.Err }
|
||
|
||
func deleteMDMConfigProfileEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
||
req := request.(*deleteMDMConfigProfileRequest)
|
||
|
||
var err error
|
||
if isAppleProfileUUID(req.ProfileUUID) { //nolint:gocritic // ignore ifElseChain
|
||
err = svc.DeleteMDMAppleConfigProfile(ctx, req.ProfileUUID)
|
||
} else if isAppleDeclarationUUID(req.ProfileUUID) {
|
||
// TODO: we could potentially combined with the other service methods
|
||
err = svc.DeleteMDMAppleDeclaration(ctx, req.ProfileUUID)
|
||
} else {
|
||
err = svc.DeleteMDMWindowsConfigProfile(ctx, req.ProfileUUID)
|
||
}
|
||
return &deleteMDMConfigProfileResponse{Err: err}, nil
|
||
}
|
||
|
||
func (svc *Service) DeleteMDMWindowsConfigProfile(ctx context.Context, profileUUID string) error {
|
||
// first we perform a perform basic authz check
|
||
if err := svc.authz.Authorize(ctx, &fleet.Team{}, fleet.ActionRead); err != nil {
|
||
return ctxerr.Wrap(ctx, err)
|
||
}
|
||
|
||
// check that Windows MDM is enabled - the middleware of that endpoint checks
|
||
// only that any MDM is enabled, maybe it's just macOS
|
||
if err := svc.VerifyMDMWindowsConfigured(ctx); err != nil {
|
||
err := fleet.NewInvalidArgumentError("profile_uuid", fleet.WindowsMDMNotConfiguredMessage).WithStatus(http.StatusBadRequest)
|
||
return ctxerr.Wrap(ctx, err, "check windows MDM enabled")
|
||
}
|
||
|
||
prof, err := svc.ds.GetMDMWindowsConfigProfile(ctx, profileUUID)
|
||
if err != nil {
|
||
return ctxerr.Wrap(ctx, err)
|
||
}
|
||
|
||
var teamName string
|
||
teamID := *prof.TeamID
|
||
if teamID >= 1 {
|
||
tm, err := svc.EnterpriseOverrides.TeamByIDOrName(ctx, &teamID, nil)
|
||
if err != nil {
|
||
return ctxerr.Wrap(ctx, err)
|
||
}
|
||
teamName = tm.Name
|
||
}
|
||
|
||
// now we can do a specific authz check based on team id of profile before we delete the profile
|
||
if err := svc.authz.Authorize(ctx, &fleet.MDMConfigProfileAuthz{TeamID: prof.TeamID}, fleet.ActionWrite); err != nil {
|
||
return ctxerr.Wrap(ctx, err)
|
||
}
|
||
|
||
// prevent deleting Fleet-managed profiles (e.g., Windows OS Updates profile controlled by the OS Updates settings)
|
||
fleetNames := mdm.FleetReservedProfileNames()
|
||
if _, ok := fleetNames[prof.Name]; ok {
|
||
err := &fleet.BadRequestError{Message: "Profiles managed by Fleet can't be deleted using this endpoint."}
|
||
return ctxerr.Wrap(ctx, err, "validate profile")
|
||
}
|
||
|
||
if err := svc.ds.DeleteMDMWindowsConfigProfile(ctx, profileUUID); err != nil {
|
||
return ctxerr.Wrap(ctx, err)
|
||
}
|
||
|
||
// cannot use the profile ID as it is now deleted
|
||
if _, err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{teamID}, nil, nil); err != nil {
|
||
return ctxerr.Wrap(ctx, err, "bulk set pending host profiles")
|
||
}
|
||
|
||
var (
|
||
actTeamID *uint
|
||
actTeamName *string
|
||
)
|
||
if teamID > 0 {
|
||
actTeamID = &teamID
|
||
actTeamName = &teamName
|
||
}
|
||
if err := svc.NewActivity(
|
||
ctx, authz.UserFromContext(ctx), &fleet.ActivityTypeDeletedWindowsProfile{
|
||
TeamID: actTeamID,
|
||
TeamName: actTeamName,
|
||
ProfileName: prof.Name,
|
||
}); err != nil {
|
||
return ctxerr.Wrap(ctx, err, "logging activity for delete mdm windows config profile")
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// returns the numeric Apple profile ID and true if it is an Apple identifier,
|
||
// or 0 and false otherwise.
|
||
func isAppleProfileUUID(profileUUID string) bool {
|
||
return strings.HasPrefix(profileUUID, "a")
|
||
}
|
||
|
||
func isAppleDeclarationUUID(profileUUID string) bool {
|
||
return strings.HasPrefix(profileUUID, fleet.MDMAppleDeclarationUUIDPrefix)
|
||
}
|
||
|
||
////////////////////////////////////////////////////////////////////////////////
|
||
// POST /mdm/profiles (Create Apple or Windows MDM Config Profile)
|
||
////////////////////////////////////////////////////////////////////////////////
|
||
|
||
type newMDMConfigProfileRequest struct {
|
||
TeamID uint
|
||
Profile *multipart.FileHeader
|
||
LabelsIncludeAll []string
|
||
LabelsIncludeAny []string
|
||
LabelsExcludeAny []string
|
||
}
|
||
|
||
func (newMDMConfigProfileRequest) DecodeRequest(ctx context.Context, r *http.Request) (interface{}, error) {
|
||
decoded := newMDMConfigProfileRequest{}
|
||
|
||
err := r.ParseMultipartForm(512 * units.MiB)
|
||
if err != nil {
|
||
return nil, &fleet.BadRequestError{
|
||
Message: "failed to parse multipart form",
|
||
InternalErr: err,
|
||
}
|
||
}
|
||
|
||
// add team_id
|
||
val, ok := r.MultipartForm.Value["team_id"]
|
||
if !ok || len(val) < 1 {
|
||
// default is no team
|
||
decoded.TeamID = 0
|
||
} else {
|
||
teamID, err := strconv.Atoi(val[0])
|
||
if err != nil {
|
||
return nil, &fleet.BadRequestError{Message: fmt.Sprintf("failed to decode team_id in multipart form: %s", err.Error())}
|
||
}
|
||
decoded.TeamID = uint(teamID) //nolint:gosec // dismiss G115
|
||
}
|
||
|
||
// add profile
|
||
fhs, ok := r.MultipartForm.File["profile"]
|
||
if !ok || len(fhs) < 1 {
|
||
return nil, &fleet.BadRequestError{Message: "no file headers for profile"}
|
||
}
|
||
decoded.Profile = fhs[0]
|
||
|
||
if decoded.Profile.Size > 1024*1024 {
|
||
return nil, fleet.NewInvalidArgumentError("mdm", "maximum configuration profile file size is 1 MB")
|
||
}
|
||
|
||
// add labels
|
||
var existsInclAll, existsInclAny, existsExclAny, existsDepr bool
|
||
var deprecatedLabels []string
|
||
decoded.LabelsIncludeAll, existsInclAll = r.MultipartForm.Value[string(fleet.LabelsIncludeAll)]
|
||
decoded.LabelsIncludeAny, existsInclAny = r.MultipartForm.Value[string(fleet.LabelsIncludeAny)]
|
||
decoded.LabelsExcludeAny, existsExclAny = r.MultipartForm.Value[string(fleet.LabelsExcludeAny)]
|
||
deprecatedLabels, existsDepr = r.MultipartForm.Value["labels"]
|
||
|
||
// validate that only one of the labels type is provided
|
||
var count int
|
||
for _, b := range []bool{existsInclAll, existsInclAny, existsExclAny, existsDepr} {
|
||
if b {
|
||
count++
|
||
}
|
||
}
|
||
if count > 1 {
|
||
return nil, &fleet.BadRequestError{Message: `Only one of "labels_exclude_any", "labels_include_all", "labels_include_any", or "labels" can be included.`}
|
||
}
|
||
if existsDepr {
|
||
decoded.LabelsIncludeAll = deprecatedLabels
|
||
}
|
||
|
||
return &decoded, nil
|
||
}
|
||
|
||
type newMDMConfigProfileResponse struct {
|
||
ProfileUUID string `json:"profile_uuid"`
|
||
Err error `json:"error,omitempty"`
|
||
}
|
||
|
||
func (r newMDMConfigProfileResponse) Error() error { return r.Err }
|
||
|
||
func newMDMConfigProfileEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
||
req := request.(*newMDMConfigProfileRequest)
|
||
|
||
ff, err := req.Profile.Open()
|
||
if err != nil {
|
||
return &newMDMConfigProfileResponse{Err: err}, nil
|
||
}
|
||
defer ff.Close()
|
||
|
||
fileExt := filepath.Ext(req.Profile.Filename)
|
||
profileName := strings.TrimSuffix(filepath.Base(req.Profile.Filename), fileExt)
|
||
isMobileConfig := strings.EqualFold(fileExt, ".mobileconfig")
|
||
isJSON := strings.EqualFold(fileExt, ".json")
|
||
|
||
var labels []string
|
||
var labelsMode fleet.MDMLabelsMode
|
||
switch {
|
||
case len(req.LabelsIncludeAny) > 0:
|
||
labels = req.LabelsIncludeAny
|
||
labelsMode = fleet.LabelsIncludeAny
|
||
case len(req.LabelsExcludeAny) > 0:
|
||
labels = req.LabelsExcludeAny
|
||
labelsMode = fleet.LabelsExcludeAny
|
||
default:
|
||
// default include all
|
||
labels = req.LabelsIncludeAll
|
||
labelsMode = fleet.LabelsIncludeAll
|
||
}
|
||
|
||
if isMobileConfig || isJSON {
|
||
// Then it's an Apple configuration file
|
||
if isJSON {
|
||
decl, err := svc.NewMDMAppleDeclaration(ctx, req.TeamID, ff, labels, profileName, labelsMode)
|
||
if err != nil {
|
||
errStr := err.Error()
|
||
if strings.Contains(errStr, "MDMAppleDeclaration.Name") && strings.Contains(errStr, "already exists") {
|
||
return &newMDMConfigProfileResponse{
|
||
Err: fleet.NewInvalidArgumentError("profile name", SameProfileNameUploadErrorMsg).WithStatus(http.StatusConflict),
|
||
}, nil
|
||
}
|
||
return &newMDMConfigProfileResponse{Err: err}, nil
|
||
}
|
||
|
||
return &newMDMConfigProfileResponse{
|
||
ProfileUUID: decl.DeclarationUUID,
|
||
}, nil
|
||
|
||
}
|
||
|
||
cp, err := svc.NewMDMAppleConfigProfile(ctx, req.TeamID, ff, labels, labelsMode)
|
||
if err != nil {
|
||
return &newMDMConfigProfileResponse{Err: err}, nil
|
||
}
|
||
return &newMDMConfigProfileResponse{
|
||
ProfileUUID: cp.ProfileUUID,
|
||
}, nil
|
||
}
|
||
|
||
if isWindows := strings.EqualFold(fileExt, ".xml"); isWindows {
|
||
cp, err := svc.NewMDMWindowsConfigProfile(ctx, req.TeamID, profileName, ff, labels, labelsMode)
|
||
if err != nil {
|
||
return &newMDMConfigProfileResponse{Err: err}, nil
|
||
}
|
||
return &newMDMConfigProfileResponse{
|
||
ProfileUUID: cp.ProfileUUID,
|
||
}, nil
|
||
}
|
||
|
||
err = svc.NewMDMUnsupportedConfigProfile(ctx, req.TeamID, req.Profile.Filename)
|
||
return &newMDMConfigProfileResponse{Err: err}, nil
|
||
}
|
||
|
||
func (svc *Service) NewMDMUnsupportedConfigProfile(ctx context.Context, teamID uint, filename string) error {
|
||
if err := svc.authz.Authorize(ctx, &fleet.MDMConfigProfileAuthz{TeamID: &teamID}, fleet.ActionWrite); err != nil {
|
||
return ctxerr.Wrap(ctx, err)
|
||
}
|
||
|
||
// this is required because we need authorize to return the error, and
|
||
// svc.authz is only available on the concrete Service struct, not on the
|
||
// Service interface so it cannot be done in the endpoint itself.
|
||
return &fleet.BadRequestError{Message: "Couldn't add profile. The file should be a .mobileconfig, XML, or JSON file."}
|
||
}
|
||
|
||
func (svc *Service) NewMDMWindowsConfigProfile(ctx context.Context, teamID uint, profileName string, r io.Reader, labels []string, labelsMembershipMode fleet.MDMLabelsMode) (*fleet.MDMWindowsConfigProfile, error) {
|
||
if err := svc.authz.Authorize(ctx, &fleet.MDMConfigProfileAuthz{TeamID: &teamID}, fleet.ActionWrite); err != nil {
|
||
return nil, ctxerr.Wrap(ctx, err)
|
||
}
|
||
|
||
// check that Windows MDM is enabled - the middleware of that endpoint checks
|
||
// only that any MDM is enabled, maybe it's just macOS
|
||
if err := svc.VerifyMDMWindowsConfigured(ctx); err != nil {
|
||
err := fleet.NewInvalidArgumentError("profile", fleet.WindowsMDMNotConfiguredMessage).WithStatus(http.StatusBadRequest)
|
||
return nil, ctxerr.Wrap(ctx, err, "check windows MDM enabled")
|
||
}
|
||
|
||
var teamName string
|
||
if teamID > 0 {
|
||
tm, err := svc.EnterpriseOverrides.TeamByIDOrName(ctx, &teamID, nil)
|
||
if err != nil {
|
||
return nil, ctxerr.Wrap(ctx, err)
|
||
}
|
||
teamName = tm.Name
|
||
}
|
||
|
||
b, err := io.ReadAll(r)
|
||
if err != nil {
|
||
return nil, ctxerr.Wrap(ctx, &fleet.BadRequestError{
|
||
Message: "failed to read Windows config profile",
|
||
InternalErr: err,
|
||
})
|
||
}
|
||
|
||
cp := fleet.MDMWindowsConfigProfile{
|
||
TeamID: &teamID,
|
||
Name: profileName,
|
||
SyncML: b,
|
||
}
|
||
if err := cp.ValidateUserProvided(); err != nil {
|
||
msg := err.Error()
|
||
if strings.Contains(msg, syncml.DiskEncryptionProfileRestrictionErrMsg) {
|
||
return nil, ctxerr.Wrap(ctx,
|
||
&fleet.BadRequestError{Message: msg + " To control these settings use disk encryption endpoint."})
|
||
}
|
||
|
||
// this is not great, but since the validations are shared between the CLI
|
||
// and the API, we must make some changes to error message here.
|
||
if ix := strings.Index(msg, "To control these settings,"); ix >= 0 {
|
||
msg = strings.TrimSpace(msg[:ix])
|
||
}
|
||
err := &fleet.BadRequestError{Message: "Couldn't add. " + msg}
|
||
return nil, ctxerr.Wrap(ctx, err, "validate profile")
|
||
}
|
||
|
||
labelMap, err := svc.validateProfileLabels(ctx, labels)
|
||
if err != nil {
|
||
return nil, ctxerr.Wrap(ctx, err, "validating labels")
|
||
}
|
||
switch labelsMembershipMode {
|
||
case fleet.LabelsIncludeAny:
|
||
cp.LabelsIncludeAny = labelMap
|
||
case fleet.LabelsExcludeAny:
|
||
cp.LabelsExcludeAny = labelMap
|
||
default:
|
||
// default include all
|
||
cp.LabelsIncludeAll = labelMap
|
||
}
|
||
|
||
if err := svc.ds.ValidateEmbeddedSecrets(ctx, []string{string(cp.SyncML)}); err != nil {
|
||
return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("profile", err.Error()))
|
||
}
|
||
|
||
err = validateWindowsProfileFleetVariables(string(cp.SyncML))
|
||
if err != nil {
|
||
return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("profile", err.Error()))
|
||
}
|
||
|
||
newCP, err := svc.ds.NewMDMWindowsConfigProfile(ctx, cp)
|
||
if err != nil {
|
||
var existsErr endpoint_utils.ExistsErrorInterface
|
||
if errors.As(err, &existsErr) {
|
||
err = fleet.NewInvalidArgumentError("profile", SameProfileNameUploadErrorMsg).
|
||
WithStatus(http.StatusConflict)
|
||
}
|
||
return nil, ctxerr.Wrap(ctx, err)
|
||
}
|
||
|
||
if _, err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, []string{newCP.ProfileUUID}, nil); err != nil {
|
||
return nil, ctxerr.Wrap(ctx, err, "bulk set pending host profiles")
|
||
}
|
||
|
||
var (
|
||
actTeamID *uint
|
||
actTeamName *string
|
||
)
|
||
if teamID > 0 {
|
||
actTeamID = &teamID
|
||
actTeamName = &teamName
|
||
}
|
||
if err := svc.NewActivity(
|
||
ctx, authz.UserFromContext(ctx), &fleet.ActivityTypeCreatedWindowsProfile{
|
||
TeamID: actTeamID,
|
||
TeamName: actTeamName,
|
||
ProfileName: newCP.Name,
|
||
}); err != nil {
|
||
return nil, ctxerr.Wrap(ctx, err, "logging activity for create mdm windows config profile")
|
||
}
|
||
|
||
return newCP, nil
|
||
}
|
||
|
||
func validateWindowsProfileFleetVariables(contents string) error {
|
||
if len(findFleetVariables(contents)) > 0 {
|
||
return &fleet.BadRequestError{Message: "Fleet variables ($FLEET_VAR_*) are not currently supported in Windows profiles"}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (svc *Service) batchValidateProfileLabels(ctx context.Context, labelNames []string) (map[string]fleet.ConfigurationProfileLabel, error) {
|
||
if len(labelNames) == 0 {
|
||
return nil, nil
|
||
}
|
||
|
||
labels, err := svc.ds.LabelIDsByName(ctx, labelNames)
|
||
if err != nil {
|
||
return nil, ctxerr.Wrap(ctx, err, "getting label IDs by name")
|
||
}
|
||
|
||
uniqueNames := make(map[string]bool)
|
||
for _, entry := range labelNames {
|
||
if _, value := uniqueNames[entry]; !value {
|
||
uniqueNames[entry] = true
|
||
}
|
||
}
|
||
|
||
if len(labels) != len(uniqueNames) {
|
||
return nil, &fleet.BadRequestError{
|
||
Message: "some or all the labels provided don't exist",
|
||
InternalErr: fmt.Errorf("names provided: %v", labelNames),
|
||
}
|
||
}
|
||
|
||
profLabels := make(map[string]fleet.ConfigurationProfileLabel)
|
||
for labelName, labelID := range labels {
|
||
profLabels[labelName] = fleet.ConfigurationProfileLabel{
|
||
LabelName: labelName,
|
||
LabelID: labelID,
|
||
}
|
||
}
|
||
return profLabels, nil
|
||
}
|
||
|
||
func (svc *Service) validateProfileLabels(ctx context.Context, labelNames []string) ([]fleet.ConfigurationProfileLabel, error) {
|
||
labelMap, err := svc.batchValidateProfileLabels(ctx, labelNames)
|
||
if err != nil {
|
||
return nil, ctxerr.Wrap(ctx, err, "validating profile labels")
|
||
}
|
||
|
||
var profLabels []fleet.ConfigurationProfileLabel
|
||
for _, label := range labelMap {
|
||
profLabels = append(profLabels, label)
|
||
}
|
||
return profLabels, nil
|
||
}
|
||
|
||
////////////////////////////////////////////////////////////////////////////////
|
||
// Batch Replace MDM Profiles
|
||
////////////////////////////////////////////////////////////////////////////////
|
||
|
||
type batchSetMDMProfilesRequest struct {
|
||
TeamID *uint `json:"-" query:"team_id,optional"`
|
||
TeamName *string `json:"-" query:"team_name,optional"`
|
||
DryRun bool `json:"-" query:"dry_run,optional"` // if true, apply validation but do not save changes
|
||
AssumeEnabled *bool `json:"-" query:"assume_enabled,optional"` // if true, assume MDM is enabled
|
||
Profiles backwardsCompatProfilesParam `json:"profiles"`
|
||
NoCache bool `json:"-" query:"no_cache,optional"`
|
||
}
|
||
|
||
type backwardsCompatProfilesParam []fleet.MDMProfileBatchPayload
|
||
|
||
func (bcp *backwardsCompatProfilesParam) UnmarshalJSON(data []byte) error {
|
||
if len(data) == 0 {
|
||
return nil
|
||
}
|
||
|
||
if lookAhead := bytes.TrimSpace(data); len(lookAhead) > 0 && lookAhead[0] == '[' {
|
||
// use []fleet.MDMProfileBatchPayload to prevent infinite recursion if we
|
||
// use `backwardsCompatProfileSlice`
|
||
var profs []fleet.MDMProfileBatchPayload
|
||
if err := json.Unmarshal(data, &profs); err != nil {
|
||
return fmt.Errorf("unmarshal profile spec. Error using new format: %w", err)
|
||
}
|
||
*bcp = profs
|
||
return nil
|
||
}
|
||
|
||
var backwardsCompat map[string][]byte
|
||
if err := json.Unmarshal(data, &backwardsCompat); err != nil {
|
||
return fmt.Errorf("unmarshal profile spec. Error using old format: %w", err)
|
||
}
|
||
|
||
*bcp = make(backwardsCompatProfilesParam, 0, len(backwardsCompat))
|
||
for name, contents := range backwardsCompat {
|
||
*bcp = append(*bcp, fleet.MDMProfileBatchPayload{Name: name, Contents: contents})
|
||
}
|
||
return nil
|
||
}
|
||
|
||
type batchSetMDMProfilesResponse struct {
|
||
Err error `json:"error,omitempty"`
|
||
}
|
||
|
||
func (r batchSetMDMProfilesResponse) Error() error { return r.Err }
|
||
|
||
func (r batchSetMDMProfilesResponse) Status() int { return http.StatusNoContent }
|
||
|
||
func batchSetMDMProfilesEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
||
req := request.(*batchSetMDMProfilesRequest)
|
||
if err := svc.BatchSetMDMProfiles(
|
||
ctx, req.TeamID, req.TeamName, req.Profiles, req.DryRun, false, req.AssumeEnabled, req.NoCache,
|
||
); err != nil {
|
||
return batchSetMDMProfilesResponse{Err: err}, nil
|
||
}
|
||
return batchSetMDMProfilesResponse{}, nil
|
||
}
|
||
|
||
func (svc *Service) BatchSetMDMProfiles(
|
||
ctx context.Context, tmID *uint, tmName *string, profiles []fleet.MDMProfileBatchPayload, dryRun, skipBulkPending bool,
|
||
assumeEnabled *bool, noCache bool,
|
||
) error {
|
||
var err error
|
||
if tmID, tmName, err = svc.authorizeBatchProfiles(ctx, tmID, tmName); err != nil {
|
||
return err
|
||
}
|
||
|
||
if noCache {
|
||
// The no_cache flag is used in situations where appConfig was just updated, such as gitops
|
||
ctx = ctxdb.BypassCachedMysql(ctx, true)
|
||
}
|
||
appCfg, err := svc.ds.AppConfig(ctx)
|
||
if err != nil {
|
||
return ctxerr.Wrap(ctx, err, "getting app config")
|
||
}
|
||
if noCache {
|
||
ctx = ctxdb.BypassCachedMysql(ctx, false)
|
||
}
|
||
|
||
if assumeEnabled != nil {
|
||
appCfg.MDM.WindowsEnabledAndConfigured = *assumeEnabled
|
||
}
|
||
|
||
// Process labels first, since we do not need to expand secrets in the profiles for this validation.
|
||
labels := []string{}
|
||
for i := range profiles {
|
||
// from this point on (after this condition), only LabelsIncludeAll, LabelsIncludeAny or
|
||
// LabelsExcludeAny need to be checked.
|
||
if len(profiles[i].Labels) > 0 {
|
||
// must update the struct in the slice directly, because we don't have a
|
||
// pointer to it (it is a slice of structs, not of pointer to structs)
|
||
profiles[i].LabelsIncludeAll = profiles[i].Labels
|
||
profiles[i].Labels = nil
|
||
}
|
||
labels = append(labels, profiles[i].LabelsIncludeAll...)
|
||
labels = append(labels, profiles[i].LabelsIncludeAny...)
|
||
labels = append(labels, profiles[i].LabelsExcludeAny...)
|
||
}
|
||
labelMap, err := svc.batchValidateProfileLabels(ctx, labels)
|
||
if err != nil {
|
||
return ctxerr.Wrap(ctx, err, "validating labels")
|
||
}
|
||
|
||
// We will not validate the profiles containing secret variables during dry run.
|
||
// This is because the secret variables may not be available (or correct) in the gitops dry run.
|
||
if dryRun {
|
||
var profilesWithoutSecrets []fleet.MDMProfileBatchPayload
|
||
for _, p := range profiles {
|
||
if len(fleet.ContainsPrefixVars(string(p.Contents), fleet.ServerSecretPrefix)) == 0 {
|
||
profilesWithoutSecrets = append(profilesWithoutSecrets, p)
|
||
}
|
||
}
|
||
profiles = profilesWithoutSecrets
|
||
}
|
||
|
||
// Expand secret variables so that profiles can be properly validated.
|
||
// Important: secret variables should never be exposed or saved in the database unencrypted
|
||
// In order to map the expanded profiles back to the original profiles, we will use the index.
|
||
profilesWithSecrets := make(map[int]fleet.MDMProfileBatchPayload, len(profiles))
|
||
for i, p := range profiles {
|
||
expanded, secretsUpdatedAt, err := svc.ds.ExpandEmbeddedSecretsAndUpdatedAt(ctx, string(p.Contents))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
p.SecretsUpdatedAt = secretsUpdatedAt
|
||
pCopy := p
|
||
// If the profile does not contain secrets, then expanded and original content point to the same slice/memory location.
|
||
pCopy.Contents = []byte(expanded)
|
||
profilesWithSecrets[i] = pCopy
|
||
}
|
||
|
||
if err := validateProfiles(profilesWithSecrets); err != nil {
|
||
return ctxerr.Wrap(ctx, err, "validating profiles")
|
||
}
|
||
|
||
appleProfiles, appleDecls, err := getAppleProfiles(ctx, tmID, appCfg, profilesWithSecrets, labelMap)
|
||
if err != nil {
|
||
return ctxerr.Wrap(ctx, err, "validating macOS profiles")
|
||
}
|
||
|
||
windowsProfiles, err := getWindowsProfiles(ctx, tmID, appCfg, profilesWithSecrets, labelMap)
|
||
if err != nil {
|
||
return ctxerr.Wrap(ctx, err, "validating Windows profiles")
|
||
}
|
||
|
||
if err := svc.validateCrossPlatformProfileNames(ctx, appleProfiles, windowsProfiles, appleDecls); err != nil {
|
||
return ctxerr.Wrap(ctx, err, "validating cross-platform profile names")
|
||
}
|
||
|
||
if dryRun {
|
||
return nil
|
||
}
|
||
|
||
err = validateFleetVariables(ctx, appCfg, appleProfiles, windowsProfiles, appleDecls)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// Now that validation is done, we remove the exposed secret variables from the profiles
|
||
appleProfilesSlice := make([]*fleet.MDMAppleConfigProfile, 0, len(appleProfiles))
|
||
for i, p := range appleProfiles {
|
||
p.Mobileconfig = profiles[i].Contents
|
||
appleProfilesSlice = append(appleProfilesSlice, p)
|
||
}
|
||
appleDeclsSlice := make([]*fleet.MDMAppleDeclaration, 0, len(appleDecls))
|
||
for i, p := range appleDecls {
|
||
p.RawJSON = profiles[i].Contents
|
||
appleDeclsSlice = append(appleDeclsSlice, p)
|
||
}
|
||
windowsProfilesSlice := make([]*fleet.MDMWindowsConfigProfile, 0, len(windowsProfiles))
|
||
for i, p := range windowsProfiles {
|
||
p.SyncML = profiles[i].Contents
|
||
windowsProfilesSlice = append(windowsProfilesSlice, p)
|
||
}
|
||
|
||
var profUpdates fleet.MDMProfilesUpdates
|
||
if profUpdates, err = svc.ds.BatchSetMDMProfiles(ctx, tmID, appleProfilesSlice, windowsProfilesSlice, appleDeclsSlice); err != nil {
|
||
return ctxerr.Wrap(ctx, err, "setting config profiles")
|
||
}
|
||
|
||
// set pending status for windows profiles
|
||
winProfUUIDs := []string{}
|
||
for _, p := range windowsProfiles {
|
||
winProfUUIDs = append(winProfUUIDs, p.ProfileUUID)
|
||
}
|
||
winUpdates, err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, winProfUUIDs, nil)
|
||
if err != nil {
|
||
return ctxerr.Wrap(ctx, err, "bulk set pending windows host profiles")
|
||
}
|
||
|
||
// set pending status for apple profiles
|
||
appleProfUUIDs := []string{}
|
||
for _, p := range appleProfiles {
|
||
appleProfUUIDs = append(appleProfUUIDs, p.ProfileUUID)
|
||
}
|
||
appleUpdates, err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, appleProfUUIDs, nil)
|
||
if err != nil {
|
||
return ctxerr.Wrap(ctx, err, "bulk set pending apple host profiles")
|
||
}
|
||
updates := fleet.MDMProfilesUpdates{
|
||
AppleConfigProfile: profUpdates.AppleConfigProfile || winUpdates.AppleConfigProfile || appleUpdates.AppleConfigProfile,
|
||
WindowsConfigProfile: profUpdates.WindowsConfigProfile || winUpdates.WindowsConfigProfile || appleUpdates.WindowsConfigProfile,
|
||
AppleDeclaration: profUpdates.AppleDeclaration || winUpdates.AppleDeclaration || appleUpdates.AppleDeclaration,
|
||
}
|
||
|
||
if updates.AppleConfigProfile {
|
||
if err := svc.NewActivity(
|
||
ctx, authz.UserFromContext(ctx), &fleet.ActivityTypeEditedMacosProfile{
|
||
TeamID: tmID,
|
||
TeamName: tmName,
|
||
}); err != nil {
|
||
return ctxerr.Wrap(ctx, err, "logging activity for edited macos profile")
|
||
}
|
||
}
|
||
if updates.WindowsConfigProfile {
|
||
if err := svc.NewActivity(
|
||
ctx, authz.UserFromContext(ctx), &fleet.ActivityTypeEditedWindowsProfile{
|
||
TeamID: tmID,
|
||
TeamName: tmName,
|
||
}); err != nil {
|
||
return ctxerr.Wrap(ctx, err, "logging activity for edited windows profile")
|
||
}
|
||
}
|
||
if updates.AppleDeclaration {
|
||
if err := svc.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
|
||
}
|
||
|
||
func validateFleetVariables(ctx context.Context, appConfig *fleet.AppConfig, appleProfiles map[int]*fleet.MDMAppleConfigProfile,
|
||
windowsProfiles map[int]*fleet.MDMWindowsConfigProfile, appleDecls map[int]*fleet.MDMAppleDeclaration,
|
||
) error {
|
||
var err error
|
||
|
||
for _, p := range appleProfiles {
|
||
err = validateConfigProfileFleetVariables(appConfig, string(p.Mobileconfig))
|
||
if err != nil {
|
||
return ctxerr.Wrap(ctx, err, "validating config profile Fleet variables")
|
||
}
|
||
}
|
||
for _, p := range windowsProfiles {
|
||
err = validateWindowsProfileFleetVariables(string(p.SyncML))
|
||
if err != nil {
|
||
return ctxerr.Wrap(ctx, err, "validating Windows profile Fleet variables")
|
||
}
|
||
}
|
||
for _, p := range appleDecls {
|
||
err = validateDeclarationFleetVariables(string(p.RawJSON))
|
||
if err != nil {
|
||
return ctxerr.Wrap(ctx, err, "validating declaration Fleet variables")
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (svc *Service) validateCrossPlatformProfileNames(ctx context.Context, appleProfiles map[int]*fleet.MDMAppleConfigProfile,
|
||
windowsProfiles map[int]*fleet.MDMWindowsConfigProfile, appleDecls map[int]*fleet.MDMAppleDeclaration,
|
||
) error {
|
||
// map all profile names to check for duplicates, regardless of platform; key is name, value is one of
|
||
// ".mobileconfig" or ".json" or ".xml"
|
||
extByName := make(map[string]string, len(appleProfiles)+len(windowsProfiles)+len(appleDecls))
|
||
for i, p := range appleProfiles {
|
||
if _, ok := extByName[p.Name]; ok {
|
||
err := fleet.NewInvalidArgumentError(fmt.Sprintf("appleProfiles[%d]", i), fmtDuplicateNameErrMsg(p.Name))
|
||
return ctxerr.Wrap(ctx, err, "duplicate mobileconfig profile by name")
|
||
}
|
||
extByName[p.Name] = ".mobileconfig"
|
||
}
|
||
for i, p := range windowsProfiles {
|
||
if _, ok := extByName[p.Name]; ok {
|
||
err := fleet.NewInvalidArgumentError(fmt.Sprintf("windowsProfiles[%d]", i), fmtDuplicateNameErrMsg(p.Name))
|
||
return ctxerr.Wrap(ctx, err, "duplicate xml by name")
|
||
}
|
||
extByName[p.Name] = ".xml"
|
||
}
|
||
for i, p := range appleDecls {
|
||
if _, ok := extByName[p.Name]; ok {
|
||
err := fleet.NewInvalidArgumentError(fmt.Sprintf("appleDecls[%d]", i), fmtDuplicateNameErrMsg(p.Name))
|
||
return ctxerr.Wrap(ctx, err, "duplicate json by name")
|
||
}
|
||
extByName[p.Name] = ".json"
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
func fmtDuplicateNameErrMsg(name string) string {
|
||
const SameProfileNameErrorMsg = "Couldn't edit custom_settings. More than one configuration profile have the same name '%s' (PayloadDisplayName for .mobileconfig and file name for .json and .xml)."
|
||
return fmt.Sprintf(SameProfileNameErrorMsg, name)
|
||
}
|
||
|
||
func (svc *Service) authorizeBatchProfiles(ctx context.Context, tmID *uint, tmName *string) (*uint, *string, error) {
|
||
if tmID != nil && tmName != nil {
|
||
svc.authz.SkipAuthorization(ctx) // so that the error message is not replaced by "forbidden"
|
||
return nil, nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("team_name", "cannot specify both team_id and team_name"))
|
||
}
|
||
if tmID != nil || tmName != nil {
|
||
license, _ := license.FromContext(ctx)
|
||
if !license.IsPremium() {
|
||
field := "team_id"
|
||
if tmName != nil {
|
||
field = "team_name"
|
||
}
|
||
svc.authz.SkipAuthorization(ctx) // so that the error message is not replaced by "forbidden"
|
||
return nil, nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError(field, ErrMissingLicense.Error()))
|
||
}
|
||
}
|
||
|
||
// if the team name is provided, load the corresponding team to get its id.
|
||
// vice-versa, if the id is provided, load it to get the name (required for
|
||
// the activity).
|
||
if tmName != nil || tmID != nil {
|
||
tm, err := svc.EnterpriseOverrides.TeamByIDOrName(ctx, tmID, tmName)
|
||
if err != nil {
|
||
return nil, nil, err
|
||
}
|
||
if tmID == nil {
|
||
tmID = &tm.ID
|
||
} else {
|
||
tmName = &tm.Name
|
||
}
|
||
}
|
||
|
||
if err := svc.authz.Authorize(ctx, &fleet.MDMConfigProfileAuthz{TeamID: tmID}, fleet.ActionWrite); err != nil {
|
||
return nil, nil, ctxerr.Wrap(ctx, err)
|
||
}
|
||
|
||
return tmID, tmName, nil
|
||
}
|
||
|
||
func getAppleProfiles(
|
||
ctx context.Context,
|
||
tmID *uint,
|
||
appCfg *fleet.AppConfig,
|
||
profiles map[int]fleet.MDMProfileBatchPayload,
|
||
labelMap map[string]fleet.ConfigurationProfileLabel,
|
||
) (map[int]*fleet.MDMAppleConfigProfile, map[int]*fleet.MDMAppleDeclaration, error) {
|
||
// any duplicate identifier or name in the provided set results in an error
|
||
profs := make(map[int]*fleet.MDMAppleConfigProfile, len(profiles))
|
||
decls := make(map[int]*fleet.MDMAppleDeclaration, len(profiles))
|
||
// we need to keep track of the names and identifiers to check for duplicates so we will use
|
||
// a map where the key is the name oridentifier and the value is either "mobileconfig" or
|
||
// "declaration" to differentiate between the two types of profiles
|
||
byName, byIdent := make(map[string]string, len(profiles)), make(map[string]string, len(profiles))
|
||
for i, prof := range profiles {
|
||
if mdm.GetRawProfilePlatform(prof.Contents) != "darwin" {
|
||
continue
|
||
}
|
||
|
||
// Check for DDM files
|
||
if mdm.GuessProfileExtension(prof.Contents) == "json" {
|
||
rawDecl, err := fleet.GetRawDeclarationValues(prof.Contents)
|
||
if err != nil {
|
||
return nil, nil, err
|
||
}
|
||
|
||
if err := rawDecl.ValidateUserProvided(); err != nil {
|
||
return nil, nil, err
|
||
}
|
||
|
||
mdmDecl := fleet.NewMDMAppleDeclaration(prof.Contents, tmID, prof.Name, rawDecl.Type, rawDecl.Identifier)
|
||
mdmDecl.SecretsUpdatedAt = prof.SecretsUpdatedAt
|
||
for _, labelName := range prof.LabelsIncludeAll {
|
||
if lbl, ok := labelMap[labelName]; ok {
|
||
declLabel := fleet.ConfigurationProfileLabel{
|
||
LabelName: lbl.LabelName,
|
||
LabelID: lbl.LabelID,
|
||
RequireAll: true,
|
||
}
|
||
mdmDecl.LabelsIncludeAll = append(mdmDecl.LabelsIncludeAll, declLabel)
|
||
}
|
||
}
|
||
for _, labelName := range prof.LabelsIncludeAny {
|
||
if lbl, ok := labelMap[labelName]; ok {
|
||
declLabel := fleet.ConfigurationProfileLabel{
|
||
LabelName: lbl.LabelName,
|
||
LabelID: lbl.LabelID,
|
||
}
|
||
mdmDecl.LabelsIncludeAny = append(mdmDecl.LabelsIncludeAny, declLabel)
|
||
}
|
||
}
|
||
for _, labelName := range prof.LabelsExcludeAny {
|
||
if lbl, ok := labelMap[labelName]; ok {
|
||
declLabel := fleet.ConfigurationProfileLabel{
|
||
LabelName: lbl.LabelName,
|
||
LabelID: lbl.LabelID,
|
||
Exclude: true,
|
||
}
|
||
mdmDecl.LabelsExcludeAny = append(mdmDecl.LabelsExcludeAny, declLabel)
|
||
}
|
||
}
|
||
|
||
v, ok := byIdent[mdmDecl.Identifier]
|
||
switch {
|
||
case !ok:
|
||
byIdent[mdmDecl.Identifier] = "declaration"
|
||
case v == "mobileconfig":
|
||
return nil, nil, ctxerr.Wrap(ctx,
|
||
fleet.NewInvalidArgumentError(mdmDecl.Identifier, "A configuration profile with this identifier already exists."),
|
||
"duplicate mobileconfig profile by identifier")
|
||
case v == "declaration":
|
||
return nil, nil, ctxerr.Wrap(ctx,
|
||
fleet.NewInvalidArgumentError(mdmDecl.Identifier, "A declaration profile with this identifier already exists."),
|
||
"duplicate declaration profile by identifier")
|
||
default:
|
||
// this should never happen but just in case
|
||
return nil, nil, ctxerr.Wrap(ctx,
|
||
fleet.NewInvalidArgumentError(mdmDecl.Identifier, "A profile with this identifier already exists."),
|
||
"duplicate identifier by identifier")
|
||
}
|
||
|
||
decls[i] = mdmDecl
|
||
|
||
continue
|
||
}
|
||
|
||
mdmProf, err := fleet.NewMDMAppleConfigProfile(prof.Contents, tmID)
|
||
mdmProf.SecretsUpdatedAt = prof.SecretsUpdatedAt
|
||
if err != nil {
|
||
return nil, nil, ctxerr.Wrap(ctx,
|
||
fleet.NewInvalidArgumentError(prof.Name, err.Error()),
|
||
"invalid mobileconfig profile")
|
||
}
|
||
|
||
for _, labelName := range prof.LabelsIncludeAll {
|
||
if lbl, ok := labelMap[labelName]; ok {
|
||
mdmLabel := fleet.ConfigurationProfileLabel{
|
||
LabelName: lbl.LabelName,
|
||
LabelID: lbl.LabelID,
|
||
RequireAll: true,
|
||
}
|
||
mdmProf.LabelsIncludeAll = append(mdmProf.LabelsIncludeAll, mdmLabel)
|
||
}
|
||
}
|
||
for _, labelName := range prof.LabelsIncludeAny {
|
||
if lbl, ok := labelMap[labelName]; ok {
|
||
mdmLabel := fleet.ConfigurationProfileLabel{
|
||
LabelName: lbl.LabelName,
|
||
LabelID: lbl.LabelID,
|
||
}
|
||
mdmProf.LabelsIncludeAny = append(mdmProf.LabelsIncludeAny, mdmLabel)
|
||
}
|
||
}
|
||
for _, labelName := range prof.LabelsExcludeAny {
|
||
if lbl, ok := labelMap[labelName]; ok {
|
||
mdmLabel := fleet.ConfigurationProfileLabel{
|
||
LabelName: lbl.LabelName,
|
||
LabelID: lbl.LabelID,
|
||
Exclude: true,
|
||
}
|
||
mdmProf.LabelsExcludeAny = append(mdmProf.LabelsExcludeAny, mdmLabel)
|
||
}
|
||
}
|
||
|
||
if err := mdmProf.ValidateUserProvided(); err != nil {
|
||
var iae *fleet.InvalidArgumentError
|
||
if strings.Contains(err.Error(), mobileconfig.DiskEncryptionProfileRestrictionErrMsg) {
|
||
iae = fleet.NewInvalidArgumentError(prof.Name,
|
||
mobileconfig.DiskEncryptionProfileRestrictionErrMsg+` To control disk encryption use config API endpoint or add "enable_disk_encryption" to your YAML file.`)
|
||
} else {
|
||
iae = fleet.NewInvalidArgumentError(prof.Name, err.Error())
|
||
}
|
||
return nil, nil, ctxerr.Wrap(ctx, iae)
|
||
}
|
||
|
||
if mdmProf.Name != prof.Name {
|
||
return nil, nil, ctxerr.Wrap(ctx,
|
||
fleet.NewInvalidArgumentError(prof.Name, fmt.Sprintf("Couldn't edit custom_settings. The name provided for the profile must match the profile PayloadDisplayName: %q", mdmProf.Name)),
|
||
"duplicate mobileconfig profile by name")
|
||
}
|
||
|
||
if _, ok := byName[mdmProf.Name]; ok {
|
||
return nil, nil, ctxerr.Wrap(ctx,
|
||
fleet.NewInvalidArgumentError(prof.Name, fmt.Sprintf("Couldn't edit custom_settings. More than one configuration profile have the same name (PayloadDisplayName): %q", mdmProf.Name)),
|
||
"duplicate mobileconfig profile by name")
|
||
}
|
||
byName[mdmProf.Name] = "mobileconfig"
|
||
|
||
// TODO: confirm error messages
|
||
if _, ok := byIdent[mdmProf.Identifier]; ok {
|
||
return nil, nil, ctxerr.Wrap(ctx,
|
||
fleet.NewInvalidArgumentError(prof.Name, fmt.Sprintf("Couldn't edit custom_settings. More than one configuration profile have the same identifier (PayloadIdentifier): %q", mdmProf.Identifier)),
|
||
"duplicate mobileconfig profile by identifier")
|
||
}
|
||
byIdent[mdmProf.Identifier] = "mobileconfig"
|
||
|
||
profs[i] = mdmProf
|
||
}
|
||
|
||
if !appCfg.MDM.EnabledAndConfigured {
|
||
// NOTE: in order to prevent an error when Fleet MDM is not enabled but no
|
||
// profile is provided, which can happen if a user runs `fleetctl get
|
||
// config` and tries to apply that YAML, as it will contain an empty/null
|
||
// custom_settings key, we just return a success response in this
|
||
// situation.
|
||
if len(profs) == 0 {
|
||
return nil, nil, nil
|
||
}
|
||
|
||
return nil, nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("mdm", "cannot set custom settings: Fleet MDM is not configured"))
|
||
}
|
||
|
||
return profs, decls, nil
|
||
}
|
||
|
||
func getWindowsProfiles(
|
||
ctx context.Context,
|
||
tmID *uint,
|
||
appCfg *fleet.AppConfig,
|
||
profiles map[int]fleet.MDMProfileBatchPayload,
|
||
labelMap map[string]fleet.ConfigurationProfileLabel,
|
||
) (map[int]*fleet.MDMWindowsConfigProfile, error) {
|
||
profs := make(map[int]*fleet.MDMWindowsConfigProfile, len(profiles))
|
||
|
||
for i, profile := range profiles {
|
||
if mdm.GetRawProfilePlatform(profile.Contents) != "windows" {
|
||
continue
|
||
}
|
||
|
||
mdmProf := &fleet.MDMWindowsConfigProfile{
|
||
TeamID: tmID,
|
||
Name: profile.Name,
|
||
SyncML: profile.Contents,
|
||
}
|
||
for _, labelName := range profile.LabelsIncludeAll {
|
||
if lbl, ok := labelMap[labelName]; ok {
|
||
mdmLabel := fleet.ConfigurationProfileLabel{
|
||
LabelName: lbl.LabelName,
|
||
LabelID: lbl.LabelID,
|
||
RequireAll: true,
|
||
}
|
||
mdmProf.LabelsIncludeAll = append(mdmProf.LabelsIncludeAll, mdmLabel)
|
||
}
|
||
}
|
||
for _, labelName := range profile.LabelsIncludeAny {
|
||
if lbl, ok := labelMap[labelName]; ok {
|
||
mdmLabel := fleet.ConfigurationProfileLabel{
|
||
LabelName: lbl.LabelName,
|
||
LabelID: lbl.LabelID,
|
||
}
|
||
mdmProf.LabelsIncludeAny = append(mdmProf.LabelsIncludeAny, mdmLabel)
|
||
}
|
||
}
|
||
for _, labelName := range profile.LabelsExcludeAny {
|
||
if lbl, ok := labelMap[labelName]; ok {
|
||
mdmLabel := fleet.ConfigurationProfileLabel{
|
||
LabelName: lbl.LabelName,
|
||
LabelID: lbl.LabelID,
|
||
Exclude: true,
|
||
}
|
||
mdmProf.LabelsExcludeAny = append(mdmProf.LabelsExcludeAny, mdmLabel)
|
||
}
|
||
}
|
||
|
||
if err := mdmProf.ValidateUserProvided(); err != nil {
|
||
msg := err.Error()
|
||
if strings.Contains(msg, syncml.DiskEncryptionProfileRestrictionErrMsg) {
|
||
msg += ` To control disk encryption use config API endpoint or add "enable_disk_encryption" to your YAML file.`
|
||
}
|
||
return nil, ctxerr.Wrap(ctx,
|
||
fleet.NewInvalidArgumentError(fmt.Sprintf("profiles[%s]", profile.Name), msg))
|
||
}
|
||
|
||
profs[i] = mdmProf
|
||
}
|
||
|
||
if !appCfg.MDM.WindowsEnabledAndConfigured {
|
||
// NOTE: in order to prevent an error when Fleet MDM is not enabled but no
|
||
// profile is provided, which can happen if a user runs `fleetctl get
|
||
// config` and tries to apply that YAML, as it will contain an empty/null
|
||
// custom_settings key, we just return a success response in this
|
||
// situation.
|
||
if len(profs) == 0 {
|
||
return nil, nil
|
||
}
|
||
|
||
return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("mdm", "cannot set custom settings: Fleet MDM is not configured"))
|
||
}
|
||
|
||
return profs, nil
|
||
}
|
||
|
||
func validateProfiles(profiles map[int]fleet.MDMProfileBatchPayload) error {
|
||
for _, profile := range profiles {
|
||
// validate that only one of labels, labels_include_all and labels_exclude_any is provided.
|
||
var count int
|
||
for _, b := range []bool{
|
||
len(profile.LabelsIncludeAll) > 0,
|
||
len(profile.LabelsIncludeAny) > 0,
|
||
len(profile.LabelsExcludeAny) > 0,
|
||
len(profile.Labels) > 0,
|
||
} {
|
||
if b {
|
||
count++
|
||
}
|
||
}
|
||
if count > 1 {
|
||
return fleet.NewInvalidArgumentError("mdm", `Couldn't edit custom_settings. For each profile, only one of "labels_exclude_any", "labels_include_all", "labels_include_any" or "labels" can be included.`)
|
||
}
|
||
|
||
if len(profile.Contents) > 1024*1024 {
|
||
return fleet.NewInvalidArgumentError("mdm", "maximum configuration profile file size is 1 MB")
|
||
}
|
||
|
||
platform := mdm.GetRawProfilePlatform(profile.Contents)
|
||
if platform != "darwin" && platform != "windows" {
|
||
// We can only display a generic error message here because at this point
|
||
// we don't know the file extension or whether the profile is intended
|
||
// for macos_settings or windows_settings. We should expecte never see this
|
||
// in practice because the client should be validating the profiles
|
||
// before sending them to the server so the client can surface more helpful
|
||
// error messages to the user. However, we're validating again here just
|
||
// in case the client is not working as expected.
|
||
return fleet.NewInvalidArgumentError("mdm", fmt.Sprintf(
|
||
"%s is not a valid macOS or Windows configuration profile. ", profile.Name)+
|
||
"macOS profiles must be valid .mobileconfig or .json files. "+
|
||
"Windows configuration profiles can only have <Replace> or <Add> top level elements.")
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
////////////////////////////////////////////////////////////////////////////////
|
||
// GET /mdm/profiles (List profiles)
|
||
////////////////////////////////////////////////////////////////////////////////
|
||
|
||
type listMDMConfigProfilesRequest struct {
|
||
TeamID *uint `query:"team_id,optional"`
|
||
ListOptions fleet.ListOptions `url:"list_options"`
|
||
}
|
||
|
||
type listMDMConfigProfilesResponse struct {
|
||
Meta *fleet.PaginationMetadata `json:"meta"`
|
||
Profiles []*fleet.MDMConfigProfilePayload `json:"profiles"`
|
||
Err error `json:"error,omitempty"`
|
||
}
|
||
|
||
func (r listMDMConfigProfilesResponse) Error() error { return r.Err }
|
||
|
||
func listMDMConfigProfilesEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
||
req := request.(*listMDMConfigProfilesRequest)
|
||
|
||
profs, meta, err := svc.ListMDMConfigProfiles(ctx, req.TeamID, req.ListOptions)
|
||
if err != nil {
|
||
return &listMDMConfigProfilesResponse{Err: err}, nil
|
||
}
|
||
|
||
res := listMDMConfigProfilesResponse{Meta: meta, Profiles: profs}
|
||
if profs == nil {
|
||
// return empty json array instead of json null
|
||
res.Profiles = []*fleet.MDMConfigProfilePayload{}
|
||
}
|
||
return &res, nil
|
||
}
|
||
|
||
func (svc *Service) ListMDMConfigProfiles(ctx context.Context, teamID *uint, opt fleet.ListOptions) ([]*fleet.MDMConfigProfilePayload, *fleet.PaginationMetadata, error) {
|
||
if err := svc.authz.Authorize(ctx, &fleet.MDMConfigProfileAuthz{TeamID: teamID}, fleet.ActionRead); err != nil {
|
||
return nil, nil, ctxerr.Wrap(ctx, err)
|
||
}
|
||
|
||
if teamID != nil && *teamID > 0 {
|
||
// confirm that team exists
|
||
if _, err := svc.ds.Team(ctx, *teamID); err != nil {
|
||
return nil, nil, ctxerr.Wrap(ctx, err)
|
||
}
|
||
}
|
||
|
||
// cursor-based pagination is not supported for profiles
|
||
opt.After = ""
|
||
// custom ordering is not supported, always by name
|
||
opt.OrderKey = "name"
|
||
opt.OrderDirection = fleet.OrderAscending
|
||
// no matching query support
|
||
opt.MatchQuery = ""
|
||
// always include metadata for profiles
|
||
opt.IncludeMetadata = true
|
||
|
||
return svc.ds.ListMDMConfigProfiles(ctx, teamID, opt)
|
||
}
|
||
|
||
////////////////////////////////////////////////////////////////////////////////
|
||
// Update MDM Disk encryption
|
||
////////////////////////////////////////////////////////////////////////////////
|
||
|
||
type updateDiskEncryptionRequest struct {
|
||
TeamID *uint `json:"team_id"`
|
||
EnableDiskEncryption bool `json:"enable_disk_encryption"`
|
||
}
|
||
|
||
type updateMDMDiskEncryptionResponse struct {
|
||
Err error `json:"error,omitempty"`
|
||
}
|
||
|
||
func (r updateMDMDiskEncryptionResponse) Error() error { return r.Err }
|
||
|
||
func (r updateMDMDiskEncryptionResponse) Status() int { return http.StatusNoContent }
|
||
|
||
func updateDiskEncryptionEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
||
req := request.(*updateDiskEncryptionRequest)
|
||
if err := svc.UpdateMDMDiskEncryption(ctx, req.TeamID, &req.EnableDiskEncryption); err != nil {
|
||
return updateMDMDiskEncryptionResponse{Err: err}, nil
|
||
}
|
||
return updateMDMDiskEncryptionResponse{}, nil
|
||
}
|
||
|
||
func (svc *Service) UpdateMDMDiskEncryption(ctx context.Context, teamID *uint, enableDiskEncryption *bool) error {
|
||
// TODO(mna): this should all move to the ee package when we remove the
|
||
// `PATCH /api/v1/fleet/mdm/apple/settings` endpoint, but for now it's better
|
||
// leave here so both endpoints can reuse the same logic.
|
||
|
||
lic, _ := license.FromContext(ctx)
|
||
if lic == nil || !lic.IsPremium() {
|
||
svc.authz.SkipAuthorization(ctx) // so that the error message is not replaced by "forbidden"
|
||
return fleet.ErrMissingLicense
|
||
}
|
||
|
||
// for historical reasons (the deprecated PATCH /mdm/apple/settings
|
||
// endpoint), this uses an Apple-specific struct for authorization. Can be improved
|
||
// once we remove the deprecated endpoint.
|
||
if err := svc.authz.Authorize(ctx, fleet.MDMAppleSettingsPayload{TeamID: teamID}, fleet.ActionWrite); err != nil {
|
||
return ctxerr.Wrap(ctx, err)
|
||
}
|
||
|
||
if teamID != nil {
|
||
tm, err := svc.EnterpriseOverrides.TeamByIDOrName(ctx, teamID, nil)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return svc.EnterpriseOverrides.UpdateTeamMDMDiskEncryption(ctx, tm, enableDiskEncryption)
|
||
}
|
||
return svc.updateAppConfigMDMDiskEncryption(ctx, enableDiskEncryption)
|
||
}
|
||
|
||
////////////////////////////////////////////////////////////////////////////////
|
||
// POST /hosts/{id:[0-9]+}/configuration_profiles/{profile_uuid}
|
||
////////////////////////////////////////////////////////////////////////////////
|
||
|
||
type resendHostMDMProfileRequest struct {
|
||
HostID uint `url:"host_id"`
|
||
ProfileUUID string `url:"profile_uuid"`
|
||
}
|
||
|
||
type resendHostMDMProfileResponse struct {
|
||
Err error `json:"error,omitempty"`
|
||
}
|
||
|
||
func (r resendHostMDMProfileResponse) Error() error { return r.Err }
|
||
|
||
func (r resendHostMDMProfileResponse) Status() int { return http.StatusAccepted }
|
||
|
||
func resendHostMDMProfileEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
||
req := request.(*resendHostMDMProfileRequest)
|
||
|
||
if err := svc.ResendHostMDMProfile(ctx, req.HostID, req.ProfileUUID); err != nil {
|
||
return resendHostMDMProfileResponse{Err: err}, nil
|
||
}
|
||
|
||
return resendHostMDMProfileResponse{}, nil
|
||
}
|
||
|
||
func (svc *Service) ResendHostMDMProfile(ctx context.Context, hostID uint, profileUUID string) error {
|
||
// first we perform a perform basic authz check, we use selective list action to include gitops users
|
||
if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionSelectiveList); err != nil {
|
||
return ctxerr.Wrap(ctx, err)
|
||
}
|
||
|
||
host, err := svc.ds.HostLite(ctx, hostID)
|
||
if err != nil {
|
||
return ctxerr.Wrap(ctx, err)
|
||
}
|
||
|
||
// now we can do a specific authz check based on team id of the host before proceeding
|
||
if err := svc.authz.Authorize(ctx, &fleet.MDMConfigProfileAuthz{TeamID: host.TeamID}, fleet.ActionWrite); err != nil {
|
||
return ctxerr.Wrap(ctx, err)
|
||
}
|
||
|
||
var profileTeamID *uint
|
||
var profileName string
|
||
switch {
|
||
case strings.HasPrefix(profileUUID, fleet.MDMAppleProfileUUIDPrefix):
|
||
if err := svc.VerifyMDMAppleConfigured(ctx); err != nil {
|
||
return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("HostMDMProfile", fleet.AppleMDMNotConfiguredMessage).WithStatus(http.StatusBadRequest), "check apple mdm enabled")
|
||
}
|
||
if host.Platform != "darwin" && host.Platform != "ios" && host.Platform != "ipados" {
|
||
return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("HostMDMProfile", "Profile is not compatible with host platform."), "check host platform")
|
||
}
|
||
prof, err := svc.ds.GetMDMAppleConfigProfile(ctx, profileUUID)
|
||
if err != nil {
|
||
return ctxerr.Wrap(ctx, err, "getting apple config profile")
|
||
}
|
||
profileTeamID = prof.TeamID
|
||
profileName = prof.Name
|
||
|
||
case strings.HasPrefix(profileUUID, fleet.MDMAppleDeclarationUUIDPrefix):
|
||
if err := svc.VerifyMDMAppleConfigured(ctx); err != nil {
|
||
return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("HostMDMProfile", fleet.AppleMDMNotConfiguredMessage).WithStatus(http.StatusBadRequest), "check apple mdm enabled")
|
||
}
|
||
if host.Platform != "darwin" && host.Platform != "ios" && host.Platform != "ipados" {
|
||
return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("HostMDMProfile", "Profile is not compatible with host platform."), "check host platform")
|
||
}
|
||
decl, err := svc.ds.GetMDMAppleDeclaration(ctx, profileUUID)
|
||
if err != nil {
|
||
return ctxerr.Wrap(ctx, err, "getting apple declaration")
|
||
}
|
||
profileTeamID = decl.TeamID
|
||
profileName = decl.Name
|
||
|
||
case strings.HasPrefix(profileUUID, fleet.MDMWindowsProfileUUIDPrefix):
|
||
if err := svc.VerifyMDMWindowsConfigured(ctx); err != nil {
|
||
return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("HostMDMProfile", fleet.WindowsMDMNotConfiguredMessage).WithStatus(http.StatusBadRequest), "check windows mdm enabled")
|
||
}
|
||
if host.Platform != "windows" {
|
||
return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("HostMDMProfile", "Profile is not compatible with host platform."), "check host platform")
|
||
}
|
||
prof, err := svc.ds.GetMDMWindowsConfigProfile(ctx, profileUUID)
|
||
if err != nil {
|
||
return ctxerr.Wrap(ctx, err, "getting windows config profile")
|
||
}
|
||
profileTeamID = prof.TeamID
|
||
profileName = prof.Name
|
||
|
||
default:
|
||
return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("HostMDMProfile", "Invalid profile UUID prefix.").WithStatus(http.StatusNotFound), "check profile UUID prefix")
|
||
}
|
||
|
||
// check again based on team id of profile before we proceeding
|
||
if err := svc.authz.Authorize(ctx, &fleet.MDMConfigProfileAuthz{TeamID: profileTeamID}, fleet.ActionWrite); err != nil {
|
||
return ctxerr.Wrap(ctx, err, "authorizing profile team")
|
||
}
|
||
|
||
status, err := svc.ds.GetHostMDMProfileInstallStatus(ctx, host.UUID, profileUUID)
|
||
if err != nil {
|
||
if fleet.IsNotFound(err) {
|
||
return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("HostMDMProfile", "Unable to match profile to host.").WithStatus(http.StatusNotFound), "getting host mdm profile status")
|
||
}
|
||
return ctxerr.Wrap(ctx, err, "getting host mdm profile status")
|
||
}
|
||
if status == fleet.MDMDeliveryPending || status == fleet.MDMDeliveryVerifying {
|
||
return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("HostMDMProfile", "Couldn’t resend. Configuration profiles with “pending” or “verifying” status can’t be resent.").WithStatus(http.StatusConflict), "check profile status")
|
||
}
|
||
if status != fleet.MDMDeliveryFailed && status != fleet.MDMDeliveryVerified {
|
||
// this should never happen, but just in case
|
||
return ctxerr.Errorf(ctx, "unrecognized profile status %s", status)
|
||
}
|
||
|
||
if err := svc.ds.ResendHostMDMProfile(ctx, host.UUID, profileUUID); err != nil {
|
||
return ctxerr.Wrap(ctx, err, "resending host mdm profile")
|
||
}
|
||
|
||
if err := svc.NewActivity(
|
||
ctx, authz.UserFromContext(ctx), &fleet.ActivityTypeResentConfigurationProfile{
|
||
HostID: &host.ID,
|
||
HostDisplayName: ptr.String(host.DisplayName()),
|
||
ProfileName: profileName,
|
||
}); err != nil {
|
||
return ctxerr.Wrap(ctx, err, "logging activity for resend config profile")
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
////////////////////////////////////////////////////////////////////////////////
|
||
// GET /mdm/apple/request_csr
|
||
////////////////////////////////////////////////////////////////////////////////
|
||
|
||
// Used for overriding the env var value in testing
|
||
var testSetEmptyPrivateKey bool
|
||
|
||
type getMDMAppleCSRRequest struct{}
|
||
|
||
type getMDMAppleCSRResponse struct {
|
||
CSR []byte `json:"csr"` // base64 encoded
|
||
Err error `json:"error,omitempty"`
|
||
}
|
||
|
||
func (r getMDMAppleCSRResponse) Error() error { return r.Err }
|
||
|
||
func getMDMAppleCSREndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
||
signedCSRB64, err := svc.GetMDMAppleCSR(ctx)
|
||
if err != nil {
|
||
return &getMDMAppleCSRResponse{Err: err}, nil
|
||
}
|
||
|
||
return &getMDMAppleCSRResponse{CSR: signedCSRB64}, nil
|
||
}
|
||
|
||
func (svc *Service) GetMDMAppleCSR(ctx context.Context) ([]byte, error) {
|
||
if err := svc.authz.Authorize(ctx, &fleet.AppleCSR{}, fleet.ActionWrite); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
privateKey := svc.config.Server.PrivateKey
|
||
if testSetEmptyPrivateKey {
|
||
privateKey = ""
|
||
}
|
||
|
||
if len(privateKey) == 0 {
|
||
return nil, ctxerr.New(ctx, "Couldn't download signed CSR. Missing required private key. Learn how to configure the private key here: https://fleetdm.com/learn-more-about/fleet-server-private-key")
|
||
}
|
||
|
||
vc, ok := viewer.FromContext(ctx)
|
||
if !ok {
|
||
return nil, fleet.ErrNoContext
|
||
}
|
||
|
||
// Check if we have existing certs and keys
|
||
var apnsKey crypto.PrivateKey
|
||
savedAssets, err := svc.ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{
|
||
fleet.MDMAssetCACert,
|
||
fleet.MDMAssetCAKey,
|
||
fleet.MDMAssetAPNSKey,
|
||
}, nil)
|
||
if err != nil {
|
||
// allow not found errors as it means we're generating the assets for
|
||
// the first time.
|
||
if !fleet.IsNotFound(err) {
|
||
return nil, ctxerr.Wrap(ctx, err, "loading existing assets from the database")
|
||
}
|
||
}
|
||
|
||
if len(savedAssets) == 0 {
|
||
// Then we should create them
|
||
|
||
scepCert, scepKey, err := apple_mdm.NewSCEPCACertKey()
|
||
if err != nil {
|
||
return nil, ctxerr.Wrap(ctx, err, "generate SCEP cert and key")
|
||
}
|
||
|
||
apnsRSAKey, err := apple_mdm.NewPrivateKey()
|
||
if err != nil {
|
||
return nil, ctxerr.Wrap(ctx, err, "generate new apns private key")
|
||
}
|
||
apnsKey = apnsRSAKey
|
||
|
||
// Store our config assets encrypted
|
||
var assets []fleet.MDMConfigAsset
|
||
for k, v := range map[fleet.MDMAssetName][]byte{
|
||
fleet.MDMAssetCACert: apple_mdm.EncodeCertPEM(scepCert),
|
||
fleet.MDMAssetCAKey: apple_mdm.EncodePrivateKeyPEM(scepKey),
|
||
fleet.MDMAssetAPNSKey: apple_mdm.EncodePrivateKeyPEM(apnsRSAKey),
|
||
} {
|
||
assets = append(assets, fleet.MDMConfigAsset{
|
||
Name: k,
|
||
Value: v,
|
||
})
|
||
}
|
||
|
||
if err := svc.ds.InsertMDMConfigAssets(ctx, assets, nil); err != nil {
|
||
return nil, ctxerr.Wrap(ctx, err, "inserting mdm config assets")
|
||
}
|
||
} else {
|
||
rawApnsKey := savedAssets[fleet.MDMAssetAPNSKey]
|
||
apnsKey, err = cryptoutil.ParsePrivateKey(rawApnsKey.Value, "APNS private key")
|
||
if err != nil {
|
||
return nil, ctxerr.Wrap(ctx, err, "parse APNS private key")
|
||
}
|
||
}
|
||
|
||
// Generate new APNS CSR every time this is called
|
||
appConfig, err := svc.ds.AppConfig(ctx)
|
||
if err != nil {
|
||
return nil, ctxerr.Wrap(ctx, err, "get app config")
|
||
}
|
||
|
||
apnsCSR, err := apple_mdm.GenerateAPNSCSR(appConfig.OrgInfo.OrgName, vc.Email(), apnsKey)
|
||
if err != nil {
|
||
return nil, ctxerr.Wrap(ctx, err, "generate APNS cert and key")
|
||
}
|
||
|
||
// Submit CSR to fleetdm.com for signing
|
||
websiteClient := fleethttp.NewClient(fleethttp.WithTimeout(10 * time.Second))
|
||
|
||
signedCSRB64, err := apple_mdm.GetSignedAPNSCSRNoEmail(websiteClient, apnsCSR)
|
||
if err != nil {
|
||
var fwe apple_mdm.FleetWebsiteError
|
||
if errors.As(err, &fwe) {
|
||
// From svc.RequestMDMAppleCSR: fleetdm.com returns a bad request here if the email is invalid.
|
||
if fwe.Status >= 400 && fwe.Status <= 499 {
|
||
return nil, ctxerr.Wrap(
|
||
ctx,
|
||
fleet.NewInvalidArgumentError(
|
||
"email_address",
|
||
fmt.Sprintf("this email address is not valid: %v", err),
|
||
),
|
||
)
|
||
}
|
||
return nil, ctxerr.Wrap(
|
||
ctx,
|
||
fleet.NewUserMessageError(
|
||
fmt.Errorf("FleetDM CSR request failed: %w", err),
|
||
http.StatusBadGateway,
|
||
),
|
||
)
|
||
}
|
||
return nil, ctxerr.Wrap(ctx, err, "get signed CSR")
|
||
}
|
||
|
||
// Return signed CSR
|
||
return signedCSRB64, nil
|
||
}
|
||
|
||
////////////////////////////////////////////////////////////////////////////////
|
||
// POST /mdm/apple/apns_certificate
|
||
////////////////////////////////////////////////////////////////////////////////
|
||
|
||
type uploadMDMAppleAPNSCertRequest struct {
|
||
File *multipart.FileHeader
|
||
}
|
||
|
||
func (uploadMDMAppleAPNSCertRequest) DecodeRequest(ctx context.Context, r *http.Request) (interface{}, error) {
|
||
decoded := uploadMDMAppleAPNSCertRequest{}
|
||
err := r.ParseMultipartForm(512 * units.MiB)
|
||
if err != nil {
|
||
return nil, &fleet.BadRequestError{
|
||
Message: "failed to parse multipart form",
|
||
InternalErr: err,
|
||
}
|
||
}
|
||
|
||
if r.MultipartForm.File["certificate"] == nil || len(r.MultipartForm.File["certificate"]) == 0 {
|
||
return nil, &fleet.BadRequestError{
|
||
Message: "certificate multipart field is required",
|
||
InternalErr: err,
|
||
}
|
||
}
|
||
|
||
decoded.File = r.MultipartForm.File["certificate"][0]
|
||
|
||
return &decoded, nil
|
||
}
|
||
|
||
type uploadMDMAppleAPNSCertResponse struct {
|
||
Err error `json:"error,omitempty"`
|
||
}
|
||
|
||
func (r uploadMDMAppleAPNSCertResponse) Error() error {
|
||
return r.Err
|
||
}
|
||
|
||
func (r uploadMDMAppleAPNSCertResponse) Status() int { return http.StatusAccepted }
|
||
|
||
func uploadMDMAppleAPNSCertEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
||
req := request.(*uploadMDMAppleAPNSCertRequest)
|
||
file, err := req.File.Open()
|
||
if err != nil {
|
||
return uploadMDMAppleAPNSCertResponse{Err: err}, nil
|
||
}
|
||
defer file.Close()
|
||
|
||
if err := svc.UploadMDMAppleAPNSCert(ctx, file); err != nil {
|
||
return &uploadMDMAppleAPNSCertResponse{Err: err}, nil
|
||
}
|
||
|
||
return &uploadMDMAppleAPNSCertResponse{}, nil
|
||
}
|
||
|
||
func (svc *Service) UploadMDMAppleAPNSCert(ctx context.Context, cert io.ReadSeeker) error {
|
||
if err := svc.authz.Authorize(ctx, &fleet.AppleCSR{}, fleet.ActionWrite); err != nil {
|
||
return err
|
||
}
|
||
|
||
privateKey := svc.config.Server.PrivateKey
|
||
if testSetEmptyPrivateKey {
|
||
privateKey = ""
|
||
}
|
||
|
||
if len(privateKey) == 0 {
|
||
return ctxerr.New(ctx, "Couldn't add APNs certificate. Missing required private key. Learn how to configure the private key here: https://fleetdm.com/learn-more-about/fleet-server-private-key")
|
||
}
|
||
|
||
if cert == nil {
|
||
return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("certificate", "Invalid certificate. Please provide a valid certificate from Apple Push Certificate Portal."))
|
||
}
|
||
|
||
// Get cert file bytes
|
||
certBytes, err := io.ReadAll(cert)
|
||
if err != nil {
|
||
return ctxerr.Wrap(ctx, err, "reading apns certificate")
|
||
}
|
||
|
||
// Validate cert
|
||
block, _ := pem.Decode(certBytes)
|
||
if block == nil {
|
||
return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("certificate", "Invalid certificate. Please provide a valid certificate from Apple Push Certificate Portal."))
|
||
}
|
||
|
||
if err := svc.authz.Authorize(ctx, &fleet.AppleMDM{}, fleet.ActionRead); err != nil {
|
||
return err
|
||
}
|
||
|
||
assets, err := svc.ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{fleet.MDMAssetAPNSKey}, nil)
|
||
if err != nil {
|
||
if fleet.IsNotFound(err) {
|
||
return ctxerr.Wrap(ctx, &fleet.BadRequestError{
|
||
Message: "Please generate a private key first.",
|
||
}, "uploading APNs certificate")
|
||
}
|
||
|
||
return ctxerr.Wrap(ctx, err, "retrieving APNs key")
|
||
}
|
||
|
||
_, err = tls.X509KeyPair(certBytes, assets[fleet.MDMAssetAPNSKey].Value)
|
||
if err != nil {
|
||
return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("certificate", "Invalid certificate. Please provide a valid certificate from Apple Push Certificate Portal."))
|
||
}
|
||
|
||
// delete the old certificate and insert the new one
|
||
// TODO(roberto): replacing the certificate should be done in a single transaction in the DB
|
||
err = svc.ds.DeleteMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{fleet.MDMAssetAPNSCert})
|
||
if err != nil {
|
||
return ctxerr.Wrap(ctx, err, "deleting old apns cert from db")
|
||
}
|
||
err = svc.ds.InsertMDMConfigAssets(ctx, []fleet.MDMConfigAsset{
|
||
{Name: fleet.MDMAssetAPNSCert, Value: certBytes},
|
||
}, nil)
|
||
if err != nil {
|
||
return ctxerr.Wrap(ctx, err, "writing apns cert to db")
|
||
}
|
||
|
||
// flip the app config flag
|
||
appCfg, err := svc.ds.AppConfig(ctx)
|
||
if err != nil {
|
||
return ctxerr.Wrap(ctx, err, "retrieving app config")
|
||
}
|
||
|
||
wasEnabledAndConfigured := appCfg.MDM.EnabledAndConfigured
|
||
appCfg.MDM.EnabledAndConfigured = true
|
||
err = svc.ds.SaveAppConfig(ctx, appCfg)
|
||
if err != nil {
|
||
return ctxerr.Wrap(ctx, err, "saving app config")
|
||
}
|
||
|
||
// Disk encryption can be enabled prior to Apple MDM being configured, but we need MDM to be set up to escrow
|
||
// FileVault keys. We handle the other order of operations elsewhere (on encryption enable, after checking to see
|
||
// if Mac MDM is already enabled). We skip this step if we were just re-uploading an APNs cert when MDM was already
|
||
// enabled.
|
||
if wasEnabledAndConfigured {
|
||
return nil
|
||
}
|
||
|
||
// Enable FileVault escrow if no-team already has disk encryption enforced
|
||
if appCfg.MDM.EnableDiskEncryption.Value {
|
||
if err := svc.EnterpriseOverrides.MDMAppleEnableFileVaultAndEscrow(ctx, nil); err != nil {
|
||
return ctxerr.Wrap(ctx, err, "enable no-team FileVault escrow")
|
||
}
|
||
if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), fleet.ActivityTypeEnabledMacosDiskEncryption{}); err != nil {
|
||
return ctxerr.Wrap(ctx, err, "create activity for enabling no-team macOS disk encryption")
|
||
}
|
||
}
|
||
// Enable FileVault escrow for teams that already have disk encryption enforced
|
||
// For later: add a data store method to avoid making an extra query per team to check whether encryption is enforced
|
||
teams, err := svc.ds.TeamsSummary(ctx)
|
||
if err != nil {
|
||
return ctxerr.Wrap(ctx, err, "listing teams")
|
||
}
|
||
for _, team := range teams {
|
||
isEncryptionEnforced, err := svc.ds.GetConfigEnableDiskEncryption(ctx, &team.ID)
|
||
if err != nil {
|
||
return ctxerr.Wrap(ctx, err, "retrieving encryption enforcement status for team")
|
||
}
|
||
if isEncryptionEnforced {
|
||
if err := svc.EnterpriseOverrides.MDMAppleEnableFileVaultAndEscrow(ctx, &team.ID); err != nil {
|
||
return ctxerr.Wrap(ctx, err, "enable FileVault escrow for team")
|
||
}
|
||
if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), fleet.ActivityTypeEnabledMacosDiskEncryption{TeamID: &team.ID, TeamName: &team.Name}); err != nil {
|
||
return ctxerr.Wrap(ctx, err, "create activity for enabling macOS disk encryption for team")
|
||
}
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
////////////////////////////////////////////////////////////////////////////////
|
||
// DELETE /mdm/apple/apns_certificate
|
||
////////////////////////////////////////////////////////////////////////////////
|
||
|
||
type deleteMDMAppleAPNSCertRequest struct{}
|
||
|
||
type deleteMDMAppleAPNSCertResponse struct {
|
||
Err error `json:"error,omitempty"`
|
||
}
|
||
|
||
func (r deleteMDMAppleAPNSCertResponse) Error() error {
|
||
return r.Err
|
||
}
|
||
|
||
func deleteMDMAppleAPNSCertEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
||
if err := svc.DeleteMDMAppleAPNSCert(ctx); err != nil {
|
||
return &deleteMDMAppleAPNSCertResponse{Err: err}, nil
|
||
}
|
||
|
||
return &deleteMDMAppleAPNSCertResponse{}, nil
|
||
}
|
||
|
||
func (svc *Service) DeleteMDMAppleAPNSCert(ctx context.Context) error {
|
||
if err := svc.authz.Authorize(ctx, &fleet.AppleCSR{}, fleet.ActionWrite); err != nil {
|
||
return err
|
||
}
|
||
|
||
err := svc.ds.DeleteMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{
|
||
fleet.MDMAssetAPNSCert,
|
||
fleet.MDMAssetAPNSKey,
|
||
fleet.MDMAssetCACert,
|
||
fleet.MDMAssetCAKey,
|
||
})
|
||
if err != nil {
|
||
return ctxerr.Wrap(ctx, err, "deleting apple mdm assets")
|
||
}
|
||
|
||
// flip the app config flag
|
||
appCfg, err := svc.ds.AppConfig(ctx)
|
||
if err != nil {
|
||
return ctxerr.Wrap(ctx, err, "retrieving app config")
|
||
}
|
||
|
||
appCfg.MDM.EnabledAndConfigured = false
|
||
|
||
return svc.ds.SaveAppConfig(ctx, appCfg)
|
||
}
|