fleet/server/service/mdm.go
Roberto Dip 33d788caed
add support for displaying EULA during ADE/DEP (#11532)
Related to #11350 and the sub-tasks for stuff that happens in setup
assistant: #11477 and #11479

This adds back-end and UI logic to show an EULA during DEP enrollment if
one was uploaded via the UI, if an EULA wasn't uploaded, we just proceed
to enroll the device right after authentication.


https://user-images.githubusercontent.com/4419992/236316655-282ee74a-5f79-4095-a950-82b77b80a5c0.mov
2023-05-05 14:36:13 -03:00

386 lines
12 KiB
Go

package service
import (
"context"
"fmt"
"io"
"mime/multipart"
"net/http"
"strconv"
"strings"
"time"
"github.com/docker/go-units"
"github.com/fleetdm/fleet/v4/pkg/fleethttp"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/contexts/logging"
"github.com/fleetdm/fleet/v4/server/fleet"
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
)
////////////////////////////////////////////////////////////////////////////////
// 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) (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
}
// if there is no apple mdm config, fail with a 404
if !svc.config.MDM.IsAppleAPNsSet() {
return nil, newNotFoundError()
}
apns, _, _, err := svc.config.MDM.AppleAPNs()
if err != nil {
return nil, err
}
appleMDM := &fleet.AppleMDM{
CommonName: apns.Leaf.Subject.CommonName,
Issuer: apns.Leaf.Issuer.CommonName,
RenewDate: apns.Leaf.NotAfter,
}
if apns.Leaf.SerialNumber != nil {
appleMDM.SerialNumber = apns.Leaf.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) (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
}
////////////////////////////////////////////////////////////////////////////////
// GET /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) (errorer, error) {
req := request.(*requestMDMAppleCSRRequest)
csr, err := svc.RequestMDMAppleCSR(ctx, req.EmailAddress, req.Organization)
if err != nil {
return requestMDMAppleCSRResponse{Err: err}, nil
}
return requestMDMAppleCSRResponse{
AppleCSR: csr,
}, nil
}
func (svc *Service) RequestMDMAppleCSR(ctx context.Context, email, org string) (*fleet.AppleCSR, error) {
if err := svc.authz.Authorize(ctx, &fleet.AppleCSR{}, fleet.ActionWrite); err != nil {
return nil, err
}
if err := fleet.ValidateEmail(email); err != nil {
if strings.TrimSpace(email) == "" {
return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("email_address", "missing email address"))
}
return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("email_address", fmt.Sprintf("invalid email address: %v", err)))
}
if strings.TrimSpace(org) == "" {
return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("organization", "missing organization"))
}
// create the raw SCEP CA cert and key (creating before the CSR signing
// request so that nothing can fail after the request is made, except for the
// network during the response of course)
scepCACert, scepCAKey, err := apple_mdm.NewSCEPCACertKey()
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "generate SCEP CA cert and key")
}
// create the APNs CSR
apnsCSR, apnsKey, err := apple_mdm.GenerateAPNSCSRKey(email, org)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "generate APNs CSR")
}
// request the signed APNs CSR from fleetdm.com
client := fleethttp.NewClient(fleethttp.WithTimeout(10 * time.Second))
if err := apple_mdm.GetSignedAPNSCSR(client, apnsCSR); err != nil {
if ferr, ok := err.(apple_mdm.FleetWebsiteError); ok {
status := http.StatusBadGateway
if ferr.Status >= 400 && ferr.Status <= 499 {
// TODO: fleetdm.com returns a genereric "Bad
// Request" message, we should coordinate and
// stablish a response schema from which we can get
// the invalid field and use
// fleet.NewInvalidArgumentError instead
//
// For now, since we have already validated
// everything else, we assume that a 4xx
// response is an email with an invalid domain
return nil, ctxerr.Wrap(
ctx,
fleet.NewInvalidArgumentError(
"email_address",
fmt.Sprintf("this email address is not valid: %v", err),
),
)
}
return nil, ctxerr.Wrap(
ctx,
fleet.NewUserMessageError(
fmt.Errorf("FleetDM CSR request failed: %w", err),
status,
),
)
}
return nil, ctxerr.Wrap(ctx, err, "get signed CSR")
}
// PEM-encode the cert and keys
scepCACertPEM := apple_mdm.EncodeCertPEM(scepCACert)
scepCAKeyPEM := apple_mdm.EncodePrivateKeyPEM(scepCAKey)
apnsKeyPEM := apple_mdm.EncodePrivateKeyPEM(apnsKey)
return &fleet.AppleCSR{
APNsKey: apnsKeyPEM,
SCEPCert: scepCACertPEM,
SCEPKey: scepCAKeyPEM,
}, nil
}
func (svc *Service) VerifyMDMAppleConfigured(ctx context.Context) error {
appCfg, err := svc.ds.AppConfig(ctx)
if err != nil {
// skipauth: Authorization is currently for user endpoints only.
svc.authz.SkipAuthorization(ctx)
return err
}
if !appCfg.MDM.EnabledAndConfigured {
// skipauth: Authorization is currently for user endpoints only.
svc.authz.SkipAuthorization(ctx)
return fleet.ErrMDMNotConfigured
}
return nil
}
////////////////////////////////////////////////////////////////////////////////
// POST /mdm/apple/setup/eula
////////////////////////////////////////////////////////////////////////////////
type createMDMAppleEULARequest struct {
EULA *multipart.FileHeader
}
// TODO: We parse the whole body before running svc.authz.Authorize.
// An authenticated but unauthorized user could abuse this.
func (createMDMAppleEULARequest) DecodeRequest(ctx context.Context, r *http.Request) (interface{}, error) {
err := r.ParseMultipartForm(512 * units.MiB)
if err != nil {
return nil, &fleet.BadRequestError{
Message: "failed to parse multipart form",
InternalErr: err,
}
}
if r.MultipartForm.File["eula"] == nil {
return nil, &fleet.BadRequestError{
Message: "eula multipart field is required",
InternalErr: err,
}
}
return &createMDMAppleEULARequest{
EULA: r.MultipartForm.File["eula"][0],
}, nil
}
type createMDMAppleEULAResponse struct {
Err error `json:"error,omitempty"`
}
func (r createMDMAppleEULAResponse) error() error { return r.Err }
func createMDMAppleEULAEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
req := request.(*createMDMAppleEULARequest)
ff, err := req.EULA.Open()
if err != nil {
return createMDMAppleEULAResponse{Err: err}, nil
}
defer ff.Close()
if err := svc.MDMAppleCreateEULA(ctx, req.EULA.Filename, ff); err != nil {
return createMDMAppleEULAResponse{Err: err}, nil
}
return createMDMAppleEULAResponse{}, nil
}
func (svc *Service) MDMAppleCreateEULA(ctx context.Context, name string, file io.ReadSeeker) error {
// skipauth: No authorization check needed due to implementation returning
// only license error.
svc.authz.SkipAuthorization(ctx)
return fleet.ErrMissingLicense
}
////////////////////////////////////////////////////////////////////////////////
// GET /mdm/apple/setup/eula?token={token}
////////////////////////////////////////////////////////////////////////////////
type getMDMAppleEULARequest struct {
Token string `url:"token"`
}
type getMDMAppleEULAResponse struct {
Err error `json:"error,omitempty"`
// fields used in hijackRender to build the response
eula *fleet.MDMAppleEULA
}
func (r getMDMAppleEULAResponse) error() error { return r.Err }
func (r getMDMAppleEULAResponse) 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 getMDMAppleEULAEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
req := request.(*getMDMAppleEULARequest)
eula, err := svc.MDMAppleGetEULABytes(ctx, req.Token)
if err != nil {
return getMDMAppleEULAResponse{Err: err}, nil
}
return getMDMAppleEULAResponse{eula: eula}, nil
}
func (svc *Service) MDMAppleGetEULABytes(ctx context.Context, token string) (*fleet.MDMAppleEULA, error) {
// skipauth: No authorization check needed due to implementation returning
// only license error.
svc.authz.SkipAuthorization(ctx)
return nil, fleet.ErrMissingLicense
}
////////////////////////////////////////////////////////////////////////////////
// GET /mdm/apple/setup/eula/{token}/metadata
////////////////////////////////////////////////////////////////////////////////
type getMDMAppleEULAMetadataRequest struct{}
type getMDMAppleEULAMetadataResponse struct {
*fleet.MDMAppleEULA
Err error `json:"error,omitempty"`
}
func (r getMDMAppleEULAMetadataResponse) error() error { return r.Err }
func getMDMAppleEULAMetadataEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
eula, err := svc.MDMAppleGetEULAMetadata(ctx)
if err != nil {
return getMDMAppleEULAMetadataResponse{Err: err}, nil
}
return getMDMAppleEULAMetadataResponse{MDMAppleEULA: eula}, nil
}
func (svc *Service) MDMAppleGetEULAMetadata(ctx context.Context) (*fleet.MDMAppleEULA, error) {
// skipauth: No authorization check needed due to implementation returning
// only license error.
svc.authz.SkipAuthorization(ctx)
return nil, fleet.ErrMissingLicense
}
////////////////////////////////////////////////////////////////////////////////
// DELETE /mdm/apple/setup/eula
////////////////////////////////////////////////////////////////////////////////
type deleteMDMAppleEULARequest struct {
Token string `url:"token"`
}
type deleteMDMAppleEULAResponse struct {
Err error `json:"error,omitempty"`
}
func (r deleteMDMAppleEULAResponse) error() error { return r.Err }
func deleteMDMAppleEULAEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
req := request.(*deleteMDMAppleEULARequest)
if err := svc.MDMAppleDeleteEULA(ctx, req.Token); err != nil {
return deleteMDMAppleEULAResponse{Err: err}, nil
}
return deleteMDMAppleEULAResponse{}, nil
}
func (svc *Service) MDMAppleDeleteEULA(ctx context.Context, token string) error {
// skipauth: No authorization check needed due to implementation returning
// only license error.
svc.authz.SkipAuthorization(ctx)
return fleet.ErrMissingLicense
}