mirror of
https://github.com/fleetdm/fleet
synced 2026-04-21 13:37:30 +00:00
Covers #36760, #36758. # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files) for more information. - [x] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements) ## Testing - [x] Added/updated automated tests - [x] Where appropriate, [automated tests simulate multiple hosts and test for host isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing) (updates to one hosts's records do not affect another) - [ ] QA'd all new/changed functionality manually
3645 lines
128 KiB
Go
3645 lines
128 KiB
Go
package service
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"crypto"
|
||
"crypto/tls"
|
||
"crypto/x509"
|
||
"encoding/json"
|
||
"encoding/pem"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"mime/multipart"
|
||
"net/http"
|
||
"net/url"
|
||
"path/filepath"
|
||
"slices"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/VividCortex/mysqlerr"
|
||
"github.com/docker/go-units"
|
||
"github.com/fleetdm/fleet/v4/pkg/certificate"
|
||
"github.com/fleetdm/fleet/v4/pkg/fleethttp"
|
||
"github.com/fleetdm/fleet/v4/server"
|
||
"github.com/fleetdm/fleet/v4/server/authz"
|
||
authz_ctx "github.com/fleetdm/fleet/v4/server/contexts/authz"
|
||
"github.com/fleetdm/fleet/v4/server/contexts/ctxdb"
|
||
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
|
||
hostctx "github.com/fleetdm/fleet/v4/server/contexts/host"
|
||
"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"
|
||
mdmlifecycle "github.com/fleetdm/fleet/v4/server/mdm/lifecycle"
|
||
"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/fleetdm/fleet/v4/server/worker"
|
||
"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 := certificate.EncodeCertPEM(scepCACert)
|
||
scepCAKeyPEM := certificate.EncodePrivateKeyPEM(scepCAKey)
|
||
apnsKeyPEM := certificate.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
|
||
DryRun bool `query:"dry_run,optional"` // if true, apply validation but do not save changes
|
||
}
|
||
|
||
// 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,
|
||
}
|
||
}
|
||
|
||
dryRun := false
|
||
if v := r.URL.Query().Get("dry_run"); v != "" {
|
||
dryRun, _ = strconv.ParseBool(v)
|
||
}
|
||
return &createMDMEULARequest{
|
||
EULA: r.MultipartForm.File["eula"][0],
|
||
DryRun: dryRun,
|
||
}, 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, req.DryRun); err != nil {
|
||
return createMDMEULAResponse{Err: err}, nil
|
||
}
|
||
|
||
return createMDMEULAResponse{}, nil
|
||
}
|
||
|
||
func (svc *Service) MDMCreateEULA(ctx context.Context, name string, file io.ReadSeeker, dryRun bool) 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 && !fleet.IsNotFound(err) {
|
||
return getMDMEULAMetadataResponse{Err: err}, nil
|
||
}
|
||
|
||
if eula == nil {
|
||
// We return the error here not as part of the response object, to signal an error to the server, but avoid logging it as an error.
|
||
return nil, newNotFoundError()
|
||
}
|
||
|
||
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"`
|
||
DryRun bool `query:"dry_run,optional"` // if true, apply validation but do not delete
|
||
}
|
||
|
||
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, req.DryRun); err != nil {
|
||
return deleteMDMEULAResponse{Err: err}, nil
|
||
}
|
||
return deleteMDMEULAResponse{}, nil
|
||
}
|
||
|
||
func (svc *Service) MDMDeleteEULA(ctx context.Context, token string, dryRun bool) 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.ErrWindowsMDMNotConfigured
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
////////////////////////////////////////////////////////////////////////////////
|
||
// Android MDM Middleware
|
||
////////////////////////////////////////////////////////////////////////////////
|
||
|
||
func (svc *Service) VerifyMDMAndroidConfigured(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
|
||
}
|
||
|
||
// Android MDM configuration setting
|
||
if !appCfg.MDM.AndroidEnabledAndConfigured {
|
||
// skipauth: Authorization is currently for user endpoints only.
|
||
svc.authz.SkipAuthorization(ctx)
|
||
return fleet.ErrAndroidMDMNotConfigured
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// VerifyAnyMDMConfigured checks that at least one MDM platform (Apple, Windows,
|
||
// or Android) is configured so callers that rely on MDM functionality can proceed.
|
||
func (svc *Service) VerifyAnyMDMConfigured(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, Windows, or Android MDM configuration setting
|
||
if !appCfg.MDM.EnabledAndConfigured && !appCfg.MDM.WindowsEnabledAndConfigured && !appCfg.MDM.AndroidEnabledAndConfigured {
|
||
// 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"`
|
||
HostIdentifier string `query:"host_identifier,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, req.HostIdentifier)
|
||
if err != nil {
|
||
return getMDMCommandResultsResponse{
|
||
Err: err,
|
||
}, nil
|
||
}
|
||
|
||
return getMDMCommandResultsResponse{
|
||
Results: results,
|
||
}, nil
|
||
}
|
||
|
||
func (svc *Service) GetMDMCommandResults(ctx context.Context, commandUUID string, hostIdentifier string) ([]*fleet.MDMCommandResult, error) {
|
||
if svc.authz.IsAuthenticatedWith(ctx, authz_ctx.AuthnDeviceToken) ||
|
||
svc.authz.IsAuthenticatedWith(ctx, authz_ctx.AuthnDeviceCertificate) ||
|
||
svc.authz.IsAuthenticatedWith(ctx, authz_ctx.AuthnDeviceURL) {
|
||
return svc.getDeviceSoftwareMDMCommandResults(ctx, commandUUID)
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
|
||
// branch if host identifier is provided to target results for a specific host
|
||
if hostIdentifier != "" {
|
||
return svc.getHostIdentifierMDMCommandResults(ctx, commandUUID, hostIdentifier)
|
||
}
|
||
|
||
// otherwise, load all command results for the command UUID without any host filtering
|
||
results, err := svc.getMDMCommandResults(ctx, commandUUID, "")
|
||
if err != nil {
|
||
return nil, ctxerr.Wrap(ctx, err, "get mdm command results with authz")
|
||
}
|
||
|
||
// 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.
|
||
hostUUIDs := make([]string, len(results))
|
||
for i, res := range results {
|
||
hostUUIDs[i] = res.HostUUID
|
||
}
|
||
|
||
// build team filter for the user from context
|
||
vc, ok := viewer.FromContext(ctx)
|
||
if !ok {
|
||
return nil, fleet.ErrNoContext
|
||
}
|
||
filter := fleet.TeamFilter{User: vc.User, IncludeObserver: true} // observers can view command results for their teams
|
||
|
||
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, and populate software_installed for VPP app installs
|
||
hasInstallApp := false
|
||
for _, res := range results {
|
||
if h := hostsByUUID[res.HostUUID]; h != nil {
|
||
res.Hostname = hostsByUUID[res.HostUUID].Hostname
|
||
}
|
||
|
||
if res.RequestType == "InstallApplication" {
|
||
hasInstallApp = true
|
||
}
|
||
}
|
||
|
||
if hasInstallApp {
|
||
// Get install status for the VPP app
|
||
installed, err := svc.ds.GetVPPAppInstallStatusByCommandUUID(ctx, commandUUID)
|
||
if err != nil {
|
||
level.Debug(svc.logger).Log("msg", "failed to check if VPP app is installed", "err", err, "command_uuid", commandUUID)
|
||
} else {
|
||
for _, res := range results {
|
||
if res.RequestType == "InstallApplication" {
|
||
if res.ResultsMetadata == nil {
|
||
res.ResultsMetadata = make(map[string]any)
|
||
}
|
||
res.ResultsMetadata["software_installed"] = installed
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return results, nil
|
||
}
|
||
|
||
func (svc *Service) getMDMCommandResults(ctx context.Context, commandUUID string, hostUUID string) ([]*fleet.MDMCommandResult, error) {
|
||
// 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, hostUUID)
|
||
case "windows":
|
||
results, err = svc.ds.GetMDMWindowsCommandResults(ctx, commandUUID, hostUUID)
|
||
case "android":
|
||
// TODO(mna): maybe in the future we'll store responses from AMAPI commands, but for
|
||
// now we don't (they are very large), just return an empty list.
|
||
results = []*fleet.MDMCommandResult{}
|
||
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
|
||
}
|
||
|
||
return results, nil
|
||
}
|
||
|
||
func (svc *Service) getDeviceSoftwareMDMCommandResults(ctx context.Context, commandUUID string) ([]*fleet.MDMCommandResult, error) {
|
||
host, ok := hostctx.FromContext(ctx) // includes UUID and hostname so we have what we need to filter results
|
||
if !ok {
|
||
return nil, ctxerr.Wrap(ctx, fleet.NewAuthRequiredError("internal error: missing host from request context"))
|
||
}
|
||
|
||
// zero-length result (command exists but no responses) is different from not-found (no command, command isn't an install, or wrong host)
|
||
results, err := svc.ds.GetVPPCommandResults(ctx, commandUUID, host.UUID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
for _, res := range results {
|
||
res.Hostname = host.Hostname
|
||
}
|
||
|
||
return results, nil
|
||
}
|
||
|
||
func (svc *Service) getHostIdentifierMDMCommandResults(ctx context.Context, commandUUID string, hostIdentifier string) ([]*fleet.MDMCommandResult, error) {
|
||
if hostIdentifier == "" {
|
||
// caller should ensure this doesn't happen, but just in case return an internal error
|
||
return nil, ctxerr.Errorf(ctx, "GetHostMDMCommandResults called without host identifier")
|
||
}
|
||
|
||
if commandUUID == "" {
|
||
return nil, fleet.NewInvalidArgumentError(
|
||
"command_uuid",
|
||
"command_uuid is required when host_identifier is provided",
|
||
).WithStatus(http.StatusBadRequest)
|
||
}
|
||
|
||
// build team filter for the user from context
|
||
vc, ok := viewer.FromContext(ctx)
|
||
if !ok {
|
||
return nil, fleet.ErrNoContext
|
||
}
|
||
tmFilter := fleet.TeamFilter{User: vc.User, IncludeObserver: true} // observers can view command results for their teams
|
||
|
||
hi, err := svc.ds.GetHostMDMIdentifiers(ctx, hostIdentifier, tmFilter)
|
||
switch {
|
||
case err != nil:
|
||
return nil, ctxerr.Wrap(ctx, err, "get host mdm identifiers")
|
||
case len(hi) == 0:
|
||
return nil, fleet.NewInvalidArgumentError(
|
||
"host_identifier",
|
||
"no host found with the provided identifier",
|
||
).WithStatus(http.StatusNotFound)
|
||
case len(hi) > 1:
|
||
// FIXME: determine what to do in this unexpected case; for now just log it and use the first one.
|
||
level.Debug(svc.logger).Log("msg", "multiple hosts found for host identifier", "host_identifier", hostIdentifier, "count", len(hi))
|
||
}
|
||
|
||
// authorize that the user can read commands for the host's team
|
||
if err := svc.authz.Authorize(ctx, fleet.MDMCommandAuthz{TeamID: hi[0].TeamID}, fleet.ActionRead); err != nil {
|
||
return nil, ctxerr.Wrap(ctx, err)
|
||
}
|
||
|
||
results, err := svc.getMDMCommandResults(ctx, commandUUID, hi[0].UUID)
|
||
if err != nil {
|
||
return nil, ctxerr.Wrap(ctx, err, "get mdm command results by platform")
|
||
}
|
||
|
||
// FIXME: we could potentially pass the hostname to the datastore method and plug it into the
|
||
// SELECT to avoid this loop, but for now we're following the same pattern as in
|
||
// GetMDMCommandResults and we're only expecting a single result here, so not a
|
||
// big deal.
|
||
for _, res := range results {
|
||
res.Hostname = hi[0].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"`
|
||
CommandStatus string `query:"command_status,optional"`
|
||
}
|
||
|
||
type listMDMCommandsResponse struct {
|
||
Meta *fleet.PaginationMetadata `json:"meta"`
|
||
Count *int64 `json:"count"`
|
||
Results []*fleet.MDMCommand `json:"results"`
|
||
Err error `json:"error,omitempty"`
|
||
}
|
||
|
||
func (r listMDMCommandsResponse) Error() error { return r.Err }
|
||
|
||
// We the DecodeBody method to perform custom validation before hitting the service layer.
|
||
func (req listMDMCommandsRequest) DecodeBody(ctx context.Context, r io.Reader, u url.Values, c []*x509.Certificate) error {
|
||
if req.CommandStatus != "" && req.HostIdentifier == "" {
|
||
return &fleet.BadRequestError{
|
||
Message: `"host_identifier" must be specified when filtering by "command_status".`,
|
||
}
|
||
}
|
||
|
||
if req.CommandStatus != "" {
|
||
statuses := strings.Split(req.CommandStatus, ",")
|
||
failed := false
|
||
for _, status := range statuses {
|
||
status = strings.TrimSpace(status)
|
||
if !slices.Contains(fleet.AllMDMCommandStatusFilters, fleet.MDMCommandStatusFilter(status)) {
|
||
failed = true
|
||
break
|
||
}
|
||
}
|
||
|
||
if failed {
|
||
allowed := make([]string, len(fleet.AllMDMCommandStatusFilters))
|
||
for i, v := range fleet.AllMDMCommandStatusFilters {
|
||
allowed[i] = string(v)
|
||
}
|
||
return &fleet.BadRequestError{
|
||
Message: fmt.Sprintf("command_status only accepts the following values: %s", strings.Join(allowed, ", ")),
|
||
}
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
func listMDMCommandsEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
||
req := request.(*listMDMCommandsRequest)
|
||
|
||
// Convert comma-separated command statuses into a list of typed values
|
||
commandStatuses := []fleet.MDMCommandStatusFilter{}
|
||
if req.CommandStatus != "" {
|
||
for val := range strings.SplitSeq(req.CommandStatus, ",") {
|
||
commandStatuses = append(commandStatuses, fleet.MDMCommandStatusFilter(strings.TrimSpace(val)))
|
||
}
|
||
}
|
||
|
||
req.ListOptions.IncludeMetadata = true
|
||
|
||
results, total, meta, err := svc.ListMDMCommands(ctx, &fleet.MDMCommandListOptions{
|
||
ListOptions: req.ListOptions,
|
||
Filters: fleet.MDMCommandFilters{HostIdentifier: req.HostIdentifier, RequestType: req.RequestType, CommandStatuses: commandStatuses},
|
||
})
|
||
bre := &fleet.BadRequestError{}
|
||
if err != nil && !errors.As(err, &bre) {
|
||
return listMDMCommandsResponse{
|
||
Err: err,
|
||
}, nil
|
||
} else if errors.As(err, &bre) {
|
||
return nil, err
|
||
}
|
||
|
||
return listMDMCommandsResponse{
|
||
Meta: meta,
|
||
Results: results,
|
||
Count: total,
|
||
}, nil
|
||
}
|
||
|
||
func (svc *Service) ListMDMCommands(ctx context.Context, opts *fleet.MDMCommandListOptions) ([]*fleet.MDMCommand, *int64, *fleet.PaginationMetadata, 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, nil, nil, ctxerr.Wrap(ctx, err)
|
||
}
|
||
|
||
vc, ok := viewer.FromContext(ctx)
|
||
if !ok {
|
||
return nil, nil, 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, total, meta, err := svc.ds.ListMDMCommands(ctx, fleet.TeamFilter{
|
||
User: vc.User,
|
||
IncludeObserver: true,
|
||
}, opts)
|
||
if err != nil {
|
||
return nil, nil, 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, nil, nil, fleet.NewInvalidArgumentError("Invalid Host", fleet.HostIdentiferNotFound).WithStatus(http.StatusNotFound)
|
||
}
|
||
}
|
||
return results, total, meta, 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{}
|
||
|
||
appleStatus, err := svc.GetMDMAppleProfilesSummary(ctx, req.TeamID)
|
||
if err != nil {
|
||
return &getMDMAppleProfilesSummaryResponse{Err: err}, nil
|
||
}
|
||
|
||
windowsStatus, err := svc.GetMDMWindowsProfilesSummary(ctx, req.TeamID)
|
||
if err != nil {
|
||
return &getMDMProfilesSummaryResponse{Err: err}, nil
|
||
}
|
||
|
||
linuxStatus, err := svc.GetMDMLinuxProfilesSummary(ctx, req.TeamID)
|
||
if err != nil {
|
||
return &getMDMProfilesSummaryResponse{Err: err}, nil
|
||
}
|
||
|
||
androidStatus, err := svc.GetMDMAndroidProfilesSummary(ctx, req.TeamID)
|
||
if err != nil {
|
||
return &getMDMProfilesSummaryResponse{Err: err}, nil
|
||
}
|
||
|
||
res.Verified = appleStatus.Verified + windowsStatus.Verified + linuxStatus.Verified + androidStatus.Verified
|
||
res.Verifying = appleStatus.Verifying + windowsStatus.Verifying + androidStatus.Verifying
|
||
res.Failed = appleStatus.Failed + windowsStatus.Failed + linuxStatus.Failed + androidStatus.Failed
|
||
res.Pending = appleStatus.Pending + windowsStatus.Pending + linuxStatus.Pending + androidStatus.Pending
|
||
|
||
return &res, nil
|
||
}
|
||
|
||
func (svc *Service) GetMDMAndroidProfilesSummary(ctx context.Context, teamID *uint) (*fleet.MDMProfilesSummary, error) {
|
||
if err := svc.authz.Authorize(ctx, fleet.MDMConfigProfileAuthz{TeamID: teamID}, fleet.ActionRead); err != nil {
|
||
return nil, ctxerr.Wrap(ctx, err)
|
||
}
|
||
|
||
if err := svc.VerifyMDMAndroidConfigured(ctx); err != nil {
|
||
return &fleet.MDMProfilesSummary{}, nil
|
||
}
|
||
|
||
ps, err := svc.ds.GetMDMAndroidProfilesSummary(ctx, teamID)
|
||
if err != nil {
|
||
return nil, ctxerr.Wrap(ctx, err)
|
||
}
|
||
|
||
return ps, 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
|
||
}
|
||
|
||
if isAndroidProfileUUID(req.ProfileUUID) {
|
||
// TODO: we could potentially combined with the other service methods
|
||
prof, err := svc.GetMDMAndroidConfigProfile(ctx, req.ProfileUUID)
|
||
if err != nil {
|
||
return &getMDMConfigProfileResponse{Err: err}, nil
|
||
}
|
||
|
||
if downloadRequested {
|
||
return downloadFileResponse{
|
||
content: prof.RawJSON,
|
||
contentType: "application/json",
|
||
filename: fmt.Sprintf("%s_%s.json", time.Now().Format("2006-01-02"), strings.ReplaceAll(prof.Name, " ", "_")),
|
||
}, nil
|
||
}
|
||
return &getMDMConfigProfileResponse{
|
||
MDMConfigProfilePayload: fleet.NewMDMConfigProfilePayloadFromAndroid(prof),
|
||
}, 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 if isAndroidProfileUUID(req.ProfileUUID) {
|
||
err = svc.DeleteMDMAndroidConfigProfile(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)
|
||
}
|
||
|
||
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)
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
func (svc *Service) GetMDMAndroidConfigProfile(ctx context.Context, profileUUID string) (*fleet.MDMAndroidConfigProfile, 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.GetMDMAndroidConfigProfile(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
|
||
}
|
||
|
||
func (svc *Service) DeleteMDMAndroidConfigProfile(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)
|
||
}
|
||
|
||
prof, err := svc.ds.GetMDMAndroidConfigProfile(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.DeleteMDMAndroidConfigProfile(ctx, profileUUID); err != nil {
|
||
return ctxerr.Wrap(ctx, err)
|
||
}
|
||
|
||
// TODO AP review this activity
|
||
var (
|
||
actTeamID *uint
|
||
actTeamName *string
|
||
)
|
||
if teamID > 0 {
|
||
actTeamID = &teamID
|
||
actTeamName = &teamName
|
||
}
|
||
if err := svc.NewActivity(
|
||
ctx, authz.UserFromContext(ctx), &fleet.ActivityTypeDeletedAndroidProfile{
|
||
TeamID: actTeamID,
|
||
TeamName: actTeamName,
|
||
ProfileName: prof.Name,
|
||
}); err != nil {
|
||
return ctxerr.Wrap(ctx, err, "logging activity for delete mdm android 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, fleet.MDMAppleProfileUUIDPrefix)
|
||
}
|
||
|
||
func isAppleDeclarationUUID(profileUUID string) bool {
|
||
return strings.HasPrefix(profileUUID, fleet.MDMAppleDeclarationUUIDPrefix)
|
||
}
|
||
|
||
func isAndroidProfileUUID(profileUUID string) bool {
|
||
return strings.HasPrefix(profileUUID, fleet.MDMAndroidProfileUUIDPrefix)
|
||
}
|
||
|
||
////////////////////////////////////////////////////////////////////////////////
|
||
// 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()
|
||
|
||
data, err := io.ReadAll(ff)
|
||
if err != nil {
|
||
return &newMDMConfigProfileResponse{Err: err}, nil
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
isAppleDeclarationJSON, isAndroidJSON := false, false
|
||
if isJSON {
|
||
isAppleDeclarationJSON, isAndroidJSON, err = mdm.DetermineJSONConfigType(data)
|
||
if err != nil {
|
||
if strings.Contains(string(data), fleet.ServerSecretPrefix) {
|
||
// Apple DDM configs allow replacing the entire file's contents via a secret. These
|
||
// cannot be validated however Android does not currently support secrets so if the
|
||
// JSON fails to parse AND it contains the server secret prefix, it is considered
|
||
// DDM and DDM validation path will handle it
|
||
// TODO AP Revisit this decision
|
||
isAppleDeclarationJSON = true
|
||
isAndroidJSON = false
|
||
} else {
|
||
return &newMDMConfigProfileResponse{Err: svc.NewMDMInvalidJSONConfigProfile(ctx, req.TeamID, err)}, nil
|
||
}
|
||
}
|
||
}
|
||
|
||
if isMobileConfig || isAppleDeclarationJSON {
|
||
// Then it's an Apple configuration file
|
||
if isJSON {
|
||
decl, err := svc.NewMDMAppleDeclaration(ctx, req.TeamID, data, 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, data, labels, labelsMode)
|
||
if err != nil {
|
||
return &newMDMConfigProfileResponse{Err: err}, nil
|
||
}
|
||
return &newMDMConfigProfileResponse{
|
||
ProfileUUID: cp.ProfileUUID,
|
||
}, nil
|
||
}
|
||
|
||
if isAndroidJSON {
|
||
cp, err := svc.NewMDMAndroidConfigProfile(ctx, req.TeamID, profileName, data, 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, data, 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) NewMDMInvalidJSONConfigProfile(ctx context.Context, teamID uint, err error) 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.NewInvalidArgumentError("profile", err.Error()).WithStatus(http.StatusBadRequest)
|
||
}
|
||
|
||
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) NewMDMAndroidConfigProfile(ctx context.Context, teamID uint, profileName string, data []byte, labels []string, labelsMembershipMode fleet.MDMLabelsMode) (*fleet.MDMAndroidConfigProfile, error) {
|
||
if err := svc.authz.Authorize(ctx, &fleet.MDMConfigProfileAuthz{TeamID: &teamID}, fleet.ActionWrite); err != nil {
|
||
return nil, ctxerr.Wrap(ctx, err)
|
||
}
|
||
|
||
// check that Android MDM is enabled - the middleware of the endpoint checks
|
||
// only that any MDM is enabled, maybe it's just macOS
|
||
if err := svc.VerifyMDMAndroidConfigured(ctx); err != nil {
|
||
err := fleet.NewInvalidArgumentError("profile", fleet.AndroidMDMNotConfiguredMessage).WithStatus(http.StatusBadRequest)
|
||
return nil, ctxerr.Wrap(ctx, err, "check android 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
|
||
}
|
||
|
||
cp := fleet.MDMAndroidConfigProfile{
|
||
TeamID: &teamID,
|
||
Name: profileName,
|
||
RawJSON: data,
|
||
}
|
||
if err := cp.ValidateUserProvided(license.IsPremium(ctx)); err != nil {
|
||
err := &fleet.BadRequestError{Message: "Couldn't add. " + err.Error()}
|
||
return nil, ctxerr.Wrap(ctx, err, "validate profile")
|
||
}
|
||
|
||
labelMap, err := svc.validateProfileLabels(ctx, &teamID, 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
|
||
}
|
||
|
||
newCP, err := svc.ds.NewMDMAndroidConfigProfile(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)
|
||
}
|
||
|
||
var (
|
||
actTeamID *uint
|
||
actTeamName *string
|
||
)
|
||
if teamID > 0 {
|
||
actTeamID = &teamID
|
||
actTeamName = &teamName
|
||
}
|
||
// TODO AP Make sure this activity is correct
|
||
if err := svc.NewActivity(
|
||
ctx, authz.UserFromContext(ctx), &fleet.ActivityTypeCreatedAndroidProfile{
|
||
TeamID: actTeamID,
|
||
TeamName: actTeamName,
|
||
ProfileName: newCP.Name,
|
||
}); err != nil {
|
||
return nil, ctxerr.Wrap(ctx, err, "logging activity for create mdm android config profile")
|
||
}
|
||
|
||
return newCP, nil
|
||
}
|
||
|
||
func (svc *Service) batchValidateProfileLabels(ctx context.Context, teamID *uint, labelNames []string) (map[string]fleet.ConfigurationProfileLabel, error) {
|
||
if len(labelNames) == 0 {
|
||
return nil, nil
|
||
}
|
||
|
||
labels, err := svc.ds.LabelIDsByName(ctx, labelNames, fleet.TeamFilter{User: authz.UserFromContext(ctx)})
|
||
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),
|
||
}
|
||
}
|
||
|
||
// NOTE(lucas): To not break API error string returned above
|
||
// AND for code reusability we are a-ok with loading labels again in verifyLabelsToAssociate.
|
||
// This can definitely be optimized if need be.
|
||
if err := verifyLabelsToAssociate(ctx, svc.ds, teamID, labelNames, authz.UserFromContext(ctx)); err != nil {
|
||
return nil, ctxerr.Wrap(ctx, err, "verify labels to associate")
|
||
}
|
||
|
||
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, teamID *uint, labelNames []string) ([]fleet.ConfigurationProfileLabel, error) {
|
||
labelMap, err := svc.batchValidateProfileLabels(ctx, teamID, 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
|
||
}
|
||
|
||
type batchModifyMDMConfigProfilesRequest 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
|
||
ConfigurationProfiles []fleet.BatchModifyMDMConfigProfilePayload `json:"configuration_profiles"`
|
||
}
|
||
|
||
type batchModifyMDMConfigProfilesResponse struct {
|
||
Err error `json:"error,omitempty"`
|
||
}
|
||
|
||
func (r batchModifyMDMConfigProfilesResponse) Error() error { return r.Err }
|
||
func (r batchModifyMDMConfigProfilesResponse) Status() int { return http.StatusNoContent }
|
||
|
||
// this is the handler for the public endpoint for batch modifying MDM profiles.
|
||
func batchModifyMDMConfigProfilesEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
||
req := request.(*batchModifyMDMConfigProfilesRequest)
|
||
|
||
// We want to still use the existing BatchSetMDMProfiles method, so we convert
|
||
// the payload to the expected type.
|
||
profiles := make([]fleet.MDMProfileBatchPayload, len(req.ConfigurationProfiles))
|
||
for i, p := range req.ConfigurationProfiles {
|
||
profiles[i] = fleet.MDMProfileBatchPayload{
|
||
Name: p.DisplayName,
|
||
Contents: p.Profile,
|
||
LabelsIncludeAll: p.LabelsIncludeAll,
|
||
LabelsIncludeAny: p.LabelsIncludeAny,
|
||
LabelsExcludeAny: p.LabelsExcludeAny,
|
||
}
|
||
}
|
||
if err := svc.BatchSetMDMProfiles(ctx, req.TeamID, req.TeamName, profiles, req.DryRun, false, nil, false); err != nil {
|
||
return batchSetMDMProfilesResponse{Err: err}, nil
|
||
}
|
||
return batchModifyMDMConfigProfilesResponse{}, 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 bool,
|
||
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...)
|
||
}
|
||
var labelMap map[string]fleet.ConfigurationProfileLabel
|
||
if !dryRun {
|
||
labelMap, err = svc.batchValidateProfileLabels(ctx, tmID, 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, svc.config.MDM.EnableCustomOSUpdatesAndFileVault)
|
||
if err != nil {
|
||
return ctxerr.Wrap(ctx, err, "validating macOS profiles")
|
||
}
|
||
|
||
windowsProfiles, err := getWindowsProfiles(ctx, tmID, appCfg, profilesWithSecrets, labelMap, svc.config.MDM.EnableCustomOSUpdatesAndFileVault)
|
||
if err != nil {
|
||
return ctxerr.Wrap(ctx, err, "validating Windows profiles")
|
||
}
|
||
|
||
androidProfiles, err := getAndroidProfiles(ctx, tmID, appCfg, profilesWithSecrets, labelMap)
|
||
if err != nil {
|
||
return ctxerr.Wrap(ctx, err, "validating Android profiles")
|
||
}
|
||
|
||
if err := svc.validateCrossPlatformProfileNames(ctx, appleProfiles, windowsProfiles, appleDecls, androidProfiles); err != nil {
|
||
return ctxerr.Wrap(ctx, err, "validating cross-platform profile names")
|
||
}
|
||
|
||
if dryRun {
|
||
return nil
|
||
}
|
||
|
||
// Get license for validation
|
||
lic, err := svc.License(ctx)
|
||
if err != nil {
|
||
return ctxerr.Wrap(ctx, err, "checking license")
|
||
}
|
||
|
||
profilesVariablesByIdentifierMap, err := validateFleetVariables(ctx, svc.ds, appCfg, lic, appleProfiles, windowsProfiles, appleDecls)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
profilesVariablesByIdentifier := make([]fleet.MDMProfileIdentifierFleetVariables, 0, len(profilesVariablesByIdentifierMap))
|
||
for identifier, variables := range profilesVariablesByIdentifierMap {
|
||
varNames := make([]fleet.FleetVarName, 0, len(variables))
|
||
for _, varName := range variables {
|
||
varNames = append(varNames, fleet.FleetVarName(varName))
|
||
}
|
||
profilesVariablesByIdentifier = append(profilesVariablesByIdentifier, fleet.MDMProfileIdentifierFleetVariables{
|
||
Identifier: identifier,
|
||
FleetVariables: varNames,
|
||
})
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
androidProfilesSlice := make([]*fleet.MDMAndroidConfigProfile, 0, len(androidProfiles))
|
||
for i, p := range androidProfiles {
|
||
p.RawJSON = profiles[i].Contents
|
||
androidProfilesSlice = append(androidProfilesSlice, p)
|
||
}
|
||
|
||
var profUpdates fleet.MDMProfilesUpdates
|
||
if profUpdates, err = svc.ds.BatchSetMDMProfiles(ctx, tmID,
|
||
appleProfilesSlice, windowsProfilesSlice, appleDeclsSlice, androidProfilesSlice, profilesVariablesByIdentifier); 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")
|
||
}
|
||
|
||
androidProfUUIDs := []string{}
|
||
for _, p := range androidProfiles {
|
||
androidProfUUIDs = append(androidProfUUIDs, p.ProfileUUID)
|
||
}
|
||
androidUpdates, err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, androidProfUUIDs, nil)
|
||
if err != nil {
|
||
return ctxerr.Wrap(ctx, err, "bulk set pending android host profiles")
|
||
}
|
||
|
||
updates := fleet.MDMProfilesUpdates{
|
||
AppleConfigProfile: profUpdates.AppleConfigProfile || appleUpdates.AppleConfigProfile,
|
||
WindowsConfigProfile: profUpdates.WindowsConfigProfile || winUpdates.WindowsConfigProfile,
|
||
AppleDeclaration: profUpdates.AppleDeclaration || appleUpdates.AppleDeclaration,
|
||
AndroidConfigProfile: profUpdates.AndroidConfigProfile || androidUpdates.AndroidConfigProfile,
|
||
}
|
||
|
||
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")
|
||
}
|
||
}
|
||
if updates.AndroidConfigProfile {
|
||
if err := svc.NewActivity(
|
||
ctx, authz.UserFromContext(ctx), &fleet.ActivityTypeEditedAndroidProfile{
|
||
TeamID: tmID,
|
||
TeamName: tmName,
|
||
}); err != nil {
|
||
return ctxerr.Wrap(ctx, err, "logging activity for edited android profile")
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
func validateFleetVariables(ctx context.Context, ds fleet.Datastore, appConfig *fleet.AppConfig, lic *fleet.LicenseInfo, appleProfiles map[int]*fleet.MDMAppleConfigProfile,
|
||
windowsProfiles map[int]*fleet.MDMWindowsConfigProfile, appleDecls map[int]*fleet.MDMAppleDeclaration,
|
||
) (map[string][]string, error) {
|
||
var err error
|
||
|
||
groupedCAs, err := ds.GetGroupedCertificateAuthorities(ctx, true)
|
||
if err != nil {
|
||
return nil, ctxerr.Wrap(ctx, err, "getting grouped certificate authorities")
|
||
}
|
||
|
||
profileVarsByProfIdentifier := make(map[string][]string)
|
||
for _, p := range appleProfiles {
|
||
profileVars, err := validateConfigProfileFleetVariables(string(p.Mobileconfig), lic, groupedCAs)
|
||
if err != nil {
|
||
return nil, ctxerr.Wrap(ctx, err, "validating config profile Fleet variables")
|
||
}
|
||
profileVarsByProfIdentifier[p.Identifier] = profileVars
|
||
}
|
||
for _, p := range windowsProfiles {
|
||
windowsVars, err := validateWindowsProfileFleetVariables(string(p.SyncML), lic, groupedCAs)
|
||
if err != nil {
|
||
return nil, ctxerr.Wrap(ctx, err, "validating Windows profile Fleet variables")
|
||
}
|
||
// Collect Fleet variables for Windows profiles (use unique Name as identifier for Windows)
|
||
if len(windowsVars) > 0 {
|
||
profileVarsByProfIdentifier[p.Name] = windowsVars
|
||
}
|
||
}
|
||
for _, p := range appleDecls {
|
||
err = validateDeclarationFleetVariables(string(p.RawJSON))
|
||
if err != nil {
|
||
return nil, ctxerr.Wrap(ctx, err, "validating declaration Fleet variables")
|
||
}
|
||
}
|
||
return profileVarsByProfIdentifier, nil
|
||
}
|
||
|
||
func (svc *Service) validateCrossPlatformProfileNames(ctx context.Context, appleProfiles map[int]*fleet.MDMAppleConfigProfile,
|
||
windowsProfiles map[int]*fleet.MDMWindowsConfigProfile, appleDecls map[int]*fleet.MDMAppleDeclaration, androidProfiles map[int]*fleet.MDMAndroidConfigProfile,
|
||
) 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)+len(androidProfiles))
|
||
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"
|
||
}
|
||
for i, p := range androidProfiles {
|
||
if _, ok := extByName[p.Name]; ok {
|
||
err := fleet.NewInvalidArgumentError(fmt.Sprintf("androidProfiles[%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,
|
||
allowCustomOSUpdatesAndFileVault bool,
|
||
) (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(allowCustomOSUpdatesAndFileVault); 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)
|
||
if err != nil {
|
||
return nil, nil, ctxerr.Wrap(ctx,
|
||
fleet.NewInvalidArgumentError(prof.Name, err.Error()),
|
||
"invalid mobileconfig profile")
|
||
}
|
||
mdmProf.SecretsUpdatedAt = prof.SecretsUpdatedAt
|
||
|
||
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(allowCustomOSUpdatesAndFileVault); 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)
|
||
}
|
||
|
||
// Don't validate name here since we always use the PayloadDisplayName from the profile.
|
||
|
||
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.ErrMDMNotConfigured.Error()))
|
||
}
|
||
|
||
return profs, decls, nil
|
||
}
|
||
|
||
func getWindowsProfiles(
|
||
ctx context.Context,
|
||
tmID *uint,
|
||
appCfg *fleet.AppConfig,
|
||
profiles map[int]fleet.MDMProfileBatchPayload,
|
||
labelMap map[string]fleet.ConfigurationProfileLabel,
|
||
enableCustomOSUpdatesAndFileVault bool,
|
||
) (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(enableCustomOSUpdatesAndFileVault); 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.ErrWindowsMDMNotConfigured.Error()))
|
||
}
|
||
|
||
return profs, nil
|
||
}
|
||
|
||
func getAndroidProfiles(ctx context.Context,
|
||
tmID *uint,
|
||
appCfg *fleet.AppConfig,
|
||
profiles map[int]fleet.MDMProfileBatchPayload,
|
||
labelMap map[string]fleet.ConfigurationProfileLabel,
|
||
) (map[int]*fleet.MDMAndroidConfigProfile, error) {
|
||
profs := make(map[int]*fleet.MDMAndroidConfigProfile, len(profiles))
|
||
for i, profile := range profiles {
|
||
if mdm.GetRawProfilePlatform(profile.Contents) != "android" {
|
||
continue
|
||
}
|
||
mdmProf := &fleet.MDMAndroidConfigProfile{
|
||
TeamID: tmID,
|
||
Name: profile.Name,
|
||
RawJSON: 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(license.IsPremium(ctx)); err != nil {
|
||
msg := err.Error()
|
||
return nil, ctxerr.Wrap(ctx,
|
||
fleet.NewInvalidArgumentError(fmt.Sprintf("profiles[%s]", profile.Name), msg))
|
||
}
|
||
|
||
profs[i] = mdmProf
|
||
}
|
||
|
||
if !appCfg.MDM.AndroidEnabledAndConfigured {
|
||
// 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: Android 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" && platform != "android" {
|
||
// 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, windows_settings or android_settings. We should expect to 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, Windows, or Android 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. "+
|
||
"Android profiles must be valid .json files.")
|
||
}
|
||
}
|
||
|
||
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.TeamLite(ctx, *teamID); err != nil { // TODO see if we can use TeamExists here instead
|
||
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"`
|
||
RequireBitLockerPIN bool `json:"windows_require_bitlocker_pin"`
|
||
}
|
||
|
||
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, &req.RequireBitLockerPIN); err != nil {
|
||
return updateMDMDiskEncryptionResponse{Err: err}, nil
|
||
}
|
||
return updateMDMDiskEncryptionResponse{}, nil
|
||
}
|
||
|
||
func (svc *Service) UpdateMDMDiskEncryption(ctx context.Context, teamID *uint, enableDiskEncryption *bool, requireBitLockerPIN *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, requireBitLockerPIN)
|
||
}
|
||
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)
|
||
}
|
||
|
||
profileTeamID, profileName, err := getProfileToResendDetails(ctx, profileUUID, svc, host)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// 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")
|
||
}
|
||
|
||
err = checkAndResendHostMDMProfile(ctx, svc, host, profileUUID, profileName)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
func (svc *Service) ResendDeviceHostMDMProfile(ctx context.Context, host *fleet.Host, profileUUID string) error {
|
||
_, profileName, err := getProfileToResendDetails(ctx, profileUUID, svc, host)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
err = checkAndResendHostMDMProfile(ctx, svc, host, profileUUID, profileName)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
func checkAndResendHostMDMProfile(ctx context.Context, svc *Service, host *fleet.Host, profileUUID string, profileName string) error {
|
||
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
|
||
}
|
||
|
||
// getPRofileToResendDetails returns the team ID and name of the profile to be resent.
|
||
func getProfileToResendDetails(ctx context.Context, profileUUID string, svc *Service, host *fleet.Host) (*uint, string, error) {
|
||
var profileTeamID *uint
|
||
var profileName string
|
||
switch {
|
||
case strings.HasPrefix(profileUUID, fleet.MDMAppleProfileUUIDPrefix):
|
||
if err := svc.VerifyMDMAppleConfigured(ctx); err != nil {
|
||
return nil, "", 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 nil, "", 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 nil, "", ctxerr.Wrap(ctx, err, "getting apple config profile")
|
||
}
|
||
profileTeamID = prof.TeamID
|
||
profileName = prof.Name
|
||
|
||
case strings.HasPrefix(profileUUID, fleet.MDMAppleDeclarationUUIDPrefix):
|
||
return nil, "", ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("HostMDMProfile", fleet.CantResendAppleDeclarationProfilesMessage).WithStatus(http.StatusBadRequest), "check apple declaration resend")
|
||
case strings.HasPrefix(profileUUID, fleet.MDMWindowsProfileUUIDPrefix):
|
||
if err := svc.VerifyMDMWindowsConfigured(ctx); err != nil {
|
||
return nil, "", ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("HostMDMProfile", fleet.WindowsMDMNotConfiguredMessage).WithStatus(http.StatusBadRequest), "check windows mdm enabled")
|
||
}
|
||
if host.Platform != "windows" {
|
||
return nil, "", 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 nil, "", ctxerr.Wrap(ctx, err, "getting windows config profile")
|
||
}
|
||
profileTeamID = prof.TeamID
|
||
profileName = prof.Name
|
||
default:
|
||
return nil, "", ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("HostMDMProfile", "Invalid profile UUID prefix.").WithStatus(http.StatusNotFound), "check profile UUID prefix")
|
||
}
|
||
return profileTeamID, profileName, 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: certificate.EncodeCertPEM(scepCert),
|
||
fleet.MDMAssetCAKey: certificate.EncodePrivateKeyPEM(scepKey),
|
||
fleet.MDMAssetAPNSKey: certificate.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: "Couldn't connect. Please download the certificate signing request (CSR) first, then upload APNs certificate.",
|
||
}, "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 {
|
||
// Delete the file vault profile first, to ensure we get updated keys.
|
||
if err := svc.EnterpriseOverrides.MDMAppleDisableFileVaultAndEscrow(ctx, nil); err != nil && !fleet.IsNotFound(err) {
|
||
return ctxerr.Wrap(ctx, err, "delete no-team FileVault profile")
|
||
}
|
||
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 {
|
||
diskEncryptionConfig, err := svc.ds.GetConfigEnableDiskEncryption(ctx, &team.ID)
|
||
if err != nil {
|
||
return ctxerr.Wrap(ctx, err, "retrieving encryption enforcement status for team")
|
||
}
|
||
if diskEncryptionConfig.Enabled {
|
||
// Delete the file vault profile first, to ensure we get updated keys.
|
||
if err := svc.EnterpriseOverrides.MDMAppleDisableFileVaultAndEscrow(ctx, &team.ID); err != nil && !fleet.IsNotFound(err) {
|
||
return ctxerr.Wrap(ctx, err, "delete team FileVault profile")
|
||
}
|
||
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
|
||
|
||
if err := svc.ds.SaveAppConfig(ctx, appCfg); err != nil {
|
||
return ctxerr.Wrap(ctx, err, "saving app config")
|
||
}
|
||
|
||
// If an install doesn't have a verification_at or verification_failed_at, then
|
||
// mark it as failed
|
||
if err := svc.ds.MarkAllPendingAppleVPPAndInHouseInstallsAsFailed(ctx, worker.AppleSoftwareJobName); err != nil {
|
||
return ctxerr.Wrap(ctx, err, "marking all pending vpp installs as failed")
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
////////////////////////////////////////////////////////////////////////////////
|
||
// POST /configuration_profiles/resend/batch
|
||
////////////////////////////////////////////////////////////////////////////////
|
||
|
||
type batchResendMDMProfileToHostsRequest struct {
|
||
ProfileUUID string `json:"profile_uuid"`
|
||
Filters struct {
|
||
ProfileStatus string `json:"profile_status"`
|
||
} `json:"filters"`
|
||
}
|
||
|
||
type batchResendMDMProfileToHostsResponse struct {
|
||
Err error `json:"error,omitempty"`
|
||
}
|
||
|
||
func (r batchResendMDMProfileToHostsResponse) Error() error { return r.Err }
|
||
|
||
func (r batchResendMDMProfileToHostsResponse) Status() int { return http.StatusAccepted }
|
||
|
||
func batchResendMDMProfileToHostsEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
||
req := request.(*batchResendMDMProfileToHostsRequest)
|
||
|
||
if err := svc.BatchResendMDMProfileToHosts(ctx, req.ProfileUUID, fleet.BatchResendMDMProfileFilters{
|
||
ProfileStatus: fleet.MDMDeliveryStatus(req.Filters.ProfileStatus),
|
||
}); err != nil {
|
||
return batchResendMDMProfileToHostsResponse{Err: err}, nil
|
||
}
|
||
return batchResendMDMProfileToHostsResponse{}, nil
|
||
}
|
||
|
||
func (svc *Service) BatchResendMDMProfileToHosts(ctx context.Context, profileUUID string, filters fleet.BatchResendMDMProfileFilters) error {
|
||
// do a basic authz check that before we can make a more specific check based
|
||
// on the team of the profile (just to ensure the user has _some_
|
||
// authorization before loading the profile).
|
||
if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionSelectiveList); err != nil {
|
||
return ctxerr.Wrap(ctx, err)
|
||
}
|
||
|
||
switch filters.ProfileStatus {
|
||
case fleet.MDMDeliveryFailed:
|
||
// ok, only supported filter for now
|
||
default:
|
||
return &fleet.BadRequestError{
|
||
Message: "Invalid profile_status filter value, only 'failed' is currently supported.",
|
||
}
|
||
}
|
||
|
||
// get the profile to get the team it belongs to
|
||
var (
|
||
teamID *uint
|
||
profileName string
|
||
)
|
||
switch {
|
||
case strings.HasPrefix(profileUUID, fleet.MDMAppleProfileUUIDPrefix):
|
||
if err := svc.VerifyMDMAppleConfigured(ctx); err != nil {
|
||
return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("profile_uuid", fleet.AppleMDMNotConfiguredMessage).WithStatus(http.StatusBadRequest), "check apple MDM enabled")
|
||
}
|
||
|
||
prof, err := svc.ds.GetMDMAppleConfigProfile(ctx, profileUUID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
teamID = prof.TeamID
|
||
profileName = prof.Name
|
||
|
||
case strings.HasPrefix(profileUUID, fleet.MDMWindowsProfileUUIDPrefix):
|
||
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 err
|
||
}
|
||
teamID = prof.TeamID
|
||
profileName = prof.Name
|
||
|
||
case strings.HasPrefix(profileUUID, fleet.MDMAppleDeclarationUUIDPrefix):
|
||
return &fleet.BadRequestError{
|
||
Message: "Can't resend declaration (DDM) profiles. Unlike configuration profiles (.mobileconfig), the host automatically checks in to get the latest DDM profiles.",
|
||
}
|
||
|
||
// TODO AP Update this method for Android
|
||
default:
|
||
return fleet.NewInvalidArgumentError("profile_uuid", "unknown profile").WithStatus(http.StatusNotFound)
|
||
}
|
||
|
||
// now we can do a write authz check based on team id of the profile
|
||
if err := svc.authz.Authorize(ctx, &fleet.MDMConfigProfileAuthz{TeamID: teamID}, fleet.ActionWrite); err != nil {
|
||
return ctxerr.Wrap(ctx, err)
|
||
}
|
||
|
||
count, err := svc.ds.BatchResendMDMProfileToHosts(ctx, profileUUID, filters)
|
||
if err != nil {
|
||
return ctxerr.Wrap(ctx, err)
|
||
}
|
||
|
||
if count > 0 {
|
||
if err := svc.NewActivity(
|
||
ctx, authz.UserFromContext(ctx), &fleet.ActivityTypeResentConfigurationProfileBatch{
|
||
ProfileName: profileName,
|
||
HostCount: count,
|
||
}); err != nil {
|
||
return ctxerr.Wrap(ctx, err, "logging activity for batch-resend of profile")
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
////////////////////////////////////////////////////////////////////////////////
|
||
// GET /configuration_profile/{profile_uuid}/status
|
||
////////////////////////////////////////////////////////////////////////////////
|
||
|
||
type getMDMConfigProfileStatusRequest struct {
|
||
ProfileUUID string `url:"profile_uuid"`
|
||
}
|
||
|
||
type getMDMConfigProfileStatusResponse struct {
|
||
fleet.MDMConfigProfileStatus
|
||
Err error `json:"error,omitempty"`
|
||
}
|
||
|
||
func (r getMDMConfigProfileStatusResponse) Error() error { return r.Err }
|
||
|
||
func getMDMConfigProfileStatusEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
||
req := request.(*getMDMConfigProfileStatusRequest)
|
||
|
||
status, err := svc.GetMDMConfigProfileStatus(ctx, req.ProfileUUID)
|
||
if err != nil {
|
||
return getMDMConfigProfileStatusResponse{Err: err}, nil
|
||
}
|
||
|
||
return getMDMConfigProfileStatusResponse{MDMConfigProfileStatus: status}, nil
|
||
}
|
||
|
||
func (svc *Service) GetMDMConfigProfileStatus(ctx context.Context, profileUUID string) (fleet.MDMConfigProfileStatus, error) {
|
||
// do a basic authz check that before we can make a more specific check based
|
||
// on the team of the profile (just to ensure the user has _some_
|
||
// authorization before loading the profile).
|
||
if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionSelectiveList); err != nil {
|
||
return fleet.MDMConfigProfileStatus{}, ctxerr.Wrap(ctx, err)
|
||
}
|
||
|
||
// get the profile to get the team it belongs to
|
||
var teamID *uint
|
||
switch {
|
||
case strings.HasPrefix(profileUUID, fleet.MDMAppleProfileUUIDPrefix):
|
||
if err := svc.VerifyMDMAppleConfigured(ctx); err != nil {
|
||
return fleet.MDMConfigProfileStatus{}, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("profile_uuid", fleet.AppleMDMNotConfiguredMessage).WithStatus(http.StatusBadRequest), "check apple MDM enabled")
|
||
}
|
||
|
||
prof, err := svc.ds.GetMDMAppleConfigProfile(ctx, profileUUID)
|
||
if err != nil {
|
||
return fleet.MDMConfigProfileStatus{}, err
|
||
}
|
||
if _, ok := mobileconfig.FleetPayloadIdentifiers()[prof.Identifier]; ok {
|
||
// this endpoint is for custom settings, not fleet-controlled profiles,
|
||
// return not found if such a profile is requested here (status of e.g.
|
||
// FileVault is considerably different than just checking the status of
|
||
// the profile).
|
||
return fleet.MDMConfigProfileStatus{}, fleet.NewInvalidArgumentError("profile_uuid", "unknown profile").WithStatus(http.StatusNotFound)
|
||
}
|
||
teamID = prof.TeamID
|
||
|
||
case strings.HasPrefix(profileUUID, fleet.MDMWindowsProfileUUIDPrefix):
|
||
if err := svc.VerifyMDMWindowsConfigured(ctx); err != nil {
|
||
err := fleet.NewInvalidArgumentError("profile_uuid", fleet.WindowsMDMNotConfiguredMessage).WithStatus(http.StatusBadRequest)
|
||
return fleet.MDMConfigProfileStatus{}, ctxerr.Wrap(ctx, err, "check windows MDM enabled")
|
||
}
|
||
|
||
prof, err := svc.ds.GetMDMWindowsConfigProfile(ctx, profileUUID)
|
||
if err != nil {
|
||
return fleet.MDMConfigProfileStatus{}, err
|
||
}
|
||
if ok := slices.Contains(mdm.ListFleetReservedWindowsProfileNames(), prof.Name); ok {
|
||
// this endpoint is for custom settings, not fleet-controlled profiles,
|
||
// return not found if such a profile is requested here.
|
||
return fleet.MDMConfigProfileStatus{}, fleet.NewInvalidArgumentError("profile_uuid", "unknown profile").WithStatus(http.StatusNotFound)
|
||
}
|
||
teamID = prof.TeamID
|
||
|
||
case strings.HasPrefix(profileUUID, fleet.MDMAppleDeclarationUUIDPrefix):
|
||
if err := svc.VerifyMDMAppleConfigured(ctx); err != nil {
|
||
return fleet.MDMConfigProfileStatus{}, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("profile_uuid", fleet.AppleMDMNotConfiguredMessage).WithStatus(http.StatusBadRequest), "check apple MDM enabled")
|
||
}
|
||
|
||
decl, err := svc.ds.GetMDMAppleDeclaration(ctx, profileUUID)
|
||
if err != nil {
|
||
return fleet.MDMConfigProfileStatus{}, err
|
||
}
|
||
if ok := slices.Contains(mdm.ListFleetReservedMacOSDeclarationNames(), decl.Name); ok {
|
||
// this endpoint is for custom settings, not fleet-controlled profiles,
|
||
// return not found if such a profile is requested here (status of e.g.
|
||
// FileVault is considerably different than just checking the status of
|
||
// the profile).
|
||
return fleet.MDMConfigProfileStatus{}, fleet.NewInvalidArgumentError("profile_uuid", "unknown profile").WithStatus(http.StatusNotFound)
|
||
}
|
||
teamID = decl.TeamID
|
||
|
||
case strings.HasPrefix(profileUUID, fleet.MDMAndroidProfileUUIDPrefix):
|
||
if err := svc.VerifyMDMAndroidConfigured(ctx); err != nil {
|
||
err := fleet.NewInvalidArgumentError("profile_uuid", fleet.AndroidMDMNotConfiguredMessage).WithStatus(http.StatusBadRequest)
|
||
return fleet.MDMConfigProfileStatus{}, ctxerr.Wrap(ctx, err, "check android MDM enabled")
|
||
}
|
||
|
||
prof, err := svc.ds.GetMDMAndroidConfigProfile(ctx, profileUUID)
|
||
if err != nil {
|
||
return fleet.MDMConfigProfileStatus{}, err
|
||
}
|
||
teamID = prof.TeamID
|
||
default:
|
||
return fleet.MDMConfigProfileStatus{}, fleet.NewInvalidArgumentError("profile_uuid", "unknown profile").WithStatus(http.StatusNotFound)
|
||
}
|
||
|
||
// now we can do a read authz check based on team id of the profile
|
||
if err := svc.authz.Authorize(ctx, &fleet.MDMConfigProfileAuthz{TeamID: teamID}, fleet.ActionRead); err != nil {
|
||
return fleet.MDMConfigProfileStatus{}, ctxerr.Wrap(ctx, err)
|
||
}
|
||
|
||
status, err := svc.ds.GetMDMConfigProfileStatus(ctx, profileUUID)
|
||
if err != nil {
|
||
return fleet.MDMConfigProfileStatus{}, ctxerr.Wrap(ctx, err)
|
||
}
|
||
return status, nil
|
||
}
|
||
|
||
type mdmUnenrollRequest struct {
|
||
HostID uint `url:"id"`
|
||
}
|
||
|
||
type mdmUnenrollResponse struct {
|
||
Err error `json:"error,omitempty"`
|
||
}
|
||
|
||
func (r mdmUnenrollResponse) Error() error { return r.Err }
|
||
|
||
func (r mdmUnenrollResponse) Status() int { return http.StatusNoContent }
|
||
|
||
func mdmUnenrollEndpoint(ctx context.Context, request any, svc fleet.Service) (fleet.Errorer, error) {
|
||
req := request.(*mdmUnenrollRequest)
|
||
err := svc.UnenrollMDM(ctx, req.HostID)
|
||
if err != nil {
|
||
return mdmUnenrollResponse{Err: err}, nil
|
||
}
|
||
return mdmUnenrollResponse{}, nil
|
||
}
|
||
|
||
func (svc *Service) UnenrollMDM(ctx context.Context, hostID uint) error {
|
||
if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil {
|
||
return err
|
||
}
|
||
|
||
host, err := svc.ds.HostLite(ctx, hostID)
|
||
if err != nil {
|
||
return ctxerr.Wrap(ctx, err, "getting host for MDM unenroll")
|
||
}
|
||
|
||
// Check authorization again based on host info for team-based permissions.
|
||
if err := svc.authz.Authorize(ctx, fleet.MDMCommandAuthz{
|
||
TeamID: host.TeamID,
|
||
}, fleet.ActionWrite); err != nil {
|
||
return err
|
||
}
|
||
|
||
installedFromDEP := false
|
||
switch host.Platform {
|
||
case "windows":
|
||
return &fleet.BadRequestError{
|
||
Message: fleet.CantTurnOffMDMForWindowsHostsMessage,
|
||
}
|
||
case "ios", "ipados", "darwin":
|
||
if err := svc.enqueueMDMAppleCommandRemoveEnrollmentProfile(ctx, host); err != nil {
|
||
return ctxerr.Wrap(ctx, err, "unenrolling apple host")
|
||
}
|
||
|
||
// We only use this call for activity logging purposes later.
|
||
info, err := svc.ds.GetHostMDMCheckinInfo(ctx, host.UUID)
|
||
if err != nil {
|
||
return ctxerr.Wrap(ctx, err, "getting mdm checkin info for mdm apple remove profile command")
|
||
}
|
||
installedFromDEP = info.InstalledFromDEP
|
||
|
||
mdmLifecycle := mdmlifecycle.New(svc.ds, svc.logger, newActivity)
|
||
err = mdmLifecycle.Do(ctx, mdmlifecycle.HostOptions{
|
||
Action: mdmlifecycle.HostActionTurnOff,
|
||
Platform: host.Platform,
|
||
UUID: host.UUID,
|
||
})
|
||
if err != nil {
|
||
return ctxerr.Wrap(ctx, err, "running turn off action in mdm lifecycle")
|
||
}
|
||
case "android":
|
||
// We need to pass host ID and let android look it up again, to avoid refercing the fleet. type for import cycles.
|
||
if err := svc.androidSvc.UnenrollAndroidHost(ctx, host.ID); err != nil {
|
||
return ctxerr.Wrap(ctx, err, "unenrolling android host")
|
||
}
|
||
default:
|
||
level.Debug(svc.logger).Log("msg", "MDM unenrollment requested for host with unknown platform", "host_id", host.ID, "platform", host.Platform)
|
||
return &fleet.BadRequestError{
|
||
Message: "MDM unenrollment is not supported for this host platform",
|
||
}
|
||
}
|
||
|
||
if err := svc.NewActivity(
|
||
ctx, authz.UserFromContext(ctx), &fleet.ActivityTypeMDMUnenrolled{
|
||
HostSerial: host.HardwareSerial,
|
||
HostDisplayName: host.DisplayName(),
|
||
InstalledFromDEP: installedFromDEP,
|
||
Platform: host.Platform,
|
||
}); err != nil {
|
||
return ctxerr.Wrap(ctx, err, "logging activity for mdm apple remove profile command")
|
||
}
|
||
return nil
|
||
}
|