<!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** Resolves #47535 ## Description `DeleteMDMAppleDeclaration` re-ran the upload-time validator (`ValidateUserProvided`) on the delete path. That validator enforces *upload-admission* rules — forbidden declaration types (`ForbiddenDeclTypes`) and the `AllowAllDeclarations` config flag — so any declaration that was accepted at upload time became **undeletable** through the API once the accepted set later shrank. Two ways this happens in practice: - A server config flag that had been enabled at upload time is later disabled (the original customer report, prod 4.86.1). - A declaration type is added to `ForbiddenDeclTypes` in a later release, after declarations of that type were already uploaded. In both cases the UI showed "Couldn't delete. Please try again." and the API returned `400` with an upload-validation message on a *delete* request. Whether a declaration is Fleet-managed (and therefore protected from deletion through this endpoint) is already determined by the Fleet reserved-name check that runs immediately above the offending block. This PR removes the upload-time validation from the delete path and relies solely on that reserved-name check, so: - A user can delete any declaration they previously uploaded, regardless of whether the current validator config would still accept it on upload. - Fleet-managed declarations (reserved names) remain protected from deletion. The `AllowAllDeclarations` flag and `ValidateUserProvided` are unchanged on the **add/upload** path — admission control still happens where it belongs. ## Testing Extended `TestMDMConfigProfileCRUD` (replacing the pre-existing `// TODO: Add tests for create/delete forbidden declaration types?`) with two cases: - A declaration whose type is in `ForbiddenDeclTypes` can be deleted (regression guard — fails before this change, passes after). - A declaration with a Fleet-reserved name remains protected from deletion (guards the reserved-name check that is now the sole Fleet-managed gate — a boundary that was previously untested). Manually verified end-to-end in the UI: reproduced the stuck declaration (upload a forbidden type with `FLEET_MDM_ALLOW_ALL_DECLARATIONS=true`, restart without the flag), confirmed the pre-fix `400`, then confirmed deletion succeeds after the fix with the flag off. # Checklist for submitter - [x] Changes file added for user-visible changes in `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), JS inline code is prevented especially for url redirects, and untrusted data interpolated into shell scripts/commands is validated against shell metacharacters. ## Testing - [x] Added/updated automated tests - [x] QA'd all new/changed functionality manually <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Improved Apple MDM declaration deletion to avoid re-running upload-time validation during the delete flow. * Declaration deletion checks now rely on Fleet management status and reserved naming, preserving protection for Fleet-managed declarations. * Added/adjusted deletion behavior for restricted and Fleet-reserved declaration types. * **Tests** * Added regression coverage for Apple declaration profile deletion via the configuration profile delete endpoint, including strict-mode scenarios and cleanup behavior. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
8269 lines
308 KiB
Go
8269 lines
308 KiB
Go
package service
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/md5" // nolint:gosec // used for declarative management token
|
|
"crypto/x509"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"regexp"
|
|
"slices"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/fleetdm/fleet/v4/pkg/file"
|
|
shared_mdm "github.com/fleetdm/fleet/v4/pkg/mdm"
|
|
"github.com/fleetdm/fleet/v4/pkg/optjson"
|
|
"github.com/fleetdm/fleet/v4/server"
|
|
platform_http "github.com/fleetdm/fleet/v4/server/platform/http"
|
|
"github.com/gorilla/mux"
|
|
|
|
"github.com/fleetdm/fleet/v4/server/authz"
|
|
"github.com/fleetdm/fleet/v4/server/config"
|
|
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
|
|
"github.com/fleetdm/fleet/v4/server/contexts/license"
|
|
"github.com/fleetdm/fleet/v4/server/contexts/logging"
|
|
"github.com/fleetdm/fleet/v4/server/contexts/viewer"
|
|
"github.com/fleetdm/fleet/v4/server/fleet"
|
|
mdm_types "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/appmanifest"
|
|
"github.com/fleetdm/fleet/v4/server/mdm/apple/gdmf"
|
|
"github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig"
|
|
"github.com/fleetdm/fleet/v4/server/mdm/assets"
|
|
mdmcrypto "github.com/fleetdm/fleet/v4/server/mdm/crypto"
|
|
mdmlifecycle "github.com/fleetdm/fleet/v4/server/mdm/lifecycle"
|
|
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/godep"
|
|
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/storage"
|
|
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/cryptoutil"
|
|
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
|
|
"github.com/fleetdm/fleet/v4/server/mdm/profiles"
|
|
|
|
nano_service "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/service"
|
|
"github.com/fleetdm/fleet/v4/server/platform/endpointer"
|
|
"github.com/fleetdm/fleet/v4/server/ptr"
|
|
"github.com/fleetdm/fleet/v4/server/variables"
|
|
"github.com/google/uuid"
|
|
"github.com/micromdm/plist"
|
|
"github.com/smallstep/pkcs7"
|
|
)
|
|
|
|
const (
|
|
maxValueCharsInError = 100
|
|
SameProfileNameUploadErrorMsg = "Couldn't add. A configuration profile with this name already exists (PayloadDisplayName for .mobileconfig and file name for .json and .xml)."
|
|
limit10KiB = 10 * 1024
|
|
)
|
|
|
|
// TODO(HCA): Can we come up with a clearer name? This looks like any variables not in this slice is not supported,
|
|
// but that is not the case, digicert, custom scep, hydrant and smallstep are totally supported just in a different way (multiple CA's)
|
|
var fleetVarsSupportedInAppleConfigProfiles = []fleet.FleetVarName{
|
|
fleet.FleetVarNDESSCEPChallenge, fleet.FleetVarNDESSCEPProxyURL, fleet.FleetVarHostEndUserEmailIDP,
|
|
fleet.FleetVarHostHardwareSerial, fleet.FleetVarHostEndUserIDPUsername, fleet.FleetVarHostEndUserIDPUsernameLocalPart,
|
|
fleet.FleetVarHostEndUserIDPGroups, fleet.FleetVarHostEndUserIDPDepartment, fleet.FleetVarHostEndUserIDPFullname,
|
|
fleet.FleetVarSCEPRenewalID, fleet.FleetVarCertificateRenewalID,
|
|
fleet.FleetVarHostUUID, fleet.FleetVarHostPlatform,
|
|
fleet.FleetVarPSSODeviceRegistrationToken,
|
|
}
|
|
|
|
// fleetVarsSupportedInDDMDeclarations is the list of Fleet variables
|
|
// supported in Apple DDM declarations.
|
|
var fleetVarsSupportedInDDMDeclarations = []fleet.FleetVarName{
|
|
fleet.FleetVarHostHardwareSerial,
|
|
fleet.FleetVarHostEndUserIDPUsername,
|
|
fleet.FleetVarHostEndUserIDPUsernameLocalPart,
|
|
fleet.FleetVarHostEndUserIDPGroups,
|
|
fleet.FleetVarHostEndUserIDPDepartment,
|
|
fleet.FleetVarHostEndUserIDPFullname,
|
|
fleet.FleetVarHostUUID,
|
|
fleet.FleetVarHostPlatform,
|
|
}
|
|
|
|
type getMDMAppleCommandResultsRequest struct {
|
|
CommandUUID string `query:"command_uuid,optional"`
|
|
}
|
|
|
|
type getMDMAppleCommandResultsResponse struct {
|
|
Results []*fleet.MDMCommandResult `json:"results,omitempty"`
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r getMDMAppleCommandResultsResponse) Error() error { return r.Err }
|
|
|
|
func getMDMAppleCommandResultsEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*getMDMAppleCommandResultsRequest)
|
|
results, err := svc.GetMDMAppleCommandResults(ctx, req.CommandUUID)
|
|
if err != nil {
|
|
return getMDMAppleCommandResultsResponse{
|
|
Err: err,
|
|
}, nil
|
|
}
|
|
|
|
return getMDMAppleCommandResultsResponse{
|
|
Results: results,
|
|
}, nil
|
|
}
|
|
|
|
func (svc *Service) GetMDMAppleCommandResults(ctx context.Context, commandUUID string) ([]*fleet.MDMCommandResult, error) {
|
|
// first, authorize that the user has the right to list hosts
|
|
if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err)
|
|
}
|
|
|
|
vc, ok := viewer.FromContext(ctx)
|
|
if !ok {
|
|
return nil, fleet.ErrNoContext
|
|
}
|
|
|
|
// check that command exists first, to return 404 on invalid commands
|
|
// (the command may exist but have no results yet).
|
|
if _, err := svc.ds.GetMDMAppleCommandRequestType(ctx, commandUUID); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// next, we need to read the command results before we know what hosts (and
|
|
// therefore what teams) we're dealing with.
|
|
results, err := svc.ds.GetMDMAppleCommandResults(ctx, commandUUID, "")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// now we can load the hosts (lite) corresponding to those command results,
|
|
// and do the final authorization check with the proper team(s). Include observers,
|
|
// as they are able to view command results for their teams' hosts.
|
|
filter := fleet.TeamFilter{User: vc.User, IncludeObserver: true}
|
|
hostUUIDs := make([]string, len(results))
|
|
for i, res := range results {
|
|
hostUUIDs[i] = res.HostUUID
|
|
}
|
|
hosts, err := svc.ds.ListHostsLiteByUUIDs(ctx, filter, hostUUIDs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(hosts) == 0 {
|
|
// do not return 404 here, as it's possible for a command to not have
|
|
// results yet
|
|
return nil, nil
|
|
}
|
|
|
|
// collect the team IDs and verify that the user has access to view commands
|
|
// on all affected teams. Index the hosts by uuid for easly lookup as
|
|
// afterwards we'll want to store the hostname on the returned results.
|
|
hostsByUUID := make(map[string]*fleet.Host, len(hosts))
|
|
teamIDs := make(map[uint]bool)
|
|
for _, h := range hosts {
|
|
var id uint
|
|
if h.TeamID != nil {
|
|
id = *h.TeamID
|
|
}
|
|
teamIDs[id] = true
|
|
hostsByUUID[h.UUID] = h
|
|
}
|
|
|
|
var commandAuthz fleet.MDMCommandAuthz
|
|
for tmID := range teamIDs {
|
|
commandAuthz.TeamID = &tmID
|
|
if tmID == 0 {
|
|
commandAuthz.TeamID = nil
|
|
}
|
|
|
|
if err := svc.authz.Authorize(ctx, commandAuthz, fleet.ActionRead); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err)
|
|
}
|
|
}
|
|
|
|
// add the hostnames to the results
|
|
for _, res := range results {
|
|
if h := hostsByUUID[res.HostUUID]; h != nil {
|
|
res.Hostname = hostsByUUID[res.HostUUID].Hostname
|
|
}
|
|
}
|
|
return results, nil
|
|
}
|
|
|
|
type listMDMAppleCommandsRequest struct {
|
|
ListOptions fleet.ListOptions `url:"list_options"`
|
|
}
|
|
|
|
type listMDMAppleCommandsResponse struct {
|
|
Results []*fleet.MDMAppleCommand `json:"results"`
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r listMDMAppleCommandsResponse) Error() error { return r.Err }
|
|
|
|
func listMDMAppleCommandsEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*listMDMAppleCommandsRequest)
|
|
results, err := svc.ListMDMAppleCommands(ctx, &fleet.MDMCommandListOptions{
|
|
ListOptions: req.ListOptions,
|
|
})
|
|
if err != nil {
|
|
return listMDMAppleCommandsResponse{
|
|
Err: err,
|
|
}, nil
|
|
}
|
|
|
|
return listMDMAppleCommandsResponse{
|
|
Results: results,
|
|
}, nil
|
|
}
|
|
|
|
func (svc *Service) ListMDMAppleCommands(ctx context.Context, opts *fleet.MDMCommandListOptions) ([]*fleet.MDMAppleCommand, error) {
|
|
// first, authorize that the user has the right to list hosts
|
|
if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err)
|
|
}
|
|
|
|
vc, ok := viewer.FromContext(ctx)
|
|
if !ok {
|
|
return nil, fleet.ErrNoContext
|
|
}
|
|
|
|
// get the list of commands so we know what hosts (and therefore what teams)
|
|
// we're dealing with. Including the observers as they are allowed to view
|
|
// MDM Apple commands.
|
|
results, err := svc.ds.ListMDMAppleCommands(ctx, fleet.TeamFilter{
|
|
User: vc.User,
|
|
IncludeObserver: true,
|
|
}, opts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// collect the different team IDs and verify that the user has access to view
|
|
// commands on all affected teams, do not assume that ListMDMAppleCommands
|
|
// 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 {
|
|
svc.logger.ErrorContext(ctx, "unauthorized to view some team commands", "details", authzErr)
|
|
|
|
// filter-out the teams that the user is not allowed to view
|
|
allowedResults := make([]*fleet.MDMAppleCommand, 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
|
|
}
|
|
|
|
return results, nil
|
|
}
|
|
|
|
type newMDMAppleConfigProfileRequest struct {
|
|
TeamID uint
|
|
Profile *multipart.FileHeader
|
|
}
|
|
|
|
type newMDMAppleConfigProfileResponse struct {
|
|
ProfileID uint `json:"profile_id"`
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
// TODO(lucas): We parse the whole body before running svc.authz.Authorize.
|
|
// An authenticated but unauthorized user could abuse this.
|
|
func (newMDMAppleConfigProfileRequest) DecodeRequest(ctx context.Context, r *http.Request) (interface{}, error) {
|
|
decoded := newMDMAppleConfigProfileRequest{}
|
|
|
|
err := parseMultipartForm(ctx, r, platform_http.MaxMultipartFormSize)
|
|
if err != nil {
|
|
return nil, &fleet.BadRequestError{
|
|
Message: "failed to parse multipart form",
|
|
InternalErr: err,
|
|
}
|
|
}
|
|
|
|
val, ok := r.MultipartForm.Value["fleet_id"]
|
|
if !ok || len(val) < 1 {
|
|
// default is no team
|
|
decoded.TeamID = 0
|
|
} else {
|
|
fleetID, err := strconv.Atoi(val[0])
|
|
if err != nil {
|
|
return nil, &fleet.BadRequestError{Message: fmt.Sprintf("failed to decode fleet_id in multipart form: %s", err.Error())}
|
|
}
|
|
decoded.TeamID = uint(fleetID) //nolint:gosec // dismiss G115
|
|
}
|
|
|
|
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]
|
|
|
|
return &decoded, nil
|
|
}
|
|
|
|
func (r newMDMAppleConfigProfileResponse) Error() error { return r.Err }
|
|
|
|
func newMDMAppleConfigProfileEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*newMDMAppleConfigProfileRequest)
|
|
|
|
ff, err := req.Profile.Open()
|
|
if err != nil {
|
|
return &newMDMAppleConfigProfileResponse{Err: err}, nil
|
|
}
|
|
defer ff.Close()
|
|
data, err := io.ReadAll(ff)
|
|
if err != nil {
|
|
return &newMDMConfigProfileResponse{Err: err}, nil
|
|
}
|
|
// providing an empty set of labels since this endpoint is only maintained for backwards compat
|
|
cp, err := svc.NewMDMAppleConfigProfile(ctx, req.TeamID, data, nil, fleet.LabelsIncludeAll, nil)
|
|
if err != nil {
|
|
return &newMDMAppleConfigProfileResponse{Err: err}, nil
|
|
}
|
|
return &newMDMAppleConfigProfileResponse{
|
|
ProfileID: cp.ProfileID,
|
|
}, nil
|
|
}
|
|
|
|
func (svc *Service) NewMDMAppleConfigProfile(ctx context.Context, teamID uint, data []byte, labelsInclude []string, labelsMembershipMode fleet.MDMLabelsMode, labelsExcludeAny []string) (*fleet.MDMAppleConfigProfile, error) {
|
|
if err := svc.authz.Authorize(ctx, &fleet.MDMConfigProfileAuthz{TeamID: &teamID}, fleet.ActionWrite); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err)
|
|
}
|
|
|
|
cp, varNames, teamName, err := svc.parseAndValidateAppleConfigProfile(ctx, teamID, data, labelsInclude, labelsMembershipMode, labelsExcludeAny)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
newCP, err := svc.ds.NewMDMAppleConfigProfile(ctx, *cp, varNames)
|
|
if err != nil {
|
|
if existsErr, ok := errors.AsType[endpointer.ExistsErrorInterface](err); ok {
|
|
msg := SameProfileNameUploadErrorMsg
|
|
if re, ok := existsErr.(interface{ Resource() string }); ok {
|
|
if re.Resource() == "MDMAppleConfigProfile.PayloadIdentifier" {
|
|
msg = "Couldn't add. A configuration profile with this identifier (PayloadIdentifier) already exists."
|
|
}
|
|
}
|
|
err = fleet.NewInvalidArgumentError("profile", msg).
|
|
WithStatus(http.StatusConflict)
|
|
}
|
|
return nil, 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.ActivityTypeCreatedMacosProfile{
|
|
TeamID: actTeamID,
|
|
TeamName: actTeamName,
|
|
ProfileName: newCP.Name,
|
|
ProfileIdentifier: newCP.Identifier,
|
|
}); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "logging activity for create mdm apple config profile")
|
|
}
|
|
|
|
return newCP, nil
|
|
}
|
|
|
|
// parseAndValidateAppleConfigProfile runs the validation shared by the
|
|
// create and update paths. It returns the constructed profile (with labels
|
|
// and the original unexpanded Mobileconfig set), the Fleet variable names it
|
|
// uses, and the team's name (empty string for no team).
|
|
func (svc *Service) parseAndValidateAppleConfigProfile(ctx context.Context, teamID uint, data []byte, labelsInclude []string, labelsMembershipMode fleet.MDMLabelsMode, labelsExcludeAny []string) (*fleet.MDMAppleConfigProfile, []fleet.FleetVarName, string, error) {
|
|
// check that Apple MDM is enabled - the middleware of that endpoint checks
|
|
// only that any MDM is enabled, maybe it's just Windows
|
|
if err := svc.VerifyMDMAppleConfigured(ctx); err != nil {
|
|
err := fleet.NewInvalidArgumentError("profile", fleet.AppleMDMNotConfiguredMessage).WithStatus(http.StatusBadRequest)
|
|
return nil, nil, "", ctxerr.Wrap(ctx, err, "check macOS MDM enabled")
|
|
}
|
|
|
|
err := CheckProfileIsNotSigned(data)
|
|
if err != nil {
|
|
return nil, nil, "", ctxerr.Wrap(ctx, err)
|
|
}
|
|
|
|
lic, err := svc.License(ctx)
|
|
if err != nil {
|
|
return nil, nil, "", ctxerr.Wrap(ctx, err, "checking license")
|
|
}
|
|
|
|
var teamName string
|
|
if teamID > 0 {
|
|
if lic == nil || !lic.IsPremium() {
|
|
return nil, nil, "", ctxerr.Wrap(ctx, fleet.ErrMissingLicense)
|
|
}
|
|
tm, err := svc.EnterpriseOverrides.TeamByIDOrName(ctx, &teamID, nil)
|
|
if err != nil {
|
|
return nil, nil, "", ctxerr.Wrap(ctx, err)
|
|
}
|
|
teamName = tm.Name
|
|
}
|
|
|
|
if len(labelsInclude) > 0 || len(labelsExcludeAny) > 0 {
|
|
if lic == nil || !lic.IsPremium() {
|
|
return nil, nil, "", ctxerr.Wrap(ctx, fleet.NewLicenseErrorWithCause(fleet.ConfigProfileLabelScopingPremiumCauseMsg), "checking license for profile label scoping")
|
|
}
|
|
}
|
|
|
|
// Check for secrets in profile name before expansion
|
|
if err := fleet.ValidateNoSecretsInProfileName(data); err != nil {
|
|
return nil, nil, "", ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("profile", err.Error()))
|
|
}
|
|
|
|
// Expand and validate secrets in profile
|
|
expanded, secretsUpdatedAt, err := svc.ds.ExpandEmbeddedSecretsAndUpdatedAt(ctx, string(data))
|
|
if err != nil {
|
|
return nil, nil, "", ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("profile", err.Error()))
|
|
}
|
|
|
|
groupedCAs, err := svc.ds.GetGroupedCertificateAuthorities(ctx, true)
|
|
if err != nil {
|
|
return nil, nil, "", ctxerr.Wrap(ctx, err, "getting grouped certificate authorities")
|
|
}
|
|
|
|
profileVars, err := validateConfigProfileFleetVariables(expanded, lic, groupedCAs)
|
|
if err != nil {
|
|
return nil, nil, "", ctxerr.Wrap(ctx, err, "validating fleet variables")
|
|
}
|
|
|
|
if err := svc.ds.ValidateReferencedCustomHostVitals(ctx, []string{string(data)}); err != nil {
|
|
if !fleet.IsInvalidReferencedCustomHostVitalsError(err) {
|
|
return nil, nil, "", ctxerr.Wrap(ctx, err, "validating referenced custom host vitals")
|
|
}
|
|
return nil, nil, "", ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("profile", err.Error()))
|
|
}
|
|
|
|
cp, err := fleet.NewMDMAppleConfigProfile([]byte(expanded), &teamID)
|
|
if err != nil {
|
|
return nil, nil, "", ctxerr.Wrap(ctx, &fleet.BadRequestError{
|
|
Message: fmt.Sprintf("failed to parse config profile: %s", err.Error()),
|
|
})
|
|
}
|
|
|
|
if err := cp.ValidateUserProvided(svc.config.MDM.IsCustomDiskEncryptionEnabled()); err != nil {
|
|
if strings.Contains(err.Error(), mobileconfig.DiskEncryptionProfileRestrictionErrMsg) {
|
|
return nil, nil, "", ctxerr.Wrap(ctx, &fleet.BadRequestError{Message: err.Error() + ` To control these settings use disk encryption endpoint.`})
|
|
}
|
|
return nil, nil, "", ctxerr.Wrap(ctx, &fleet.BadRequestError{Message: err.Error()})
|
|
}
|
|
|
|
// Save the original unexpanded profile
|
|
cp.Mobileconfig = data
|
|
cp.SecretsUpdatedAt = secretsUpdatedAt
|
|
|
|
if overlap := fleet.LabelOverlap(labelsInclude, labelsExcludeAny); overlap != "" {
|
|
return nil, nil, "", ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("labels", fmt.Sprintf("label %q cannot appear in both include and exclude lists", overlap)))
|
|
}
|
|
includeLabels, excludeLabels, err := svc.validateProfileLabelSets(ctx, &teamID, labelsInclude, labelsExcludeAny)
|
|
if err != nil {
|
|
return nil, nil, "", ctxerr.Wrap(ctx, err, "validating labels")
|
|
}
|
|
switch labelsMembershipMode {
|
|
case fleet.LabelsIncludeAll:
|
|
cp.LabelsIncludeAll = includeLabels
|
|
case fleet.LabelsIncludeAny:
|
|
cp.LabelsIncludeAny = includeLabels
|
|
}
|
|
cp.LabelsExcludeAny = excludeLabels
|
|
|
|
// Convert profile variable names to FleetVarName type
|
|
varNames := make([]fleet.FleetVarName, 0, len(profileVars))
|
|
for _, varName := range profileVars {
|
|
varNames = append(varNames, fleet.FleetVarName(varName))
|
|
}
|
|
|
|
return cp, varNames, teamName, nil
|
|
}
|
|
|
|
// CheckProfileIsNotSigned checks if the provided profile data is a signed profile.
|
|
// If it is signed, it returns a BadRequestError indicating that signed profiles
|
|
// are not allowed. If the profile is not signed, it returns nil.
|
|
func CheckProfileIsNotSigned(data []byte) error {
|
|
mc := mobileconfig.Mobileconfig(data)
|
|
if mc.IsSignedProfile() {
|
|
return &fleet.BadRequestError{
|
|
Message: "Couldn't add. Configuration profiles can't be signed. Fleet will sign the profile for you. Learn more: https://fleetdm.com/learn-more-about/unsigning-configuration-profiles",
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateConfigProfileFleetVariables(contents string, lic *fleet.LicenseInfo, groupedCAs *fleet.GroupedCertificateAuthorities) ([]string, error) {
|
|
fleetVars := variables.Find(contents)
|
|
if len(fleetVars) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
// Validate against all valid Fleet variables in configuration profiles
|
|
for _, fleetVar := range fleetVars {
|
|
if !slices.Contains(fleetVarsSupportedInAppleConfigProfiles, fleet.FleetVarName(fleetVar)) &&
|
|
!strings.HasPrefix(fleetVar, string(fleet.FleetVarDigiCertDataPrefix)) &&
|
|
!strings.HasPrefix(fleetVar, string(fleet.FleetVarDigiCertPasswordPrefix)) &&
|
|
!strings.HasPrefix(fleetVar, string(fleet.FleetVarCustomSCEPProxyURLPrefix)) &&
|
|
!strings.HasPrefix(fleetVar, string(fleet.FleetVarCustomSCEPChallengePrefix)) &&
|
|
!strings.HasPrefix(fleetVar, string(fleet.FleetVarSmallstepSCEPProxyURLPrefix)) &&
|
|
!strings.HasPrefix(fleetVar, string(fleet.FleetVarSmallstepSCEPChallengePrefix)) {
|
|
return nil, &fleet.BadRequestError{Message: fmt.Sprintf("Fleet variable $FLEET_VAR_%s is not supported in configuration profiles.", fleetVar)}
|
|
}
|
|
}
|
|
|
|
if slices.Contains(fleetVars, string(fleet.FleetVarPSSODeviceRegistrationToken)) {
|
|
if lic == nil || !lic.IsPremium() {
|
|
return nil, &fleet.BadRequestError{Message: fmt.Sprintf("Variable %s requires a Fleet Premium license.", fleet.FleetVarPSSODeviceRegistrationToken.WithPrefix())}
|
|
}
|
|
if err := validatePSSORegistrationTokenVariable(contents); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
err := validateProfileCertificateAuthorityVariables(contents, lic, groupedCAs,
|
|
additionalDigiCertValidation, additionalCustomSCEPValidation, additionalNDESValidation, additionalSmallstepValidation)
|
|
// We avoid checking for all nil here (due to no variables, as we ran our own variable check above.)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return fleetVars, nil
|
|
}
|
|
|
|
// extensibleSSOProfileContent is the subset of an Apple configuration profile
|
|
// used to validate placement of the PSSO device registration token variable.
|
|
type extensibleSSOProfileContent struct {
|
|
PayloadContent []extensibleSSOPayload `plist:"PayloadContent"`
|
|
}
|
|
|
|
type extensibleSSOPayload struct {
|
|
PayloadType string `plist:"PayloadType"`
|
|
ExtensionIdentifier string `plist:"ExtensionIdentifier"`
|
|
RegistrationToken string `plist:"RegistrationToken"`
|
|
PlatformSSO struct {
|
|
UseSharedDeviceKeys bool `plist:"UseSharedDeviceKeys"`
|
|
} `plist:"PlatformSSO"`
|
|
}
|
|
|
|
// validatePSSORegistrationTokenVariable enforces that
|
|
// $FLEET_VAR_PSSO_DEVICE_REGISTRATION_TOKEN appears only as the RegistrationToken
|
|
// value of a Fleet Platform SSO v2 payload — a com.apple.extensiblesso payload
|
|
// whose ExtensionIdentifier starts with "com.fleetdm" and whose PlatformSSO
|
|
// dictionary sets UseSharedDeviceKeys to true — and nowhere else in the profile.
|
|
// This stops an admin from leaking the device registration token into another
|
|
// payload (e.g. a third-party IdP extension) or field.
|
|
func validatePSSORegistrationTokenVariable(contents string) error {
|
|
re := fleet.FleetVarPSSODeviceRegistrationTokenRegexp
|
|
totalOccurrences := len(re.FindAllString(contents, -1))
|
|
if totalOccurrences == 0 {
|
|
return nil
|
|
}
|
|
|
|
// Guard against a Fleet variable inside a <data> field elsewhere in the
|
|
// profile breaking the plist unmarshal (mirrors the DigiCert/SCEP validators).
|
|
escaped := variables.ProfileDataVariableRegex.ReplaceAllStringFunc(contents, func(match string) string {
|
|
return base64.StdEncoding.EncodeToString([]byte(match))
|
|
})
|
|
|
|
var prof extensibleSSOProfileContent
|
|
if err := plist.Unmarshal([]byte(escaped), &prof); err != nil {
|
|
return &fleet.BadRequestError{Message: fmt.Sprintf("Failed to parse Platform SSO payload with Fleet variables: %s", err.Error())}
|
|
}
|
|
|
|
varWithPrefix := fleet.FleetVarPSSODeviceRegistrationToken.WithPrefix()
|
|
varWithBraces := fleet.FleetVarPSSODeviceRegistrationToken.WithBraces()
|
|
|
|
validPlacements := 0
|
|
for _, p := range prof.PayloadContent {
|
|
if p.PayloadType != "com.apple.extensiblesso" {
|
|
continue
|
|
}
|
|
if p.RegistrationToken != varWithPrefix && p.RegistrationToken != varWithBraces {
|
|
continue
|
|
}
|
|
if !strings.HasPrefix(p.ExtensionIdentifier, "com.fleetdm") {
|
|
return &fleet.BadRequestError{Message: fmt.Sprintf(
|
|
"Variable %s is only allowed in a Fleet Platform SSO payload (the ExtensionIdentifier must start with \"com.fleetdm\").",
|
|
varWithPrefix)}
|
|
}
|
|
if !p.PlatformSSO.UseSharedDeviceKeys {
|
|
return &fleet.BadRequestError{Message: fmt.Sprintf(
|
|
"Variable %s requires the Platform SSO payload to set UseSharedDeviceKeys to true.",
|
|
varWithPrefix)}
|
|
}
|
|
validPlacements++
|
|
}
|
|
|
|
if validPlacements != totalOccurrences {
|
|
return &fleet.BadRequestError{Message: fmt.Sprintf(
|
|
"Variable %s is only allowed in the RegistrationToken of a Fleet Platform SSO payload.",
|
|
varWithPrefix)}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// additionalDigiCertValidation checks that Password/ContentType fields match DigiCert Fleet variables exactly,
|
|
// and that these variables are only present in a "com.apple.security.pkcs12" payload
|
|
func additionalDigiCertValidation(contents string, digiCertVars *DigiCertVarsFound) error {
|
|
// Find and replace matches in base64 encoded data contents so we can unmarshal the plist and keep the Fleet vars.
|
|
contents = variables.ProfileDataVariableRegex.ReplaceAllStringFunc(contents, func(match string) string {
|
|
return base64.StdEncoding.EncodeToString([]byte(match))
|
|
})
|
|
|
|
var pkcs12Prof PKCS12ProfileContent
|
|
err := plist.Unmarshal([]byte(contents), &pkcs12Prof)
|
|
if err != nil {
|
|
return &fleet.BadRequestError{Message: fmt.Sprintf("Failed to parse PKCS12 payload with Fleet variables: %s", err.Error())}
|
|
}
|
|
var foundCAs []string
|
|
passwordPrefix := "FLEET_VAR_" + string(fleet.FleetVarDigiCertPasswordPrefix)
|
|
dataPrefix := "FLEET_VAR_" + string(fleet.FleetVarDigiCertDataPrefix)
|
|
for _, payload := range pkcs12Prof.PayloadContent {
|
|
if payload.PayloadType == "com.apple.security.pkcs12" {
|
|
for _, ca := range digiCertVars.CAs() {
|
|
// Check for exact match on password and data
|
|
if payload.Password == "$"+passwordPrefix+ca || payload.Password == "${"+passwordPrefix+ca+"}" {
|
|
if string(payload.PayloadContent) == "$"+dataPrefix+ca || string(payload.PayloadContent) == "${"+dataPrefix+ca+"}" {
|
|
foundCAs = append(foundCAs, ca)
|
|
break
|
|
}
|
|
payloadContent := string(payload.PayloadContent)
|
|
if len(payloadContent) > maxValueCharsInError {
|
|
payloadContent = payloadContent[:maxValueCharsInError] + "..."
|
|
}
|
|
return &fleet.BadRequestError{Message: "CA name mismatch between $" + passwordPrefix + ca + " and " +
|
|
payloadContent + " in PKCS12 payload."}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if len(foundCAs) < len(digiCertVars.CAs()) {
|
|
for _, ca := range digiCertVars.CAs() {
|
|
if !slices.Contains(foundCAs, ca) {
|
|
return &fleet.BadRequestError{Message: fmt.Sprintf("Variables $%s and $%s can only be included in the 'com.apple.security.pkcs12' payload under Password and PayloadContent, respectively.",
|
|
passwordPrefix+ca, dataPrefix+ca)}
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type PKCS12ProfileContent struct {
|
|
PayloadContent []PKCS12Payload `plist:"PayloadContent"`
|
|
}
|
|
type PKCS12Payload struct {
|
|
Password string `plist:"Password"`
|
|
PayloadContent PKCS12PayloadContent `plist:"PayloadContent"`
|
|
PayloadType string `plist:"PayloadType"`
|
|
}
|
|
|
|
type PKCS12PayloadContent []byte
|
|
|
|
func (p *PKCS12PayloadContent) UnmarshalPlist(f func(interface{}) error) error {
|
|
var val []byte
|
|
err := f(&val)
|
|
if err != nil {
|
|
// Ignore unmarshalling issues
|
|
return nil
|
|
}
|
|
*p = val
|
|
return nil
|
|
}
|
|
|
|
// additionalCustomSCEPValidation checks that Challenge/URL fields march Custom SCEP Fleet variables
|
|
// exactly, that the SCEP renewal ID variable is present in the CN and that these variables are only
|
|
// present in a "com.apple.security.scep" payload
|
|
func additionalCustomSCEPValidation(contents string, customSCEPVars *CustomSCEPVarsFound) error {
|
|
scepProf, err := unmarshalSCEPProfile(contents)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
scepPayloadContent, err := checkThatOnlyOneSCEPPayloadIsPresent(scepProf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var foundCAs []string
|
|
for _, ca := range customSCEPVars.CAs() {
|
|
// Although this is a loop, we know that we can only have 1 set of SCEP vars because Apple only allows 1 SCEP payload in a profile.
|
|
// Check for the exact match on challenge and URL
|
|
challengePrefix := "FLEET_VAR_" + string(fleet.FleetVarCustomSCEPChallengePrefix)
|
|
if scepPayloadContent.Challenge != "$"+challengePrefix+ca && scepPayloadContent.Challenge != "${"+challengePrefix+ca+"}" {
|
|
payloadChallenge := scepPayloadContent.Challenge
|
|
if len(payloadChallenge) > maxValueCharsInError {
|
|
payloadChallenge = payloadChallenge[:maxValueCharsInError] + "..."
|
|
}
|
|
return &fleet.BadRequestError{
|
|
Message: "Variable \"$FLEET_VAR_" +
|
|
string(fleet.FleetVarCustomSCEPChallengePrefix) + ca + "\" must be in the SCEP certificate's \"Challenge\" field.",
|
|
InternalErr: fmt.Errorf("Challenge: %s", payloadChallenge),
|
|
}
|
|
}
|
|
urlPrefix := "FLEET_VAR_" + string(fleet.FleetVarCustomSCEPProxyURLPrefix)
|
|
if scepPayloadContent.URL != "$"+urlPrefix+ca && scepPayloadContent.URL != "${"+urlPrefix+ca+"}" {
|
|
payloadURL := scepPayloadContent.URL
|
|
if len(payloadURL) > maxValueCharsInError {
|
|
payloadURL = payloadURL[:maxValueCharsInError] + "..."
|
|
}
|
|
return &fleet.BadRequestError{
|
|
Message: "Variable \"$FLEET_VAR_" +
|
|
string(fleet.FleetVarCustomSCEPProxyURLPrefix) + ca + "\" must be in the SCEP certificate's \"URL\" field.",
|
|
InternalErr: fmt.Errorf("URL: %s", payloadURL),
|
|
}
|
|
}
|
|
foundCAs = append(foundCAs, ca)
|
|
}
|
|
if !fleet.FleetVarRenewalIDRegexp.MatchString(scepPayloadContent.CommonName) && !fleet.FleetVarRenewalIDRegexp.MatchString(scepPayloadContent.OrganizationalUnit) {
|
|
return &fleet.BadRequestError{Message: "Variable $FLEET_VAR_" + string(fleet.FleetVarCertificateRenewalID) + " must be in the SCEP certificate's organizational unit (OU)."}
|
|
}
|
|
if len(foundCAs) < len(customSCEPVars.CAs()) {
|
|
for _, ca := range customSCEPVars.CAs() {
|
|
if !slices.Contains(foundCAs, ca) {
|
|
return &fleet.BadRequestError{Message: fleet.SCEPVariablesNotInSCEPPayloadErrMsg}
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func additionalSmallstepValidation(contents string, smallstepVars *SmallstepVarsFound) error {
|
|
scepProf, err := unmarshalSCEPProfile(contents)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
scepPayloadContent, err := checkThatOnlyOneSCEPPayloadIsPresent(scepProf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var foundCAs []string
|
|
for _, ca := range smallstepVars.CAs() {
|
|
// Although this is a loop, we know that we can only have 1 set of SCEP vars because Apple only allows 1 SCEP payload in a profile.
|
|
// Check for the exact match on challenge and URL
|
|
challengePrefix := "FLEET_VAR_" + string(fleet.FleetVarSmallstepSCEPChallengePrefix)
|
|
if scepPayloadContent.Challenge != "$"+challengePrefix+ca && scepPayloadContent.Challenge != "${"+challengePrefix+ca+"}" {
|
|
payloadChallenge := scepPayloadContent.Challenge
|
|
if len(payloadChallenge) > maxValueCharsInError {
|
|
payloadChallenge = payloadChallenge[:maxValueCharsInError] + "..."
|
|
}
|
|
return &fleet.BadRequestError{
|
|
Message: "Variable \"$FLEET_VAR_" +
|
|
string(fleet.FleetVarSmallstepSCEPChallengePrefix) + ca + "\" must be in the SCEP certificate's \"Challenge\" field.",
|
|
InternalErr: fmt.Errorf("Challenge: %s", payloadChallenge),
|
|
}
|
|
}
|
|
urlPrefix := "FLEET_VAR_" + string(fleet.FleetVarSmallstepSCEPProxyURLPrefix)
|
|
if scepPayloadContent.URL != "$"+urlPrefix+ca && scepPayloadContent.URL != "${"+urlPrefix+ca+"}" {
|
|
payloadURL := scepPayloadContent.URL
|
|
if len(payloadURL) > maxValueCharsInError {
|
|
payloadURL = payloadURL[:maxValueCharsInError] + "..."
|
|
}
|
|
return &fleet.BadRequestError{
|
|
Message: "Variable \"$FLEET_VAR_" +
|
|
string(fleet.FleetVarSmallstepSCEPProxyURLPrefix) + ca + "\" must be in the SCEP certificate's \"URL\" field.",
|
|
InternalErr: fmt.Errorf("URL: %s", payloadURL),
|
|
}
|
|
}
|
|
foundCAs = append(foundCAs, ca)
|
|
}
|
|
if !fleet.FleetVarRenewalIDRegexp.MatchString(scepPayloadContent.CommonName) && !fleet.FleetVarRenewalIDRegexp.MatchString(scepPayloadContent.OrganizationalUnit) {
|
|
return &fleet.BadRequestError{Message: "Variable $FLEET_VAR_" + string(fleet.FleetVarCertificateRenewalID) + " must be in the SCEP certificate's organizational unit (OU)."}
|
|
}
|
|
if len(foundCAs) < len(smallstepVars.CAs()) {
|
|
for _, ca := range smallstepVars.CAs() {
|
|
if !slices.Contains(foundCAs, ca) {
|
|
return &fleet.BadRequestError{Message: fleet.SCEPVariablesNotInSCEPPayloadErrMsg}
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func checkThatOnlyOneSCEPPayloadIsPresent(scepProf SCEPProfileContent) (SCEPPayloadContent, error) {
|
|
scepPayloadsFound := 0
|
|
var scepPayloadContent SCEPPayloadContent
|
|
for _, payload := range scepProf.PayloadContent {
|
|
if payload.PayloadType == "com.apple.security.scep" {
|
|
scepPayloadContent = payload.PayloadContent
|
|
scepPayloadsFound++
|
|
}
|
|
}
|
|
if scepPayloadsFound > 1 {
|
|
return SCEPPayloadContent{}, &fleet.BadRequestError{Message: fleet.MultipleSCEPPayloadsErrMsg}
|
|
}
|
|
if scepPayloadsFound == 0 {
|
|
return SCEPPayloadContent{}, &fleet.BadRequestError{Message: fleet.SCEPVariablesNotInSCEPPayloadErrMsg}
|
|
}
|
|
return scepPayloadContent, nil
|
|
}
|
|
|
|
func unmarshalSCEPProfile(contents string) (SCEPProfileContent, error) {
|
|
// Replace any Fleet variables in data fields. SCEP payload does not need them and we cannot unmarshal if they are present.
|
|
contents = variables.ProfileDataVariableRegex.ReplaceAllString(contents, "")
|
|
var scepProf SCEPProfileContent
|
|
err := plist.Unmarshal([]byte(contents), &scepProf)
|
|
if err != nil {
|
|
return SCEPProfileContent{}, &fleet.BadRequestError{Message: fmt.Sprintf("Failed to parse SCEP payload with Fleet variables: %s",
|
|
err.Error())}
|
|
}
|
|
return scepProf, nil
|
|
}
|
|
|
|
type SCEPProfileContent struct {
|
|
PayloadContent []SCEPPayload `plist:"PayloadContent"`
|
|
}
|
|
type SCEPPayload struct {
|
|
PayloadContent SCEPPayloadContent `plist:"PayloadContent"`
|
|
PayloadType string `plist:"PayloadType"`
|
|
}
|
|
type SCEPPayloadContent struct {
|
|
Challenge string
|
|
URL string
|
|
CommonName string
|
|
OrganizationalUnit string
|
|
}
|
|
|
|
func (p *SCEPPayloadContent) UnmarshalPlist(f func(interface{}) error) error {
|
|
val := &struct {
|
|
Challenge string `plist:"Challenge"`
|
|
URL string `plist:"URL"`
|
|
// Subject is an RDN Sequence which is ultimately a nested key-value pair structure with a
|
|
// shape like the one shown below. We just need to extract the CN and OU values from it. While
|
|
// uncommon it is possible for multiple CNs or OUs to be present so we should account for that.
|
|
// Subject: [
|
|
// [
|
|
// [ "CN", "Fleet" ]
|
|
// ],
|
|
// [
|
|
// [ "OU", "Fleet Device Management"]
|
|
// ]
|
|
// ]
|
|
Subject [][][]string
|
|
}{}
|
|
err := f(&val)
|
|
if err != nil {
|
|
// Ignore unmarshalling issues
|
|
*p = SCEPPayloadContent{}
|
|
return nil
|
|
}
|
|
commonName := ""
|
|
organizationalUnit := ""
|
|
for i := 0; i < len(val.Subject); i++ {
|
|
for j := 0; j < len(val.Subject[i]); j++ {
|
|
if len(val.Subject[i][j]) == 2 && val.Subject[i][j][0] == "CN" {
|
|
// adding a separator here in the case of multiple CNs so someting silly like the required var split over
|
|
// multiple CNs gets caught
|
|
if commonName != "" {
|
|
commonName += ","
|
|
}
|
|
commonName += val.Subject[i][j][1]
|
|
}
|
|
if len(val.Subject[i][j]) == 2 && val.Subject[i][j][0] == "OU" {
|
|
if organizationalUnit != "" {
|
|
organizationalUnit += ","
|
|
}
|
|
organizationalUnit += val.Subject[i][j][1]
|
|
}
|
|
}
|
|
}
|
|
*p = SCEPPayloadContent{
|
|
Challenge: val.Challenge,
|
|
URL: val.URL,
|
|
CommonName: commonName,
|
|
OrganizationalUnit: organizationalUnit,
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// additionalNDESValidation checks that Challenge/URL fields match NDES Fleet variables
|
|
// exactly, that the SCEP renewal ID variable is present in the CN, and that these variables are only
|
|
// present in a "com.apple.security.scep" payload
|
|
func additionalNDESValidation(contents string, ndesVars *NDESVarsFound) error {
|
|
scepProf, err := unmarshalSCEPProfile(contents)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
scepPayloadContent, err := checkThatOnlyOneSCEPPayloadIsPresent(scepProf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if !fleet.FleetVarRenewalIDRegexp.MatchString(scepPayloadContent.CommonName) && !fleet.FleetVarRenewalIDRegexp.MatchString(scepPayloadContent.OrganizationalUnit) {
|
|
return &fleet.BadRequestError{Message: "Variable $FLEET_VAR_" + string(fleet.FleetVarCertificateRenewalID) + " must be in the SCEP certificate's organizational unit (OU)."}
|
|
}
|
|
|
|
// Check for the exact match on challenge and URL
|
|
challenge := "FLEET_VAR_" + string(fleet.FleetVarNDESSCEPChallenge)
|
|
if scepPayloadContent.Challenge != "$"+challenge && scepPayloadContent.Challenge != "${"+challenge+"}" {
|
|
payloadChallenge := scepPayloadContent.Challenge
|
|
if len(payloadChallenge) > maxValueCharsInError {
|
|
payloadChallenge = payloadChallenge[:maxValueCharsInError] + "..."
|
|
}
|
|
return &fleet.BadRequestError{
|
|
Message: "Variable \"$FLEET_VAR_" +
|
|
string(fleet.FleetVarNDESSCEPChallenge) + "\" must be in the SCEP certificate's \"Challenge\" field.",
|
|
InternalErr: fmt.Errorf("Challenge: %s", payloadChallenge),
|
|
}
|
|
}
|
|
ndesURL := "FLEET_VAR_" + string(fleet.FleetVarNDESSCEPProxyURL)
|
|
if scepPayloadContent.URL != "$"+ndesURL && scepPayloadContent.URL != "${"+ndesURL+"}" {
|
|
payloadURL := scepPayloadContent.URL
|
|
if len(payloadURL) > maxValueCharsInError {
|
|
payloadURL = payloadURL[:maxValueCharsInError] + "..."
|
|
}
|
|
return &fleet.BadRequestError{
|
|
Message: "Variable \"$FLEET_VAR_" +
|
|
string(fleet.FleetVarNDESSCEPProxyURL) + "\" must be in the SCEP certificate's \"URL\" field.",
|
|
InternalErr: fmt.Errorf("URL: %s", payloadURL),
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (svc *Service) NewMDMAppleDeclaration(ctx context.Context, teamID uint, data []byte, labelsInclude []string, name string, labelsMembershipMode fleet.MDMLabelsMode, labelsExcludeAny []string) (*fleet.MDMAppleDeclaration, error) {
|
|
if err := svc.authz.Authorize(ctx, &fleet.MDMConfigProfileAuthz{TeamID: &teamID}, fleet.ActionWrite); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err)
|
|
}
|
|
|
|
// check that Apple MDM is enabled - the middleware of that endpoint checks
|
|
// only that any MDM is enabled, maybe it's just Windows
|
|
if err := svc.VerifyMDMAppleConfigured(ctx); err != nil {
|
|
err := fleet.NewInvalidArgumentError("declaration", fleet.AppleMDMNotConfiguredMessage).WithStatus(http.StatusBadRequest)
|
|
return nil, ctxerr.Wrap(ctx, err, "check macOS MDM enabled")
|
|
}
|
|
|
|
fleetNames := mdm_types.FleetReservedProfileNames()
|
|
if _, ok := fleetNames[name]; ok {
|
|
err := fleet.NewInvalidArgumentError("declaration", fmt.Sprintf("Profile name %q is not allowed.", name)).WithStatus(http.StatusBadRequest)
|
|
return nil, err
|
|
}
|
|
|
|
d, varNames, teamName, err := svc.parseAndValidateAppleDeclaration(ctx, teamID, name, data, labelsInclude, labelsMembershipMode, labelsExcludeAny)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
decl, err := svc.ds.NewMDMAppleDeclaration(ctx, d, varNames)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var (
|
|
actTeamID *uint
|
|
actTeamName *string
|
|
)
|
|
if teamID > 0 {
|
|
actTeamID = &teamID
|
|
actTeamName = &teamName
|
|
}
|
|
if err := svc.NewActivity(
|
|
ctx, authz.UserFromContext(ctx), &fleet.ActivityTypeCreatedDeclarationProfile{
|
|
TeamID: actTeamID,
|
|
TeamName: actTeamName,
|
|
ProfileName: decl.Name,
|
|
Identifier: decl.Identifier,
|
|
}); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "logging activity for create mdm apple declaration")
|
|
}
|
|
|
|
return decl, nil
|
|
}
|
|
|
|
// parseAndValidateAppleDeclaration runs the validation shared by the create
|
|
// and update paths. It returns the constructed declaration (DeclarationUUID
|
|
// not yet set), the Fleet variable names it references, and the team's name
|
|
// (empty string for no team).
|
|
func (svc *Service) parseAndValidateAppleDeclaration(ctx context.Context, teamID uint, name string, data []byte, labelsInclude []string, labelsMembershipMode fleet.MDMLabelsMode, labelsExcludeAny []string) (*fleet.MDMAppleDeclaration, []fleet.FleetVarName, string, error) {
|
|
// Get license for team lookup and variable validation
|
|
lic, _ := license.FromContext(ctx)
|
|
|
|
var teamName string
|
|
if teamID > 0 {
|
|
if lic == nil || !lic.IsPremium() {
|
|
return nil, nil, "", ctxerr.Wrap(ctx, fleet.ErrMissingLicense)
|
|
}
|
|
tm, err := svc.EnterpriseOverrides.TeamByIDOrName(ctx, &teamID, nil)
|
|
if err != nil {
|
|
return nil, nil, "", ctxerr.Wrap(ctx, err)
|
|
}
|
|
teamName = tm.Name
|
|
}
|
|
if len(labelsInclude) > 0 || len(labelsExcludeAny) > 0 {
|
|
if lic == nil || !lic.IsPremium() {
|
|
return nil, nil, "", ctxerr.Wrap(ctx, fleet.NewLicenseErrorWithCause(fleet.ConfigProfileLabelScopingPremiumCauseMsg), "checking license for declaration profile label scoping")
|
|
}
|
|
}
|
|
|
|
if overlap := fleet.LabelOverlap(labelsInclude, labelsExcludeAny); overlap != "" {
|
|
return nil, nil, "", ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("labels", fmt.Sprintf("label %q cannot appear in both include and exclude lists", overlap)))
|
|
}
|
|
validatedIncludeLabels, excludeLabels, err := svc.validateDeclarationLabelSets(ctx, teamID, labelsInclude, labelsExcludeAny)
|
|
if err != nil {
|
|
return nil, nil, "", err
|
|
}
|
|
|
|
dataWithSecrets, secretsUpdatedAt, err := svc.ds.ExpandEmbeddedSecretsAndUpdatedAt(ctx, string(data))
|
|
if err != nil {
|
|
return nil, nil, "", fleet.NewInvalidArgumentError("profile", err.Error())
|
|
}
|
|
|
|
declVars, err := validateDeclarationFleetVariables(dataWithSecrets, lic)
|
|
if err != nil {
|
|
var badReqErr *fleet.BadRequestError
|
|
if errors.As(err, &badReqErr) {
|
|
badReqErr.Message = "Couldn't upload profile. " + badReqErr.Message
|
|
err = badReqErr
|
|
}
|
|
return nil, nil, "", ctxerr.Wrap(ctx, err, "validating declaration Fleet variables")
|
|
}
|
|
|
|
// Validate custom host vital references (top-level $FLEET_HOST_VITAL_<id>).
|
|
if err := svc.ds.ValidateReferencedCustomHostVitals(ctx, []string{string(data)}); err != nil {
|
|
if !fleet.IsInvalidReferencedCustomHostVitalsError(err) {
|
|
return nil, nil, "", ctxerr.Wrap(ctx, err, "validating referenced custom host vitals")
|
|
}
|
|
return nil, nil, "", fleet.NewInvalidArgumentError("profile", err.Error())
|
|
}
|
|
|
|
varNames := make([]fleet.FleetVarName, 0, len(declVars))
|
|
for _, v := range declVars {
|
|
varNames = append(varNames, fleet.FleetVarName(v))
|
|
}
|
|
|
|
// TODO(roberto): Maybe GetRawDeclarationValues belongs inside NewMDMAppleDeclaration? We can refactor this in a follow up.
|
|
rawDecl, err := fleet.GetRawDeclarationValues([]byte(dataWithSecrets))
|
|
if err != nil {
|
|
return nil, nil, "", err
|
|
}
|
|
// After validation, we should no longer need to keep the expanded secrets.
|
|
|
|
if !svc.config.MDM.AllowAllDeclarations {
|
|
if err := rawDecl.ValidateUserProvided(); err != nil {
|
|
return nil, nil, "", err
|
|
}
|
|
}
|
|
|
|
if err := rawDecl.ValidateScope(); err != nil {
|
|
return nil, nil, "", err
|
|
}
|
|
|
|
decl := fleet.NewMDMAppleDeclaration(data, &teamID, name, rawDecl.Type, rawDecl.Identifier)
|
|
decl.SecretsUpdatedAt = secretsUpdatedAt
|
|
// PayloadScope is a Fleet extension (not part of Apple's DDM schema). The
|
|
// parsed value drives the scope column; the key stays in the stored JSON and
|
|
// is stripped only at delivery time so it isn't sent to the device.
|
|
decl.Scope = rawDecl.ScopeOrDefault()
|
|
|
|
switch labelsMembershipMode {
|
|
case fleet.LabelsIncludeAny:
|
|
decl.LabelsIncludeAny = validatedIncludeLabels
|
|
default:
|
|
// default to include all
|
|
decl.LabelsIncludeAll = validatedIncludeLabels
|
|
}
|
|
decl.LabelsExcludeAny = excludeLabels
|
|
|
|
if err := svc.handleDeclarationSoftwareUpdate(ctx, rawDecl, teamID); err != nil {
|
|
return nil, nil, "", ctxerr.Wrap(ctx, err, "handling declaration software update")
|
|
}
|
|
|
|
assetRefs, err := svc.handleDeclarationAssetReferences(ctx, decl, nil)
|
|
if err != nil {
|
|
return nil, nil, "", ctxerr.Wrap(ctx, err, "handling declaration asset references")
|
|
}
|
|
decl.AssetReferenceUUIDs = assetRefs
|
|
|
|
return decl, varNames, teamName, nil
|
|
}
|
|
|
|
// updateMDMAppleDeclaration implements the Apple DDM declaration branch of
|
|
// UpdateMDMConfigProfile.
|
|
//
|
|
// No explicit "mark pending" call is needed here:
|
|
// mdm_apple_declarations.token is a MySQL generated column derived from
|
|
// raw_json, so the ReconcileAppleDeclarations cron picks up a content change
|
|
// on its own.
|
|
func (svc *Service) updateMDMAppleDeclaration(ctx context.Context, profileUUID string, profile []byte, labelsInclude []string, labelsMembershipMode fleet.MDMLabelsMode, labelsExcludeAny []string) error {
|
|
// first we perform a basic authz check
|
|
if err := svc.authz.Authorize(ctx, &fleet.Team{}, fleet.ActionRead); err != nil {
|
|
return ctxerr.Wrap(ctx, err)
|
|
}
|
|
|
|
existing, err := svc.ds.GetMDMAppleDeclaration(ctx, profileUUID)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err)
|
|
}
|
|
|
|
teamID, teamName, err := svc.resolveProfileTeam(ctx, existing.TeamID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// now we can do a specific authz check based on team id of the declaration before we update it
|
|
if err := svc.authz.Authorize(ctx, &fleet.MDMConfigProfileAuthz{TeamID: existing.TeamID}, fleet.ActionWrite); err != nil {
|
|
return ctxerr.Wrap(ctx, err)
|
|
}
|
|
|
|
// prevent editing declarations that are managed by Fleet
|
|
fleetNames := mdm_types.FleetReservedProfileNames()
|
|
if _, ok := fleetNames[existing.Name]; ok {
|
|
return &fleet.BadRequestError{
|
|
Message: "profiles managed by Fleet can't be edited using this endpoint.",
|
|
InternalErr: fmt.Errorf("editing declaration %s for team %s not allowed because it's managed by Fleet", existing.Name, teamName),
|
|
}
|
|
}
|
|
|
|
var (
|
|
decl *fleet.MDMAppleDeclaration
|
|
varNames []fleet.FleetVarName
|
|
)
|
|
if len(profile) > 0 {
|
|
decl, varNames, _, err = svc.parseAndValidateAppleDeclaration(ctx, teamID, existing.Name, profile, labelsInclude, labelsMembershipMode, labelsExcludeAny)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if decl.Identifier != existing.Identifier {
|
|
return fleet.NewInvalidArgumentError("profile",
|
|
"The new profile's Identifier must match the existing profile's.").WithStatus(http.StatusBadRequest)
|
|
}
|
|
} else {
|
|
// no new content -- only labels are being changed.
|
|
if err := svc.checkLabelsOnlyProfileUpdate(ctx, labelsInclude, labelsExcludeAny); err != nil {
|
|
return err
|
|
}
|
|
includeLabels, excludeLabels, err := svc.validateDeclarationLabelSets(ctx, teamID, labelsInclude, labelsExcludeAny)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "validating labels")
|
|
}
|
|
// SetOrUpdateMDMAppleDeclaration always rewrites the declaration's
|
|
// variable associations from varNames, so re-derive them from the
|
|
// unchanged content the same way the create path does -- otherwise a
|
|
// labels-only edit would wipe them while the content still uses them.
|
|
expanded, _, err := svc.ds.ExpandEmbeddedSecretsAndUpdatedAt(ctx, string(existing.RawJSON))
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "expanding secrets for existing declaration")
|
|
}
|
|
for _, v := range variables.Find(expanded) {
|
|
varNames = append(varNames, fleet.FleetVarName(v))
|
|
}
|
|
decl = &fleet.MDMAppleDeclaration{
|
|
Name: existing.Name,
|
|
Identifier: existing.Identifier,
|
|
TeamID: existing.TeamID,
|
|
RawJSON: existing.RawJSON,
|
|
SecretsUpdatedAt: existing.SecretsUpdatedAt,
|
|
// the upsert writes scope unconditionally, so the unchanged
|
|
// content's scope must be carried over or it would be cleared
|
|
Scope: existing.Scope,
|
|
}
|
|
switch labelsMembershipMode {
|
|
case fleet.LabelsIncludeAll:
|
|
decl.LabelsIncludeAll = includeLabels
|
|
case fleet.LabelsIncludeAny:
|
|
decl.LabelsIncludeAny = includeLabels
|
|
}
|
|
decl.LabelsExcludeAny = excludeLabels
|
|
}
|
|
|
|
if _, err := svc.ds.SetOrUpdateMDMAppleDeclaration(ctx, decl, varNames); err != nil {
|
|
if _, ok := errors.AsType[endpointer.ExistsErrorInterface](err); ok {
|
|
err = fleet.NewInvalidArgumentError("profile", "Couldn't edit. A configuration profile with this identifier already exists.").WithStatus(http.StatusConflict)
|
|
}
|
|
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.ActivityTypeEditedDeclarationProfile{
|
|
TeamID: actTeamID,
|
|
TeamName: actTeamName,
|
|
ProfileName: decl.Name,
|
|
ProfileIdentifier: decl.Identifier,
|
|
}); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "logging activity for edit mdm apple declaration")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (svc *Service) handleDeclarationAssetReferences(ctx context.Context, decl *fleet.MDMAppleDeclaration, assets []*fleet.DDMAsset) ([]string, error) {
|
|
assetRefs, err := findAssetReferences(string(decl.RawJSON))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if len(assetRefs) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
if assets == nil {
|
|
// List all assets for the given team
|
|
assets, err = svc.ds.ListAppleDDMAssets(ctx, decl.TeamID)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "listing DDM assets")
|
|
}
|
|
}
|
|
|
|
assetsByIdentifier := make(map[string]string, len(assets))
|
|
for _, asset := range assets {
|
|
assetsByIdentifier[asset.Identifier] = asset.AssetUUID
|
|
}
|
|
|
|
assetReferenceUUIDs := make([]string, 0, len(assetRefs))
|
|
for _, ref := range assetRefs {
|
|
assetUUID, ok := assetsByIdentifier[ref]
|
|
if !ok {
|
|
return nil, &fleet.BadRequestError{
|
|
Message: fmt.Sprintf("Couldn't add. Asset (%q) doesn't exist. Make sure the asset is uploaded in OS Settings > Configuration profiles > Assets before referencing it in a profile.", ref),
|
|
}
|
|
}
|
|
assetReferenceUUIDs = append(assetReferenceUUIDs, assetUUID)
|
|
}
|
|
|
|
return assetReferenceUUIDs, nil
|
|
}
|
|
|
|
// findAssetReferences walks the raw declaration JSON recursively to find any DDM Asset references
|
|
// it returns a list of asset identifiers, or an error.
|
|
func findAssetReferences(contents string) ([]string, error) {
|
|
var root map[string]any
|
|
if err := json.Unmarshal([]byte(contents), &root); err != nil {
|
|
return nil, &fleet.BadRequestError{
|
|
Message: "invalid declaration JSON",
|
|
}
|
|
}
|
|
|
|
payload, ok := root["Payload"]
|
|
if !ok {
|
|
return nil, &fleet.BadRequestError{
|
|
Message: `declaration is missing required "Payload" key`,
|
|
}
|
|
}
|
|
|
|
refs := make([]string, 0)
|
|
seen := make(map[string]struct{})
|
|
|
|
var walk func(node any) error
|
|
walk = func(node any) error {
|
|
switch v := node.(type) {
|
|
case map[string]any:
|
|
for key, child := range v {
|
|
if strings.HasSuffix(key, "AssetReference") {
|
|
ref, ok := child.(string)
|
|
if !ok || ref == "" {
|
|
return &fleet.BadRequestError{
|
|
Message: fmt.Sprintf(`expected "%s" to be a non-empty string`, key),
|
|
}
|
|
}
|
|
if _, exists := seen[ref]; !exists {
|
|
seen[ref] = struct{}{}
|
|
refs = append(refs, ref)
|
|
}
|
|
}
|
|
if err := walk(child); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
case []any:
|
|
for _, item := range v {
|
|
if err := walk(item); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
if err := walk(payload); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return refs, nil
|
|
}
|
|
|
|
func validateDeclarationFleetVariables(contents string, lic license.LicenseChecker) ([]string, error) {
|
|
fleetVars := variables.Find(contents)
|
|
if len(fleetVars) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
// Check premium license
|
|
if lic == nil || !lic.IsPremium() {
|
|
return nil, fleet.ErrMissingLicense
|
|
}
|
|
|
|
// Validate against allowed list
|
|
for _, fleetVar := range fleetVars {
|
|
if !slices.Contains(fleetVarsSupportedInDDMDeclarations, fleet.FleetVarName(fleetVar)) {
|
|
return nil, &fleet.BadRequestError{
|
|
Message: fmt.Sprintf("Fleet variable $FLEET_VAR_%s is not supported in DDM profiles.", fleetVar),
|
|
}
|
|
}
|
|
}
|
|
|
|
return fleetVars, nil
|
|
}
|
|
|
|
// handleDeclarationSoftwareUpdate validates the preconditions for an OS-update
|
|
// (software update) declaration: premium license and OS updates not already
|
|
// configured via settings. The "already exists" check and tracking-table insert
|
|
// happen atomically in ds.NewMDMAppleDeclaration.
|
|
func (svc *Service) handleDeclarationSoftwareUpdate(
|
|
ctx context.Context,
|
|
rawDecl *fleet.MDMAppleRawDeclaration,
|
|
teamID uint,
|
|
) error {
|
|
if rawDecl.Type != apple_mdm.DeclarationTypeSoftwareUpdate {
|
|
return nil
|
|
}
|
|
|
|
lic, _ := license.FromContext(ctx)
|
|
if lic == nil || !lic.IsPremium() {
|
|
return fleet.ErrMissingLicense
|
|
}
|
|
|
|
osUpdatesConfigured, err := isAppleOSUpdatesConfigured(ctx, teamID, svc)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if osUpdatesConfigured {
|
|
return &fleet.BadRequestError{
|
|
Message: fleet.OSUpdatesAlreadyConfiguredErrorMessage,
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func isAppleOSUpdatesConfigured(ctx context.Context, teamID uint, svc *Service) (bool, error) {
|
|
type AppleOSUpdates struct {
|
|
MacOSUpdates fleet.AppleOSUpdateSettings
|
|
IOSUpdates fleet.AppleOSUpdateSettings
|
|
IPadOSUpdates fleet.AppleOSUpdateSettings
|
|
}
|
|
|
|
// Get the relevant team-config, to check for OS updates being configured
|
|
var appleOSUpdates AppleOSUpdates
|
|
if teamID > 0 {
|
|
teamConfig, err := svc.ds.TeamMDMConfig(ctx, teamID)
|
|
if err != nil {
|
|
return false, ctxerr.Wrap(ctx, err, "getting team config")
|
|
}
|
|
appleOSUpdates = AppleOSUpdates{
|
|
MacOSUpdates: teamConfig.MacOSUpdates,
|
|
IOSUpdates: teamConfig.IOSUpdates,
|
|
IPadOSUpdates: teamConfig.IPadOSUpdates,
|
|
}
|
|
} else {
|
|
appConfig, err := svc.ds.AppConfig(ctx)
|
|
if err != nil {
|
|
return false, ctxerr.Wrap(ctx, err, "getting app config")
|
|
}
|
|
appleOSUpdates = AppleOSUpdates{
|
|
MacOSUpdates: appConfig.MDM.MacOSUpdates,
|
|
IOSUpdates: appConfig.MDM.IOSUpdates,
|
|
IPadOSUpdates: appConfig.MDM.IPadOSUpdates,
|
|
}
|
|
}
|
|
|
|
if appleOSUpdates.MacOSUpdates.Configured() || appleOSUpdates.IOSUpdates.Configured() || appleOSUpdates.IPadOSUpdates.Configured() {
|
|
return true, nil
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
// jsonEscapeString returns the JSON-escaped interior of a string value
|
|
// (without surrounding quotes), suitable for embedding inside a JSON string.
|
|
func jsonEscapeString(s string) string {
|
|
b, err := json.Marshal(s)
|
|
if err != nil {
|
|
// json.Marshal on a string should never fail, but return the
|
|
// original string as a fallback.
|
|
return s
|
|
}
|
|
// strip surrounding quotes
|
|
return string(b[1 : len(b)-1])
|
|
}
|
|
|
|
// replaceDeclarationFleetVariables replaces $FLEET_VAR_* placeholders in a
|
|
// DDM declaration with host-specific values. Values are JSON-string-escaped
|
|
// so they are safe inside JSON string fields.
|
|
func (svc *MDMAppleDDMService) replaceDeclarationFleetVariables(
|
|
ctx context.Context, contents string, hostUUID string,
|
|
) (string, error) {
|
|
// variables.Find only detects $FLEET_VAR_; custom host vitals are a separate
|
|
// top-level prefix, so gate on both so a declaration referencing only custom
|
|
// host vitals is still expanded.
|
|
fleetVars := variables.Find(contents)
|
|
hasHostVitals := len(fleet.ContainsCustomHostVitalIDs(contents)) > 0
|
|
if len(fleetVars) == 0 && !hasHostVitals {
|
|
return contents, nil
|
|
}
|
|
|
|
var hostLite fleet.Host
|
|
hostLite.UUID = hostUUID
|
|
hostHydrated := false
|
|
|
|
hydrateHost := func() error {
|
|
if hostHydrated {
|
|
return nil
|
|
}
|
|
h, ok, err := profiles.HydrateHost(ctx, svc.ds, hostLite, func(n int) error {
|
|
return fmt.Errorf("unexpected number of hosts (%d) for UUID %s", n, hostUUID)
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !ok {
|
|
return fmt.Errorf("host not found for UUID %s", hostUUID)
|
|
}
|
|
hostLite = h
|
|
hostHydrated = true
|
|
return nil
|
|
}
|
|
|
|
var idpUser *fleet.HostEndUser
|
|
resolveIDPUser := func(varName string) (*fleet.HostEndUser, error) {
|
|
if idpUser != nil {
|
|
return idpUser, nil
|
|
}
|
|
if err := hydrateHost(); err != nil {
|
|
return nil, err
|
|
}
|
|
users, err := fleet.GetEndUsers(ctx, svc.ds, hostLite.ID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get end users for host: %w", err)
|
|
}
|
|
if len(users) == 0 || users[0].IdpUserName == "" {
|
|
return nil, fmt.Errorf("There is no IdP username for this host. Fleet couldn't populate $FLEET_VAR_%s.", varName)
|
|
}
|
|
idpUser = &users[0]
|
|
return idpUser, nil
|
|
}
|
|
|
|
for _, fleetVar := range fleetVars {
|
|
var value string
|
|
switch fleet.FleetVarName(fleetVar) {
|
|
case fleet.FleetVarHostUUID:
|
|
value = hostUUID
|
|
|
|
case fleet.FleetVarHostHardwareSerial:
|
|
if err := hydrateHost(); err != nil {
|
|
return "", err
|
|
}
|
|
if strings.TrimSpace(hostLite.HardwareSerial) == "" {
|
|
return "", fmt.Errorf("There is no serial number for this host. Fleet couldn't populate $FLEET_VAR_%s.", fleetVar)
|
|
}
|
|
value = hostLite.HardwareSerial
|
|
|
|
case fleet.FleetVarHostPlatform:
|
|
if err := hydrateHost(); err != nil {
|
|
return "", err
|
|
}
|
|
value = hostLite.Platform
|
|
if value == "darwin" {
|
|
value = "macos"
|
|
}
|
|
|
|
case fleet.FleetVarHostEndUserIDPUsername:
|
|
user, err := resolveIDPUser(fleetVar)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
value = user.IdpUserName
|
|
|
|
case fleet.FleetVarHostEndUserIDPUsernameLocalPart:
|
|
user, err := resolveIDPUser(fleetVar)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
local, _, _ := strings.Cut(user.IdpUserName, "@")
|
|
value = local
|
|
|
|
case fleet.FleetVarHostEndUserIDPGroups:
|
|
user, err := resolveIDPUser(fleetVar)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if len(user.IdpGroups) == 0 {
|
|
return "", fmt.Errorf("There are no IdP groups for this host. Fleet couldn't populate $FLEET_VAR_%s.", fleetVar)
|
|
}
|
|
value = strings.Join(user.IdpGroups, ",")
|
|
|
|
case fleet.FleetVarHostEndUserIDPDepartment:
|
|
user, err := resolveIDPUser(fleetVar)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if user.Department == "" {
|
|
return "", fmt.Errorf("There is no IdP department for this host. Fleet couldn't populate $FLEET_VAR_%s.", fleetVar)
|
|
}
|
|
value = user.Department
|
|
|
|
case fleet.FleetVarHostEndUserIDPFullname:
|
|
user, err := resolveIDPUser(fleetVar)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if strings.TrimSpace(user.IdpFullName) == "" {
|
|
return "", fmt.Errorf("There is no IdP full name for this host. Fleet couldn't populate $FLEET_VAR_%s.", fleetVar)
|
|
}
|
|
value = strings.TrimSpace(user.IdpFullName)
|
|
|
|
default:
|
|
return "", fmt.Errorf("Fleet variable $FLEET_VAR_%s is not supported in DDM declarations.", fleetVar)
|
|
}
|
|
|
|
contents = variables.Replace(contents, fleetVar, jsonEscapeString(value))
|
|
}
|
|
|
|
// Expand custom host vitals last, after the Fleet-var pass. variables.Replace
|
|
// is a blind global string replace, so expanding vitals earlier would let a
|
|
// vital value that happens to contain a literal $FLEET_VAR_<name> be rewritten
|
|
// by that pass. Doing it last makes the vital value the terminal substitution.
|
|
// On a missing/empty value the caller marks the declaration failed with this
|
|
// error's Detail.
|
|
if hasHostVitals {
|
|
if err := hydrateHost(); err != nil {
|
|
return "", err
|
|
}
|
|
expanded, err := svc.ds.ExpandCustomHostVitals(ctx, hostLite.ID, contents)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
contents = expanded
|
|
}
|
|
|
|
return contents, nil
|
|
}
|
|
|
|
// markDeclarationFailed marks a DDM declaration as failed for a specific host.
|
|
func (svc *MDMAppleDDMService) markDeclarationFailed(ctx context.Context, hostUUID string, declarationUUID string, detail string) error {
|
|
status := fleet.MDMDeliveryFailed
|
|
return svc.ds.SetHostMDMAppleDeclarationStatus(ctx, hostUUID, declarationUUID, &status, detail, nil)
|
|
}
|
|
|
|
func (svc *Service) batchValidateDeclarationLabels(ctx context.Context, labelNames []string, teamID uint) (map[string]fleet.ConfigurationProfileLabel, error) {
|
|
if len(labelNames) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
uniqueNames := server.RemoveDuplicatesFromSlice(labelNames)
|
|
|
|
labels, err := svc.ds.LabelIDsByName(ctx, uniqueNames, fleet.TeamFilter{User: authz.UserFromContext(ctx), TeamID: &teamID})
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "getting label IDs by name")
|
|
}
|
|
|
|
if len(labels) != len(uniqueNames) {
|
|
labelError := fleet.NewMissingLabelError(uniqueNames, labels)
|
|
return nil, &fleet.BadRequestError{
|
|
InternalErr: labelError,
|
|
Message: fmt.Sprintf("Couldn't update. Label %q doesn't exist. Please remove the label from the configuration profile.", labelError.MissingLabelName),
|
|
}
|
|
}
|
|
|
|
profLabels := make(map[string]fleet.ConfigurationProfileLabel)
|
|
for labelName, labelID := range labels {
|
|
profLabels[labelName] = fleet.ConfigurationProfileLabel{
|
|
LabelName: labelName,
|
|
LabelID: labelID,
|
|
}
|
|
}
|
|
return profLabels, nil
|
|
}
|
|
|
|
type listMDMAppleConfigProfilesRequest struct {
|
|
TeamID uint `query:"team_id,optional" renameto:"fleet_id"`
|
|
}
|
|
|
|
type listMDMAppleConfigProfilesResponse struct {
|
|
ConfigProfiles []*fleet.MDMAppleConfigProfile `json:"profiles"`
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r listMDMAppleConfigProfilesResponse) Error() error { return r.Err }
|
|
|
|
func listMDMAppleConfigProfilesEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*listMDMAppleConfigProfilesRequest)
|
|
|
|
cps, err := svc.ListMDMAppleConfigProfiles(ctx, req.TeamID)
|
|
if err != nil {
|
|
return &listMDMAppleConfigProfilesResponse{Err: err}, nil
|
|
}
|
|
|
|
res := listMDMAppleConfigProfilesResponse{ConfigProfiles: cps}
|
|
if cps == nil {
|
|
res.ConfigProfiles = []*fleet.MDMAppleConfigProfile{} // return empty json array instead of json null
|
|
}
|
|
return &res, nil
|
|
}
|
|
|
|
func (svc *Service) ListMDMAppleConfigProfiles(ctx context.Context, teamID uint) ([]*fleet.MDMAppleConfigProfile, error) {
|
|
if err := svc.authz.Authorize(ctx, &fleet.MDMConfigProfileAuthz{TeamID: &teamID}, fleet.ActionRead); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err)
|
|
}
|
|
|
|
if teamID >= 1 {
|
|
// confirm that team exists
|
|
if _, err := svc.ds.TeamLite(ctx, teamID); err != nil { // TODO see if we can use TeamExists here instead
|
|
return nil, ctxerr.Wrap(ctx, err)
|
|
}
|
|
}
|
|
|
|
cps, err := svc.ds.ListMDMAppleConfigProfiles(ctx, &teamID)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err)
|
|
}
|
|
|
|
return cps, nil
|
|
}
|
|
|
|
type getMDMAppleConfigProfileRequest struct {
|
|
ProfileID uint `url:"profile_id"`
|
|
}
|
|
|
|
type getMDMAppleConfigProfileResponse struct {
|
|
Err error `json:"error,omitempty"`
|
|
|
|
// file fields below are used in hijackRender for the response
|
|
fileReader io.ReadCloser
|
|
fileLength int64
|
|
fileName string
|
|
}
|
|
|
|
func (r getMDMAppleConfigProfileResponse) Error() error { return r.Err }
|
|
|
|
func (r getMDMAppleConfigProfileResponse) HijackRender(ctx context.Context, w http.ResponseWriter) {
|
|
w.Header().Set("Content-Length", strconv.FormatInt(r.fileLength, 10))
|
|
w.Header().Set("Content-Type", "application/x-apple-aspen-config")
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment;filename="%s.mobileconfig"`, r.fileName))
|
|
|
|
// 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
|
|
wl, err := io.Copy(w, r.fileReader)
|
|
if err != nil {
|
|
logging.WithExtras(ctx, "mobileconfig_copy_error", err, "bytes_copied", wl)
|
|
}
|
|
r.fileReader.Close()
|
|
}
|
|
|
|
func getMDMAppleConfigProfileEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*getMDMAppleConfigProfileRequest)
|
|
|
|
cp, err := svc.GetMDMAppleConfigProfileByDeprecatedID(ctx, req.ProfileID)
|
|
if err != nil {
|
|
return getMDMAppleConfigProfileResponse{Err: err}, nil
|
|
}
|
|
reader := bytes.NewReader(cp.Mobileconfig)
|
|
fileName := fmt.Sprintf("%s_%s", time.Now().Format("2006-01-02"), strings.ReplaceAll(cp.Name, " ", "_"))
|
|
|
|
return getMDMAppleConfigProfileResponse{fileReader: io.NopCloser(reader), fileLength: reader.Size(), fileName: fileName}, nil
|
|
}
|
|
|
|
func (svc *Service) GetMDMAppleConfigProfileByDeprecatedID(ctx context.Context, profileID uint) (*fleet.MDMAppleConfigProfile, error) {
|
|
// first we perform a basic authz check
|
|
if err := svc.authz.Authorize(ctx, &fleet.Team{}, fleet.ActionRead); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
cp, err := svc.ds.GetMDMAppleConfigProfileByDeprecatedID(ctx, profileID)
|
|
if err != nil {
|
|
if fleet.IsNotFound(err) {
|
|
// call the standard service method with a profile UUID that will not be
|
|
// found, just to ensure the same sequence of validations are applied.
|
|
return svc.GetMDMAppleConfigProfile(ctx, "-")
|
|
}
|
|
return nil, ctxerr.Wrap(ctx, err)
|
|
}
|
|
return svc.GetMDMAppleConfigProfile(ctx, cp.ProfileUUID)
|
|
}
|
|
|
|
func (svc *Service) GetMDMAppleConfigProfile(ctx context.Context, profileUUID string) (*fleet.MDMAppleConfigProfile, error) {
|
|
// first we perform a basic authz check
|
|
if err := svc.authz.Authorize(ctx, &fleet.Team{}, fleet.ActionRead); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
cp, err := svc.ds.GetMDMAppleConfigProfile(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) GetMDMAppleDeclaration(ctx context.Context, profileUUID string) (*fleet.MDMAppleDeclaration, error) {
|
|
// first we perform a basic authz check
|
|
if err := svc.authz.Authorize(ctx, &fleet.Team{}, fleet.ActionRead); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
cp, err := svc.ds.GetMDMAppleDeclaration(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
|
|
}
|
|
|
|
type deleteMDMAppleConfigProfileRequest struct {
|
|
ProfileID uint `url:"profile_id"`
|
|
}
|
|
|
|
type deleteMDMAppleConfigProfileResponse struct {
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r deleteMDMAppleConfigProfileResponse) Error() error { return r.Err }
|
|
|
|
func deleteMDMAppleConfigProfileEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*deleteMDMAppleConfigProfileRequest)
|
|
|
|
if err := svc.DeleteMDMAppleConfigProfileByDeprecatedID(ctx, req.ProfileID); err != nil {
|
|
return &deleteMDMAppleConfigProfileResponse{Err: err}, nil
|
|
}
|
|
|
|
return &deleteMDMAppleConfigProfileResponse{}, nil
|
|
}
|
|
|
|
// updateMDMAppleConfigProfile implements the Apple .mobileconfig branch of
|
|
// UpdateMDMConfigProfile.
|
|
func (svc *Service) updateMDMAppleConfigProfile(ctx context.Context, profileUUID string, profile []byte, labelsInclude []string, labelsMembershipMode fleet.MDMLabelsMode, labelsExcludeAny []string) error {
|
|
// first we perform a basic authz check
|
|
if err := svc.authz.Authorize(ctx, &fleet.Team{}, fleet.ActionRead); err != nil {
|
|
return ctxerr.Wrap(ctx, err)
|
|
}
|
|
|
|
existing, err := svc.ds.GetMDMAppleConfigProfile(ctx, profileUUID)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err)
|
|
}
|
|
|
|
teamID, teamName, err := svc.resolveProfileTeam(ctx, existing.TeamID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// now we can do a specific authz check based on team id of profile before we update it
|
|
if err := svc.authz.Authorize(ctx, &fleet.MDMConfigProfileAuthz{TeamID: existing.TeamID}, fleet.ActionWrite); err != nil {
|
|
return ctxerr.Wrap(ctx, err)
|
|
}
|
|
|
|
// prevent editing profiles that are managed by Fleet
|
|
if _, ok := mobileconfig.FleetPayloadIdentifiers()[existing.Identifier]; ok {
|
|
return &fleet.BadRequestError{
|
|
Message: "profiles managed by Fleet can't be edited using this endpoint.",
|
|
InternalErr: fmt.Errorf("editing profile %s for team %s not allowed because it's managed by Fleet", existing.Identifier, teamName),
|
|
}
|
|
}
|
|
|
|
var cp *fleet.MDMAppleConfigProfile
|
|
var varNames []fleet.FleetVarName
|
|
if len(profile) > 0 {
|
|
cp, varNames, _, err = svc.parseAndValidateAppleConfigProfile(ctx, teamID, profile, labelsInclude, labelsMembershipMode, labelsExcludeAny)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if cp.Identifier != existing.Identifier {
|
|
return fleet.NewInvalidArgumentError("profile",
|
|
"The new profile's PayloadIdentifier must match the existing profile's.").WithStatus(http.StatusBadRequest)
|
|
}
|
|
} else {
|
|
// no new content -- only labels are being changed.
|
|
if err := svc.checkLabelsOnlyProfileUpdate(ctx, labelsInclude, labelsExcludeAny); err != nil {
|
|
return err
|
|
}
|
|
includeLabels, excludeLabels, err := svc.validateProfileLabelSets(ctx, &teamID, labelsInclude, labelsExcludeAny)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "validating labels")
|
|
}
|
|
cp = &fleet.MDMAppleConfigProfile{
|
|
Identifier: existing.Identifier,
|
|
Name: existing.Name,
|
|
TeamID: existing.TeamID,
|
|
}
|
|
switch labelsMembershipMode {
|
|
case fleet.LabelsIncludeAll:
|
|
cp.LabelsIncludeAll = includeLabels
|
|
case fleet.LabelsIncludeAny:
|
|
cp.LabelsIncludeAny = includeLabels
|
|
}
|
|
cp.LabelsExcludeAny = excludeLabels
|
|
}
|
|
cp.ProfileUUID = profileUUID
|
|
|
|
if _, err := svc.ds.UpdateMDMAppleConfigProfile(ctx, *cp, varNames); err != nil {
|
|
if _, ok := errors.AsType[endpointer.ExistsErrorInterface](err); ok {
|
|
err = fleet.NewInvalidArgumentError("profile", SameProfileNameUploadErrorMsg).WithStatus(http.StatusConflict)
|
|
}
|
|
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.ActivityTypeEditedMacosProfile{
|
|
TeamID: actTeamID,
|
|
TeamName: actTeamName,
|
|
ProfileName: cp.Name,
|
|
ProfileIdentifier: cp.Identifier,
|
|
}); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "logging activity for edit mdm apple config profile")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (svc *Service) DeleteMDMAppleConfigProfileByDeprecatedID(ctx context.Context, profileID uint) error {
|
|
// first we perform a basic authz check
|
|
if err := svc.authz.Authorize(ctx, &fleet.Team{}, fleet.ActionRead); err != nil {
|
|
return ctxerr.Wrap(ctx, err)
|
|
}
|
|
|
|
// get the profile by ID and call the standard delete function
|
|
cp, err := svc.ds.GetMDMAppleConfigProfileByDeprecatedID(ctx, profileID)
|
|
if err != nil {
|
|
if fleet.IsNotFound(err) {
|
|
// call the standard service method with a profile UUID that will not be
|
|
// found, just to ensure the same sequence of validations are applied.
|
|
return svc.DeleteMDMAppleConfigProfile(ctx, "-")
|
|
}
|
|
return ctxerr.Wrap(ctx, err)
|
|
}
|
|
return svc.DeleteMDMAppleConfigProfile(ctx, cp.ProfileUUID)
|
|
}
|
|
|
|
func (svc *Service) DeleteMDMAppleConfigProfile(ctx context.Context, profileUUID string) error {
|
|
// first we perform a basic authz check
|
|
if err := svc.authz.Authorize(ctx, &fleet.Team{}, fleet.ActionRead); err != nil {
|
|
return ctxerr.Wrap(ctx, err)
|
|
}
|
|
|
|
cp, err := svc.ds.GetMDMAppleConfigProfile(ctx, profileUUID)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err)
|
|
}
|
|
|
|
teamID, teamName, err := svc.resolveProfileTeam(ctx, cp.TeamID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// 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: cp.TeamID}, fleet.ActionWrite); err != nil {
|
|
return ctxerr.Wrap(ctx, err)
|
|
}
|
|
|
|
// prevent deleting profiles that are managed by Fleet
|
|
if _, ok := mobileconfig.FleetPayloadIdentifiers()[cp.Identifier]; ok {
|
|
return &fleet.BadRequestError{
|
|
Message: "profiles managed by Fleet can't be deleted using this endpoint.",
|
|
InternalErr: fmt.Errorf("deleting profile %s for team %s not allowed because it's managed by Fleet", cp.Identifier, teamName),
|
|
}
|
|
}
|
|
|
|
// This call will also delete host_mdm_apple_profiles references IFF the profile has not been sent to
|
|
// the host yet.
|
|
if err := svc.ds.DeleteMDMAppleConfigProfile(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.ActivityTypeDeletedMacosProfile{
|
|
TeamID: actTeamID,
|
|
TeamName: actTeamName,
|
|
ProfileName: cp.Name,
|
|
ProfileIdentifier: cp.Identifier,
|
|
}); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "logging activity for delete mdm apple config profile")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (svc *Service) DeleteMDMAppleDeclaration(ctx context.Context, declUUID string) error {
|
|
// first we perform a basic authz check
|
|
if err := svc.authz.Authorize(ctx, &fleet.Team{}, fleet.ActionRead); err != nil {
|
|
return ctxerr.Wrap(ctx, err)
|
|
}
|
|
|
|
decl, err := svc.ds.GetMDMAppleDeclaration(ctx, declUUID)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err)
|
|
}
|
|
|
|
// Check if the declaration contains a secret variable. If it does, this means that the declaration
|
|
// has been provided by the user and can be deleted. We don't need to validate that it is a Fleet declaration.
|
|
//
|
|
// Whether a declaration is Fleet-managed (and therefore protected from
|
|
// deletion through this endpoint) is determined solely by its reserved name.
|
|
// We deliberately do NOT run the upload-time validator (ValidateUserProvided)
|
|
// here: any declaration already stored was accepted at upload time, so
|
|
// re-validating it on delete would trap user-uploaded declarations whenever
|
|
// the accepted set later shrinks (a config flag is toggled, or a type is
|
|
// added to ForbiddenDeclTypes). See https://github.com/fleetdm/fleet/issues/47535.
|
|
hasSecretVariable := len(fleet.ContainsPrefixVars(string(decl.RawJSON), fleet.ServerSecretPrefix)) > 0
|
|
if !hasSecretVariable {
|
|
if _, ok := mdm_types.FleetReservedProfileNames()[decl.Name]; ok {
|
|
return &fleet.BadRequestError{
|
|
Message: "profiles managed by Fleet can't be deleted using this endpoint.",
|
|
InternalErr: fmt.Errorf("deleting profile %s is not allowed because it's managed by Fleet", decl.Name),
|
|
}
|
|
}
|
|
}
|
|
|
|
teamID, teamName, err := svc.resolveProfileTeam(ctx, decl.TeamID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// 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: decl.TeamID}, fleet.ActionWrite); err != nil {
|
|
return ctxerr.Wrap(ctx, err)
|
|
}
|
|
|
|
if err := svc.ds.DeleteMDMAppleDeclaration(ctx, declUUID); 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.ActivityTypeDeletedDeclarationProfile{
|
|
TeamID: actTeamID,
|
|
TeamName: actTeamName,
|
|
ProfileName: decl.Name,
|
|
Identifier: decl.Identifier,
|
|
}); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "logging activity for delete mdm apple declaration")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
type getMDMAppleFileVaultSummaryRequest struct {
|
|
TeamID *uint `query:"team_id,optional" renameto:"fleet_id"`
|
|
}
|
|
|
|
type getMDMAppleFileVaultSummaryResponse struct {
|
|
*fleet.MDMAppleFileVaultSummary
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r getMDMAppleFileVaultSummaryResponse) Error() error { return r.Err }
|
|
|
|
func getMdmAppleFileVaultSummaryEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*getMDMAppleFileVaultSummaryRequest)
|
|
|
|
fvs, err := svc.GetMDMAppleFileVaultSummary(ctx, req.TeamID)
|
|
if err != nil {
|
|
return &getMDMAppleFileVaultSummaryResponse{Err: err}, nil
|
|
}
|
|
|
|
return &getMDMAppleFileVaultSummaryResponse{
|
|
MDMAppleFileVaultSummary: fvs,
|
|
}, nil
|
|
}
|
|
|
|
func (svc *Service) GetMDMAppleFileVaultSummary(ctx context.Context, teamID *uint) (*fleet.MDMAppleFileVaultSummary, error) {
|
|
if err := svc.authz.Authorize(ctx, fleet.MDMConfigProfileAuthz{TeamID: teamID}, fleet.ActionRead); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err)
|
|
}
|
|
|
|
fvs, err := svc.ds.GetMDMAppleFileVaultSummary(ctx, teamID)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err)
|
|
}
|
|
|
|
return fvs, nil
|
|
}
|
|
|
|
type getMDMAppleProfilesSummaryRequest struct {
|
|
TeamID *uint `query:"team_id,optional" renameto:"fleet_id"`
|
|
}
|
|
|
|
type getMDMAppleProfilesSummaryResponse struct {
|
|
fleet.MDMProfilesSummary
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r getMDMAppleProfilesSummaryResponse) Error() error { return r.Err }
|
|
|
|
func getMDMAppleProfilesSummaryEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*getMDMAppleProfilesSummaryRequest)
|
|
res := getMDMAppleProfilesSummaryResponse{}
|
|
|
|
ps, err := svc.GetMDMAppleProfilesSummary(ctx, req.TeamID)
|
|
if err != nil {
|
|
return &getMDMAppleProfilesSummaryResponse{Err: err}, nil
|
|
}
|
|
|
|
res.Verified = ps.Verified
|
|
res.Verifying = ps.Verifying
|
|
res.Failed = ps.Failed
|
|
res.Pending = ps.Pending
|
|
|
|
return &res, nil
|
|
}
|
|
|
|
func (svc *Service) GetMDMAppleProfilesSummary(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.VerifyMDMAppleConfigured(ctx); err != nil {
|
|
return &fleet.MDMProfilesSummary{}, nil
|
|
}
|
|
|
|
ps, err := svc.ds.GetMDMAppleProfilesSummary(ctx, teamID)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err)
|
|
}
|
|
|
|
return ps, nil
|
|
}
|
|
|
|
type uploadAppleInstallerRequest struct {
|
|
Installer *multipart.FileHeader
|
|
}
|
|
|
|
type uploadAppleInstallerResponse struct {
|
|
ID uint `json:"installer_id"`
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (uploadAppleInstallerRequest) DecodeRequest(ctx context.Context, r *http.Request) (interface{}, error) {
|
|
err := r.ParseMultipartForm(platform_http.MaxMultipartFormSize)
|
|
if err != nil {
|
|
return nil, &fleet.BadRequestError{
|
|
Message: "failed to parse multipart form",
|
|
InternalErr: err,
|
|
}
|
|
}
|
|
installer := r.MultipartForm.File["installer"][0]
|
|
return &uploadAppleInstallerRequest{
|
|
Installer: installer,
|
|
}, nil
|
|
}
|
|
|
|
func (r uploadAppleInstallerResponse) Error() error { return r.Err }
|
|
|
|
// Deprecated: Not in Use
|
|
func uploadAppleInstallerEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*uploadAppleInstallerRequest)
|
|
ff, err := req.Installer.Open()
|
|
if err != nil {
|
|
return uploadAppleInstallerResponse{Err: err}, nil
|
|
}
|
|
defer ff.Close()
|
|
installer, err := svc.UploadMDMAppleInstaller(ctx, req.Installer.Filename, req.Installer.Size, ff)
|
|
if err != nil {
|
|
return uploadAppleInstallerResponse{Err: err}, nil
|
|
}
|
|
return &uploadAppleInstallerResponse{
|
|
ID: installer.ID,
|
|
}, nil
|
|
}
|
|
|
|
func (svc *Service) UploadMDMAppleInstaller(ctx context.Context, name string, size int64, installer io.Reader) (*fleet.MDMAppleInstaller, error) {
|
|
if err := svc.authz.Authorize(ctx, &fleet.MDMAppleInstaller{}, fleet.ActionWrite); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err)
|
|
}
|
|
|
|
appConfig, err := svc.ds.AppConfig(ctx)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err)
|
|
}
|
|
|
|
token := uuid.New().String()
|
|
|
|
url := svc.installerURL(token, appConfig)
|
|
|
|
var installerBuf bytes.Buffer
|
|
manifest, err := createManifest(size, io.TeeReader(installer, &installerBuf), url)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err)
|
|
}
|
|
|
|
inst, err := svc.ds.NewMDMAppleInstaller(ctx, name, size, manifest, installerBuf.Bytes(), token)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err)
|
|
}
|
|
|
|
return inst, nil
|
|
}
|
|
|
|
func (svc *Service) installerURL(token string, appConfig *fleet.AppConfig) string {
|
|
return fmt.Sprintf("%s%s?token=%s", appConfig.ServerSettings.ServerURL, apple_mdm.InstallerPath, token)
|
|
}
|
|
|
|
func createManifest(size int64, installer io.Reader, url string) (string, error) {
|
|
manifest, err := appmanifest.New(&readerWithSize{
|
|
Reader: installer,
|
|
size: size,
|
|
}, url)
|
|
if err != nil {
|
|
return "", fmt.Errorf("create manifest file: %w", err)
|
|
}
|
|
var buf bytes.Buffer
|
|
enc := plist.NewEncoder(&buf)
|
|
enc.Indent(" ")
|
|
if err := enc.Encode(manifest); err != nil {
|
|
return "", fmt.Errorf("encode manifest: %w", err)
|
|
}
|
|
return buf.String(), nil
|
|
}
|
|
|
|
type readerWithSize struct {
|
|
io.Reader
|
|
size int64
|
|
}
|
|
|
|
func (r *readerWithSize) Size() int64 {
|
|
return r.size
|
|
}
|
|
|
|
type getAppleInstallerDetailsRequest struct {
|
|
ID uint `url:"installer_id"`
|
|
}
|
|
|
|
type getAppleInstallerDetailsResponse struct {
|
|
Installer *fleet.MDMAppleInstaller
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r getAppleInstallerDetailsResponse) Error() error { return r.Err }
|
|
|
|
func getAppleInstallerEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*getAppleInstallerDetailsRequest)
|
|
installer, err := svc.GetMDMAppleInstallerByID(ctx, req.ID)
|
|
if err != nil {
|
|
return getAppleInstallerDetailsResponse{Err: err}, nil
|
|
}
|
|
return &getAppleInstallerDetailsResponse{
|
|
Installer: installer,
|
|
}, nil
|
|
}
|
|
|
|
func (svc *Service) GetMDMAppleInstallerByID(ctx context.Context, id uint) (*fleet.MDMAppleInstaller, error) {
|
|
if err := svc.authz.Authorize(ctx, &fleet.MDMAppleInstaller{}, fleet.ActionWrite); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err)
|
|
}
|
|
|
|
inst, err := svc.ds.MDMAppleInstallerDetailsByID(ctx, id)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err)
|
|
}
|
|
return inst, nil
|
|
}
|
|
|
|
type deleteAppleInstallerDetailsRequest struct {
|
|
ID uint `url:"installer_id"`
|
|
}
|
|
|
|
type deleteAppleInstallerDetailsResponse struct {
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r deleteAppleInstallerDetailsResponse) Error() error { return r.Err }
|
|
|
|
func deleteAppleInstallerEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*deleteAppleInstallerDetailsRequest)
|
|
if err := svc.DeleteMDMAppleInstaller(ctx, req.ID); err != nil {
|
|
return deleteAppleInstallerDetailsResponse{Err: err}, nil
|
|
}
|
|
return &deleteAppleInstallerDetailsResponse{}, nil
|
|
}
|
|
|
|
func (svc *Service) DeleteMDMAppleInstaller(ctx context.Context, id uint) error {
|
|
if err := svc.authz.Authorize(ctx, &fleet.MDMAppleInstaller{}, fleet.ActionWrite); err != nil {
|
|
return ctxerr.Wrap(ctx, err)
|
|
}
|
|
|
|
if err := svc.ds.DeleteMDMAppleInstaller(ctx, id); err != nil {
|
|
return ctxerr.Wrap(ctx, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type listMDMAppleDevicesRequest struct{}
|
|
|
|
type listMDMAppleDevicesResponse struct {
|
|
Devices []fleet.MDMAppleDevice `json:"devices"`
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r listMDMAppleDevicesResponse) Error() error { return r.Err }
|
|
|
|
func listMDMAppleDevicesEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
devices, err := svc.ListMDMAppleDevices(ctx)
|
|
if err != nil {
|
|
return listMDMAppleDevicesResponse{Err: err}, nil
|
|
}
|
|
return &listMDMAppleDevicesResponse{
|
|
Devices: devices,
|
|
}, nil
|
|
}
|
|
|
|
func (svc *Service) ListMDMAppleDevices(ctx context.Context) ([]fleet.MDMAppleDevice, error) {
|
|
if err := svc.authz.Authorize(ctx, &fleet.MDMAppleDevice{}, fleet.ActionWrite); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err)
|
|
}
|
|
|
|
return svc.ds.MDMAppleListDevices(ctx)
|
|
}
|
|
|
|
type newMDMAppleDEPKeyPairResponse struct {
|
|
PublicKey []byte `json:"public_key,omitempty"`
|
|
PrivateKey []byte `json:"private_key,omitempty"`
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r newMDMAppleDEPKeyPairResponse) Error() error { return r.Err }
|
|
|
|
func newMDMAppleDEPKeyPairEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
keyPair, err := svc.NewMDMAppleDEPKeyPair(ctx)
|
|
if err != nil {
|
|
return newMDMAppleDEPKeyPairResponse{
|
|
Err: err,
|
|
}, nil
|
|
}
|
|
|
|
return newMDMAppleDEPKeyPairResponse{
|
|
PublicKey: keyPair.PublicKey,
|
|
PrivateKey: keyPair.PrivateKey,
|
|
}, nil
|
|
}
|
|
|
|
func (svc *Service) NewMDMAppleDEPKeyPair(ctx context.Context) (*fleet.MDMAppleDEPKeyPair, error) {
|
|
// skipauth: Generating a new key pair does not actually make any changes to fleet, or expose any
|
|
// information. The user must configure fleet with the new key pair and restart the server.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
publicKeyPEM, privateKeyPEM, err := apple_mdm.NewDEPKeyPairPEM()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("generate key pair: %w", err)
|
|
}
|
|
|
|
return &fleet.MDMAppleDEPKeyPair{
|
|
PublicKey: publicKeyPEM,
|
|
PrivateKey: privateKeyPEM,
|
|
}, nil
|
|
}
|
|
|
|
type enqueueMDMAppleCommandRequest struct {
|
|
Command string `json:"command"`
|
|
DeviceIDs []string `json:"device_ids"`
|
|
}
|
|
|
|
type enqueueMDMAppleCommandResponse struct {
|
|
*fleet.CommandEnqueueResult
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r enqueueMDMAppleCommandResponse) Error() error { return r.Err }
|
|
|
|
// Deprecated: enqueueMDMAppleCommandEndpoint is now deprecated, replaced by
|
|
// the platform-agnostic runMDMCommandEndpoint. It is still supported
|
|
// indefinitely for backwards compatibility.
|
|
func enqueueMDMAppleCommandEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*enqueueMDMAppleCommandRequest)
|
|
result, err := svc.EnqueueMDMAppleCommand(ctx, req.Command, req.DeviceIDs)
|
|
if err != nil {
|
|
return enqueueMDMAppleCommandResponse{Err: err}, nil
|
|
}
|
|
return enqueueMDMAppleCommandResponse{
|
|
CommandEnqueueResult: result,
|
|
}, nil
|
|
}
|
|
|
|
func (svc *Service) EnqueueMDMAppleCommand(
|
|
ctx context.Context,
|
|
rawBase64Cmd string,
|
|
deviceIDs []string,
|
|
) (result *fleet.CommandEnqueueResult, err error) {
|
|
hosts, err := svc.authorizeAllHostsTeams(ctx, deviceIDs, fleet.ActionWrite, &fleet.MDMCommandAuthz{})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(hosts) == 0 {
|
|
return nil, newNotFoundError()
|
|
}
|
|
|
|
// using a padding agnostic decoder because we released this using
|
|
// base64.RawStdEncoding, but it was causing problems as many standard
|
|
// libraries default to padded strings. We're now supporting both for
|
|
// backwards compatibility.
|
|
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")
|
|
}
|
|
|
|
// Validate the command before enqueueing
|
|
if err := svc.validateAppleMDMCommand(ctx, rawXMLCmd, hosts); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return svc.enqueueAppleMDMCommand(ctx, rawXMLCmd, deviceIDs)
|
|
}
|
|
|
|
type mdmAppleEnrollRequest struct {
|
|
// Token is expected to be a UUID string that identifies a template MDM Apple enrollment profile.
|
|
Token string `query:"token"`
|
|
// EnrollmentReference is expected to be a UUID string that identifies the MDM IdP account used
|
|
// to authenticate the end user as part of the MDM IdP flow.
|
|
EnrollmentReference string `query:"enrollment_reference,optional"`
|
|
// DeviceInfo is expected to be a base64 encoded string extracted during MDM IdP enrollment from the
|
|
// x-apple-aspen-deviceinfo header of the original configuration web view request and
|
|
// persisted by the client in local storage for inclusion in a subsequent enrollment request as
|
|
// part of the MDM IdP flow.
|
|
// See https://developer.apple.com/documentation/devicemanagement/device_assignment/authenticating_through_web_views
|
|
DeviceInfo string `query:"deviceinfo,optional"`
|
|
// MachineInfo is the decoded deviceinfo URL query param for MDM IdP enrollments or the decoded
|
|
// x-apple-aspen-deviceinfo header for non-IdP enrollments.
|
|
MachineInfo *fleet.MDMAppleMachineInfo
|
|
}
|
|
|
|
func (mdmAppleEnrollRequest) DecodeRequest(ctx context.Context, r *http.Request) (interface{}, error) {
|
|
decoded := mdmAppleEnrollRequest{}
|
|
|
|
tok := r.URL.Query().Get("token")
|
|
if tok == "" {
|
|
return nil, &fleet.BadRequestError{
|
|
Message: "token is required",
|
|
}
|
|
}
|
|
decoded.Token = tok
|
|
|
|
er := r.URL.Query().Get("enrollment_reference")
|
|
decoded.EnrollmentReference = er
|
|
|
|
// Parse the machine info from the request header or URL query param.
|
|
di := r.Header.Get("x-apple-aspen-deviceinfo")
|
|
if di == "" {
|
|
vals, err := url.ParseQuery(r.URL.RawQuery)
|
|
if err != nil {
|
|
return nil, &fleet.BadRequestError{
|
|
Message: "unable to parse query string",
|
|
InternalErr: err,
|
|
}
|
|
}
|
|
di = vals.Get("deviceinfo")
|
|
decoded.DeviceInfo = di
|
|
}
|
|
|
|
if di != "" {
|
|
// parse the base64 encoded deviceinfo
|
|
parsed, err := apple_mdm.ParseDeviceinfo(di, false) // FIXME: use verify=true when we have better parsing for various Apple certs (https://github.com/fleetdm/fleet/issues/20879)
|
|
if err != nil {
|
|
return nil, &fleet.BadRequestError{
|
|
Message: "unable to parse deviceinfo header",
|
|
InternalErr: err,
|
|
}
|
|
}
|
|
decoded.MachineInfo = parsed
|
|
}
|
|
|
|
if decoded.MachineInfo == nil && r.Header.Get("Content-Type") == "application/pkcs7-signature" {
|
|
defer r.Body.Close()
|
|
// We limit the amount we read since this is an untrusted HTTP request -- a potential DoS attack from huge payloads.
|
|
body, err := io.ReadAll(io.LimitReader(r.Body, limit10KiB))
|
|
if err != nil {
|
|
return nil, &fleet.BadRequestError{
|
|
Message: "unable to read request body",
|
|
InternalErr: err,
|
|
}
|
|
}
|
|
|
|
// FIXME: use verify=true when we have better parsing for various Apple certs (https://github.com/fleetdm/fleet/issues/20879)
|
|
decoded.MachineInfo, err = apple_mdm.ParseMachineInfoFromPKCS7(body, false)
|
|
if err != nil {
|
|
return nil, &fleet.BadRequestError{
|
|
Message: "unable to parse machine info",
|
|
InternalErr: err,
|
|
}
|
|
}
|
|
}
|
|
|
|
return &decoded, nil
|
|
}
|
|
|
|
func (r mdmAppleEnrollResponse) Error() error { return r.Err }
|
|
|
|
type mdmAppleEnrollResponse struct {
|
|
Err error `json:"error,omitempty"`
|
|
|
|
// Profile field is used in HijackRender for the response.
|
|
Profile []byte
|
|
|
|
SoftwareUpdateRequired *fleet.MDMAppleSoftwareUpdateRequired
|
|
}
|
|
|
|
func (r mdmAppleEnrollResponse) HijackRender(ctx context.Context, w http.ResponseWriter) {
|
|
if r.SoftwareUpdateRequired != nil {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusForbidden)
|
|
if err := json.NewEncoder(w).Encode(r.SoftwareUpdateRequired); err != nil {
|
|
encodeError(ctx, ctxerr.New(ctx, "failed to encode software update required"), w)
|
|
}
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Length", strconv.FormatInt(int64(len(r.Profile)), 10))
|
|
w.Header().Set("Content-Type", "application/x-apple-aspen-config")
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
w.Header().Set("Content-Disposition", "attachment;fleet-enrollment-profile.mobileconfig")
|
|
|
|
// 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.Profile); err != nil {
|
|
logging.WithExtras(ctx, "err", err, "written", n)
|
|
}
|
|
}
|
|
|
|
func mdmAppleEnrollEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*mdmAppleEnrollRequest)
|
|
|
|
if req.DeviceInfo == "" {
|
|
// This is a non-IdP enrollment, so we need to check the OS version here. For IdP enrollments
|
|
// os version checks is performed by the frontend MDM enrollment handler.
|
|
sur, err := svc.CheckMDMAppleEnrollmentWithMinimumOSVersion(ctx, req.MachineInfo)
|
|
if err != nil {
|
|
return mdmAppleEnrollResponse{Err: err}, nil
|
|
}
|
|
if sur != nil {
|
|
return mdmAppleEnrollResponse{
|
|
SoftwareUpdateRequired: sur,
|
|
}, nil
|
|
}
|
|
}
|
|
|
|
legacyRef, err := svc.ReconcileMDMAppleEnrollRef(ctx, req.EnrollmentReference, req.MachineInfo)
|
|
if err != nil {
|
|
return mdmAppleEnrollResponse{Err: err}, nil
|
|
}
|
|
|
|
profile, err := svc.GetMDMAppleEnrollmentProfileByToken(ctx, req.Token, legacyRef, req.MachineInfo)
|
|
if err != nil {
|
|
return mdmAppleEnrollResponse{Err: err}, nil
|
|
}
|
|
return mdmAppleEnrollResponse{
|
|
Profile: profile,
|
|
}, nil
|
|
}
|
|
|
|
type mdmAppleAccountEnrollRequest struct {
|
|
EnrollReference *string
|
|
DeviceInfo fleet.MDMAppleAccountDrivenUserEnrollDeviceInfo
|
|
// EnrollmentToken is the token extracted from the URL variable. It contains the ABM unique token to link this enrollment attempt to an ABM token.
|
|
EnrollmentToken string
|
|
}
|
|
|
|
func (mdmAppleAccountEnrollRequest) DecodeRequest(ctx context.Context, r *http.Request) (interface{}, error) {
|
|
decoded := mdmAppleAccountEnrollRequest{}
|
|
|
|
rawData, err := io.ReadAll(io.LimitReader(r.Body, limit10KiB))
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "reading body from request")
|
|
}
|
|
|
|
if err := cryptoutil.ValidateBERDepth(rawData, cryptoutil.MaxBERDepth); err != nil {
|
|
return nil, &fleet.BadRequestError{
|
|
Message: "invalid request body",
|
|
InternalErr: err,
|
|
}
|
|
}
|
|
|
|
p7, err := pkcs7.Parse(rawData)
|
|
if err != nil {
|
|
return nil, &fleet.BadRequestError{
|
|
Message: "invalid request body",
|
|
InternalErr: err,
|
|
}
|
|
}
|
|
|
|
deviceInfo := fleet.MDMAppleAccountDrivenUserEnrollDeviceInfo{}
|
|
|
|
err = apple_mdm.BoundedPlistUnmarshal(p7.Content, &deviceInfo)
|
|
if err != nil {
|
|
return nil, &fleet.BadRequestError{
|
|
Message: "invalid request body",
|
|
InternalErr: err,
|
|
}
|
|
}
|
|
decoded.DeviceInfo = deviceInfo
|
|
|
|
auth := r.Header.Get("Authorization")
|
|
if strings.HasPrefix(auth, "Bearer ") {
|
|
decoded.EnrollReference = ptr.String(strings.Split(auth, "Bearer ")[1])
|
|
}
|
|
|
|
token := mux.Vars(r)["token"]
|
|
if token != "" {
|
|
decoded.EnrollmentToken = token
|
|
}
|
|
|
|
return &decoded, nil
|
|
}
|
|
|
|
type mdmAppleAccountEnrollResponse struct {
|
|
Err error `json:"error,omitempty"`
|
|
mdmSSOUrl string
|
|
}
|
|
|
|
func (r mdmAppleAccountEnrollResponse) Error() error { return r.Err }
|
|
|
|
func (r mdmAppleAccountEnrollResponse) HijackRender(ctx context.Context, w http.ResponseWriter) {
|
|
w.Header().Set("WWW-Authenticate",
|
|
`Bearer method="apple-as-web" `+
|
|
`url="`+r.mdmSSOUrl+`"`,
|
|
)
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
}
|
|
|
|
// This endpoint gets called twice by the Apple account driven enrollment flow. The first time it
|
|
// is called without a bearer token which results in a 401 Unauthorized response where we tell it
|
|
// to go through MDM SSO End User Authentication. The second time it is called with a bearer token,
|
|
// in this case an enrollment reference which is used to fetch the enrollment profile. The device
|
|
// then has the user sign in with the Apple ID specified in the enrollment profile
|
|
func mdmAppleAccountEnrollEndpoint(ctx context.Context, request any, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*mdmAppleAccountEnrollRequest)
|
|
svc.SkipAuth(ctx)
|
|
deviceProduct := strings.ToLower(req.DeviceInfo.Product)
|
|
if !(strings.HasPrefix(deviceProduct, "ipad") || strings.HasPrefix(deviceProduct, "iphone") || strings.HasPrefix(deviceProduct, "ipod")) {
|
|
// There is unfortunately no good way to get the client to show this error, they will see a
|
|
// generic error about a failure to get an enrollment profile.
|
|
return mdmAppleEnrollResponse{
|
|
Err: &fleet.BadRequestError{
|
|
Message: "only iOS and iPadOS devices are supported for account driven user enrollment",
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
if req.EnrollReference == nil {
|
|
mdmSSOUrl, err := svc.GetMDMAccountDrivenEnrollmentSSOURL(ctx, req.EnrollmentToken)
|
|
if err != nil {
|
|
return mdmAppleAccountEnrollResponse{Err: err}, nil
|
|
}
|
|
return mdmAppleAccountEnrollResponse{mdmSSOUrl: mdmSSOUrl}, nil
|
|
}
|
|
|
|
// Fetch the enrollment reference
|
|
profile, err := svc.GetMDMAppleAccountEnrollmentProfile(ctx, *req.EnrollReference)
|
|
if err != nil {
|
|
return mdmAppleEnrollResponse{Err: err}, nil
|
|
}
|
|
return mdmAppleEnrollResponse{Profile: profile}, nil
|
|
}
|
|
|
|
func (svc *Service) SkipAuth(ctx context.Context) {
|
|
svc.authz.SkipAuthorization(ctx)
|
|
}
|
|
|
|
func (svc *Service) GetMDMAccountDrivenEnrollmentSSOURL(ctx context.Context, enrollmentToken string) (string, error) {
|
|
// skipauth: No authorization check needed due to implementation returning
|
|
// only license error.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
return "", fleet.ErrMissingLicense
|
|
}
|
|
|
|
func (svc *Service) GetMDMAppleAccountEnrollmentProfile(ctx context.Context, enrollRef string) (profile []byte, err error) {
|
|
// skipauth: No authorization check needed due to implementation returning
|
|
// only license error.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
return nil, fleet.ErrMissingLicense
|
|
}
|
|
|
|
func (svc *Service) ReconcileMDMAppleEnrollRef(ctx context.Context, enrollRef string, machineInfo *fleet.MDMAppleMachineInfo) (string, error) {
|
|
if machineInfo == nil {
|
|
// TODO: what to do here? We can't reconcile the enroll ref without machine info
|
|
svc.logger.InfoContext(ctx, "missing machine info, failing enroll ref check", "enroll_ref", enrollRef)
|
|
return "", &fleet.BadRequestError{
|
|
Message: "missing deviceinfo",
|
|
}
|
|
}
|
|
|
|
legacyRef, err := svc.ds.ReconcileMDMAppleEnrollRef(ctx, enrollRef, machineInfo)
|
|
if err != nil && !fleet.IsNotFound(err) {
|
|
return "", ctxerr.Wrap(ctx, err, "check legacy enroll ref")
|
|
}
|
|
svc.logger.InfoContext(ctx, "check legacy enroll ref", "host_uuid", machineInfo.UDID, "legacy_enroll_ref", legacyRef)
|
|
|
|
return legacyRef, nil
|
|
}
|
|
|
|
func (svc *Service) GetMDMAppleEnrollmentProfileByToken(ctx context.Context, token string, ref string, machineInfo *fleet.MDMAppleMachineInfo) (profile []byte, err error) {
|
|
// skipauth: The enroll profile endpoint is unauthenticated.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
if machineInfo == nil {
|
|
// TODO: confirm how we want to handle this case
|
|
return nil, ctxerr.New(ctx, "get enrollment profile: missing machine info")
|
|
}
|
|
|
|
_, err = svc.ds.GetMDMAppleEnrollmentProfileByToken(ctx, token)
|
|
if err != nil {
|
|
if fleet.IsNotFound(err) {
|
|
return nil, fleet.NewAuthFailedError("enrollment profile not found")
|
|
}
|
|
return nil, ctxerr.Wrap(ctx, err, "get enrollment profile")
|
|
}
|
|
|
|
if machineInfo.MandatorySoftwareUpdateRequired {
|
|
// Log an info message if the device is requiring a mandatory software update.
|
|
svc.logger.InfoContext(ctx, "device requires mandatory software update", "host_uuid", machineInfo.UDID, "serial", machineInfo.Serial, "product", machineInfo.Product, "os_version", machineInfo.OSVersion)
|
|
}
|
|
|
|
appConfig, err := svc.ds.AppConfig(ctx)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err)
|
|
}
|
|
|
|
mdmURL, err := apple_mdm.AddEnrollmentRefToFleetURL(appConfig.MDMUrl(), ref)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "adding reference to fleet URL")
|
|
}
|
|
|
|
topic, err := apple_mdm.MDMPushCertTopic(ctx, svc.ds)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "extracting topic from APNs cert")
|
|
}
|
|
|
|
var requireACME bool
|
|
if appConfig.MDM.AppleRequireHardwareAttestation {
|
|
requireACME, err = svc.isMDMAppleACMERequired(ctx, machineInfo)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "checking if ACME enrollment is required")
|
|
}
|
|
|
|
svc.logger.InfoContext(ctx, "hardware attestastion required", "require_acme", requireACME, "product", machineInfo.Product, "serial", machineInfo.Serial)
|
|
|
|
}
|
|
|
|
var enrollProf []byte
|
|
if requireACME {
|
|
enrollProf, err = svc.generateMDMAppleACMEEnrollProfile(ctx, machineInfo.Serial, appConfig.OrgInfo.OrgName, mdmURL, topic)
|
|
} else {
|
|
enrollProf, err = svc.generateMDMAppleSCEPEnrollProfile(ctx, appConfig.OrgInfo.OrgName, mdmURL, topic)
|
|
}
|
|
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "generating enrollment profile")
|
|
}
|
|
|
|
signed, err := mdmcrypto.Sign(ctx, enrollProf, svc.ds)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "signing profile")
|
|
}
|
|
|
|
return signed, nil
|
|
}
|
|
|
|
func (svc *Service) NewACMEEnrollment(ctx context.Context, hardwareSerial string) (string, error) {
|
|
// skipauth: The enroll profile endpoint is unauthenticated, and this method is only called from
|
|
// there or the renewal cron
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
return svc.acmeSvc.NewACMEEnrollment(ctx, hardwareSerial)
|
|
}
|
|
|
|
func (svc *Service) isMDMAppleACMERequired(ctx context.Context, machineInfo *fleet.MDMAppleMachineInfo) (bool, error) {
|
|
if machineInfo == nil {
|
|
return false, ctxerr.New(ctx, "machine info is nil")
|
|
}
|
|
|
|
// account-driven user enrollment does not include a serial number in the device info, so we can't require ACME without a serial number
|
|
if machineInfo.Serial == "" {
|
|
svc.logger.InfoContext(ctx, "missing serial number in machine info, skipping ACME requirement check")
|
|
return false, nil
|
|
}
|
|
|
|
isSupported, err := isMacACMESupported(machineInfo.Product, machineInfo.OSVersion)
|
|
if err != nil {
|
|
return false, ctxerr.Wrap(ctx, err, "checking if device is capable of ACME")
|
|
} else if !isSupported {
|
|
svc.logger.InfoContext(ctx, "ACME not supported, skipping ACME requirement", "serial", machineInfo.Serial, "model_identifier", machineInfo.Product, "os_version", machineInfo.OSVersion)
|
|
return false, nil
|
|
}
|
|
|
|
// we only require ACME if the serial is DEP-assigned to Fleet
|
|
assignments, err := svc.ds.GetHostDEPAssignmentsBySerial(ctx, machineInfo.Serial)
|
|
if err != nil {
|
|
return false, ctxerr.Wrap(ctx, err, "checking DEP assignment status")
|
|
}
|
|
svc.logger.InfoContext(ctx, "checking DEP assignment status for ACME requirement", "serial", machineInfo.Serial, "dep_assignments_count", len(assignments))
|
|
|
|
return len(assignments) > 0, nil
|
|
}
|
|
|
|
// isMacACMESupported checks if the device is supported for ACME enrollment. Fleet only
|
|
// supports ACME enrollment for Apple Silicon Macs running macOS 14 or later. Other Apple devices
|
|
// may support ACME enrollment (e.g., iOS on Apple Silicon), but they are not currently supported by Fleet.
|
|
func isMacACMESupported(modelIdentifier string, osVersion string) (bool, error) {
|
|
// we only require ACME for Apple Silicon Macs
|
|
if isMacAppleSilicon, err := fleet.IsMacAppleSilicon(modelIdentifier); err != nil {
|
|
return false, fmt.Errorf("checking if device is Apple Silicon: %w", err)
|
|
} else if !isMacAppleSilicon {
|
|
return false, nil
|
|
}
|
|
|
|
// we only require ACME for Apple Silicon Macs running macOS 14 or later
|
|
if isLessThanMacOS14, err := apple_mdm.IsLessThanVersion(osVersion, "14.0"); err != nil {
|
|
return false, fmt.Errorf("checking if device is less than macOS 14: %w", err)
|
|
} else if isLessThanMacOS14 {
|
|
return false, nil
|
|
}
|
|
|
|
return true, nil
|
|
}
|
|
|
|
func (svc *Service) generateMDMAppleACMEEnrollProfile(ctx context.Context, hardwareSerial string, orgName string, mdmURL string, topic string) ([]byte, error) {
|
|
acmeIdent, err := svc.acmeSvc.NewACMEEnrollment(ctx, hardwareSerial)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "creating ACME enrollment")
|
|
}
|
|
|
|
b, err := apple_mdm.GenerateACMEEnrollmentProfileMobileconfig(
|
|
orgName,
|
|
mdmURL,
|
|
acmeIdent,
|
|
hardwareSerial,
|
|
topic,
|
|
apple_mdm.MDMAccessRightAll,
|
|
true, // fresh enrollment
|
|
)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "generateMDMAppleACMEEnrollProfile: generating ACME enrollment profile")
|
|
}
|
|
|
|
return b, nil
|
|
}
|
|
|
|
func (svc *Service) generateMDMAppleSCEPEnrollProfile(ctx context.Context, orgName string, mdmURL string, topic string) ([]byte, error) {
|
|
assets, err := svc.ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{
|
|
fleet.MDMAssetSCEPChallenge,
|
|
}, nil)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "generateMDMAppleSCEPEnrollProfile: loading SCEP challenge from the database")
|
|
}
|
|
|
|
enrollProf, err := apple_mdm.GenerateEnrollmentProfileMobileconfig(
|
|
orgName,
|
|
mdmURL,
|
|
string(assets[fleet.MDMAssetSCEPChallenge].Value),
|
|
topic,
|
|
apple_mdm.MDMAccessRightAll,
|
|
true, // fresh enrollment
|
|
)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "generateMDMAppleSCEPEnrollProfile: generating enrollment profile")
|
|
}
|
|
|
|
return enrollProf, nil
|
|
}
|
|
|
|
func (svc *Service) CheckMDMAppleEnrollmentWithMinimumOSVersion(ctx context.Context, m *fleet.MDMAppleMachineInfo) (*fleet.MDMAppleSoftwareUpdateRequired, error) {
|
|
// skipauth: The enroll profile endpoint is unauthenticated.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
if m == nil {
|
|
svc.logger.DebugContext(ctx, "no machine info, skipping os version check")
|
|
return nil, nil
|
|
}
|
|
|
|
svc.logger.DebugContext(ctx, "checking os version", "serial", m.Serial, "current_version", m.OSVersion)
|
|
|
|
if !m.MDMCanRequestSoftwareUpdate {
|
|
svc.logger.DebugContext(ctx, "mdm cannot request software update, skipping os version check", "serial", m.Serial)
|
|
return nil, nil
|
|
}
|
|
|
|
// shouldUpdate depends on the app_config settings for minimum_version and update_new_hosts
|
|
shouldUpdate, err := svc.shouldOSUpdateForDEPEnrollment(ctx, *m)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "checking os updates settings", "serial", m.Serial)
|
|
} else if !shouldUpdate {
|
|
svc.logger.DebugContext(ctx, "device is above minimum or update new host not checked, skipping os version check", "serial", m.Serial)
|
|
return nil, nil
|
|
}
|
|
|
|
// if the device should update based on appconfig settings, we also need to check what versions
|
|
// are actually available for the device from Apple
|
|
sur, err := svc.getAppleSoftwareUpdateRequiredForDEPEnrollment(*m)
|
|
if err != nil {
|
|
// log for debugging but allow enrollment to proceed
|
|
svc.logger.InfoContext(ctx, "getting apple software update required", "serial", m.Serial, "err", err)
|
|
return nil, nil
|
|
}
|
|
|
|
return sur, nil
|
|
}
|
|
|
|
func (svc *Service) shouldOSUpdateForDEPEnrollment(ctx context.Context, m fleet.MDMAppleMachineInfo) (bool, error) {
|
|
// NOTE: Under the hood, the datastore is joining host_dep_assignments to the hosts table to
|
|
// look up DEP hosts by serial number. It grabs the team id and platform from the
|
|
// hosts table. Then it uses the team id to get either the global config or team config.
|
|
// Finally, it uses the platform to get os updates settings from the config for
|
|
// one of ios, ipados, or darwin, as applicable. There's a lot of assumptions going on here, not
|
|
// least of which is that the platform is correct in the hosts table. If the platform is wrong,
|
|
// we'll end up with a meaningless comparison of unrelated versions. We could potentially add
|
|
// some cross-check against the machine info to ensure that the platform of the host aligns with
|
|
// what we expect from the machine info. But that would involve work to derive the platform from
|
|
// the machine info (presumably from the product name, but that's not a 1:1 mapping).
|
|
platform, settings, err := svc.ds.GetMDMAppleOSUpdatesSettingsByHostSerial(ctx, m.Serial)
|
|
if err != nil {
|
|
if fleet.IsNotFound(err) {
|
|
svc.logger.InfoContext(ctx, "checking os updates settings, settings not found",
|
|
"serial", m.Serial,
|
|
)
|
|
return false, nil
|
|
}
|
|
return false, err
|
|
}
|
|
|
|
minVersion := settings.MinimumVersion.Value
|
|
isSetMinVersion := settings.MinimumVersion.Set && settings.MinimumVersion.Valid && minVersion != ""
|
|
logs := []any{
|
|
"platform", platform,
|
|
"minimum_version", minVersion,
|
|
"current_version", m.OSVersion,
|
|
"serial", m.Serial,
|
|
}
|
|
|
|
if platform != "darwin" && !isSetMinVersion {
|
|
svc.logger.InfoContext(ctx, "checking os updates settings for non-macos platform, minimum version not set, skipping version check", logs...)
|
|
return false, nil
|
|
}
|
|
|
|
if platform == "darwin" {
|
|
updateNewHosts := settings.UpdateNewHosts.Set && settings.UpdateNewHosts.Valid && settings.UpdateNewHosts.Value
|
|
logs = append(logs, "update_new_hosts", updateNewHosts)
|
|
switch {
|
|
case !updateNewHosts:
|
|
// never update macos if updateNewHosts is false
|
|
svc.logger.InfoContext(ctx, "checking os updates settings for macos, new hosts should not update", logs...)
|
|
return false, nil
|
|
case !isSetMinVersion:
|
|
// always update macos if updateNewHosts is true and minimum version is not set
|
|
svc.logger.InfoContext(ctx, "checking os updates settings for macos, new hosts should always update to latest", logs...)
|
|
return true, nil
|
|
default:
|
|
// default to normal version check (require update if less than minimum version)
|
|
svc.logger.InfoContext(ctx, "checking os updates settings for macos, new hosts should update to latest if below minimum version", logs...)
|
|
}
|
|
}
|
|
|
|
needsUpdate, err := apple_mdm.IsLessThanVersion(m.OSVersion, minVersion)
|
|
if err != nil {
|
|
svc.logger.InfoContext(ctx, "checking os updates settings, cannot compare versions", logs...)
|
|
return false, nil
|
|
}
|
|
|
|
return needsUpdate, nil
|
|
}
|
|
|
|
func (svc *Service) getAppleSoftwareUpdateRequiredForDEPEnrollment(m fleet.MDMAppleMachineInfo) (*fleet.MDMAppleSoftwareUpdateRequired, error) {
|
|
latest, err := gdmf.GetLatestOSVersion(m)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
needsUpdate, err := apple_mdm.IsLessThanVersion(m.OSVersion, latest.ProductVersion)
|
|
if err != nil {
|
|
return nil, err
|
|
} else if !needsUpdate {
|
|
return nil, nil
|
|
}
|
|
|
|
return fleet.NewMDMAppleSoftwareUpdateRequired(latest.ProductVersion), nil
|
|
}
|
|
|
|
// enqueueMDMAppleCommandRemoveEnrollmentProfile enqueues a RemoveProfile MDM command for the given host.
|
|
// It is a no-op for non-Apple hosts.
|
|
func (svc *Service) enqueueMDMAppleCommandRemoveEnrollmentProfile(ctx context.Context, host *fleet.Host) error {
|
|
if !fleet.IsApplePlatform(host.Platform) {
|
|
svc.logger.DebugContext(ctx, "Skipping mdm apple remove profile command for non-Apple host", "host_id", host.ID, "platform", host.Platform)
|
|
return nil // no-op for non-Apple hosts
|
|
}
|
|
|
|
nanoEnroll, err := svc.ds.GetNanoMDMEnrollment(ctx, host.UUID)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "getting mdm enrollment status for mdm apple remove profile command")
|
|
}
|
|
if nanoEnroll == nil || !nanoEnroll.Enabled {
|
|
return fleet.NewUserMessageError(ctxerr.New(ctx, fmt.Sprintf("mdm is not enabled for host %d", host.ID)), http.StatusConflict)
|
|
}
|
|
|
|
cmdUUID := uuid.New().String()
|
|
err = svc.mdmAppleCommander.RemoveProfile(ctx, []string{nanoEnroll.ID}, apple_mdm.FleetPayloadIdentifier, cmdUUID, "")
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "enqueuing mdm apple remove profile command")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
type mdmAppleGetInstallerRequest struct {
|
|
Token string `query:"token"`
|
|
}
|
|
|
|
func (r mdmAppleGetInstallerResponse) Error() error { return r.Err }
|
|
|
|
type mdmAppleGetInstallerResponse struct {
|
|
Err error `json:"error,omitempty"`
|
|
|
|
// head is used by hijackRender for the response.
|
|
head bool
|
|
// Name field is used in hijackRender for the response.
|
|
name string
|
|
// Size field is used in hijackRender for the response.
|
|
size int64
|
|
// Installer field is used in hijackRender for the response.
|
|
installer []byte
|
|
}
|
|
|
|
func (r mdmAppleGetInstallerResponse) HijackRender(ctx context.Context, w http.ResponseWriter) {
|
|
w.Header().Set("Content-Length", strconv.FormatInt(r.size, 10))
|
|
w.Header().Set("Content-Type", "application/octet-stream")
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment;filename="%s"`, r.name))
|
|
|
|
if r.head {
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
|
|
// 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.installer); err != nil {
|
|
logging.WithExtras(ctx, "err", err, "bytes_copied", n)
|
|
}
|
|
}
|
|
|
|
func mdmAppleGetInstallerEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*mdmAppleGetInstallerRequest)
|
|
installer, err := svc.GetMDMAppleInstallerByToken(ctx, req.Token)
|
|
if err != nil {
|
|
return mdmAppleGetInstallerResponse{Err: err}, nil
|
|
}
|
|
return mdmAppleGetInstallerResponse{
|
|
head: false,
|
|
name: installer.Name,
|
|
size: installer.Size,
|
|
installer: installer.Installer,
|
|
}, nil
|
|
}
|
|
|
|
func (svc *Service) GetMDMAppleInstallerByToken(ctx context.Context, token string) (*fleet.MDMAppleInstaller, error) {
|
|
// skipauth: The installer endpoint uses token authentication.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
installer, err := svc.ds.MDMAppleInstaller(ctx, token)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err)
|
|
}
|
|
return installer, nil
|
|
}
|
|
|
|
type mdmAppleHeadInstallerRequest struct {
|
|
Token string `query:"token"`
|
|
}
|
|
|
|
func mdmAppleHeadInstallerEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*mdmAppleHeadInstallerRequest)
|
|
installer, err := svc.GetMDMAppleInstallerDetailsByToken(ctx, req.Token)
|
|
if err != nil {
|
|
return mdmAppleGetInstallerResponse{Err: err}, nil
|
|
}
|
|
return mdmAppleGetInstallerResponse{
|
|
head: true,
|
|
name: installer.Name,
|
|
size: installer.Size,
|
|
}, nil
|
|
}
|
|
|
|
func (svc *Service) GetMDMAppleInstallerDetailsByToken(ctx context.Context, token string) (*fleet.MDMAppleInstaller, error) {
|
|
// skipauth: The installer endpoint uses token authentication.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
installer, err := svc.ds.MDMAppleInstallerDetailsByToken(ctx, token)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err)
|
|
}
|
|
return installer, nil
|
|
}
|
|
|
|
type listMDMAppleInstallersRequest struct{}
|
|
|
|
type listMDMAppleInstallersResponse struct {
|
|
Installers []fleet.MDMAppleInstaller `json:"installers"`
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r listMDMAppleInstallersResponse) Error() error { return r.Err }
|
|
|
|
func listMDMAppleInstallersEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
installers, err := svc.ListMDMAppleInstallers(ctx)
|
|
if err != nil {
|
|
return listMDMAppleInstallersResponse{
|
|
Err: err,
|
|
}, nil
|
|
}
|
|
return listMDMAppleInstallersResponse{
|
|
Installers: installers,
|
|
}, nil
|
|
}
|
|
|
|
func (svc *Service) ListMDMAppleInstallers(ctx context.Context) ([]fleet.MDMAppleInstaller, error) {
|
|
if err := svc.authz.Authorize(ctx, &fleet.MDMAppleInstaller{}, fleet.ActionWrite); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err)
|
|
}
|
|
|
|
appConfig, err := svc.ds.AppConfig(ctx)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err)
|
|
}
|
|
|
|
installers, err := svc.ds.ListMDMAppleInstallers(ctx)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err)
|
|
}
|
|
for i := range installers {
|
|
installers[i].URL = svc.installerURL(installers[i].URLToken, appConfig)
|
|
}
|
|
return installers, nil
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Lock a device
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type deviceLockRequest struct {
|
|
HostID uint `url:"id"`
|
|
}
|
|
|
|
type deviceLockResponse struct {
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r deviceLockResponse) Error() error { return r.Err }
|
|
|
|
func (r deviceLockResponse) Status() int { return http.StatusNoContent }
|
|
|
|
func deviceLockEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*deviceLockRequest)
|
|
err := svc.MDMAppleDeviceLock(ctx, req.HostID)
|
|
if err != nil {
|
|
return deviceLockResponse{Err: err}, nil
|
|
}
|
|
return deviceLockResponse{}, nil
|
|
}
|
|
|
|
func (svc *Service) MDMAppleDeviceLock(ctx context.Context, hostID uint) error {
|
|
// skipauth: No authorization check needed due to implementation returning
|
|
// only license error.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
return fleet.ErrMissingLicense
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Wipe a device
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type deviceWipeRequest struct {
|
|
HostID uint `url:"id"`
|
|
}
|
|
|
|
type deviceWipeResponse struct {
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r deviceWipeResponse) Error() error { return r.Err }
|
|
|
|
func (r deviceWipeResponse) Status() int { return http.StatusNoContent }
|
|
|
|
func deviceWipeEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*deviceWipeRequest)
|
|
err := svc.MDMAppleEraseDevice(ctx, req.HostID)
|
|
if err != nil {
|
|
return deviceWipeResponse{Err: err}, nil
|
|
}
|
|
return deviceWipeResponse{}, nil
|
|
}
|
|
|
|
func (svc *Service) MDMAppleEraseDevice(ctx context.Context, hostID uint) error {
|
|
// skipauth: No authorization check needed due to implementation returning
|
|
// only license error.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
return fleet.ErrMissingLicense
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Get profiles assigned to a host
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type getHostProfilesRequest struct {
|
|
ID uint `url:"id"`
|
|
}
|
|
|
|
type getHostProfilesResponse struct {
|
|
HostID uint `json:"host_id"`
|
|
Profiles []*fleet.MDMAppleConfigProfile `json:"profiles"`
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r getHostProfilesResponse) Error() error { return r.Err }
|
|
|
|
func getHostProfilesEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*getHostProfilesRequest)
|
|
sums, err := svc.MDMListHostConfigurationProfiles(ctx, req.ID)
|
|
if err != nil {
|
|
return getHostProfilesResponse{Err: err}, nil
|
|
}
|
|
res := getHostProfilesResponse{Profiles: sums, HostID: req.ID}
|
|
if res.Profiles == nil {
|
|
res.Profiles = []*fleet.MDMAppleConfigProfile{} // return empty json array instead of json null
|
|
}
|
|
return res, nil
|
|
}
|
|
|
|
func (svc *Service) MDMListHostConfigurationProfiles(ctx context.Context, hostID uint) ([]*fleet.MDMAppleConfigProfile, error) {
|
|
// skipauth: No authorization check needed due to implementation returning
|
|
// only license error.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
return nil, fleet.ErrMissingLicense
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Batch Replace MDM Apple Profiles
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type batchSetMDMAppleProfilesRequest struct {
|
|
TeamID *uint `json:"-" query:"team_id,optional" renameto:"fleet_id"`
|
|
TeamName *string `json:"-" query:"team_name,optional" renameto:"fleet_name"`
|
|
DryRun bool `json:"-" query:"dry_run,optional"` // if true, apply validation but do not save changes
|
|
Profiles [][]byte `json:"profiles"`
|
|
}
|
|
|
|
type batchSetMDMAppleProfilesResponse struct {
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r batchSetMDMAppleProfilesResponse) Error() error { return r.Err }
|
|
|
|
func (r batchSetMDMAppleProfilesResponse) Status() int { return http.StatusNoContent }
|
|
|
|
func batchSetMDMAppleProfilesEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*batchSetMDMAppleProfilesRequest)
|
|
if err := svc.BatchSetMDMAppleProfiles(ctx, req.TeamID, req.TeamName, req.Profiles, req.DryRun, false); err != nil {
|
|
return batchSetMDMAppleProfilesResponse{Err: err}, nil
|
|
}
|
|
return batchSetMDMAppleProfilesResponse{}, nil
|
|
}
|
|
|
|
func (svc *Service) BatchSetMDMAppleProfiles(ctx context.Context, tmID *uint, tmName *string, profiles [][]byte, dryRun, skipBulkPending bool) error {
|
|
var err error
|
|
tmID, tmName, err = svc.authorizeBatchProfiles(ctx, tmID, tmName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
appCfg, err := svc.ds.AppConfig(ctx)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err)
|
|
}
|
|
|
|
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(profiles) == 0 {
|
|
return nil
|
|
}
|
|
|
|
return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("mdm", "cannot set custom settings: "+fleet.ErrMDMNotConfigured.Error()))
|
|
}
|
|
|
|
// any duplicate identifier or name in the provided set results in an error
|
|
profs := make([]*fleet.MDMAppleConfigProfile, 0, len(profiles))
|
|
byName, byIdent := make(map[string]bool, len(profiles)), make(map[string]bool, len(profiles))
|
|
for i, prof := range profiles {
|
|
if len(prof) > 1024*1024 {
|
|
return ctxerr.Wrap(ctx,
|
|
fleet.NewInvalidArgumentError(fmt.Sprintf("profiles[%d]", i), fleet.MaxProfileSizeErrMsg),
|
|
)
|
|
}
|
|
|
|
err := CheckProfileIsNotSigned(prof)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err)
|
|
}
|
|
|
|
// Check for secrets in profile name before expansion
|
|
if err := fleet.ValidateNoSecretsInProfileName(prof); err != nil {
|
|
return ctxerr.Wrap(ctx,
|
|
fleet.NewInvalidArgumentError(fmt.Sprintf("profiles[%d]", i), err.Error()))
|
|
}
|
|
// Expand profile for validation
|
|
expanded, secretsUpdatedAt, err := svc.ds.ExpandEmbeddedSecretsAndUpdatedAt(ctx, string(prof))
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx,
|
|
fleet.NewInvalidArgumentError(fmt.Sprintf("profiles[%d]", i), err.Error()),
|
|
"missing fleet secrets")
|
|
}
|
|
if err := svc.ds.ValidateReferencedCustomHostVitals(ctx, []string{string(prof)}); err != nil {
|
|
if !fleet.IsInvalidReferencedCustomHostVitalsError(err) {
|
|
return ctxerr.Wrap(ctx, err, "validating referenced custom host vitals")
|
|
}
|
|
return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError(fmt.Sprintf("profiles[%d]", i), err.Error()))
|
|
}
|
|
mdmProf, err := fleet.NewMDMAppleConfigProfile([]byte(expanded), tmID)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx,
|
|
fleet.NewInvalidArgumentError(fmt.Sprintf("profiles[%d]", i), err.Error()),
|
|
"invalid mobileconfig profile")
|
|
}
|
|
|
|
if err := mdmProf.ValidateUserProvided(svc.config.MDM.IsCustomDiskEncryptionEnabled()); err != nil {
|
|
return ctxerr.Wrap(ctx,
|
|
fleet.NewInvalidArgumentError(fmt.Sprintf("profiles[%d]", i), err.Error()))
|
|
}
|
|
|
|
// check if the profile has any fleet variable, not supported by this deprecated endpoint
|
|
if vars := variables.FindKeepDuplicates(expanded); len(vars) > 0 {
|
|
return ctxerr.Wrap(ctx,
|
|
fleet.NewInvalidArgumentError(
|
|
fmt.Sprintf("profiles[%d]", i), "profile variables are not supported by this deprecated endpoint, use POST /api/latest/fleet/mdm/profiles/batch"))
|
|
}
|
|
|
|
// Store original unexpanded profile
|
|
mdmProf.Mobileconfig = prof
|
|
mdmProf.SecretsUpdatedAt = secretsUpdatedAt
|
|
|
|
if byName[mdmProf.Name] {
|
|
return ctxerr.Wrap(ctx,
|
|
fleet.NewInvalidArgumentError(fmt.Sprintf("profiles[%d]", i), fmt.Sprintf("Couldn't edit configuration_profiles. More than one configuration profile have the same name (PayloadDisplayName): %q", mdmProf.Name)),
|
|
"duplicate mobileconfig profile by name")
|
|
}
|
|
byName[mdmProf.Name] = true
|
|
|
|
if byIdent[mdmProf.Identifier] {
|
|
return ctxerr.Wrap(ctx,
|
|
fleet.NewInvalidArgumentError(fmt.Sprintf("profiles[%d]", i), fmt.Sprintf("Couldn't edit configuration_profiles. More than one configuration profile have the same identifier (PayloadIdentifier): %q", mdmProf.Identifier)),
|
|
"duplicate mobileconfig profile by identifier")
|
|
}
|
|
byIdent[mdmProf.Identifier] = true
|
|
|
|
profs = append(profs, mdmProf)
|
|
}
|
|
|
|
if !skipBulkPending {
|
|
// check for duplicates with existing profiles, skipBulkPending signals that the caller
|
|
// is responsible for ensuring that the profiles names are unique (e.g., MDMAppleMatchPreassignment)
|
|
allProfs, _, err := svc.ds.ListMDMConfigProfiles(ctx, tmID, fleet.ListOptions{PerPage: 0})
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "list mdm config profiles")
|
|
}
|
|
for _, p := range allProfs {
|
|
if byName[p.Name] {
|
|
switch {
|
|
case strings.HasPrefix(p.ProfileUUID, "a"):
|
|
// do nothing, all existing mobileconfigs will be replaced and we've already checked
|
|
// the new mobileconfigs for duplicates
|
|
continue
|
|
case strings.HasPrefix(p.ProfileUUID, "w"):
|
|
err := fleet.NewInvalidArgumentError("PayloadDisplayName", fmt.Sprintf(
|
|
"Couldn't edit configuration_profiles. A Windows configuration profile shares the same name as a macOS configuration profile (PayloadDisplayName): %q", p.Name))
|
|
return ctxerr.Wrap(ctx, err, "duplicate xml and mobileconfig by name")
|
|
default:
|
|
err := fleet.NewInvalidArgumentError("PayloadDisplayName", fmt.Sprintf(
|
|
"Couldn't edit configuration_profiles. More than one configuration profile have the same name (PayloadDisplayName): %q", p.Name))
|
|
return ctxerr.Wrap(ctx, err, "duplicate json and mobileconfig by name")
|
|
}
|
|
}
|
|
byName[p.Name] = true
|
|
}
|
|
}
|
|
|
|
// Verify profile scopes conflict before stopping dry-run, but also before entering the transaction in BatchSetMDMAppleProfiles
|
|
err = svc.ds.VerifyAppleConfigProfileScopesDoNotConflict(ctx, profs)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if dryRun {
|
|
return nil
|
|
}
|
|
|
|
if err := svc.ds.BatchSetMDMAppleProfiles(ctx, tmID, profs); err != nil {
|
|
return err
|
|
}
|
|
|
|
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")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Preassign a profile to a host
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type preassignMDMAppleProfileRequest struct {
|
|
fleet.MDMApplePreassignProfilePayload
|
|
}
|
|
|
|
type preassignMDMAppleProfileResponse struct {
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r preassignMDMAppleProfileResponse) Error() error { return r.Err }
|
|
|
|
func (r preassignMDMAppleProfileResponse) Status() int { return http.StatusNoContent }
|
|
|
|
func preassignMDMAppleProfileEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*preassignMDMAppleProfileRequest)
|
|
if err := svc.MDMApplePreassignProfile(ctx, req.MDMApplePreassignProfilePayload); err != nil {
|
|
return preassignMDMAppleProfileResponse{Err: err}, nil
|
|
}
|
|
return preassignMDMAppleProfileResponse{}, nil
|
|
}
|
|
|
|
func (svc *Service) MDMApplePreassignProfile(ctx context.Context, payload fleet.MDMApplePreassignProfilePayload) error {
|
|
// skipauth: No authorization check needed due to implementation returning
|
|
// only license error.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
return fleet.ErrMissingLicense
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Match a set of pre-assigned profiles with a team
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type matchMDMApplePreassignmentRequest struct {
|
|
ExternalHostIdentifier string `json:"external_host_identifier"`
|
|
}
|
|
|
|
type matchMDMApplePreassignmentResponse struct {
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r matchMDMApplePreassignmentResponse) Error() error { return r.Err }
|
|
|
|
func (r matchMDMApplePreassignmentResponse) Status() int { return http.StatusNoContent }
|
|
|
|
func matchMDMApplePreassignmentEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*matchMDMApplePreassignmentRequest)
|
|
if err := svc.MDMAppleMatchPreassignment(ctx, req.ExternalHostIdentifier); err != nil {
|
|
return matchMDMApplePreassignmentResponse{Err: err}, nil
|
|
}
|
|
return matchMDMApplePreassignmentResponse{}, nil
|
|
}
|
|
|
|
func (svc *Service) MDMAppleMatchPreassignment(ctx context.Context, ref string) error {
|
|
// skipauth: No authorization check needed due to implementation returning
|
|
// only license error.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
return fleet.ErrMissingLicense
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Update MDM Apple Settings
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type updateMDMAppleSettingsRequest struct {
|
|
fleet.MDMAppleSettingsPayload
|
|
}
|
|
|
|
type updateMDMAppleSettingsResponse struct {
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r updateMDMAppleSettingsResponse) Error() error { return r.Err }
|
|
|
|
func (r updateMDMAppleSettingsResponse) Status() int { return http.StatusNoContent }
|
|
|
|
// This endpoint is required because the UI must allow maintainers (in addition
|
|
// to admins) to update some MDM Apple settings, while the update config/update
|
|
// team endpoints only allow write access to admins.
|
|
func updateMDMAppleSettingsEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*updateMDMAppleSettingsRequest)
|
|
if err := svc.UpdateMDMDiskEncryption(ctx, req.MDMAppleSettingsPayload.TeamID, req.MDMAppleSettingsPayload.EnableDiskEncryption, nil); err != nil {
|
|
return updateMDMAppleSettingsResponse{Err: err}, nil
|
|
}
|
|
return updateMDMAppleSettingsResponse{}, nil
|
|
}
|
|
|
|
func (svc *Service) updateAppConfigMDMDiskEncryption(ctx context.Context, enabled *bool) error {
|
|
// appconfig is only used internally, it's fine to read it unobfuscated
|
|
// (svc.AppConfigObfuscated must not be used because the write-only users
|
|
// such as gitops will fail to access it).
|
|
ac, err := svc.ds.AppConfig(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var didUpdate bool
|
|
if enabled != nil {
|
|
if ac.MDM.EnableDiskEncryption.Value != *enabled {
|
|
if *enabled && svc.config.Server.PrivateKey == "" {
|
|
return ctxerr.New(ctx, "Missing required private key. Learn how to configure the private key here: https://fleetdm.com/learn-more-about/fleet-server-private-key")
|
|
}
|
|
|
|
ac.MDM.EnableDiskEncryption = optjson.SetBool(*enabled)
|
|
didUpdate = true
|
|
}
|
|
}
|
|
if didUpdate {
|
|
if err := svc.ds.SaveAppConfig(ctx, ac); err != nil {
|
|
return err
|
|
}
|
|
if ac.MDM.EnabledAndConfigured { // if macOS MDM is configured, set up FileVault escrow
|
|
var act fleet.ActivityDetails
|
|
if ac.MDM.EnableDiskEncryption.Value {
|
|
act = fleet.ActivityTypeEnabledMacosDiskEncryption{}
|
|
if err := svc.EnterpriseOverrides.MDMAppleEnableFileVaultAndEscrow(ctx, nil); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "enable no-team filevault and escrow")
|
|
}
|
|
} else {
|
|
act = fleet.ActivityTypeDisabledMacosDiskEncryption{}
|
|
if err := svc.EnterpriseOverrides.MDMAppleDisableFileVaultAndEscrow(ctx, nil); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "disable no-team filevault and escrow")
|
|
}
|
|
}
|
|
if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), act); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "create activity for app config macos disk encryption")
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// updateAppConfigMDMHostNameTemplate saves the "No team" host name template on
|
|
// the global AppConfig.MDM struct and reconciles enforcement.
|
|
func (svc *Service) updateAppConfigMDMHostNameTemplate(ctx context.Context, nameTemplate string) error {
|
|
ac, err := svc.ds.AppConfig(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if ac.MDM.HostNameTemplate.Value == nameTemplate {
|
|
return nil
|
|
}
|
|
|
|
ac.MDM.HostNameTemplate = optjson.SetString(nameTemplate)
|
|
if err := svc.ds.SaveAppConfig(ctx, ac); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "save app config for host name template")
|
|
}
|
|
|
|
return svc.EnterpriseOverrides.ApplyHostNameTemplateChange(ctx, nil, nameTemplate)
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Upload a bootstrap package
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type uploadBootstrapPackageRequest struct {
|
|
Package *multipart.FileHeader
|
|
DryRun bool `json:"-" query:"dry_run,optional"` // if true, apply validation but do not save changes
|
|
TeamID uint
|
|
}
|
|
|
|
type uploadBootstrapPackageResponse struct {
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
// TODO: We parse the whole body before running svc.authz.Authorize.
|
|
// An authenticated but unauthorized user could abuse this.
|
|
func (uploadBootstrapPackageRequest) DecodeRequest(ctx context.Context, r *http.Request) (interface{}, error) {
|
|
decoded := uploadBootstrapPackageRequest{}
|
|
err := parseMultipartForm(ctx, r, platform_http.MaxMultipartFormSize)
|
|
if err != nil {
|
|
return nil, &fleet.BadRequestError{
|
|
Message: "failed to parse multipart form",
|
|
InternalErr: err,
|
|
}
|
|
}
|
|
|
|
if r.MultipartForm.File["package"] == nil {
|
|
return nil, &fleet.BadRequestError{
|
|
Message: "package multipart field is required",
|
|
InternalErr: err,
|
|
}
|
|
}
|
|
|
|
decoded.Package = r.MultipartForm.File["package"][0]
|
|
if !file.IsValidMacOSName(decoded.Package.Filename) {
|
|
return nil, &fleet.BadRequestError{
|
|
Message: "package name contains invalid characters",
|
|
InternalErr: ctxerr.New(ctx, "package name contains invalid characters"),
|
|
}
|
|
}
|
|
|
|
// default is no team
|
|
decoded.TeamID = 0
|
|
val, ok := r.MultipartForm.Value["fleet_id"]
|
|
if ok && len(val) > 0 {
|
|
fleetID, err := strconv.Atoi(val[0])
|
|
if err != nil {
|
|
return nil, &fleet.BadRequestError{Message: fmt.Sprintf("failed to decode fleet_id in multipart form: %s", err.Error())}
|
|
}
|
|
decoded.TeamID = uint(fleetID) //nolint:gosec // dismiss G115
|
|
}
|
|
|
|
// Dry run
|
|
decoded.DryRun = r.URL.Query().Get("dry_run") == "true"
|
|
|
|
return &decoded, nil
|
|
}
|
|
|
|
func (r uploadBootstrapPackageResponse) Error() error { return r.Err }
|
|
|
|
func uploadBootstrapPackageEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*uploadBootstrapPackageRequest)
|
|
ff, err := req.Package.Open()
|
|
if err != nil {
|
|
return uploadBootstrapPackageResponse{Err: err}, nil
|
|
}
|
|
defer ff.Close()
|
|
|
|
if err := svc.MDMAppleUploadBootstrapPackage(ctx, req.Package.Filename, ff, req.TeamID, req.DryRun); err != nil {
|
|
return uploadBootstrapPackageResponse{Err: err}, nil
|
|
}
|
|
return &uploadBootstrapPackageResponse{}, nil
|
|
}
|
|
|
|
func (svc *Service) MDMAppleUploadBootstrapPackage(ctx context.Context, name string, pkg io.Reader, teamID uint, dryRun bool) error {
|
|
// skipauth: No authorization check needed due to implementation returning
|
|
// only license error.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
return fleet.ErrMissingLicense
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Download a bootstrap package
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type downloadBootstrapPackageRequest struct {
|
|
Token string `query:"token"`
|
|
}
|
|
|
|
type downloadBootstrapPackageResponse struct {
|
|
Err error `json:"error,omitempty"`
|
|
|
|
// fields used by hijackRender for the response.
|
|
pkg *fleet.MDMAppleBootstrapPackage
|
|
}
|
|
|
|
func (r downloadBootstrapPackageResponse) Error() error { return r.Err }
|
|
|
|
func (r downloadBootstrapPackageResponse) HijackRender(ctx context.Context, w http.ResponseWriter) {
|
|
w.Header().Set("Content-Length", strconv.Itoa(len(r.pkg.Bytes)))
|
|
w.Header().Set("Content-Type", "application/octet-stream")
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment;filename="%s"`, r.pkg.Name))
|
|
|
|
// 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.pkg.Bytes); err != nil {
|
|
logging.WithExtras(ctx, "err", err, "bytes_copied", n)
|
|
}
|
|
}
|
|
|
|
func downloadBootstrapPackageEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*downloadBootstrapPackageRequest)
|
|
pkg, err := svc.GetMDMAppleBootstrapPackageBytes(ctx, req.Token)
|
|
if err != nil {
|
|
return downloadBootstrapPackageResponse{Err: err}, nil
|
|
}
|
|
return downloadBootstrapPackageResponse{pkg: pkg}, nil
|
|
}
|
|
|
|
func (svc *Service) GetMDMAppleBootstrapPackageBytes(ctx context.Context, token string) (*fleet.MDMAppleBootstrapPackage, error) {
|
|
// skipauth: No authorization check needed due to implementation returning
|
|
// only license error.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
return nil, fleet.ErrMissingLicense
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Get metadata about a bootstrap package
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type bootstrapPackageMetadataRequest struct {
|
|
TeamID uint `url:"fleet_id"`
|
|
|
|
// ForUpdate is used to indicate that the authorization should be for a
|
|
// "write" instead of a "read", this is needed specifically for the gitops
|
|
// user which is a write-only user, but needs to call this endpoint to check
|
|
// if it needs to upload the bootstrap package (if the hashes are different).
|
|
//
|
|
// NOTE: this parameter is going to be removed in a future version.
|
|
// Prefer other ways to allow gitops read access.
|
|
// For context, see: https://github.com/fleetdm/fleet/issues/15337#issuecomment-1932878997
|
|
ForUpdate bool `query:"for_update,optional"`
|
|
}
|
|
|
|
type bootstrapPackageMetadataResponse struct {
|
|
Err error `json:"error,omitempty"`
|
|
*fleet.MDMAppleBootstrapPackage `json:",omitempty"`
|
|
}
|
|
|
|
func (r bootstrapPackageMetadataResponse) Error() error { return r.Err }
|
|
|
|
func bootstrapPackageMetadataEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*bootstrapPackageMetadataRequest)
|
|
meta, err := svc.GetMDMAppleBootstrapPackageMetadata(ctx, req.TeamID, req.ForUpdate)
|
|
switch {
|
|
case fleet.IsNotFound(err):
|
|
return bootstrapPackageMetadataResponse{Err: fleet.NewInvalidArgumentError("team_id/fleet_id",
|
|
"bootstrap package for this fleet does not exist").WithStatus(http.StatusNotFound)}, nil
|
|
case err != nil:
|
|
return bootstrapPackageMetadataResponse{Err: err}, nil
|
|
}
|
|
return bootstrapPackageMetadataResponse{MDMAppleBootstrapPackage: meta}, nil
|
|
}
|
|
|
|
func (svc *Service) GetMDMAppleBootstrapPackageMetadata(ctx context.Context, teamID uint, forUpdate bool) (*fleet.MDMAppleBootstrapPackage, error) {
|
|
// skipauth: No authorization check needed due to implementation returning
|
|
// only license error.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
return nil, fleet.ErrMissingLicense
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Delete a bootstrap package
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type deleteBootstrapPackageRequest struct {
|
|
TeamID uint `url:"fleet_id"`
|
|
DryRun bool `query:"dry_run,optional"` // if true, apply validation but do not delete
|
|
}
|
|
|
|
type deleteBootstrapPackageResponse struct {
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r deleteBootstrapPackageResponse) Error() error { return r.Err }
|
|
|
|
func deleteBootstrapPackageEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*deleteBootstrapPackageRequest)
|
|
if err := svc.DeleteMDMAppleBootstrapPackage(ctx, &req.TeamID, req.DryRun); err != nil {
|
|
return deleteBootstrapPackageResponse{Err: err}, nil
|
|
}
|
|
return deleteBootstrapPackageResponse{}, nil
|
|
}
|
|
|
|
func (svc *Service) DeleteMDMAppleBootstrapPackage(ctx context.Context, teamID *uint, dryRun bool) error {
|
|
// skipauth: No authorization check needed due to implementation returning
|
|
// only license error.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
return fleet.ErrMissingLicense
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Get aggregated summary about a team's bootstrap package
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type getMDMAppleBootstrapPackageSummaryRequest struct {
|
|
TeamID *uint `query:"team_id,optional" renameto:"fleet_id"`
|
|
}
|
|
|
|
type getMDMAppleBootstrapPackageSummaryResponse struct {
|
|
fleet.MDMAppleBootstrapPackageSummary
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r getMDMAppleBootstrapPackageSummaryResponse) Error() error { return r.Err }
|
|
|
|
func getMDMAppleBootstrapPackageSummaryEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*getMDMAppleBootstrapPackageSummaryRequest)
|
|
summary, err := svc.GetMDMAppleBootstrapPackageSummary(ctx, req.TeamID)
|
|
if err != nil {
|
|
return getMDMAppleBootstrapPackageSummaryResponse{Err: err}, nil
|
|
}
|
|
return getMDMAppleBootstrapPackageSummaryResponse{MDMAppleBootstrapPackageSummary: *summary}, nil
|
|
}
|
|
|
|
func (svc *Service) GetMDMAppleBootstrapPackageSummary(ctx context.Context, teamID *uint) (*fleet.MDMAppleBootstrapPackageSummary, error) {
|
|
// skipauth: No authorization check needed due to implementation returning
|
|
// only license error.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
return &fleet.MDMAppleBootstrapPackageSummary{}, fleet.ErrMissingLicense
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Create or update an MDM Apple Setup Assistant
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type createMDMAppleSetupAssistantRequest struct {
|
|
TeamID *uint `json:"team_id" renameto:"fleet_id"`
|
|
Name string `json:"name"`
|
|
EnrollmentProfile json.RawMessage `json:"enrollment_profile"`
|
|
}
|
|
|
|
type createMDMAppleSetupAssistantResponse struct {
|
|
fleet.MDMAppleSetupAssistant
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r createMDMAppleSetupAssistantResponse) Error() error { return r.Err }
|
|
|
|
func createMDMAppleSetupAssistantEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*createMDMAppleSetupAssistantRequest)
|
|
asst, err := svc.SetOrUpdateMDMAppleSetupAssistant(ctx, &fleet.MDMAppleSetupAssistant{
|
|
TeamID: req.TeamID,
|
|
Name: req.Name,
|
|
Profile: req.EnrollmentProfile,
|
|
})
|
|
if err != nil {
|
|
return createMDMAppleSetupAssistantResponse{Err: err}, nil
|
|
}
|
|
return createMDMAppleSetupAssistantResponse{MDMAppleSetupAssistant: *asst}, nil
|
|
}
|
|
|
|
func (svc *Service) SetOrUpdateMDMAppleSetupAssistant(ctx context.Context, asst *fleet.MDMAppleSetupAssistant) (*fleet.MDMAppleSetupAssistant, error) {
|
|
// skipauth: No authorization check needed due to implementation returning
|
|
// only license error.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
return nil, fleet.ErrMissingLicense
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Get the MDM Apple Setup Assistant
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type getMDMAppleSetupAssistantRequest struct {
|
|
TeamID *uint `query:"team_id,optional" renameto:"fleet_id"`
|
|
}
|
|
|
|
type getMDMAppleSetupAssistantResponse struct {
|
|
fleet.MDMAppleSetupAssistant
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r getMDMAppleSetupAssistantResponse) Error() error { return r.Err }
|
|
|
|
func getMDMAppleSetupAssistantEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*getMDMAppleSetupAssistantRequest)
|
|
asst, err := svc.GetMDMAppleSetupAssistant(ctx, req.TeamID)
|
|
if err != nil {
|
|
return getMDMAppleSetupAssistantResponse{Err: err}, nil
|
|
}
|
|
return getMDMAppleSetupAssistantResponse{MDMAppleSetupAssistant: *asst}, nil
|
|
}
|
|
|
|
func (svc *Service) GetMDMAppleSetupAssistant(ctx context.Context, teamID *uint) (*fleet.MDMAppleSetupAssistant, error) {
|
|
// skipauth: No authorization check needed due to implementation returning
|
|
// only license error.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
return nil, fleet.ErrMissingLicense
|
|
}
|
|
|
|
type getDefaultMDMAppleSetupAssistantProfileResponse struct {
|
|
Profile godep.Profile `json:"enrollment_profile" db:"profile"`
|
|
UpdatedAt *time.Time `json:"updated_at"`
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r getDefaultMDMAppleSetupAssistantProfileResponse) Error() error { return r.Err }
|
|
|
|
func getDefaultMDMAppleSetupAssistantProfileEndpoint(ctx context.Context, request any, svc fleet.Service) (fleet.Errorer, error) {
|
|
profile, updatedAt, err := svc.GetDefaultMDMAppleSetupAssistantProfile(ctx)
|
|
if err != nil {
|
|
return getDefaultMDMAppleSetupAssistantProfileResponse{Err: err}, nil
|
|
}
|
|
return getDefaultMDMAppleSetupAssistantProfileResponse{Profile: profile, UpdatedAt: updatedAt}, nil
|
|
}
|
|
|
|
func (svc *Service) GetDefaultMDMAppleSetupAssistantProfile(ctx context.Context) (godep.Profile, *time.Time, error) {
|
|
// skipauth: No authorization check needed due to implementation returning
|
|
// only license error.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
return godep.Profile{}, nil, fleet.ErrMissingLicense
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Delete an MDM Apple Setup Assistant
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type deleteMDMAppleSetupAssistantRequest struct {
|
|
TeamID *uint `query:"team_id,optional" renameto:"fleet_id"`
|
|
}
|
|
|
|
type deleteMDMAppleSetupAssistantResponse struct {
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r deleteMDMAppleSetupAssistantResponse) Error() error { return r.Err }
|
|
func (r deleteMDMAppleSetupAssistantResponse) Status() int { return http.StatusNoContent }
|
|
|
|
func deleteMDMAppleSetupAssistantEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*deleteMDMAppleSetupAssistantRequest)
|
|
if err := svc.DeleteMDMAppleSetupAssistant(ctx, req.TeamID); err != nil {
|
|
return deleteMDMAppleSetupAssistantResponse{Err: err}, nil
|
|
}
|
|
return deleteMDMAppleSetupAssistantResponse{}, nil
|
|
}
|
|
|
|
func (svc *Service) DeleteMDMAppleSetupAssistant(ctx context.Context, teamID *uint) error {
|
|
// skipauth: No authorization check needed due to implementation returning
|
|
// only license error.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
return fleet.ErrMissingLicense
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Update MDM Apple Setup
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type updateMDMAppleSetupRequest struct {
|
|
fleet.MDMAppleSetupPayload
|
|
}
|
|
|
|
type updateMDMAppleSetupResponse struct {
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r updateMDMAppleSetupResponse) Error() error { return r.Err }
|
|
|
|
func (r updateMDMAppleSetupResponse) Status() int { return http.StatusNoContent }
|
|
|
|
// This endpoint is required because the UI must allow maintainers (in addition
|
|
// to admins) to update some MDM Apple settings, while the update config/update
|
|
// team endpoints only allow write access to admins.
|
|
func updateMDMAppleSetupEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*updateMDMAppleSetupRequest)
|
|
if err := svc.UpdateMDMAppleSetup(ctx, req.MDMAppleSetupPayload); err != nil {
|
|
return updateMDMAppleSetupResponse{Err: err}, nil
|
|
}
|
|
return updateMDMAppleSetupResponse{}, nil
|
|
}
|
|
|
|
func (svc *Service) UpdateMDMAppleSetup(ctx context.Context, payload fleet.MDMAppleSetupPayload) error {
|
|
// skipauth: No authorization check needed due to implementation returning
|
|
// only license error.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
return fleet.ErrMissingLicense
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// POST /mdm/sso
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type initiateMDMSSORequest struct {
|
|
Initiator string `json:"initiator,omitempty"` // optional, passed by the UI during account-driven enrollment, or by Orbit for non-Apple IdP auth.
|
|
UserIdentifier string `json:"user_identifier,omitempty"` // optional, passed by Apple for account-driven enrollment
|
|
HostUUID string `json:"host_uuid,omitempty"` // optional, passed by Orbit for non-Apple IdP auth
|
|
}
|
|
|
|
type initiateMDMSSOResponse struct {
|
|
URL string `json:"url,omitempty"`
|
|
Err error `json:"error,omitempty"`
|
|
|
|
sessionID string
|
|
sessionDurationSeconds int
|
|
}
|
|
|
|
func (r initiateMDMSSOResponse) Error() error { return r.Err }
|
|
|
|
func (r initiateMDMSSOResponse) SetCookies(_ context.Context, w http.ResponseWriter) {
|
|
setSSOCookie(w, r.sessionID, r.sessionDurationSeconds)
|
|
}
|
|
|
|
func initiateMDMSSOEndpoint(ctx context.Context, request any, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*initiateMDMSSORequest)
|
|
sessionID, sessionDurationSeconds, idpProviderURL, err := svc.InitiateMDMSSO(ctx, req.Initiator, "", req.HostUUID)
|
|
if err != nil {
|
|
return initiateMDMSSOResponse{Err: err}, nil
|
|
}
|
|
|
|
return initiateMDMSSOResponse{
|
|
URL: idpProviderURL,
|
|
sessionID: sessionID,
|
|
sessionDurationSeconds: sessionDurationSeconds,
|
|
}, nil
|
|
}
|
|
|
|
func (svc *Service) InitiateMDMSSO(ctx context.Context, initiator, customOriginalURL string, hostUUID string) (sessionID string, sessionDurationSeconds int, idpURL string, err error) {
|
|
// skipauth: No authorization check needed due to implementation
|
|
// returning only license error.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
return "", 0, "", fleet.ErrMissingLicense
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// POST /mdm/sso/callback
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type callbackMDMSSORequest struct {
|
|
sessionID string
|
|
samlResponse []byte
|
|
}
|
|
|
|
// TODO: these errors will result in JSON being returned, but we should
|
|
// redirect to the UI and let the UI display an error instead. The errors are
|
|
// rare enough (malformed data coming from the SSO provider) so they shouldn't
|
|
// affect many users.
|
|
func (callbackMDMSSORequest) DecodeRequest(ctx context.Context, r *http.Request) (interface{}, error) {
|
|
sessionID, samlResponse, err := decodeCallbackRequest(ctx, r)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &callbackMDMSSORequest{
|
|
sessionID: sessionID,
|
|
samlResponse: samlResponse,
|
|
}, nil
|
|
}
|
|
|
|
type callbackMDMSSOResponse struct {
|
|
redirectURL string
|
|
byodEnrollCookieValue string
|
|
}
|
|
|
|
func (r callbackMDMSSOResponse) HijackRender(ctx context.Context, w http.ResponseWriter) {
|
|
w.Header().Set("Location", r.redirectURL)
|
|
w.WriteHeader(http.StatusSeeOther)
|
|
}
|
|
|
|
func (r callbackMDMSSOResponse) SetCookies(_ context.Context, w http.ResponseWriter) {
|
|
deleteSSOCookie(w)
|
|
if r.byodEnrollCookieValue != "" {
|
|
setBYODCookie(w, r.byodEnrollCookieValue, 30*60) // valid for 30 minutes
|
|
}
|
|
}
|
|
|
|
// Error will always be nil because errors are handled by sending a query
|
|
// parameter in the URL response, this way the UI is able to display an error
|
|
// message.
|
|
func (r callbackMDMSSOResponse) Error() error { return nil }
|
|
|
|
func callbackMDMSSOEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
callbackRequest := request.(*callbackMDMSSORequest)
|
|
redirectURL, byodCookieValue := svc.MDMSSOCallback(ctx, callbackRequest.sessionID, callbackRequest.samlResponse)
|
|
return callbackMDMSSOResponse{
|
|
redirectURL: redirectURL,
|
|
byodEnrollCookieValue: byodCookieValue,
|
|
}, nil
|
|
}
|
|
|
|
func (svc *Service) MDMSSOCallback(ctx context.Context, sessionID string, samlResponse []byte) (redirectURL, byodCookieValue string) {
|
|
// skipauth: No authorization check needed due to implementation
|
|
// returning only license error.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
return apple_mdm.FleetUISSOCallbackPath + "?error=true", ""
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// GET /mdm/manual_enrollment_profile
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type getManualEnrollmentProfileResponse struct {
|
|
// Profile field is used in HijackRender for the response.
|
|
Profile []byte
|
|
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r getManualEnrollmentProfileResponse) HijackRender(ctx context.Context, w http.ResponseWriter) {
|
|
// make the browser download the content to a file
|
|
w.Header().Add("Content-Disposition", `attachment; filename="fleet-mdm-enrollment-profile.mobileconfig"`)
|
|
// explicitly set the content length before the write, so the caller can
|
|
// detect short writes (if it fails to send the full content properly)
|
|
w.Header().Set("Content-Length", strconv.FormatInt(int64(len(r.Profile)), 10))
|
|
// this content type will make macos open the profile with the proper application
|
|
w.Header().Set("Content-Type", "application/x-apple-aspen-config; charset=utf-8")
|
|
// prevent detection of content, obey the provided content-type
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
|
|
if n, err := w.Write(r.Profile); err != nil {
|
|
logging.WithExtras(ctx, "err", err, "written", n)
|
|
}
|
|
}
|
|
|
|
func (r getManualEnrollmentProfileResponse) Error() error { return r.Err }
|
|
|
|
type getManualEnrollmentProfileRequest struct {
|
|
// Personal indicates the end user chose "Personal (BYOD)" on the /enroll page.
|
|
// Defaults to false (company-owned) when omitted to preserve backwards-compatibility.
|
|
Personal bool `query:"byod,optional"`
|
|
}
|
|
|
|
func getManualEnrollmentProfileEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*getManualEnrollmentProfileRequest)
|
|
profile, err := svc.GetMDMManualEnrollmentProfile(ctx, req.Personal)
|
|
if err != nil {
|
|
return getManualEnrollmentProfileResponse{Err: err}, nil
|
|
}
|
|
|
|
return getManualEnrollmentProfileResponse{Profile: profile}, nil
|
|
}
|
|
|
|
func (svc *Service) GetMDMManualEnrollmentProfile(ctx context.Context, personal bool) ([]byte, error) {
|
|
// skipauth: No authorization check needed due to implementation returning
|
|
// only license error.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
return nil, fleet.ErrMissingLicense
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// FileVault-related free version implementation
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
func (svc *Service) MDMAppleEnableFileVaultAndEscrow(ctx context.Context, teamID *uint) error {
|
|
return fleet.ErrMissingLicense
|
|
}
|
|
|
|
func (svc *Service) MDMAppleDisableFileVaultAndEscrow(ctx context.Context, teamID *uint) error {
|
|
return fleet.ErrMissingLicense
|
|
}
|
|
|
|
type listAppleDDMAssetsRequest struct {
|
|
TeamID *uint `query:"fleet_id,optional"`
|
|
}
|
|
|
|
type listAppleDDMAssetsResponse struct {
|
|
Assets []*fleet.DDMAsset `json:"assets"`
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r listAppleDDMAssetsResponse) Error() error { return r.Err }
|
|
|
|
func listAppleDDMAssetsEndpoint(ctx context.Context, request any, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*listAppleDDMAssetsRequest)
|
|
assets, err := svc.ListAppleDDMAssets(ctx, req.TeamID)
|
|
if err != nil {
|
|
return listAppleDDMAssetsResponse{Err: err}, nil
|
|
}
|
|
return listAppleDDMAssetsResponse{Assets: assets}, nil
|
|
}
|
|
|
|
func (svc *Service) ListAppleDDMAssets(ctx context.Context, teamID *uint) ([]*fleet.DDMAsset, error) {
|
|
// skipauth: No authorization check needed due to implementation returning
|
|
// only license error.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
return nil, fleet.ErrMissingLicense
|
|
}
|
|
|
|
type getAppleDDMAssetRequest struct {
|
|
AssetUUID string `url:"asset_uuid"`
|
|
Alt string `query:"alt,optional"`
|
|
}
|
|
|
|
func (r getAppleDDMAssetRequest) ValidateRequest() error {
|
|
if r.Alt != "" && strings.ToLower(r.Alt) != "media" {
|
|
return &fleet.BadRequestError{Message: "Alt query param value is invalid. Supported values are empty and \"media\""}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type getAppleDDMAssetResponse struct {
|
|
Asset *fleet.DDMAsset `json:",omitempty"`
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r getAppleDDMAssetResponse) Error() error { return r.Err }
|
|
|
|
type downloadAppleDDMAssetResponse struct {
|
|
Name string
|
|
Data []byte
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r downloadAppleDDMAssetResponse) Error() error { return r.Err }
|
|
|
|
func (r downloadAppleDDMAssetResponse) HijackRender(ctx context.Context, w http.ResponseWriter) {
|
|
w.Header().Set("Content-Length", strconv.Itoa(len(r.Data)))
|
|
w.Header().Set("Content-Type", "application/json") // We know we return JSON, if we ever serve other files we need a generic octet-stream
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment;filename=%q`, r.Name)) // make the caller download the file
|
|
if n, err := w.Write(r.Data); err != nil {
|
|
logging.WithExtras(ctx, "err", err, "written", n)
|
|
}
|
|
}
|
|
|
|
func getAppleDDMAssetEndpoint(ctx context.Context, request any, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*getAppleDDMAssetRequest)
|
|
|
|
if strings.ToLower(req.Alt) == "media" {
|
|
name, data, err := svc.DownloadAppleDDMAsset(ctx, req.AssetUUID)
|
|
if err != nil {
|
|
return downloadAppleDDMAssetResponse{Err: err}, nil
|
|
}
|
|
return downloadAppleDDMAssetResponse{Name: name, Data: data}, nil
|
|
}
|
|
|
|
asset, err := svc.GetAppleDDMAsset(ctx, req.AssetUUID)
|
|
if err != nil {
|
|
return getAppleDDMAssetResponse{Err: err}, nil
|
|
}
|
|
return getAppleDDMAssetResponse{Asset: asset}, nil
|
|
}
|
|
|
|
func (svc *Service) GetAppleDDMAsset(ctx context.Context, assetUUID string) (*fleet.DDMAsset, error) {
|
|
// skipauth: No authorization check needed due to implementation returning
|
|
// only license error.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
return nil, fleet.ErrMissingLicense
|
|
}
|
|
|
|
func (svc *Service) DownloadAppleDDMAsset(ctx context.Context, assetUUID string) (name string, data []byte, err error) {
|
|
// skipauth: No authorization check needed due to implementation returning
|
|
// only license error.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
return "", nil, fleet.ErrMissingLicense
|
|
}
|
|
|
|
type createAppleDDMAssetRequest struct {
|
|
TeamID *uint
|
|
Asset *multipart.FileHeader
|
|
}
|
|
|
|
func (createAppleDDMAssetRequest) DecodeRequest(ctx context.Context, r *http.Request) (any, error) {
|
|
decoded := new(createAppleDDMAssetRequest)
|
|
|
|
err := parseMultipartForm(ctx, r, platform_http.MaxMultipartFormSize)
|
|
if err != nil {
|
|
return nil, &fleet.BadRequestError{
|
|
Message: "failed to parse multipart form",
|
|
InternalErr: err,
|
|
}
|
|
}
|
|
|
|
val, ok := r.MultipartForm.Value["fleet_id"]
|
|
if !ok || len(val) < 1 {
|
|
// default is no team
|
|
decoded.TeamID = new(uint(0))
|
|
} else {
|
|
fleetID, err := strconv.ParseUint(val[0], 10, 32) // nolint:staticcheck // it's used...
|
|
if err != nil {
|
|
return nil, &fleet.BadRequestError{Message: fmt.Sprintf("Invalid fleet_id: %s", val[0])}
|
|
}
|
|
decoded.TeamID = new(uint(fleetID))
|
|
}
|
|
|
|
fhs, ok := r.MultipartForm.File["asset"]
|
|
if !ok || len(fhs) < 1 {
|
|
return nil, &fleet.BadRequestError{Message: "no file headers for asset"}
|
|
}
|
|
decoded.Asset = fhs[0]
|
|
|
|
if !strings.HasSuffix(decoded.Asset.Filename, ".json") {
|
|
return nil, &fleet.BadRequestError{Message: "Invalid file type for asset. Only \".json\" files are allowed"}
|
|
}
|
|
|
|
return decoded, nil
|
|
}
|
|
|
|
type createAppleDDMAssetResponse struct {
|
|
AssetUUID string `json:"asset_uuid,omitempty"`
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r createAppleDDMAssetResponse) Error() error { return r.Err }
|
|
|
|
func createAppleDDMAssetEndpoint(ctx context.Context, request any, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*createAppleDDMAssetRequest)
|
|
f, err := req.Asset.Open()
|
|
if err != nil {
|
|
return createAppleDDMAssetResponse{Err: err}, nil
|
|
}
|
|
defer f.Close()
|
|
assetData, err := io.ReadAll(f)
|
|
if err != nil {
|
|
return createAppleDDMAssetResponse{Err: err}, nil
|
|
}
|
|
assetName := strings.TrimSuffix(req.Asset.Filename, ".json")
|
|
assetUUID, err := svc.CreateAppleDDMAsset(ctx, req.TeamID, assetName, assetData)
|
|
if err != nil {
|
|
return createAppleDDMAssetResponse{Err: err}, nil
|
|
}
|
|
return createAppleDDMAssetResponse{AssetUUID: assetUUID}, nil
|
|
}
|
|
|
|
func (svc *Service) CreateAppleDDMAsset(ctx context.Context, teamID *uint, name string, data []byte) (string, error) {
|
|
// skipauth: No authorization check needed due to implementation returning
|
|
// only license error.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
return "", fleet.ErrMissingLicense
|
|
}
|
|
|
|
type deleteAppleDDMAssetRequest struct {
|
|
AssetUUID string `url:"asset_uuid"`
|
|
}
|
|
|
|
type deleteAppleDDMAssetResponse struct {
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r deleteAppleDDMAssetResponse) Error() error { return r.Err }
|
|
|
|
func (r deleteAppleDDMAssetResponse) Status() int { return http.StatusNoContent }
|
|
|
|
func deleteAppleDDMAssetEndpoint(ctx context.Context, request any, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*deleteAppleDDMAssetRequest)
|
|
if err := svc.DeleteAppleDDMAsset(ctx, req.AssetUUID); err != nil {
|
|
return deleteAppleDDMAssetResponse{Err: err}, nil
|
|
}
|
|
return deleteAppleDDMAssetResponse{}, nil
|
|
}
|
|
|
|
func (svc *Service) DeleteAppleDDMAsset(ctx context.Context, assetUUID string) error {
|
|
// skipauth: No authorization check needed due to implementation returning
|
|
// only license error.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
return fleet.ErrMissingLicense
|
|
}
|
|
|
|
type batchSetAppleDDMAssetsRequest struct {
|
|
TeamID *uint `json:"-" query:"fleet_id,optional"`
|
|
TeamName string `json:"-" query:"team_name,optional" renameto:"fleet_name"`
|
|
DryRun bool `json:"-" query:"dry_run,optional"`
|
|
Assets []fleet.MDMAppleDDMAssetBatchPayload `json:"assets"`
|
|
}
|
|
|
|
type batchSetAppleDDMAssetsResponse struct {
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r batchSetAppleDDMAssetsResponse) Error() error { return r.Err }
|
|
|
|
func (r batchSetAppleDDMAssetsResponse) Status() int { return http.StatusNoContent }
|
|
|
|
func batchSetAppleDDMAssetsEndpoint(ctx context.Context, request any, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*batchSetAppleDDMAssetsRequest)
|
|
if err := svc.BatchSetAppleDDMAssets(ctx, req.TeamID, req.TeamName, req.Assets, req.DryRun); err != nil {
|
|
return batchSetAppleDDMAssetsResponse{Err: err}, nil
|
|
}
|
|
return batchSetAppleDDMAssetsResponse{}, nil
|
|
}
|
|
|
|
func (svc *Service) BatchSetAppleDDMAssets(ctx context.Context, teamID *uint, teamName string, assets []fleet.MDMAppleDDMAssetBatchPayload, dryRun bool) error {
|
|
// skipauth: No authorization check needed due to implementation returning
|
|
// only license error.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
return fleet.ErrMissingLicense
|
|
}
|
|
|
|
type releaseABDevicesRequest struct {
|
|
HostIDs []uint `json:"ids"`
|
|
}
|
|
|
|
type releaseABDevicesResponse struct {
|
|
Results []*fleet.ABReleaseDeviceResponse `json:"results"`
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r releaseABDevicesResponse) Error() error { return r.Err }
|
|
|
|
func releaseABDevicesEndpoint(ctx context.Context, request any, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*releaseABDevicesRequest)
|
|
results, err := svc.ReleaseABDevices(ctx, req.HostIDs)
|
|
if err != nil {
|
|
return releaseABDevicesResponse{Results: nil, Err: err}, nil
|
|
}
|
|
return releaseABDevicesResponse{Results: results}, nil
|
|
}
|
|
|
|
func (svc *Service) ReleaseABDevices(ctx context.Context, hostIDs []uint) ([]*fleet.ABReleaseDeviceResponse, error) {
|
|
// skipauth: No authorization check needed due to implementation returning
|
|
// only license error.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
return nil, fleet.ErrMissingLicense
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Implementation of nanomdm's CheckinAndCommandService interface
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type MDMAppleCheckinAndCommandService struct {
|
|
ds fleet.Datastore
|
|
logger *slog.Logger
|
|
commander *apple_mdm.MDMAppleCommander
|
|
vppInstaller fleet.AppleMDMVPPInstaller
|
|
mdmLifecycle *mdmlifecycle.HostLifecycle
|
|
commandHandlers map[string][]fleet.MDMCommandResultsHandler
|
|
keyValueStore fleet.AdvancedKeyValueStore
|
|
newActivityFn mdmlifecycle.NewActivityFunc
|
|
isPremium bool
|
|
}
|
|
|
|
func NewMDMAppleCheckinAndCommandService(
|
|
ds fleet.Datastore,
|
|
commander *apple_mdm.MDMAppleCommander,
|
|
vppInstaller fleet.AppleMDMVPPInstaller,
|
|
isPremium bool,
|
|
logger *slog.Logger,
|
|
keyValueStore fleet.AdvancedKeyValueStore,
|
|
newActivityFn mdmlifecycle.NewActivityFunc,
|
|
) *MDMAppleCheckinAndCommandService {
|
|
mdmLifecycle := mdmlifecycle.New(ds, logger, newActivityFn)
|
|
return &MDMAppleCheckinAndCommandService{
|
|
ds: ds,
|
|
commander: commander,
|
|
logger: logger,
|
|
mdmLifecycle: mdmLifecycle,
|
|
vppInstaller: vppInstaller,
|
|
isPremium: isPremium,
|
|
commandHandlers: map[string][]fleet.MDMCommandResultsHandler{},
|
|
keyValueStore: keyValueStore,
|
|
newActivityFn: newActivityFn,
|
|
}
|
|
}
|
|
|
|
func (svc *MDMAppleCheckinAndCommandService) RegisterResultsHandler(commandType string, handler fleet.MDMCommandResultsHandler) {
|
|
svc.commandHandlers[commandType] = append(svc.commandHandlers[commandType], handler)
|
|
}
|
|
|
|
// certIsFromNewEnrollment reports whether the device's MDM identity certificate was issued from a
|
|
// new-enrollment profile, identified by apple_mdm.FleetEnrollmentSubjectOU in the Subject OU. Renewal
|
|
// profiles omit this marker, so its presence means the current checkin belongs to a fresh enrollment
|
|
// rather than a SCEP renewal.
|
|
func certIsFromNewEnrollment(cert *x509.Certificate) bool {
|
|
if cert == nil {
|
|
return false
|
|
}
|
|
return slices.Contains(cert.Subject.OrganizationalUnit, apple_mdm.FleetEnrollmentSubjectOU)
|
|
}
|
|
|
|
// Authenticate handles MDM [Authenticate][1] requests.
|
|
//
|
|
// This method is executed after the request has been handled by nanomdm, note
|
|
// that at this point you can't send any commands to the device yet because we
|
|
// haven't received a token, nor a PushMagic.
|
|
//
|
|
// We use it to perform post-enrollment tasks such as creating a host record,
|
|
// adding activities to the log, etc.
|
|
//
|
|
// [1]: https://developer.apple.com/documentation/devicemanagement/authenticate
|
|
func (svc *MDMAppleCheckinAndCommandService) Authenticate(r *mdm.Request, m *mdm.Authenticate) error {
|
|
var scepRenewalInProgress bool
|
|
existingDeviceInfo, err := svc.ds.GetHostMDMCheckinInfo(r.Context, r.ID)
|
|
if err != nil {
|
|
var nfe fleet.NotFoundError
|
|
if !errors.As(err, &nfe) {
|
|
return ctxerr.Wrap(r.Context, err, "getting checkin info")
|
|
}
|
|
}
|
|
if existingDeviceInfo != nil {
|
|
scepRenewalInProgress = existingDeviceInfo.SCEPRenewalInProgress
|
|
}
|
|
|
|
// A pending SCEP renewal command only means "this checkin is a renewal" if the device didn't just
|
|
// re-enroll. New-enrollment profiles carry apple_mdm.FleetEnrollmentSubjectOU in their SCEP Subject
|
|
// (renewal profiles omit it), and that OU survives into the identity cert the device presents here.
|
|
// If it's present, treat this as a fresh enrollment: clear the stale renew_command_uuid so
|
|
// GetHostMDMCheckinInfo reports SCEPRenewalInProgress=false for every downstream consumer
|
|
// (TokenUpdate, the profile verifier, the nano_devices bootstrap logic) and the normal enrollment
|
|
// side effects (activity, host reset, post-enroll worker) run.
|
|
if scepRenewalInProgress && certIsFromNewEnrollment(r.Certificate) {
|
|
svc.logger.InfoContext(r.Context, "identity cert is from a new enrollment, treating as fresh enrollment despite pending SCEP renewal", "host_uuid", r.ID)
|
|
if err := svc.ds.CleanSCEPRenewRefs(r.Context, r.ID); err != nil {
|
|
return ctxerr.Wrap(r.Context, err, "cleaning SCEP refs for fresh enrollment")
|
|
}
|
|
scepRenewalInProgress = false
|
|
}
|
|
|
|
// iPhones, iPads, and iPods send ProductName but not Model/ModelName,
|
|
// thus we use this field as the device's Model (which is required on lifecycle stages).
|
|
platform := "darwin"
|
|
iPhone := strings.HasPrefix(m.ProductName, "iPhone") || strings.HasPrefix(m.ProductName, "iPod")
|
|
iPad := strings.HasPrefix(m.ProductName, "iPad")
|
|
if iPhone || iPad {
|
|
m.Model = m.ProductName
|
|
if iPhone {
|
|
platform = "ios"
|
|
} else {
|
|
platform = "ipados"
|
|
}
|
|
}
|
|
|
|
if m.Model == "" {
|
|
m.Model = m.ProductName
|
|
}
|
|
|
|
// For account driven user enrollments, we get the challenge in the Bearer token
|
|
// which is used to look up the default team for BYOD enrollments.
|
|
var byodTeamID *uint
|
|
if r.Type == mdm.UserEnrollmentDevice && strings.HasPrefix(r.Authorization, "Bearer ") {
|
|
// Split enrollment challenge off the Bearer prefix
|
|
challenge := strings.TrimPrefix(r.Authorization, "Bearer ")
|
|
|
|
enrollChallenge, err := svc.ds.GetADUEEnrollmentChallenge(r.Context, challenge)
|
|
if err != nil {
|
|
return ctxerr.Wrap(r.Context, err, "getting adue enrollment challenge")
|
|
}
|
|
|
|
if enrollChallenge.ABMTokenID != nil {
|
|
abmToken, err := svc.ds.GetABMTokenByID(r.Context, *enrollChallenge.ABMTokenID)
|
|
if err != nil {
|
|
return ctxerr.Wrap(r.Context, err, "getting abm token by id")
|
|
}
|
|
byodTeamID = abmToken.BYODDefaultTeamID
|
|
}
|
|
|
|
}
|
|
|
|
// Read the personal enrollment flag from the MDM ServerURL query params.
|
|
// AddPersonalEnrollmentToFleetURL bakes "byod=1" into the ServerURL when
|
|
// the end user chose "Personal (BYOD)" on the /enroll page; nanomdm surfaces it here.
|
|
isPersonal := r.Params != nil && r.Params[apple_mdm.FleetPersonalEnrollmentKey] == "1"
|
|
|
|
if err := svc.mdmLifecycle.Do(r.Context, mdmlifecycle.HostOptions{
|
|
Action: mdmlifecycle.HostActionReset,
|
|
Platform: platform,
|
|
UUID: m.UDID,
|
|
HardwareSerial: m.SerialNumber,
|
|
HardwareModel: m.Model,
|
|
SCEPRenewalInProgress: scepRenewalInProgress,
|
|
UserEnrollmentID: m.EnrollmentID,
|
|
TeamID: byodTeamID,
|
|
IsPersonalEnrollment: isPersonal,
|
|
}); err != nil {
|
|
svc.logger.WarnContext(r.Context, "could not reset Apple mdm information", "UDID", m.UDID, "EnrollmentID", m.EnrollmentID, "err", err)
|
|
return err
|
|
}
|
|
|
|
// Persist the access rights for this host so SCEP/ACME renewal can honour
|
|
// the monotonic-narrowing invariant (Apple disallows widening on replace).
|
|
// Skip during SCEP renewal: the renewed profile's ServerURL doesn't carry
|
|
// byod=1, so isPersonal would be false here and we'd widen the stored
|
|
// bitmask back to MDMAccessRightAll, breaking the next renewal.
|
|
if !scepRenewalInProgress {
|
|
accessRights := apple_mdm.AppleEnrollmentAccessRights(isPersonal)
|
|
if err := svc.ds.SetHostMDMAppleEnrollmentPermissions(r.Context, r.ID, accessRights); err != nil {
|
|
svc.logger.ErrorContext(r.Context, "failed to persist enrollment permissions", "host_uuid", r.ID, "err", err)
|
|
// Non-fatal: worst-case the next SCEP renewal uses MDMAccessRightAll (the pre-feature default).
|
|
}
|
|
}
|
|
|
|
if svc.keyValueStore != nil {
|
|
// Set profile processing flag, is being handled by the apple_mdm worker, it will be cleared later if it's a SCEP renewal.
|
|
if err := svc.keyValueStore.Set(r.Context, fleet.MDMProfileProcessingKeyPrefix+":"+r.ID, "1", fleet.MDMProfileProcessingTTL); err != nil {
|
|
svc.logger.ErrorContext(r.Context, "failed to set mdm profile processing key", "err", err, "host_uuid", r.ID)
|
|
// We do not want to fail here, just log the error to notify of issues
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// TokenUpdate handles MDM [TokenUpdate][1] requests.
|
|
//
|
|
// This method is executed after the request has been handled by nanomdm.
|
|
//
|
|
// [1]: https://developer.apple.com/documentation/devicemanagement/token_update
|
|
func (svc *MDMAppleCheckinAndCommandService) TokenUpdate(r *mdm.Request, m *mdm.TokenUpdate) error {
|
|
svc.logger.InfoContext(r.Context, "received token update", "host_uuid", r.ID)
|
|
info, err := svc.ds.GetHostMDMCheckinInfo(r.Context, r.ID)
|
|
if err != nil {
|
|
return ctxerr.Wrap(r.Context, err, "getting checkin info")
|
|
}
|
|
|
|
// FIXME: We need to revisit this flow. Short-circuiting in random places means it is
|
|
// much more difficult to reason about the state of the host. We should try instead
|
|
// to centralize the flow control in the lifecycle methods.
|
|
if info.SCEPRenewalInProgress {
|
|
svc.logger.InfoContext(r.Context, "token update received with known SCEP renewal in process, cleaning SCEP refs", "host_uuid", r.ID)
|
|
if err := svc.ds.CleanSCEPRenewRefs(r.Context, r.ID); err != nil {
|
|
return ctxerr.Wrap(r.Context, err, "cleaning SCEP refs")
|
|
}
|
|
|
|
if !m.AwaitingConfiguration {
|
|
// Normal SCEP renewal - device is NOT at Setup Assistant. Clean refs and short-circuit.
|
|
svc.logger.InfoContext(r.Context, "cleaned SCEP refs, skipping setup experience and mdm lifecycle turn on action", "host_uuid", r.ID)
|
|
|
|
// Clean up redis key for profile processing if set.
|
|
if svc.keyValueStore != nil {
|
|
if err := svc.keyValueStore.Delete(r.Context, fleet.MDMProfileProcessingKeyPrefix+":"+r.ID); err != nil {
|
|
svc.logger.ErrorContext(r.Context, "failed to delete mdm profile processing key", "err", err, "host_uuid", r.ID)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
svc.logger.InfoContext(r.Context, "resetting mdm enrollment for old SCEP renewal", "host_uuid", r.ID)
|
|
if err := svc.ds.MDMResetEnrollment(r.Context, r.ID, false); err != nil {
|
|
return ctxerr.Wrap(r.Context, err, "failed resetting enrollment for device with old SCEP renewal", "host_uuid", r.ID)
|
|
}
|
|
|
|
// Device is awaiting configuration (wiped DEP device re-enrolling). The pending SCEP
|
|
// renewal was from the previous enrollment. Continue the normal enrollment flow so
|
|
// the device gets released from the setup assistant.
|
|
svc.logger.InfoContext(r.Context, "continuing with token update, due to awaiting configuration from new enrollment", "host_uuid", r.ID)
|
|
}
|
|
|
|
var hasSetupExpItems bool
|
|
enqueueSetupExperienceItems := false
|
|
|
|
if m.AwaitingConfiguration {
|
|
// We are sure that the device is going through a new enrollment here, so we want to
|
|
// remove the enrolled from migration flag as it is no longer true.
|
|
if err := svc.ds.ClearHostEnrolledFromMigration(r.Context, r.ID); err != nil {
|
|
return ctxerr.Wrap(r.Context, err, "resetting enrolled from migration flag", "host_uuid", r.ID)
|
|
}
|
|
|
|
nanoEnroll, err := svc.ds.GetNanoMDMEnrollment(r.Context, r.ID)
|
|
if err != nil {
|
|
return ctxerr.Wrap(r.Context, err, "getting nanomdm enrollment")
|
|
}
|
|
|
|
skipDarwinMigration := info.MigrationInProgress && info.Platform == "darwin"
|
|
|
|
// Reset host details on re-enrollment, since we are in DEP (AwaitingConfiguration) and new TokenUpdate (TokenUpdateTally == 1).
|
|
if nanoEnroll != nil && nanoEnroll.TokenUpdateTally == 1 && !skipDarwinMigration {
|
|
appCfg, err := svc.ds.AppConfig(r.Context)
|
|
if err != nil {
|
|
return ctxerr.Wrap(r.Context, err, "getting app config")
|
|
}
|
|
|
|
if err := svc.ds.MDMAppleResetOnReenrollment(r.Context, r.ID, appCfg.ActivityExpirySettings.PreserveHostActivitiesOnReenrollment); err != nil {
|
|
return ctxerr.Wrap(r.Context, err, "resetting enrollment on re-enrollment", "host_uuid", r.ID)
|
|
}
|
|
}
|
|
|
|
// Note that Setup Experience is only skipped for macOS during DEP migration. iOS and iPadOS will still get VPP apps
|
|
if skipDarwinMigration {
|
|
svc.logger.InfoContext(r.Context, "skipping setup experience enqueueing because DEP migration is in progress", "host_uuid", r.ID)
|
|
} else {
|
|
enqueueSetupExperienceItems = true
|
|
}
|
|
} else if info.Platform != "darwin" && (r.Type == mdm.Device || r.Type == mdm.UserEnrollmentDevice) && !info.InstalledFromDEP {
|
|
// For manual and Account-Driven User Enrolled (BYOD) iOS/iPadOS device
|
|
// enrollments, check the `TokenUpdateTally` so that we only run the
|
|
// setup experience enqueueing once per device. The downstream install
|
|
// flow (via InstallVPPAppPostValidation) handles user-scoped licensing
|
|
// for User Enrollments.
|
|
nanoEnroll, err := svc.ds.GetNanoMDMEnrollment(r.Context, r.ID)
|
|
if err != nil {
|
|
return ctxerr.Wrap(r.Context, err, "getting nanomdm enrollment")
|
|
}
|
|
if nanoEnroll != nil && nanoEnroll.TokenUpdateTally == 1 {
|
|
enqueueSetupExperienceItems = true
|
|
}
|
|
}
|
|
|
|
// TODO -- See if there's a way to check license here to avoid unnecessary work.
|
|
// We do check the license before actually _running_ setup experience items.
|
|
if enqueueSetupExperienceItems {
|
|
// Enqueue setup experience items and mark the host as being in setup experience
|
|
// NOTE: we don't have PlatformLike field for `info`, but that's fine as this is Apple-specific
|
|
// flow and the platform is always the same as platform-like.
|
|
hasSetupExpItems, err = svc.ds.EnqueueSetupExperienceItems(r.Context, info.Platform, info.Platform, r.ID, info.TeamID)
|
|
if err != nil {
|
|
return ctxerr.Wrap(r.Context, err, "queueing setup experience tasks")
|
|
}
|
|
}
|
|
|
|
if info.MigrationInProgress {
|
|
// If the checkin info says a migration is in progress, mark the migration as completed even if
|
|
// the device doesn't report awaiting configuration(basically a device already enrolled and checking in
|
|
// with fleet has logically always completed any migration that might be in progress)
|
|
err = svc.ds.SetHostMDMMigrationCompleted(r.Context, info.HostID)
|
|
if err != nil {
|
|
return ctxerr.Wrap(r.Context, err, "setting mdm migration completed")
|
|
}
|
|
}
|
|
|
|
var acctUUID, managedAppleID string
|
|
idp, err := svc.ds.GetMDMIdPAccountByHostUUID(r.Context, r.ID)
|
|
if err != nil {
|
|
return ctxerr.Wrap(r.Context, err, "getting idp account")
|
|
}
|
|
if idp != nil {
|
|
acctUUID = idp.UUID
|
|
managedAppleID = idp.Email
|
|
}
|
|
|
|
// User (Device) enrollments, also known as Account Driven enrollments or BYOD enrollments,
|
|
// are a special case where the bearer token is used to link the enrollment to the IDP account and it's default team.
|
|
if r.Type == mdm.UserEnrollmentDevice && idp == nil && strings.HasPrefix(r.Authorization, "Bearer ") {
|
|
// Split enrollment challenge off the Bearer prefix
|
|
challenge := strings.TrimPrefix(r.Authorization, "Bearer ")
|
|
|
|
enrollChallenge, err := svc.ds.GetADUEEnrollmentChallenge(r.Context, challenge)
|
|
if err != nil && !fleet.IsNotFound(err) {
|
|
return ctxerr.Wrap(r.Context, err, "getting adue enrollment challenge")
|
|
}
|
|
if fleet.IsNotFound(err) || enrollChallenge == nil {
|
|
// This should never happen, but we skip IDP assocation.
|
|
svc.logger.ErrorContext(r.Context, "no enrollment challenge found for User (Device) enrollment",
|
|
"host_uuid", r.ID, "challenge", challenge)
|
|
} else {
|
|
idpAccount, err := svc.ds.GetMDMIdPAccountByUUID(r.Context, enrollChallenge.IdPAccountUUID)
|
|
if err != nil && !fleet.IsNotFound(err) {
|
|
return ctxerr.Wrap(r.Context, err, "getting idp account by UUID")
|
|
}
|
|
if fleet.IsNotFound(err) || idpAccount == nil {
|
|
// This should never happen but we still want to process the token update
|
|
svc.logger.ErrorContext(r.Context, "no IDP account found for User (Device) enrollment",
|
|
"host_uuid", r.ID, "account_uuid", enrollChallenge.IdPAccountUUID)
|
|
} else {
|
|
acctUUID = idpAccount.UUID
|
|
managedAppleID = idpAccount.Email
|
|
err = svc.ds.AssociateHostMDMIdPAccount(r.Context, r.ID, acctUUID)
|
|
if err != nil {
|
|
return ctxerr.Wrap(r.Context, err, "associating host with idp account")
|
|
}
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
// For Account-Driven User Enrollment (BYOD iOS/iPadOS), keep host_mdm's
|
|
// managed_apple_id in sync with the current IdP-resolved email — including
|
|
// clearing it when resolution failed, so a stale value from a prior
|
|
// enrollment can't be reused for user-scoped VPP actions. The canonical
|
|
// source is the IDP account email resolved from the OAuth Bearer token at
|
|
// enrollment; Apple does not reliably populate UserLongName for User
|
|
// Enrollment so we don't fall back to it. NotFound is logged rather than
|
|
// returned because the lifecycle reset above should have inserted the
|
|
// host_mdm row already, and we don't want a transient race to break
|
|
// enrollment.
|
|
if r.Type == mdm.UserEnrollmentDevice {
|
|
if err := svc.ds.SetHostManagedAppleID(r.Context, info.HostID, managedAppleID); err != nil {
|
|
if fleet.IsNotFound(err) {
|
|
svc.logger.WarnContext(r.Context, "setting managed apple id: host_mdm row not found",
|
|
"host_id", info.HostID, "host_uuid", r.ID)
|
|
} else {
|
|
return ctxerr.Wrap(r.Context, err, "setting managed apple id")
|
|
}
|
|
}
|
|
}
|
|
|
|
return svc.mdmLifecycle.Do(r.Context, mdmlifecycle.HostOptions{
|
|
Action: mdmlifecycle.HostActionTurnOn,
|
|
Platform: info.Platform,
|
|
UUID: r.ID,
|
|
EnrollReference: acctUUID,
|
|
HasSetupExperienceItems: hasSetupExpItems,
|
|
UserEnrollmentID: m.EnrollmentID,
|
|
FromMDMMigration: info.MigrationInProgress || (info.DEPAssignedToFleet && !m.AwaitingConfiguration),
|
|
})
|
|
}
|
|
|
|
// CheckOut handles MDM [CheckOut][1] requests.
|
|
//
|
|
// This method is executed after the request has been handled by nanomdm, note
|
|
// that this message is sent on a best-effort basis, don't rely exclusively on
|
|
// it.
|
|
//
|
|
// [1]: https://developer.apple.com/documentation/devicemanagement/check_out
|
|
func (svc *MDMAppleCheckinAndCommandService) CheckOut(r *mdm.Request, m *mdm.CheckOut) error {
|
|
info, err := svc.ds.GetHostMDMCheckinInfo(r.Context, m.Enrollment.Identifier())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = svc.mdmLifecycle.Do(r.Context, mdmlifecycle.HostOptions{
|
|
Action: mdmlifecycle.HostActionTurnOff,
|
|
Platform: info.Platform,
|
|
UUID: r.ID,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return svc.newActivityFn(
|
|
r.Context, nil, &fleet.ActivityTypeMDMUnenrolled{
|
|
HostID: info.HostID,
|
|
HostSerial: info.HardwareSerial,
|
|
HostDisplayName: info.DisplayName,
|
|
InstalledFromDEP: info.InstalledFromDEP,
|
|
Platform: info.Platform,
|
|
},
|
|
)
|
|
}
|
|
|
|
// SetBootstrapToken handles MDM [SetBootstrapToken][1] requests.
|
|
//
|
|
// This method is executed after the request has been handled by nanomdm.
|
|
//
|
|
// [1]: https://developer.apple.com/documentation/devicemanagement/set_bootstrap_token
|
|
func (svc *MDMAppleCheckinAndCommandService) SetBootstrapToken(*mdm.Request, *mdm.SetBootstrapToken) error {
|
|
return nil
|
|
}
|
|
|
|
// GetBootstrapToken handles MDM [GetBootstrapToken][1] requests.
|
|
//
|
|
// This method is executed after the request has been handled by nanomdm.
|
|
//
|
|
// [1]: https://developer.apple.com/documentation/devicemanagement/get_bootstrap_token
|
|
func (svc *MDMAppleCheckinAndCommandService) GetBootstrapToken(*mdm.Request, *mdm.GetBootstrapToken) (*mdm.BootstrapToken, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
// UserAuthenticate handles MDM [UserAuthenticate][1] requests.
|
|
//
|
|
// This method is executed after the request has been handled by nanomdm.
|
|
//
|
|
// [1]: https://developer.apple.com/documentation/devicemanagement/userauthenticate
|
|
func (svc *MDMAppleCheckinAndCommandService) UserAuthenticate(r *mdm.Request, ua *mdm.UserAuthenticate) ([]byte, error) {
|
|
svc.logger.DebugContext(r.Context, "declining management of network user", "host_uuid", r.ID, "host_user_uuid", ua.UserID)
|
|
return nil, nano_service.NewHTTPStatusError(http.StatusGone, ctxerr.New(r.Context, "userAuthenticate not supported"))
|
|
}
|
|
|
|
// DeclarativeManagement handles MDM [DeclarativeManagement][1] requests.
|
|
//
|
|
// This method is executed after the request has been handled by nanomdm.
|
|
//
|
|
// [1]: https://developer.apple.com/documentation/devicemanagement/declarative_management_checkin
|
|
func (svc *MDMAppleCheckinAndCommandService) DeclarativeManagement(r *mdm.Request, dm *mdm.DeclarativeManagement) ([]byte, error) {
|
|
// DeclarativeManagement is handled by the MDMAppleDDMService.
|
|
return nil, nil
|
|
}
|
|
|
|
// GetToken handles MDM [GetToken][1] requests.
|
|
//
|
|
// This method is executed after the request has been handled by nanomdm.
|
|
//
|
|
// [1]: https://developer.apple.com/documentation/devicemanagement/get_token
|
|
func (svc *MDMAppleCheckinAndCommandService) GetToken(_ *mdm.Request, _ *mdm.GetToken) (*mdm.GetTokenResponse, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (svc *MDMAppleCheckinAndCommandService) runCommandHandlers(ctx context.Context, cmdName string, result fleet.MDMCommandResults) error {
|
|
handlers, ok := svc.commandHandlers[cmdName]
|
|
if ok {
|
|
for _, f := range handlers {
|
|
if err := f(ctx, result); err != nil {
|
|
// TODO: should we run as many as we can? if so we have to collect into a multierror
|
|
return ctxerr.Wrapf(ctx, err, "%s handler failed", cmdName)
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// CommandAndReportResults handles MDM [Commands and Queries][1].
|
|
//
|
|
// This method is executed after the request has been handled by nanomdm.
|
|
//
|
|
// [1]: https://developer.apple.com/documentation/devicemanagement/commands_and_queries
|
|
func (svc *MDMAppleCheckinAndCommandService) CommandAndReportResults(r *mdm.Request, cmdResult *mdm.CommandResults) (*mdm.Command, error) {
|
|
if cmdResult.Status == "Idle" {
|
|
// NOTE: iPhone/iPod/iPad devices that are still enroled in Fleet's MDM but have
|
|
// been deleted from Fleet (no host entry) will still send checkin
|
|
// requests from time to time. Those should be Idle requests without a
|
|
// CommandUUID. As stated in tickets #22941 and #22391, Fleet iDevices
|
|
// should be re-created when they checkin with MDM.
|
|
deletedDevice, err := svc.ds.GetMDMAppleEnrolledDeviceDeletedFromFleet(r.Context, cmdResult.Identifier())
|
|
if err != nil && !fleet.IsNotFound(err) {
|
|
return nil, ctxerr.Wrap(r.Context, err, "lookup enrolled but deleted device info")
|
|
}
|
|
|
|
// only re-create iPhone/iPod/iPad devices, macOS are recreated via the fleetd checkin
|
|
if deletedDevice != nil && (deletedDevice.Platform == "ios" || deletedDevice.Platform == "ipados") {
|
|
msg, err := mdm.DecodeCheckin([]byte(deletedDevice.Authenticate))
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(r.Context, err, "decode authenticate enrollment message to re-create a deleted host")
|
|
}
|
|
authMsg, ok := msg.(*mdm.Authenticate)
|
|
if !ok {
|
|
return nil, ctxerr.Errorf(r.Context, "authenticate enrollment message to re-create a deleted host is not of the expected type: %T", msg)
|
|
}
|
|
|
|
err = svc.mdmLifecycle.Do(r.Context, mdmlifecycle.HostOptions{
|
|
Action: mdmlifecycle.HostActionReset,
|
|
Platform: deletedDevice.Platform,
|
|
UUID: deletedDevice.ID,
|
|
HardwareSerial: deletedDevice.SerialNumber,
|
|
HardwareModel: authMsg.ProductName,
|
|
})
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(r.Context, err, "trigger mdm reset lifecycle to re-create a deleted host")
|
|
}
|
|
|
|
if deletedDevice.EnrollTeamID != nil {
|
|
host, err := svc.ds.HostLiteByIdentifier(r.Context, deletedDevice.ID)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(r.Context, err, "load re-created host by identifier")
|
|
}
|
|
if err := svc.ds.AddHostsToTeam(r.Context, fleet.NewAddHostsToTeamParams(deletedDevice.EnrollTeamID, []uint{host.ID})); err != nil {
|
|
return nil, ctxerr.Wrap(r.Context, err, "transfer re-created host to enrollment team")
|
|
}
|
|
}
|
|
}
|
|
|
|
// macOS hosts are considered unlocked if they are online any time
|
|
// after they have been unlocked. If the host has been seen after a
|
|
// successful unlock, take the opportunity and update the value in the
|
|
// db as well.
|
|
//
|
|
// TODO: sanity check if this approach is still valid after we implement wipe
|
|
|
|
// if there is a deleted device, it means there is no hosts entry so no need to clean the lock
|
|
if deletedDevice == nil {
|
|
if err := svc.ds.CleanAppleMDMLock(r.Context, cmdResult.UDID); err != nil {
|
|
return nil, ctxerr.Wrap(r.Context, err, "cleaning macOS host lock/wipe status")
|
|
}
|
|
}
|
|
|
|
return nil, nil
|
|
}
|
|
|
|
// Check if this is a result of a "refetch" command sent to iPhones/iPads
|
|
// to fetch their device information periodically.
|
|
if strings.HasPrefix(cmdResult.CommandUUID, fleet.RefetchBaseCommandUUIDPrefix) && !strings.HasPrefix(cmdResult.CommandUUID, fleet.VerifySoftwareInstallVPPPrefix) {
|
|
return svc.handleRefetch(r, cmdResult)
|
|
}
|
|
|
|
// Results of the Settings/DeviceName command that enforces a team's host
|
|
// name template are routed by their UUID prefix rather than a "Settings"
|
|
// request-type case: other features may send Settings commands carrying
|
|
// different items, so the prefix scopes handling to renames.
|
|
if strings.HasPrefix(cmdResult.CommandUUID, fleet.DeviceNameCommandUUIDPrefix) {
|
|
return nil, svc.handleDeviceNameCommandResult(r.Context, cmdResult)
|
|
}
|
|
|
|
// We explicitly get the request type because it comes empty. There's a
|
|
// RequestType field in the struct, but it's used when a mdm.Command is
|
|
// issued.
|
|
requestType, err := svc.ds.GetMDMAppleCommandRequestType(r.Context, cmdResult.CommandUUID)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(r.Context, err, "command service")
|
|
}
|
|
|
|
switch requestType {
|
|
case "InstallProfile":
|
|
status := mdmAppleDeliveryStatusFromCommandStatus(cmdResult.Status)
|
|
if err := apple_mdm.HandleHostMDMProfileInstallResult(
|
|
r.Context,
|
|
svc.ds,
|
|
cmdResult.Identifier(),
|
|
cmdResult.CommandUUID,
|
|
status,
|
|
apple_mdm.FmtErrorChain(cmdResult.ErrorChain),
|
|
svc.newActivityFn,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
// Best-effort: when an ACME profile is acknowledged on macOS, queue
|
|
// CertificateList so hardware-bound certs (invisible to osquery) get
|
|
// ingested into host_certificates. Failures here are logged but don't
|
|
// affect the ack.
|
|
if status != nil && *status == fleet.MDMDeliveryVerifying {
|
|
if err := svc.maybeQueueCertificateListForACMEProfile(r.Context, cmdResult.Identifier(), cmdResult.CommandUUID); err != nil {
|
|
svc.logger.WarnContext(r.Context, "queue CertificateList after ACME profile install",
|
|
"err", err, "host_uuid", cmdResult.Identifier(), "command_uuid", cmdResult.CommandUUID)
|
|
}
|
|
// Okta conditional access bundles a SCEP payload with an Identity
|
|
// Preference payload, which leaves the previous cert pinned in
|
|
// the per-user keychain across renewals. After a successful ack,
|
|
// queue an internal cleanup script that removes the orphaned
|
|
// duplicate. No-op when the profile isn't the Okta CA one.
|
|
if err := svc.maybeRunOktaCACleanupScript(r.Context, cmdResult.Identifier(), cmdResult.CommandUUID); err != nil {
|
|
svc.logger.WarnContext(r.Context, "run Okta CA keychain cleanup after profile install",
|
|
"err", err, "host_uuid", cmdResult.Identifier(), "command_uuid", cmdResult.CommandUUID)
|
|
}
|
|
}
|
|
return nil, nil
|
|
case "RemoveProfile":
|
|
status := mdmAppleDeliveryStatusFromCommandStatus(cmdResult.Status)
|
|
detail := apple_mdm.FmtErrorChain(cmdResult.ErrorChain)
|
|
// MDMClientError 89 means "Profile not found" — for a removal, this
|
|
// is the desired outcome, so treat it as successful.
|
|
if status != nil && *status == fleet.MDMDeliveryFailed &&
|
|
apple_mdm.IsProfileNotFoundError(cmdResult.ErrorChain) {
|
|
status = &fleet.MDMDeliveryVerifying
|
|
detail = ""
|
|
}
|
|
// Refetch certs when an ACME profile is removed so the stale row clears.
|
|
// Must run before UpdateOrDeleteHostMDMAppleProfile deletes the row the
|
|
// probe reads. Best-effort.
|
|
if status != nil && *status == fleet.MDMDeliveryVerifying {
|
|
if err := svc.maybeQueueCertificateListForACMEProfile(r.Context, cmdResult.Identifier(), cmdResult.CommandUUID); err != nil {
|
|
svc.logger.WarnContext(r.Context, "queue CertificateList after ACME profile removal",
|
|
"err", err, "host_uuid", cmdResult.Identifier(), "command_uuid", cmdResult.CommandUUID)
|
|
}
|
|
}
|
|
return nil, svc.ds.UpdateOrDeleteHostMDMAppleProfile(r.Context, &fleet.HostMDMAppleProfile{
|
|
CommandUUID: cmdResult.CommandUUID,
|
|
HostUUID: cmdResult.Identifier(),
|
|
Status: status,
|
|
Detail: detail,
|
|
OperationType: fleet.MDMOperationTypeRemove,
|
|
})
|
|
case "DeviceLock", "EraseDevice":
|
|
// these commands will always fail if sent to a User Enrolled device as of iOS/iPadOS 18
|
|
if cmdResult.Status == fleet.MDMAppleStatusAcknowledged ||
|
|
cmdResult.Status == fleet.MDMAppleStatusError ||
|
|
cmdResult.Status == fleet.MDMAppleStatusCommandFormatError {
|
|
succeeded := cmdResult.Status == fleet.MDMAppleStatusAcknowledged
|
|
failed := cmdResult.Status == fleet.MDMAppleStatusError
|
|
if err := svc.ds.UpdateHostLockWipeStatusFromAppleMDMResult(r.Context, cmdResult.Identifier(), cmdResult.CommandUUID, requestType, succeeded); err != nil {
|
|
return nil, err
|
|
}
|
|
// If succesful or only if failed on non user-enrollment, as those always fail but never wipe.
|
|
if requestType == "EraseDevice" && (succeeded || (failed && r.Type == mdm.Device)) {
|
|
host, err := svc.ds.HostByIdentifier(r.Context, cmdResult.Identifier())
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(r.Context, err, "EraseDevice: get host by identifier")
|
|
}
|
|
if _, err := svc.ds.BatchCancelAllHostUpcomingActivities(r.Context, host.ID); err != nil {
|
|
return nil, ctxerr.Wrap(r.Context, err, "cancel upcoming activities after wipe")
|
|
}
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
case fleet.DisableLostModeCmdName:
|
|
if cmdResult.Status == fleet.MDMAppleStatusAcknowledged ||
|
|
cmdResult.Status == fleet.MDMAppleStatusError ||
|
|
cmdResult.Status == fleet.MDMAppleStatusCommandFormatError {
|
|
|
|
host, err := svc.ds.HostByIdentifier(r.Context, cmdResult.Identifier())
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(r.Context, err, "DisableLostMode: get host by identifier")
|
|
}
|
|
|
|
if err := svc.ds.DeleteHostLocationData(r.Context, host.ID); err != nil {
|
|
return nil, ctxerr.Wrap(r.Context, err, "DisableLostMode: delete host location data")
|
|
}
|
|
|
|
return nil, svc.ds.UpdateHostLockWipeStatusFromAppleMDMResult(r.Context, cmdResult.Identifier(), cmdResult.CommandUUID, requestType,
|
|
cmdResult.Status == fleet.MDMAppleStatusAcknowledged)
|
|
}
|
|
|
|
case fleet.EnableLostModeCmdName:
|
|
|
|
// these commands will always fail if sent to a User Enrolled device as of iOS/iPadOS 18
|
|
if cmdResult.Status == fleet.MDMAppleStatusAcknowledged ||
|
|
cmdResult.Status == fleet.MDMAppleStatusError ||
|
|
cmdResult.Status == fleet.MDMAppleStatusCommandFormatError {
|
|
|
|
err := svc.commander.DeviceLocation(r.Context, []string{cmdResult.Identifier()}, uuid.NewString())
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(r.Context, err, "EnableLostMode: enqueue DeviceLocation command")
|
|
}
|
|
|
|
return nil, svc.ds.UpdateHostLockWipeStatusFromAppleMDMResult(r.Context, cmdResult.Identifier(), cmdResult.CommandUUID, requestType,
|
|
cmdResult.Status == fleet.MDMAppleStatusAcknowledged)
|
|
}
|
|
case "DeclarativeManagement":
|
|
// set "pending-install" profiles to "verifying" or "failed"
|
|
// depending on the status of the DeviceManagement command. The ack
|
|
// arrives on a single channel (device or user), so scope the transition
|
|
// to that channel — cmdResult.Identifier() is the device UDID on both
|
|
// channels, while r.EnrollID distinguishes them.
|
|
status := mdmAppleDeliveryStatusFromCommandStatus(cmdResult.Status)
|
|
detail := fmt.Sprintf("%s. Make sure the host is on macOS 13+, iOS 17+, iPadOS 17+.", apple_mdm.FmtErrorChain(cmdResult.ErrorChain))
|
|
err := svc.ds.MDMAppleSetPendingDeclarationsAs(r.Context, cmdResult.Identifier(), ddmScopeForRequest(r), status, detail)
|
|
return nil, ctxerr.Wrap(r.Context, err, "update declaration status on DeclarativeManagement ack")
|
|
case "InstallApplication":
|
|
// "Already installed" handling depends on enrollment type:
|
|
// - Fully managed hosts: treat as a successful install — MDM can take
|
|
// over the App Store copy, and verification will confirm presence.
|
|
// - BYOD / Account-Driven User Enrollment: Apple won't let MDM claim
|
|
// a personally-installed app, and InstalledApplicationList with
|
|
// managedAppsOnly=true never reports it. Verification would loop
|
|
// forever, so we fail the install with the user-facing message
|
|
// from #31138 Figma and skip retries (replays produce the same
|
|
// answer from Apple).
|
|
alreadyInstalledBYOD := false
|
|
if (cmdResult.Status == fleet.MDMAppleStatusError || cmdResult.Status == fleet.MDMAppleStatusCommandFormatError) &&
|
|
apple_mdm.IsAppAlreadyInstalledError(cmdResult.ErrorChain) {
|
|
isPersonal := r.Type == mdm.UserEnrollmentDevice
|
|
if !isPersonal {
|
|
svc.logger.InfoContext(r.Context, "InstallApplication reported app already installed; treating as success",
|
|
"host_uuid", cmdResult.Identifier(), "command_uuid", cmdResult.CommandUUID)
|
|
cmdResult.Status = fleet.MDMAppleStatusAcknowledged
|
|
} else {
|
|
alreadyInstalledBYOD = true
|
|
// Replace Apple's raw "The app with iTunes Store ID <id> is
|
|
// already installed." with the user-facing copy. Downstream
|
|
// failure-display callers (FmtErrorChain etc.) will surface
|
|
// the substituted message.
|
|
cmdResult.ErrorChain = []mdm.ErrorChain{{
|
|
ErrorDomain: "FleetInstallError",
|
|
ErrorCode: 12042,
|
|
USEnglishDescription: apple_mdm.AppAlreadyInstalledBYODUserMessage,
|
|
}}
|
|
svc.logger.InfoContext(r.Context, "InstallApplication failed on BYOD host: app already installed personally",
|
|
"host_uuid", cmdResult.Identifier(), "command_uuid", cmdResult.CommandUUID)
|
|
}
|
|
}
|
|
|
|
// create an activity for installing only if we're in a terminal error state
|
|
if cmdResult.Status == fleet.MDMAppleStatusError ||
|
|
cmdResult.Status == fleet.MDMAppleStatusCommandFormatError {
|
|
|
|
// Retry VPP install on any MDM error (up to MaxSoftwareInstallAttempts).
|
|
// N.b., VPP uses 0-based retry_count, so this comparison gives
|
|
// MaxSoftwareInstallAttempts retries (not attempts). This pre-dates
|
|
// the non-policy retry feature and is intentionally left as-is.
|
|
// Exception: the BYOD already-installed case is terminal — retrying
|
|
// would just hit the same wall.
|
|
vppInstall, err := svc.ds.GetHostVPPInstallByCommandUUID(r.Context, cmdResult.CommandUUID)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(r.Context, err, "fetching host vpp install by command uuid")
|
|
}
|
|
if !alreadyInstalledBYOD && vppInstall != nil && vppInstall.RetryCount < fleet.MaxSoftwareInstallAttempts {
|
|
if err := svc.ds.RetryVPPInstall(r.Context, vppInstall); err != nil {
|
|
return nil, ctxerr.Wrap(r.Context, err, "retrying VPP install for host")
|
|
}
|
|
svc.logger.InfoContext(r.Context, "re-queued VPP app installation",
|
|
"host_id", vppInstall.HostID, "command_uuid", cmdResult.CommandUUID,
|
|
"retry_count", vppInstall.RetryCount+1, "error_status", cmdResult.Status)
|
|
return nil, nil
|
|
}
|
|
|
|
// this might be a setup experience VPP install, so we'll try to update setup experience status
|
|
var fromSetupExperience bool
|
|
if updated, err := maybeUpdateSetupExperienceStatus(r.Context, svc.ds, fleet.SetupExperienceVPPInstallResult{
|
|
HostUUID: cmdResult.Identifier(),
|
|
CommandUUID: cmdResult.CommandUUID,
|
|
CommandStatus: cmdResult.Status,
|
|
}, svc.newActivityFn); err != nil {
|
|
return nil, ctxerr.Wrap(r.Context, err, "updating setup experience status from VPP install result")
|
|
} else if updated {
|
|
// TODO: call next step of setup experience?
|
|
fromSetupExperience = true
|
|
svc.logger.DebugContext(r.Context, "setup experience VPP install result updated",
|
|
"host_uuid", cmdResult.Identifier(), "execution_id", cmdResult.CommandUUID)
|
|
}
|
|
user, act, err := svc.ds.GetPastActivityDataForVPPAppInstall(r.Context, cmdResult)
|
|
if err != nil {
|
|
if fleet.IsNotFound(err) {
|
|
// Then this isn't a VPP install, so no activity generated
|
|
return nil, nil
|
|
}
|
|
|
|
return nil, ctxerr.Wrap(r.Context, err, "fetching data for installed app store app activity")
|
|
}
|
|
act.FromSetupExperience = fromSetupExperience
|
|
if err := svc.newActivityFn(r.Context, user, act); err != nil {
|
|
return nil, ctxerr.Wrap(r.Context, err, "creating activity for installed app store app")
|
|
}
|
|
}
|
|
|
|
// If the command succeeded, then start the install verification process.
|
|
if cmdResult.Status == fleet.MDMAppleStatusAcknowledged {
|
|
// Only send a new InstalledApplicationList command if there's not one in flight
|
|
commandsPending, err := svc.ds.IsHostPendingMDMInstallVerification(r.Context, cmdResult.Identifier())
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(r.Context, err, "get pending mdm commands by host")
|
|
}
|
|
if !commandsPending {
|
|
cmdUUID := fleet.VerifySoftwareInstallCommandUUID()
|
|
// for app verification, we always request only managed apps
|
|
if err := svc.commander.InstalledApplicationList(r.Context, []string{cmdResult.Identifier()}, cmdUUID, true); err != nil {
|
|
return nil, ctxerr.Wrap(r.Context, err, "sending list app command to verify install")
|
|
}
|
|
|
|
// update the install record
|
|
if err := svc.ds.AssociateMDMInstallToVerificationUUID(r.Context, cmdResult.CommandUUID, cmdUUID, cmdResult.Identifier()); err != nil {
|
|
return nil, ctxerr.Wrap(r.Context, err, "update install record")
|
|
}
|
|
|
|
}
|
|
}
|
|
case "DeviceConfigured":
|
|
if err := svc.ds.SetHostAwaitingConfiguration(r.Context, r.ID, false); err != nil {
|
|
return nil, ctxerr.Wrap(r.Context, err, "failed to mark host as non longer awaiting configuration")
|
|
}
|
|
case "InstalledApplicationList":
|
|
svc.logger.DebugContext(r.Context, "calling handlers for InstalledApplicationList")
|
|
host, err := svc.ds.HostByIdentifier(r.Context, cmdResult.Identifier())
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(r.Context, err, "get host by identifier")
|
|
}
|
|
res, err := NewInstalledApplicationListResult(r.Context, cmdResult.Raw, cmdResult.CommandUUID, cmdResult.Identifier(), host.Platform)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(r.Context, err, "new installed application list result")
|
|
}
|
|
|
|
for _, f := range svc.commandHandlers["InstalledApplicationList"] {
|
|
if err := f(r.Context, res); err != nil {
|
|
return nil, ctxerr.Wrap(r.Context, err, "InstalledApplicationList handler failed")
|
|
}
|
|
}
|
|
|
|
case fleet.DeviceLocationCmdName:
|
|
if cmdResult.Status == fleet.MDMAppleStatusAcknowledged {
|
|
host, err := svc.ds.HostByIdentifier(r.Context, cmdResult.Identifier())
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(r.Context, err, "device location command result: get host by identifier")
|
|
}
|
|
|
|
res, err := NewDeviceLocationResult(cmdResult, host.ID)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(r.Context, err, "build device location command result")
|
|
}
|
|
err = svc.runCommandHandlers(r.Context, fleet.DeviceLocationCmdName, res)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(r.Context, err, "DeviceLocation: calling handlers")
|
|
}
|
|
}
|
|
|
|
case fleet.SetRecoveryLockCmdName:
|
|
res := NewRecoveryLockResult(cmdResult)
|
|
if err := svc.runCommandHandlers(r.Context, fleet.SetRecoveryLockCmdName, res); err != nil {
|
|
return nil, ctxerr.Wrap(r.Context, err, "SetRecoveryLock: calling handlers")
|
|
}
|
|
|
|
case fleet.AccountConfigurationCmdName:
|
|
// Look up managed local account by command_uuid to distinguish from SSO-only AccountConfiguration
|
|
host, err := svc.ds.GetManagedLocalAccountByCommandUUID(r.Context, cmdResult.CommandUUID)
|
|
if err != nil && !fleet.IsNotFound(err) {
|
|
return nil, ctxerr.Wrap(r.Context, err, "get managed local account for command")
|
|
}
|
|
if host != nil && host.UUID != "" {
|
|
// Validate that the command response is from the expected device.
|
|
if host.UUID != r.ID {
|
|
svc.logger.WarnContext(r.Context, "managed local account command UUID matched a different host",
|
|
"expected_host_uuid", host.UUID, "checkin_host_uuid", r.ID, "command_uuid", cmdResult.CommandUUID)
|
|
break
|
|
}
|
|
// This AccountConfiguration included a managed local account
|
|
switch cmdResult.Status {
|
|
case fleet.MDMAppleStatusAcknowledged:
|
|
if err := svc.ds.SetHostManagedLocalAccountStatus(r.Context, host.UUID, fleet.MDMDeliveryVerified); err != nil {
|
|
return nil, ctxerr.Wrap(r.Context, err, "set managed local account status to verified")
|
|
}
|
|
if err := svc.newActivityFn(r.Context, nil, fleet.ActivityTypeCreatedManagedLocalAccount{HostID: host.ID, HostDisplayName: host.DisplayName()}); err != nil {
|
|
return nil, ctxerr.Wrap(r.Context, err, "create managed local account activity")
|
|
}
|
|
// Kickstart a refetch so we capture _fleetadmin's UUID from osquery on the next
|
|
// detail cycle (needed by SetAutoAdminPassword for rotation). Best-effort.
|
|
svc.maybeRefetchForManagedLocalAccountUUID(r.Context, host)
|
|
|
|
case fleet.MDMAppleStatusError, fleet.MDMAppleStatusCommandFormatError:
|
|
if err := svc.ds.SetHostManagedLocalAccountStatus(r.Context, host.UUID, fleet.MDMDeliveryFailed); err != nil {
|
|
return nil, ctxerr.Wrap(r.Context, err, "set managed local account status to failed")
|
|
}
|
|
}
|
|
}
|
|
// No matching row = SSO-only AccountConfiguration, no-op
|
|
|
|
case fleet.SetAutoAdminPasswordCmdName:
|
|
host, err := svc.ds.GetManagedLocalAccountByPendingCommandUUID(r.Context, cmdResult.CommandUUID)
|
|
if err != nil {
|
|
if fleet.IsNotFound(err) {
|
|
// Hard to say what happened here, most likely a command was superseded by another or a user
|
|
// sent this command manually (e.g. via Commands endpoint)
|
|
break
|
|
}
|
|
return nil, ctxerr.Wrap(r.Context, err, "get managed local account by pending command uuid")
|
|
}
|
|
if host == nil || host.UUID == "" {
|
|
break
|
|
}
|
|
if host.UUID != r.ID {
|
|
svc.logger.WarnContext(r.Context, "SetAutoAdminPassword command UUID matched a different host",
|
|
"expected_host_uuid", host.UUID, "checkin_host_uuid", r.ID, "command_uuid", cmdResult.CommandUUID)
|
|
break
|
|
}
|
|
// NotNow will leave the row in pending state; the device will retry on the next checkin.
|
|
switch cmdResult.Status {
|
|
case fleet.MDMAppleStatusAcknowledged:
|
|
if err := svc.ds.CompleteManagedLocalAccountRotation(r.Context, host.UUID, cmdResult.CommandUUID); err != nil {
|
|
return nil, ctxerr.Wrap(r.Context, err, "complete managed local account rotation")
|
|
}
|
|
case fleet.MDMAppleStatusError, fleet.MDMAppleStatusCommandFormatError:
|
|
errMsg := "device returned " + cmdResult.Status
|
|
if err := svc.ds.FailManagedLocalAccountRotation(r.Context, host.UUID, cmdResult.CommandUUID, errMsg); err != nil {
|
|
return nil, ctxerr.Wrap(r.Context, err, "fail managed local account rotation")
|
|
}
|
|
// Failure activity is always attributed to Fleet (no viewer context here,
|
|
// and the original initiating user — if any — isn't tracked on the row).
|
|
if err := svc.newActivityFn(r.Context, nil, fleet.ActivityTypeFailedToRotateManagedLocalAccountPassword{
|
|
HostID: host.ID,
|
|
HostDisplayName: host.DisplayName(),
|
|
}); err != nil {
|
|
return nil, ctxerr.Wrap(r.Context, err, "create failed-to-rotate managed local account activity")
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil, nil
|
|
}
|
|
|
|
// maybeRefetchForManagedLocalAccountUUID requests a host refetch when the
|
|
// managed local account row exists but we haven't yet captured _fleetadmin's
|
|
// uuid from osquery. This shortens the window between AccountConfiguration ack
|
|
// and being able to rotate the password (which requires the uuid) without
|
|
// changing the host detail interval. Best-effort — errors are logged only.
|
|
func (svc *MDMAppleCheckinAndCommandService) maybeRefetchForManagedLocalAccountUUID(ctx context.Context, host *fleet.Host) {
|
|
existing, err := svc.ds.GetManagedLocalAccountUUID(ctx, host.UUID)
|
|
if fleet.IsNotFound(err) {
|
|
return
|
|
}
|
|
if err != nil {
|
|
svc.logger.ErrorContext(ctx, "get managed local account uuid for refetch kickstart",
|
|
"err", err, "host_id", host.ID, "host_uuid", host.UUID)
|
|
return
|
|
}
|
|
if existing != nil {
|
|
return
|
|
}
|
|
if err := svc.ds.UpdateHostRefetchRequested(ctx, host.ID, true); err != nil {
|
|
svc.logger.ErrorContext(ctx, "request host refetch after managed local account ack",
|
|
"err", err, "host_id", host.ID, "host_uuid", host.UUID)
|
|
}
|
|
}
|
|
|
|
func (svc *MDMAppleCheckinAndCommandService) handleRefetch(r *mdm.Request, cmdResult *mdm.CommandResults) (*mdm.Command, error) {
|
|
ctx := r.Context
|
|
host, err := svc.ds.HostByIdentifier(ctx, cmdResult.Identifier())
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "failed to get host by identifier")
|
|
}
|
|
|
|
switch {
|
|
case strings.HasPrefix(cmdResult.CommandUUID, fleet.RefetchAppsCommandUUIDPrefix):
|
|
return svc.handleRefetchAppsResults(ctx, host, cmdResult)
|
|
|
|
case strings.HasPrefix(cmdResult.CommandUUID, fleet.RefetchCertsCommandUUIDPrefix):
|
|
return svc.handleRefetchCertsResults(ctx, host, cmdResult)
|
|
|
|
case strings.HasPrefix(cmdResult.CommandUUID, fleet.RefetchDeviceCommandUUIDPrefix):
|
|
// for devices added via legacy enrollment flows, we may need to set an enroll reference
|
|
// we don't expect this info to change often so we're only checking on device info refetches
|
|
if r.Params != nil {
|
|
if _, err := svc.maybeUpdateIDeviceEnrollRef(ctx, host, r.Params["enroll_reference"]); err != nil {
|
|
// TODO: consider if we want to return an error here, for now we just log and continue
|
|
svc.logger.ErrorContext(ctx, "maybe update enroll reference",
|
|
"host_uuid", host.UUID, "enroll_reference", r.Params["enroll_reference"], "err", err)
|
|
}
|
|
}
|
|
return svc.handleRefetchDeviceResults(ctx, host, cmdResult)
|
|
|
|
default:
|
|
// This should never happen, but just in case we'll return an error.
|
|
return nil, ctxerr.New(ctx, fmt.Sprintf("unknown refetch command type %s", cmdResult.CommandUUID))
|
|
}
|
|
}
|
|
|
|
func (svc *MDMAppleCheckinAndCommandService) maybeUpdateIDeviceEnrollRef(ctx context.Context, host *fleet.Host, enrollRef string) (bool, error) {
|
|
if host.Platform != "ios" && host.Platform != "ipados" {
|
|
// caller should ensure this doesn't happen, but just in case we'll log it and return false
|
|
svc.logger.DebugContext(ctx, "unexpected usage of maybeUpdateIDeviceEnrollRef for non-iOS/non-iPadOS host",
|
|
"host_id", host.ID, "host_uuid", host.UUID, "platform", host.Platform)
|
|
return false, nil
|
|
}
|
|
hmer, err := svc.ds.GetMDMAppleHostMDMEnrollRef(ctx, host.ID)
|
|
if err != nil {
|
|
return false, ctxerr.Wrap(ctx, err, "checking enroll reference")
|
|
}
|
|
if hmer == enrollRef {
|
|
// no change so return early
|
|
return false, nil
|
|
}
|
|
|
|
svc.logger.InfoContext(ctx, "updating enroll reference for host",
|
|
"host_id", host.ID, "host_uuid", host.UUID, "old_enroll_ref", hmer, "new_enroll_ref", enrollRef)
|
|
didUpdate, err := svc.ds.UpdateMDMAppleHostMDMEnrollRef(ctx, host.ID, enrollRef)
|
|
if err != nil {
|
|
return false, ctxerr.Wrap(ctx, err, "updating enroll reference")
|
|
}
|
|
|
|
if !didUpdate {
|
|
svc.logger.DebugContext(ctx, "unexpected enroll reference update no-op", "host_id", host.ID, "host_uuid", host.UUID)
|
|
}
|
|
|
|
// clear SCEP renew refs if any
|
|
if err := svc.ds.DeactivateMDMAppleHostSCEPRenewCommands(ctx, host.UUID); err != nil {
|
|
return didUpdate, ctxerr.Wrap(ctx, err, "updating enroll reference: deactivate renew commands")
|
|
}
|
|
|
|
return didUpdate, nil
|
|
}
|
|
|
|
func (svc *MDMAppleCheckinAndCommandService) handleRefetchAppsResults(ctx context.Context, host *fleet.Host, cmdResult *mdm.CommandResults) (*mdm.Command, error) {
|
|
if !strings.HasPrefix(cmdResult.CommandUUID, fleet.RefetchAppsCommandUUIDPrefix) {
|
|
// Caller should have checked this, but just in case we'll return an error.
|
|
return nil, ctxerr.New(ctx, fmt.Sprintf("expected REFETCH-APPS- prefix but got %s", cmdResult.CommandUUID))
|
|
}
|
|
|
|
// We remove pending command first in case there is an error processing the results, so that we don't prevent another refetch.
|
|
if err := svc.ds.RemoveHostMDMCommand(ctx, fleet.HostMDMCommand{
|
|
HostID: host.ID,
|
|
CommandType: fleet.RefetchAppsCommandUUIDPrefix,
|
|
}); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "remove refetch apps command")
|
|
}
|
|
|
|
if host.Platform != "ios" && host.Platform != "ipados" {
|
|
return nil, ctxerr.New(ctx, "refetch apps command sent to non-iOS/non-iPadOS host")
|
|
}
|
|
source := "ios_apps"
|
|
if host.Platform == "ipados" {
|
|
source = "ipados_apps"
|
|
}
|
|
|
|
response := cmdResult.Raw
|
|
software, err := unmarshalAppList(ctx, response, source)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "unmarshal app list")
|
|
}
|
|
|
|
if _, err := svc.ds.UpdateHostSoftware(ctx, host.ID, software); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "update host software")
|
|
}
|
|
|
|
if svc.isPremium {
|
|
if err := svc.handleScheduledUpdates(ctx, host, software); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "handle scheduled updates")
|
|
}
|
|
}
|
|
|
|
// Best-effort cleanup of stale refetch commands of the same type.
|
|
if err := svc.ds.CleanupStaleNanoRefetchCommands(ctx, host.UUID, fleet.RefetchAppsCommandUUIDPrefix, cmdResult.CommandUUID); err != nil {
|
|
svc.logger.ErrorContext(ctx, "cleanup stale nano refetch apps commands", "err", err, "host_uuid", host.UUID, "command_prefix", fleet.RefetchAppsCommandUUIDPrefix)
|
|
}
|
|
|
|
return nil, nil
|
|
}
|
|
|
|
var versionPattern = regexp.MustCompile(
|
|
`^v?\s*(\d+(?:\.\d+)*)\s*$`,
|
|
)
|
|
|
|
// Allows alphanumeric characters, spaces, and the punctuation Apple uses in RSR suffixes (e.g. "(a)").
|
|
var supplementalOSVersionExtraRe = regexp.MustCompile(`^[A-Za-z0-9 ()._-]+$`)
|
|
|
|
// buildOSVersion combines osVersion and supplementalOSVersionExtra into a
|
|
// single version string, validating the supplemental value before appending.
|
|
// The result is capped at 150 characters to match the operating_systems.version
|
|
// column, which is the tighter of the two columns this value is written to.
|
|
// Callers that prepend a platform prefix (e.g. "iOS ") must still truncate the
|
|
// final combined string to fit their own column limit.
|
|
func buildOSVersion(osVersion, supplementalOSVersionExtra string) (string, error) {
|
|
if supplementalOSVersionExtra != "" {
|
|
if len(supplementalOSVersionExtra) > 32 {
|
|
return "", fmt.Errorf("invalid SupplementalOSVersionExtra: too long (length=%d, value=%q)", len(supplementalOSVersionExtra), supplementalOSVersionExtra)
|
|
}
|
|
if !supplementalOSVersionExtraRe.MatchString(supplementalOSVersionExtra) {
|
|
return "", fmt.Errorf("invalid SupplementalOSVersionExtra: contains disallowed characters (value=%q)", supplementalOSVersionExtra)
|
|
}
|
|
osVersion += " " + supplementalOSVersionExtra
|
|
}
|
|
if len(osVersion) > 150 {
|
|
osVersion = osVersion[:150]
|
|
}
|
|
return osVersion, nil
|
|
}
|
|
|
|
// trimLeadingZeros converts "00123" → "123", "000" → "0", "0" → "0"
|
|
func trimLeadingZeros(s string) string {
|
|
s = strings.TrimLeft(s, "0")
|
|
if s == "" {
|
|
return "0"
|
|
}
|
|
return s
|
|
}
|
|
|
|
// toValidSemVer is a best effort transformation to make `version` a valid semantic version.
|
|
// Currently doesn't support fixing versions that have non-numerical pre-release strings (because
|
|
// we haven't seen those in the wild for the apps where this method is used, currently VPP apps).
|
|
func toValidSemVer(version string) string {
|
|
// Cleanup spaces.
|
|
version = strings.TrimSpace(version)
|
|
if version == "" {
|
|
// Empty version, nothing to clean up.
|
|
return version
|
|
}
|
|
|
|
versionModified := strings.ReplaceAll(version, "-", ".")
|
|
matches := versionPattern.FindStringSubmatch(versionModified)
|
|
if matches == nil {
|
|
// May not be a valid version string, nothing we can do.
|
|
return version
|
|
}
|
|
|
|
partsStr := matches[1]
|
|
parts := strings.Split(partsStr, ".")
|
|
|
|
// Clean each numeric part (remove leading zeros)
|
|
// Leading zeros are not valid in semantic versioning.
|
|
cleanParts := make([]string, 0, len(parts))
|
|
for _, p := range parts {
|
|
clean := trimLeadingZeros(p)
|
|
cleanParts = append(cleanParts, clean)
|
|
}
|
|
|
|
switch len(cleanParts) {
|
|
case 1: // major
|
|
version = cleanParts[0]
|
|
case 2: // major.minor
|
|
version = fmt.Sprintf("%s.%s", cleanParts[0], cleanParts[1])
|
|
case 3: // major.minor.patch
|
|
version = fmt.Sprintf("%s.%s.%s", cleanParts[0], cleanParts[1], cleanParts[2])
|
|
case 4: // major.minor.patch.build
|
|
build := cleanParts[3]
|
|
if build == "0" {
|
|
version = fmt.Sprintf("%s.%s.%s", cleanParts[0], cleanParts[1], cleanParts[2])
|
|
} else {
|
|
version = fmt.Sprintf("%s.%s.%s-%s", cleanParts[0], cleanParts[1], cleanParts[2], build)
|
|
}
|
|
default: // For safety: more than 4 parts, take first 3 + rest as pre-release.
|
|
version = fmt.Sprintf("%s.%s.%s-%s",
|
|
cleanParts[0],
|
|
cleanParts[1],
|
|
cleanParts[2],
|
|
strings.Join(cleanParts[3:], "."))
|
|
}
|
|
|
|
return version
|
|
}
|
|
|
|
func (svc *MDMAppleCheckinAndCommandService) handleScheduledUpdates(
|
|
ctx context.Context,
|
|
host *fleet.Host,
|
|
softwares []fleet.Software,
|
|
) error {
|
|
logger := svc.logger.With(
|
|
"method", "handle_scheduled_updates",
|
|
"host_id", host.ID,
|
|
)
|
|
|
|
if host.TimeZone == nil || *host.TimeZone == "" {
|
|
// We cannot determine if it's safe to schedule an update on this host.
|
|
logger.DebugContext(ctx, "skipping updates, host has no timezone")
|
|
return nil
|
|
}
|
|
|
|
// Get VPP token, fail early if we cannot get it
|
|
// (e.g. not configured, or not configured for the host's team).
|
|
vppToken, err := svc.ds.GetVPPTokenByTeamID(ctx, host.TeamID)
|
|
switch {
|
|
case err == nil:
|
|
// OK
|
|
case fleet.IsNotFound(err):
|
|
logger.DebugContext(ctx, "no VPP token configured for this host's team")
|
|
return nil
|
|
default:
|
|
return ctxerr.Wrap(ctx, err, "get VPP token if can install VPP apps")
|
|
}
|
|
|
|
// Confirm the host has a nano enrollment record. User-enrolled (BYOD)
|
|
// hosts are no longer skipped — InstallVPPAppPostValidation routes them
|
|
// through the user-scoped VPP path (clientUserIds) downstream.
|
|
enrollment, err := svc.ds.GetNanoMDMEnrollment(ctx, host.UUID)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "getting nano mdm enrollment")
|
|
}
|
|
if enrollment == nil {
|
|
logger.DebugContext(ctx, "skipping updates, missing nano enrollment type")
|
|
return nil
|
|
}
|
|
|
|
var teamID uint
|
|
if host.TeamID != nil {
|
|
teamID = *host.TeamID
|
|
}
|
|
source := "ios_apps"
|
|
if host.Platform == string(fleet.IPadOSPlatform) {
|
|
source = "ipados_apps"
|
|
}
|
|
softwaresWithAutoUpdateSchedule, err := svc.ds.ListSoftwareAutoUpdateSchedules(ctx,
|
|
teamID,
|
|
source,
|
|
fleet.SoftwareAutoUpdateScheduleFilter{
|
|
Enabled: ptr.Bool(true),
|
|
},
|
|
)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "list software auto update schedules")
|
|
}
|
|
|
|
// Code below assumes svc.ds.ListSoftwareAutoUpdateSchedules with Enabled=true returns:
|
|
// - all entries with non-nil AutoUpdateStartTime and AutoUpdateEndTime
|
|
// - returned title IDs are VPP applications (currently the only entities that can have update window configured).
|
|
|
|
if len(softwaresWithAutoUpdateSchedule) == 0 {
|
|
// Nothing else to do.
|
|
return nil
|
|
}
|
|
logger.DebugContext(ctx, "found software with auto update scheduled",
|
|
"count", len(softwaresWithAutoUpdateSchedule),
|
|
)
|
|
|
|
// Create map of installed software title versions by bundle identifier and source.
|
|
installedVersionByBundleIdentifierAndSource := make(map[string]string, len(softwares))
|
|
for _, software := range softwares {
|
|
installedVersionByBundleIdentifierAndSource[software.BundleIdentifier+software.Source] = software.Version
|
|
}
|
|
|
|
// 1. Filter out software that is not within the configured update window in the host timezone.
|
|
var softwaresWithinUpdateSchedule []fleet.SoftwareAutoUpdateSchedule
|
|
for _, softwareWithAutoUpdateSchedule := range softwaresWithAutoUpdateSchedule {
|
|
logger := logger.With(
|
|
"software_title_id", softwareWithAutoUpdateSchedule.TitleID,
|
|
"team_id", softwareWithAutoUpdateSchedule.TeamID,
|
|
"update_window_start", softwareWithAutoUpdateSchedule.AutoUpdateStartTime,
|
|
"update_window_end", softwareWithAutoUpdateSchedule.AutoUpdateEndTime,
|
|
"host_timezone", *host.TimeZone,
|
|
)
|
|
ok, err := isTimezoneInWindow(ctx,
|
|
*host.TimeZone,
|
|
*softwareWithAutoUpdateSchedule.AutoUpdateStartTime,
|
|
*softwareWithAutoUpdateSchedule.AutoUpdateEndTime,
|
|
)
|
|
if err != nil {
|
|
logger.ErrorContext(ctx, "skipping software, failed to check if timezone is in window",
|
|
"err", err,
|
|
)
|
|
continue
|
|
}
|
|
if !ok {
|
|
logger.DebugContext(ctx, "host's local time is not within update window")
|
|
continue
|
|
}
|
|
softwaresWithinUpdateSchedule = append(softwaresWithinUpdateSchedule, softwareWithAutoUpdateSchedule)
|
|
}
|
|
if len(softwaresWithinUpdateSchedule) == 0 {
|
|
// Nothing else to do.
|
|
return nil
|
|
}
|
|
logger.DebugContext(ctx, "found software with auto update scheduled, with host local time currently in window",
|
|
"count", len(softwaresWithinUpdateSchedule),
|
|
)
|
|
|
|
// 2. Filter out software that is already at the latest version or higher.
|
|
var (
|
|
softwaresWithinUpdateWindowThatNeedUpdate []fleet.SoftwareAutoUpdateSchedule
|
|
softwareTitles = make(map[uint]*fleet.SoftwareTitle)
|
|
)
|
|
for _, softwareWithAutoUpdateSchedule := range softwaresWithinUpdateSchedule {
|
|
// Load software title.
|
|
teamID := host.TeamID
|
|
if teamID == nil {
|
|
teamID = ptr.Uint(0)
|
|
}
|
|
softwareTitle, err := svc.ds.SoftwareTitleByID(ctx, softwareWithAutoUpdateSchedule.TitleID, teamID, fleet.TeamFilter{})
|
|
if err != nil {
|
|
logger.ErrorContext(ctx, "software title by id",
|
|
"software_title_id", softwareWithAutoUpdateSchedule.TitleID,
|
|
"team_id", host.TeamID,
|
|
"err", err,
|
|
)
|
|
continue
|
|
}
|
|
logger := logger.With(
|
|
"name", softwareTitle.Name,
|
|
"bundle_identifier", softwareTitle.BundleIdentifier,
|
|
"source", softwareTitle.Source,
|
|
)
|
|
|
|
// Load VPP metadata for the software title.
|
|
vppAppMetadata, err := svc.ds.GetVPPAppMetadataByTeamAndTitleID(ctx, host.TeamID, softwareTitle.ID)
|
|
switch {
|
|
case err == nil:
|
|
// OK
|
|
case fleet.IsNotFound(err):
|
|
logger.ErrorContext(ctx, "title should be VPP app",
|
|
"software_title_id", softwareTitle.ID,
|
|
"team_id", host.TeamID,
|
|
)
|
|
continue
|
|
default:
|
|
logger.ErrorContext(ctx, "get VPP app metadata by team and title",
|
|
"software_title_id", softwareTitle.ID,
|
|
"team_id", host.TeamID,
|
|
"err", err,
|
|
)
|
|
continue
|
|
}
|
|
softwareTitle.AppStoreApp = vppAppMetadata
|
|
|
|
// Incoming softwares have Name, BundleIdentifier, Source and Version.
|
|
var bundleIdentifier string
|
|
if softwareTitle.BundleIdentifier != nil {
|
|
bundleIdentifier = *softwareTitle.BundleIdentifier
|
|
}
|
|
installedVersion, ok := installedVersionByBundleIdentifierAndSource[bundleIdentifier+softwareTitle.Source]
|
|
if !ok {
|
|
// There are some cases where InstalledApplicationList skips the software from the list
|
|
// when the update is ocurring. It seems the software is probably being skipped because
|
|
// it's on a temporary state of installation/replacement.
|
|
logger.DebugContext(ctx, "software title not installed on device or currently in the process of updating, skipping from update",
|
|
"name", softwareTitle.Name,
|
|
"bundle_identifier", bundleIdentifier,
|
|
"source", softwareTitle.Source,
|
|
)
|
|
continue
|
|
}
|
|
installedVersion = toValidSemVer(installedVersion)
|
|
if installedVersion == "" {
|
|
// software.Version is empty when !software.Installed, which means the software is installing (see unmarshalAppList).
|
|
// Here's a sample:
|
|
//
|
|
// <dict>
|
|
// <key>Identifier</key>
|
|
// <string>foo.bar.app</string>
|
|
// <key>Installing</key>
|
|
// <true/>
|
|
// <key>Name</key>
|
|
// <string>Foobar</string>
|
|
// </dict>
|
|
//
|
|
// Note that "Installing" is true and there's no "ShortVersion":
|
|
logger.ErrorContext(ctx, "skipping software, currently installing")
|
|
continue
|
|
}
|
|
if _, err := fleet.VersionToSemverVersion(installedVersion); err != nil {
|
|
logger.ErrorContext(ctx, "invalid installed version",
|
|
"version", installedVersion,
|
|
)
|
|
continue
|
|
}
|
|
latestVersion := toValidSemVer(softwareTitle.AppStoreApp.LatestVersion)
|
|
if _, err := fleet.VersionToSemverVersion(latestVersion); err != nil {
|
|
logger.ErrorContext(ctx, "invalid latest version",
|
|
"version", latestVersion,
|
|
)
|
|
continue
|
|
}
|
|
if fleet.CompareVersions(latestVersion, installedVersion) != 1 {
|
|
// Installed version is equal or higher than latest version, so nothing to do here.
|
|
logger.DebugContext(ctx, "skipping software version",
|
|
"latest_version", latestVersion,
|
|
"installed_version", installedVersion,
|
|
)
|
|
continue
|
|
}
|
|
softwaresWithinUpdateWindowThatNeedUpdate = append(softwaresWithinUpdateWindowThatNeedUpdate, softwareWithAutoUpdateSchedule)
|
|
softwareTitles[softwareTitle.ID] = softwareTitle
|
|
}
|
|
if len(softwaresWithinUpdateWindowThatNeedUpdate) == 0 {
|
|
// Nothing else to do.
|
|
return nil
|
|
}
|
|
logger.DebugContext(ctx, "found software with auto update scheduled, with host local time currently in window, that need update",
|
|
"count", len(softwaresWithinUpdateWindowThatNeedUpdate),
|
|
)
|
|
|
|
// 3. Filter out software that has been issued an install on this host in the last hour.
|
|
//
|
|
// The main reason we must do this filtering is because if the target application is currently in use
|
|
// by the end-user, then the app installation has been acknowledged and verified, but the reported version
|
|
// by InstalledApplicationList is still the old version until the user closes the app or the device goes to
|
|
// sleep and the app is closed and reopened automatically.
|
|
//
|
|
adamIDsRecentInstallForHost, err := svc.ds.MapAdamIDsRecentInstalls(ctx, host.ID, 3600)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "get Adam IDs recent installs for host")
|
|
}
|
|
var softwaresWithinUpdateScheduleNoRecentInstalls []fleet.SoftwareAutoUpdateSchedule
|
|
for _, softwareWithinUpdateSchedule := range softwaresWithinUpdateWindowThatNeedUpdate {
|
|
softwareTitle, ok := softwareTitles[softwareWithinUpdateSchedule.TitleID]
|
|
if !ok {
|
|
// "Should not happen", so we log it just in case.
|
|
logger.ErrorContext(ctx, "missing title ID from map",
|
|
"software_title_id", softwareWithinUpdateSchedule.TitleID,
|
|
)
|
|
continue
|
|
}
|
|
if _, ok := adamIDsRecentInstallForHost[softwareTitle.AppStoreApp.AdamID]; ok {
|
|
logger.DebugContext(ctx, "skipping software, recent install for title",
|
|
"software_title_id", softwareTitle.ID,
|
|
"adam_id", softwareTitle.AppStoreApp.AdamID,
|
|
)
|
|
continue
|
|
}
|
|
softwaresWithinUpdateScheduleNoRecentInstalls = append(softwaresWithinUpdateScheduleNoRecentInstalls, softwareWithinUpdateSchedule)
|
|
}
|
|
if len(softwaresWithinUpdateScheduleNoRecentInstalls) == 0 {
|
|
// Nothing else to do.
|
|
return nil
|
|
}
|
|
logger.DebugContext(ctx, "found software with auto update scheduled, with host local time currently in window, that need update, no recent install",
|
|
"count", len(softwaresWithinUpdateScheduleNoRecentInstalls),
|
|
)
|
|
|
|
// 4. Filter out software that already has a pending installation.
|
|
adamIDsPendingInstallForHost, err := svc.ds.MapAdamIDsPendingInstallVerification(ctx, host.ID)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "get Adam IDs pending install for host")
|
|
}
|
|
var softwaresWithinUpdateScheduleToInstall []*fleet.SoftwareTitle
|
|
for _, softwareWithinUpdateSchedule := range softwaresWithinUpdateScheduleNoRecentInstalls {
|
|
softwareTitle, ok := softwareTitles[softwareWithinUpdateSchedule.TitleID]
|
|
if !ok {
|
|
// "Should not happen", so we log it just in case.
|
|
logger.ErrorContext(ctx, "missing title ID from map",
|
|
"software_title_id", softwareWithinUpdateSchedule.TitleID,
|
|
)
|
|
continue
|
|
}
|
|
if _, ok := adamIDsPendingInstallForHost[softwareTitle.AppStoreApp.AdamID]; ok {
|
|
logger.DebugContext(ctx, "skipping software, pending install for title",
|
|
"software_title_id", softwareTitle.ID,
|
|
"adam_id", softwareTitle.AppStoreApp.AdamID,
|
|
)
|
|
continue
|
|
}
|
|
softwaresWithinUpdateScheduleToInstall = append(softwaresWithinUpdateScheduleToInstall, softwareTitle)
|
|
}
|
|
if len(softwaresWithinUpdateScheduleToInstall) == 0 {
|
|
// Nothing else to do.
|
|
return nil
|
|
}
|
|
logger.DebugContext(ctx,
|
|
"found software with auto update scheduled, with host local time currently in window, that need update, no recent install, no pending installation",
|
|
"count", len(softwaresWithinUpdateScheduleToInstall),
|
|
)
|
|
|
|
// 5. Issue installation of the software titles to update.
|
|
for _, softwareTitle := range softwaresWithinUpdateScheduleToInstall {
|
|
var bundleIdentifier string
|
|
if softwareTitle.BundleIdentifier != nil {
|
|
bundleIdentifier = *softwareTitle.BundleIdentifier
|
|
}
|
|
logger := logger.With(
|
|
"software_title_id", softwareTitle.ID,
|
|
"team_id", host.TeamID,
|
|
"adam_id", softwareTitle.AppStoreApp.AdamID,
|
|
"latest_version", softwareTitle.AppStoreApp.LatestVersion,
|
|
"installed_version", installedVersionByBundleIdentifierAndSource[bundleIdentifier+softwareTitle.Source],
|
|
)
|
|
|
|
vppApp, err := svc.ds.GetVPPAppByTeamAndTitleID(ctx, host.TeamID, softwareTitle.ID)
|
|
if err != nil {
|
|
logger.ErrorContext(ctx, "get VPP app by team and title",
|
|
"err", err,
|
|
)
|
|
continue
|
|
}
|
|
|
|
// Check the label scoping for this VPP app and host.
|
|
scoped, err := svc.ds.IsVPPAppLabelScoped(ctx, vppApp.VPPAppTeam.AppTeamID, host.ID)
|
|
if err != nil {
|
|
logger.ErrorContext(ctx, "get VPP app by team and title",
|
|
"err", err,
|
|
)
|
|
continue
|
|
}
|
|
if !scoped {
|
|
logger.DebugContext(ctx, "skipping host because it's not scoped by the configured labels")
|
|
continue
|
|
}
|
|
|
|
commandUUID, err := svc.vppInstaller.InstallVPPAppPostValidation(ctx, host, vppApp, vppToken.Token, fleet.HostSoftwareInstallOptions{
|
|
ForScheduledUpdates: true,
|
|
})
|
|
if err != nil {
|
|
logger.ErrorContext(ctx, "install VPP app post validation",
|
|
"err", err,
|
|
)
|
|
continue
|
|
}
|
|
|
|
logger.DebugContext(ctx, "update scheduled",
|
|
"command_uuid", commandUUID,
|
|
)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// nowFunc is to be used in tests.
|
|
var nowFunc = time.Now
|
|
|
|
// getCurrentLocalTimeInHostTimeZone returns the current time of the given IANA time zone string.
|
|
func getCurrentLocalTimeInHostTimeZone(ctx context.Context, timeZone string) (time.Time, error) {
|
|
loc, err := time.LoadLocation(timeZone)
|
|
if err != nil {
|
|
return time.Time{}, ctxerr.Wrap(ctx, err, "load location")
|
|
}
|
|
|
|
// Convert now to the specified location using the In() method
|
|
localTime := nowFunc().In(loc)
|
|
return localTime, nil
|
|
}
|
|
|
|
// isTimezoneInWindow checks if the given timezone is currently within
|
|
// the time window defined by start and end.
|
|
// Arguments start and end should be in "HH:MM" format (24-hour clock).
|
|
// Returns true if the timezone current time is within [start, end], inclusive.
|
|
// Handles windows that cross midnight (e.g., "22:00" to "06:00").
|
|
func isTimezoneInWindow(ctx context.Context, timezone string, start string, end string) (bool, error) {
|
|
t, err := getCurrentLocalTimeInHostTimeZone(ctx, timezone)
|
|
if err != nil {
|
|
return false, ctxerr.Wrap(ctx, err, "get current local time in host timezone")
|
|
}
|
|
|
|
// Parse hour and minute from start and end strings
|
|
startHour, startMin, err := parseHHMM(start)
|
|
if err != nil {
|
|
return false, fmt.Errorf("invalid start time: %w", err)
|
|
}
|
|
endHour, endMin, err := parseHHMM(end)
|
|
if err != nil {
|
|
return false, fmt.Errorf("invalid end time: %w", err)
|
|
}
|
|
|
|
// Get the clock time from t (in its own location)
|
|
currentHour := t.Hour()
|
|
currentMin := t.Minute()
|
|
|
|
// Convert everything to minutes since midnight for easy comparison
|
|
currentMins := currentHour*60 + currentMin
|
|
startMins := startHour*60 + startMin
|
|
endMins := endHour*60 + endMin
|
|
|
|
// Normal case: window does not cross midnight
|
|
if startMins <= endMins {
|
|
return currentMins >= startMins && currentMins <= endMins, nil
|
|
}
|
|
|
|
// Window crosses midnight (e.g., 22:00 to 06:00)
|
|
// True if time is after start OR before end
|
|
return currentMins >= startMins || currentMins <= endMins, nil
|
|
}
|
|
|
|
// parseHHMM parses "HH:MM" into hour and minute.
|
|
func parseHHMM(s string) (hour, min_ int, err error) {
|
|
var h, m int
|
|
n, err := fmt.Sscanf(s, "%d:%d", &h, &m)
|
|
if err != nil || n != 2 {
|
|
return 0, 0, fmt.Errorf("expected HH:MM format, got %q", s)
|
|
}
|
|
if h < 0 || h > 23 {
|
|
return 0, 0, fmt.Errorf("hour must be 0-23, got %d", h)
|
|
}
|
|
if m < 0 || m > 59 {
|
|
return 0, 0, fmt.Errorf("minute must be 0-59, got %d", m)
|
|
}
|
|
return h, m, nil
|
|
}
|
|
|
|
func (svc *MDMAppleCheckinAndCommandService) handleRefetchCertsResults(ctx context.Context, host *fleet.Host, cmdResult *mdm.CommandResults) (*mdm.Command, error) {
|
|
if !strings.HasPrefix(cmdResult.CommandUUID, fleet.RefetchCertsCommandUUIDPrefix) {
|
|
// Caller should have checked this, but just in case we'll return an error.
|
|
return nil, ctxerr.New(ctx, fmt.Sprintf("expected REFETCH-CERTS- prefix but got %s", cmdResult.CommandUUID))
|
|
}
|
|
|
|
// We remove pending command first in case there is an error processing the results, so that we don't prevent another refetch.
|
|
if err := svc.ds.RemoveHostMDMCommand(ctx, fleet.HostMDMCommand{
|
|
HostID: host.ID,
|
|
CommandType: fleet.RefetchCertsCommandUUIDPrefix,
|
|
}); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "refetch certs: remove refetch command")
|
|
}
|
|
|
|
// TODO(mna): when we add iOS/iPadOS support for https://github.com/fleetdm/fleet/issues/26913,
|
|
// this is where we'll need to identify user-keychain certs for iPad/iPhone. For now we set
|
|
// them all as "system" certificates.
|
|
var listResp fleet.MDMAppleCertificateListResponse
|
|
if err := plist.Unmarshal(cmdResult.Raw, &listResp); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "refetch certs: unmarshal certificate list command result")
|
|
}
|
|
payload := make([]*fleet.HostCertificateRecord, 0, len(listResp.CertificateList))
|
|
for _, cert := range listResp.CertificateList {
|
|
parsed, err := cert.Parse(host.ID)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "refetch certs: parse certificate")
|
|
}
|
|
payload = append(payload, parsed)
|
|
}
|
|
|
|
if err := svc.ds.UpdateHostCertificates(ctx, host.ID, host.UUID, payload, fleet.HostCertificateOriginMDM, nil); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "refetch certs: update host certificates")
|
|
}
|
|
|
|
// Best-effort cleanup of stale refetch commands of the same type.
|
|
if err := svc.ds.CleanupStaleNanoRefetchCommands(ctx, host.UUID, fleet.RefetchCertsCommandUUIDPrefix, cmdResult.CommandUUID); err != nil {
|
|
svc.logger.ErrorContext(ctx, "cleanup stale nano refetch certs commands", "err", err, "host_uuid", host.UUID, "command_prefix", fleet.RefetchCertsCommandUUIDPrefix)
|
|
}
|
|
|
|
return nil, nil
|
|
}
|
|
|
|
// maybeQueueCertificateListForACMEProfile fires a CertificateList MDM command
|
|
// after a macOS InstallProfile or RemoveProfile ack on a profile containing a
|
|
// com.apple.security.acme payload. Install captures the new hardware-bound cert
|
|
// (invisible to osquery) into host_certificates; remove lets the refetch
|
|
// observe its absence so the stale row clears. iOS/iPadOS do not need this hook
|
|
// because IOSiPadOSRefetch already runs CertificateList on a cron.
|
|
//
|
|
// Gating happens server-side in a single indexed query
|
|
// (ProfileHasACMEPayloadForCommand): host platform and ACME payload presence.
|
|
// The hot path early-returns for the common non-ACME / non-darwin cases
|
|
// without parsing the profile or making additional roundtrips.
|
|
//
|
|
// We deliberately do NOT dedupe against an in-flight CertificateList: if a
|
|
// previous refetch is still pending when this trigger fires, that earlier
|
|
// refetch can capture state that predates the new ACME exchange completing
|
|
// on-device. Letting the new install queue its own refetch ensures the new
|
|
// cert is captured even if the earlier refetch was already in flight.
|
|
// host_mdm_commands has a (host_id, command_type) PK so duplicate INSERTs
|
|
// collapse via ON DUPLICATE KEY UPDATE, and handleRefetchCertsResults is
|
|
// safe to call on an already-removed row.
|
|
func (svc *MDMAppleCheckinAndCommandService) maybeQueueCertificateListForACMEProfile(ctx context.Context, hostUUID, commandUUID string) error {
|
|
res, err := svc.ds.ProfileHasACMEPayloadForCommand(ctx, hostUUID, commandUUID)
|
|
if err != nil {
|
|
if fleet.IsNotFound(err) {
|
|
return nil
|
|
}
|
|
return ctxerr.Wrap(ctx, err, "probe profile for ACME payload")
|
|
}
|
|
if res.Platform != "darwin" || !res.HasACMEPayload {
|
|
return nil
|
|
}
|
|
|
|
cmdUUID := uuid.NewString()
|
|
if err := svc.commander.CertificateList(ctx, []string{hostUUID}, fleet.RefetchCertsCommandUUIDPrefix+cmdUUID); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "enqueue CertificateList")
|
|
}
|
|
|
|
// Track after the commander call so a CertificateList enqueue failure
|
|
// doesn't leave a stale tracking row that would suppress future
|
|
// triggers. Matches the iOS/iPadOS pattern in IOSiPadOSRefetch.
|
|
if err := svc.ds.AddHostMDMCommands(ctx, []fleet.HostMDMCommand{{
|
|
HostID: res.HostID,
|
|
CommandType: fleet.RefetchCertsCommandUUIDPrefix,
|
|
}}); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "track refetch certs command")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// handleDeviceNameCommandResult processes the result of a Settings/DeviceName
|
|
// command sent to enforce a team's host name template. On acknowledgment the
|
|
// host is renamed in Fleet right away — the device just applied the name, so
|
|
// the next osquery/DeviceInformation ingest confirms the rename (verifying →
|
|
// verified) instead of reverting an optimistic early write. On error the
|
|
// enforcement row lands failed with Apple's error chain; the cron only picks
|
|
// up queued rows, so a failed command is not retried until an admin resends.
|
|
func (svc *MDMAppleCheckinAndCommandService) handleDeviceNameCommandResult(ctx context.Context, cmdResult *mdm.CommandResults) error {
|
|
status := cmdResult.Status
|
|
detail := ""
|
|
switch status {
|
|
case fleet.MDMAppleStatusAcknowledged:
|
|
// A Settings command can report per-item failures inside an
|
|
// acknowledged result; this command carries a single DeviceName item,
|
|
// so any item-level error means the rename failed.
|
|
if itemDetail, itemFailed := deviceNameSettingsItemError(ctx, svc.logger, cmdResult.Raw); itemFailed {
|
|
status = fleet.MDMAppleStatusError
|
|
detail = itemDetail
|
|
}
|
|
case fleet.MDMAppleStatusError, fleet.MDMAppleStatusCommandFormatError:
|
|
detail = apple_mdm.FmtErrorChain(cmdResult.ErrorChain)
|
|
default:
|
|
// Idle/NotNow — the command hasn't completed yet; nothing to record.
|
|
return nil
|
|
}
|
|
|
|
if status == fleet.MDMAppleStatusAcknowledged {
|
|
// On acknowledgment the datastore moves the row to verifying and renames
|
|
// the host in Fleet in the same transaction. A not-found means the row
|
|
// tracks a newer command (template re-saved or resend clicked before this
|
|
// result arrived); this result is stale and the newer command's result
|
|
// carries the final name, so it's ignored.
|
|
if err := svc.ds.UpdateHostDeviceNameStatusFromCommand(ctx, cmdResult.CommandUUID, true, ""); err != nil && !fleet.IsNotFound(err) {
|
|
return ctxerr.Wrap(ctx, err, "update device name row from acknowledged command")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
if err := svc.ds.UpdateHostDeviceNameStatusFromCommand(ctx, cmdResult.CommandUUID, false, detail); err != nil && !fleet.IsNotFound(err) {
|
|
return ctxerr.Wrap(ctx, err, "update device name row from failed command")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// deviceNameSettingsItemError inspects a Settings command acknowledgment for
|
|
// per-item statuses: each item in the Settings array of the response can
|
|
// individually report an Error even when the overall command is Acknowledged.
|
|
// It returns a human-readable detail and true when any item failed.
|
|
func deviceNameSettingsItemError(ctx context.Context, logger *slog.Logger, raw []byte) (string, bool) {
|
|
var ack struct {
|
|
Settings []struct {
|
|
Status string `plist:"Status"`
|
|
ErrorChain []mdm.ErrorChain `plist:"ErrorChain"`
|
|
} `plist:"Settings"`
|
|
}
|
|
if err := plist.Unmarshal(raw, &ack); err != nil {
|
|
// A malformed per-item array shouldn't fail the acknowledged command.
|
|
logger.WarnContext(ctx, "unmarshal Settings command acknowledgment for per-item statuses", "err", err)
|
|
return "", false
|
|
}
|
|
for _, item := range ack.Settings {
|
|
if item.Status != "" && item.Status != fleet.MDMAppleStatusAcknowledged {
|
|
detail := apple_mdm.FmtErrorChain(item.ErrorChain)
|
|
if detail == "" {
|
|
detail = "Settings item returned status " + item.Status + "."
|
|
}
|
|
return detail, true
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
func (svc *MDMAppleCheckinAndCommandService) handleRefetchDeviceResults(ctx context.Context, host *fleet.Host, cmdResult *mdm.CommandResults) (*mdm.Command, error) {
|
|
if !strings.HasPrefix(cmdResult.CommandUUID, fleet.RefetchDeviceCommandUUIDPrefix) {
|
|
// Caller should have checked this, but just in case we'll return an error.
|
|
return nil, ctxerr.New(ctx, fmt.Sprintf("expected REFETCH-DEVICE- prefix but got %s", cmdResult.CommandUUID))
|
|
}
|
|
|
|
// We remove pending command first in case there is an error processing the results, so that we don't prevent another refetch.
|
|
if err := svc.ds.RemoveHostMDMCommand(ctx, fleet.HostMDMCommand{
|
|
HostID: host.ID,
|
|
CommandType: fleet.RefetchDeviceCommandUUIDPrefix,
|
|
}); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "remove refetch device command")
|
|
}
|
|
|
|
var deviceInformationResponse struct {
|
|
QueryResponses map[string]interface{} `plist:"QueryResponses"`
|
|
}
|
|
if err := plist.Unmarshal(cmdResult.Raw, &deviceInformationResponse); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "failed to unmarshal device information command result")
|
|
}
|
|
queryResponses := deviceInformationResponse.QueryResponses
|
|
deviceName, deviceNameOK := queryResponses["DeviceName"].(string)
|
|
deviceCapacity, deviceCapacityOK := queryResponses["DeviceCapacity"].(float64)
|
|
availableDeviceCapacity, availableDeviceCapacityOK := queryResponses["AvailableDeviceCapacity"].(float64)
|
|
rawOSVersion, osVersionOK := queryResponses["OSVersion"].(string)
|
|
var supplementalExtra string
|
|
if v, ok := queryResponses["SupplementalOSVersionExtra"]; ok {
|
|
supplementalExtra, _ = v.(string)
|
|
}
|
|
osVersion, err := buildOSVersion(rawOSVersion, supplementalExtra)
|
|
if err != nil {
|
|
svc.logger.WarnContext(ctx, "ignoring invalid SupplementalOSVersionExtra from device", "host_uuid", host.UUID, "err", err)
|
|
osVersion, _ = buildOSVersion(rawOSVersion, "")
|
|
}
|
|
productName, productNameOK := queryResponses["ProductName"].(string)
|
|
wifiMac, _ := queryResponses["WiFiMAC"].(string) // not present for user-enrolled devices
|
|
isLostModeEnabled, _ := queryResponses["IsMDMLostModeEnabled"].(bool)
|
|
|
|
var missingFields []string
|
|
if !deviceNameOK {
|
|
missingFields = append(missingFields, "DeviceName")
|
|
}
|
|
if !deviceCapacityOK {
|
|
missingFields = append(missingFields, "DeviceCapacity")
|
|
}
|
|
if !availableDeviceCapacityOK {
|
|
missingFields = append(missingFields, "AvailableDeviceCapacity")
|
|
}
|
|
if !osVersionOK {
|
|
missingFields = append(missingFields, "OSVersion")
|
|
}
|
|
if !productNameOK {
|
|
missingFields = append(missingFields, "ProductName")
|
|
}
|
|
if len(missingFields) > 0 {
|
|
svc.logger.WarnContext(ctx, "DeviceInformation response missing or unexpectedly typed fields; preserving existing host values",
|
|
"host_id", host.ID,
|
|
"host_uuid", host.UUID,
|
|
"missing_or_invalid_fields", missingFields,
|
|
)
|
|
}
|
|
|
|
if deviceNameOK {
|
|
host.ComputerName = deviceName
|
|
host.Hostname = deviceName
|
|
}
|
|
if availableDeviceCapacityOK {
|
|
host.GigsDiskSpaceAvailable = availableDeviceCapacity
|
|
}
|
|
if deviceCapacityOK {
|
|
host.GigsTotalDiskSpace = deviceCapacity
|
|
}
|
|
|
|
// Determine platform/osVersionPrefix from ProductName when present; otherwise
|
|
// fall back to the previously-known platform on the host.
|
|
var (
|
|
osVersionPrefix string
|
|
platform string
|
|
)
|
|
if productNameOK {
|
|
if strings.HasPrefix(productName, "iPhone") || strings.HasPrefix(productName, "iPod") {
|
|
osVersionPrefix = "iOS"
|
|
platform = "ios"
|
|
} else { // iPad
|
|
osVersionPrefix = "iPadOS"
|
|
platform = "ipados"
|
|
}
|
|
} else {
|
|
// Fall back to the host's known platform when ProductName is absent so
|
|
// the OS version is still updated with the correct prefix.
|
|
platform = host.Platform
|
|
switch platform {
|
|
case "ios":
|
|
osVersionPrefix = "iOS"
|
|
case "ipados":
|
|
osVersionPrefix = "iPadOS"
|
|
}
|
|
}
|
|
|
|
// Only update host.OSVersion when we have both the prefix (from ProductName or
|
|
// preserved platform) and the version string itself.
|
|
if osVersionOK && osVersionPrefix != "" {
|
|
host.OSVersion = osVersionPrefix + " " + osVersion
|
|
if len(host.OSVersion) > 255 {
|
|
host.OSVersion = host.OSVersion[:255]
|
|
}
|
|
}
|
|
host.PrimaryMac = wifiMac
|
|
if productNameOK {
|
|
host.HardwareModel = productName
|
|
}
|
|
host.DetailUpdatedAt = time.Now()
|
|
// iOS/iPadOS devices do not support dynamic labels at this time so we should update their LabelUpdatedAt timestamp
|
|
// on refetch similar to other platforms to simplify exclusion logic with dynamic labels
|
|
host.LabelUpdatedAt = time.Now()
|
|
host.RefetchRequested = false
|
|
|
|
timeZone, _ := queryResponses["TimeZone"].(string)
|
|
host.TimeZone = &timeZone
|
|
|
|
if err := svc.ds.UpdateHost(ctx, host); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "failed to update host")
|
|
}
|
|
|
|
if deviceNameOK && deviceName != "" && fleet.IsAppleMobilePlatform(host.Platform) {
|
|
// Reconcile the host-name enforcement row (if any) against the name the
|
|
// device reported: confirms a rename (verifying → verified) or records
|
|
// drift (verified → failed). No-op for hosts without a row. A failure here
|
|
// is logged rather than returned: the refetch results are already
|
|
// persisted, this is a non-critical verify transition the next refetch
|
|
// will redo, and aborting would fail the whole MDM check-in. Mirrors the
|
|
// macOS osquery hook (server/service/osquery.go).
|
|
if err := svc.ds.UpdateHostDeviceNameStatusFromReport(ctx, host.UUID, deviceName); err != nil {
|
|
svc.logger.ErrorContext(ctx, "update host device name status from refetch", "host_uuid", host.UUID, "err", err)
|
|
}
|
|
}
|
|
|
|
// Skip the disk space update when either capacity field is missing/invalid,
|
|
// since the percent-available calculation divides by deviceCapacity.
|
|
if deviceCapacityOK && availableDeviceCapacityOK && deviceCapacity > 0 {
|
|
if err := svc.ds.SetOrUpdateHostDisksSpace(ctx, host.ID, availableDeviceCapacity, 100*availableDeviceCapacity/deviceCapacity,
|
|
deviceCapacity, nil); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "failed to update host storage")
|
|
}
|
|
}
|
|
// Skip the operating system row update unless we have a version and a known
|
|
// platform/prefix to associate it with — otherwise we'd write a junk row.
|
|
if osVersionOK && osVersionPrefix != "" && platform != "" {
|
|
if err := svc.ds.UpdateHostOperatingSystem(ctx, host.ID, fleet.OperatingSystem{
|
|
Name: osVersionPrefix,
|
|
Version: osVersion,
|
|
Platform: platform,
|
|
}); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "failed to update host operating system")
|
|
}
|
|
}
|
|
|
|
if host.MDM.EnrollmentStatus != nil && *host.MDM.EnrollmentStatus == fleet.MDMEnrollmentStatusPending {
|
|
// Since the device has been refetched, we can assume it's enrolled.
|
|
if err := svc.ds.UpdateMDMData(ctx, host.ID, true); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "failed to update MDM data")
|
|
}
|
|
|
|
// We run this check here as we only want to run it on re-check ins for deleted hosts.
|
|
if (platform == "ios" || platform == "ipados") && isLostModeEnabled {
|
|
cmd, err := svc.ds.GetLatestAppleMDMCommandOfType(ctx, host.UUID, "EnableLostMode")
|
|
if err != nil && !fleet.IsNotFound(err) {
|
|
return nil, ctxerr.Wrap(ctx, err, "check for existing EnableLostMode command")
|
|
}
|
|
if fleet.IsNotFound(err) {
|
|
// Device is in lost mode, but we do not have a lock command record for it.
|
|
// Lost mode was enabled outside of Fleet?
|
|
return nil, ctxerr.NewWithData(ctx, "device is in lost mode but no EnableLostMode command record found", map[string]interface{}{"host_uuid": host.UUID})
|
|
}
|
|
|
|
svc.logger.DebugContext(ctx, "device is in lost mode and EnableLostMode command record found, updating host lock/wipe status",
|
|
"host_uuid", host.UUID)
|
|
err = svc.ds.SetLockCommandForLostModeCheckin(ctx, host.ID, cmd.CommandUUID)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "update host lost mode status on refetch")
|
|
}
|
|
}
|
|
}
|
|
|
|
// Best-effort cleanup of stale refetch commands of the same type.
|
|
if err := svc.ds.CleanupStaleNanoRefetchCommands(ctx, host.UUID, fleet.RefetchDeviceCommandUUIDPrefix, cmdResult.CommandUUID); err != nil {
|
|
svc.logger.ErrorContext(ctx, "cleanup stale nano refetch device commands", "err", err, "host_uuid", host.UUID, "command_prefix", fleet.RefetchDeviceCommandUUIDPrefix)
|
|
}
|
|
|
|
return nil, nil
|
|
}
|
|
|
|
func unmarshalAppList(ctx context.Context, response []byte, source string) ([]fleet.Software,
|
|
error,
|
|
) {
|
|
var appsResponse struct {
|
|
InstalledApplicationList []map[string]interface{} `plist:"InstalledApplicationList"`
|
|
}
|
|
if err := plist.Unmarshal(response, &appsResponse); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "failed to unmarshal installed application list command result")
|
|
}
|
|
|
|
truncateString := func(item interface{}, length int) string {
|
|
str, ok := item.(string)
|
|
if !ok {
|
|
return ""
|
|
}
|
|
runes := []rune(str)
|
|
if len(runes) > length {
|
|
return string(runes[:length])
|
|
}
|
|
return str
|
|
}
|
|
|
|
var software []fleet.Software
|
|
for _, app := range appsResponse.InstalledApplicationList {
|
|
sw := fleet.Software{
|
|
Name: truncateString(app["Name"], fleet.SoftwareNameMaxLength),
|
|
Version: truncateString(app["ShortVersion"], fleet.SoftwareVersionMaxLength),
|
|
BundleIdentifier: truncateString(app["Identifier"], fleet.SoftwareBundleIdentifierMaxLength),
|
|
Source: source,
|
|
}
|
|
if val, ok := app["Installing"]; ok {
|
|
installing, ok := val.(bool)
|
|
if !ok {
|
|
return nil, ctxerr.New(ctx, "parsing Installing key")
|
|
}
|
|
|
|
sw.Installed = !installing
|
|
}
|
|
software = append(software, sw)
|
|
}
|
|
|
|
return software, nil
|
|
}
|
|
|
|
// mdmAppleDeliveryStatusFromCommandStatus converts a MDM command status to a
|
|
// fleet.MDMAppleDeliveryStatus.
|
|
//
|
|
// NOTE: this mapping does not include all
|
|
// possible delivery statuses (e.g., verified status is not included) is intended to
|
|
// only be used in the context of CommandAndReportResults in the MDMAppleCheckinAndCommandService.
|
|
// Extra care should be taken before using this function in other contexts.
|
|
func mdmAppleDeliveryStatusFromCommandStatus(cmdStatus string) *fleet.MDMDeliveryStatus {
|
|
switch cmdStatus {
|
|
case fleet.MDMAppleStatusAcknowledged:
|
|
return &fleet.MDMDeliveryVerifying
|
|
case fleet.MDMAppleStatusError, fleet.MDMAppleStatusCommandFormatError:
|
|
return &fleet.MDMDeliveryFailed
|
|
case fleet.MDMAppleStatusIdle, fleet.MDMAppleStatusNotNow:
|
|
return &fleet.MDMDeliveryPending
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// ensureFleetProfiles ensures there's a fleetd configuration profile in
|
|
// mdm_apple_configuration_profiles for each team and for "no team"
|
|
//
|
|
// We try our best to use each team's secret but we default to creating a
|
|
// profile with the global enroll secret if the team doesn't have any enroll
|
|
// secrets.
|
|
//
|
|
// This profile will be installed to all hosts in the team (or "no team",) but it
|
|
// will only be used by hosts that have a fleetd installation without an enroll
|
|
// secret and fleet URL (mainly DEP enrolled hosts).
|
|
func ensureFleetProfiles(ctx context.Context, ds fleet.Datastore, logger *slog.Logger, signingCertDER []byte) error {
|
|
appCfg, err := ds.AppConfig(ctx)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "fetching app config")
|
|
}
|
|
|
|
var rootCAProfContents bytes.Buffer
|
|
params := mobileconfig.FleetCARootTemplateOptions{
|
|
PayloadIdentifier: mobileconfig.FleetCARootConfigPayloadIdentifier,
|
|
PayloadName: mdm_types.FleetCAConfigProfileName,
|
|
Certificate: base64.StdEncoding.EncodeToString(signingCertDER),
|
|
}
|
|
|
|
if err := mobileconfig.FleetCARootTemplate.Execute(&rootCAProfContents, params); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "executing fleet root CA config template")
|
|
}
|
|
|
|
b := rootCAProfContents.Bytes()
|
|
|
|
enrollSecrets, err := ds.AggregateEnrollSecretPerTeam(ctx)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "getting enroll secrets aggregates")
|
|
}
|
|
|
|
globalSecret := ""
|
|
for _, es := range enrollSecrets {
|
|
if es.TeamID == nil {
|
|
globalSecret = es.Secret
|
|
}
|
|
}
|
|
|
|
var profiles []*fleet.MDMAppleConfigProfile
|
|
for _, es := range enrollSecrets {
|
|
if es.Secret == "" {
|
|
var msg string
|
|
if es.TeamID != nil {
|
|
msg += fmt.Sprintf("team_id %d doesn't have an enroll secret, ", *es.TeamID)
|
|
}
|
|
if globalSecret == "" {
|
|
logger.WarnContext(ctx, msg+"no global enroll secret found, skipping the creation of a com.fleetdm.fleetd.config profile")
|
|
continue
|
|
}
|
|
logger.WarnContext(ctx, msg+"using a global enroll secret for com.fleetdm.fleetd.config profile")
|
|
es.Secret = globalSecret
|
|
}
|
|
|
|
var contents bytes.Buffer
|
|
params := mobileconfig.FleetdProfileOptions{
|
|
EnrollSecret: es.Secret,
|
|
ServerURL: appCfg.ServerSettings.ServerURL, // ServerURL must be set to the Fleet URL. Do not use appCfg.MDMUrl() here.
|
|
PayloadType: mobileconfig.FleetdConfigPayloadIdentifier,
|
|
PayloadName: mdm_types.FleetdConfigProfileName,
|
|
}
|
|
|
|
if err := mobileconfig.FleetdProfileTemplate.Execute(&contents, params); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "executing fleetd config template")
|
|
}
|
|
|
|
cp, err := fleet.NewMDMAppleConfigProfile(contents.Bytes(), es.TeamID)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "building fleetd configuration profile")
|
|
}
|
|
profiles = append(profiles, cp)
|
|
|
|
rootCAProf, err := fleet.NewMDMAppleConfigProfile(b, es.TeamID)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "building root CA configuration profile")
|
|
}
|
|
profiles = append(profiles, rootCAProf)
|
|
}
|
|
|
|
if err := ds.BulkUpsertMDMAppleConfigProfiles(ctx, profiles); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "bulk-upserting configuration profiles")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func SendPushesToPendingDevices(
|
|
ctx context.Context,
|
|
ds fleet.Datastore,
|
|
commander *apple_mdm.MDMAppleCommander,
|
|
logger *slog.Logger,
|
|
) error {
|
|
enrollmentIDs, err := ds.GetEnrollmentIDsWithPendingMDMAppleCommands(ctx)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "getting host uuids with pending commands")
|
|
}
|
|
|
|
if len(enrollmentIDs) == 0 {
|
|
return nil
|
|
}
|
|
|
|
if err := commander.SendNotifications(ctx, enrollmentIDs); err != nil {
|
|
var apnsErr *apple_mdm.APNSDeliveryError
|
|
if errors.As(err, &apnsErr) {
|
|
logger.InfoContext(ctx, "failed to send APNs notification to some hosts", "error", apnsErr.Error())
|
|
return nil
|
|
}
|
|
|
|
return ctxerr.Wrap(ctx, err, "sending push notifications")
|
|
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// scepCertRenewalThresholdDays defines the number of days before a SCEP
|
|
// certificate must be renewed.
|
|
const scepCertRenewalThresholdDays = 180
|
|
|
|
// maxCertsRenewalPerRun specifies the maximum number of certificates to renew
|
|
// in a single cron run.
|
|
//
|
|
// Assuming that the cron runs every hour, we'll enqueue 24,000 renewals per
|
|
// day, and we have room for 24,000 * scepCertRenewalThresholdDays total
|
|
// renewals.
|
|
//
|
|
// For a default of 180 days as a threshold this gives us room for a fleet of
|
|
// ~4 million devices expiring at the same time.
|
|
const maxCertsRenewalPerRun = 100
|
|
|
|
func RenewSCEPCertificates(
|
|
ctx context.Context,
|
|
logger *slog.Logger,
|
|
ds fleet.Datastore,
|
|
config *config.FleetConfig,
|
|
commander *apple_mdm.MDMAppleCommander,
|
|
acmeService fleet.ACMEWriteService,
|
|
) error {
|
|
renewalDisable, exists := os.LookupEnv("FLEET_MDM_APPLE_SCEP_RENEWAL_DISABLE")
|
|
if exists && (strings.EqualFold(renewalDisable, "true") || renewalDisable == "1") {
|
|
logger.InfoContext(ctx, "skipping renewal of macOS SCEP certificates as FLEET_MDM_APPLE_SCEP_RENEWAL_DISABLE is set to true")
|
|
return nil
|
|
}
|
|
|
|
appConfig, err := ds.AppConfig(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("reading app config: %w", err)
|
|
}
|
|
if !appConfig.MDM.EnabledAndConfigured {
|
|
logger.DebugContext(ctx, "skipping renewal of macOS SCEP certificates as MDM is not fully configured")
|
|
return nil
|
|
}
|
|
|
|
if commander == nil {
|
|
logger.DebugContext(ctx, "skipping renewal of macOS SCEP certificates as apple_mdm.MDMAppleCommander was not provided")
|
|
return nil
|
|
}
|
|
|
|
// for each hash, grab the host that uses it as its identity certificate
|
|
certAssociations, err := ds.GetHostCertAssociationsToExpire(ctx, scepCertRenewalThresholdDays, maxCertsRenewalPerRun)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "getting host cert associations")
|
|
}
|
|
|
|
if len(certAssociations) == 0 {
|
|
logger.DebugContext(ctx, "no certs to renew")
|
|
return nil
|
|
}
|
|
// maybeACMEUUIDs stores a subset of host UUIDs that we want to check for ACME renewal requirements.
|
|
maybeACMEUUIDs := make([]string, 0, len(certAssociations))
|
|
|
|
// assocsWithRefs stores hosts that have enrollment references on their
|
|
// enrollment profiles. This is the case for ADE-enrolled hosts using
|
|
// SSO to authenticate.
|
|
assocsWithRefs := []fleet.SCEPIdentityAssociation{}
|
|
// assocsWithoutRefs stores hosts that don't have an enrollment
|
|
// reference in their enrollment profile.
|
|
assocsWithoutRefs := []fleet.SCEPIdentityAssociation{}
|
|
// assocsFromMigration stores hosts that were migrated from another MDM
|
|
// using the process described in
|
|
// https://github.com/fleetdm/fleet/issues/19387
|
|
assocsFromMigration := []fleet.SCEPIdentityAssociation{}
|
|
// userDeviceAssocs stores hosts enrolled using Account Driven User Enrollment
|
|
// which results in a "User Enrollment (Device)" enrollment type and requires
|
|
// a different type of enrollment profile sent to the host.
|
|
userDeviceAssocs := []fleet.SCEPIdentityAssociation{}
|
|
for _, assoc := range certAssociations {
|
|
if assoc.EnrolledFromMigration {
|
|
assocsFromMigration = append(assocsFromMigration, assoc)
|
|
continue
|
|
}
|
|
|
|
if assoc.EnrollmentType == "User Enrollment (Device)" {
|
|
userDeviceAssocs = append(userDeviceAssocs, assoc)
|
|
continue
|
|
}
|
|
|
|
// Note we don't want to check ACME renewal requirements for hosts that were enrolled from
|
|
// migration or using account driven user enrollment. Now that those are ruled out we can
|
|
// append maybeACMEUUIDs.
|
|
maybeACMEUUIDs = append(maybeACMEUUIDs, assoc.HostUUID)
|
|
|
|
if assoc.EnrollReference != "" {
|
|
assocsWithRefs = append(assocsWithRefs, assoc)
|
|
continue
|
|
}
|
|
assocsWithoutRefs = append(assocsWithoutRefs, assoc)
|
|
}
|
|
|
|
mdmPushCertTopic, err := assets.APNSTopic(ctx, ds)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "extracting topic from APNs certificate")
|
|
}
|
|
|
|
acmeRequiredByHostUUID := make(map[string]fleet.DeviceInfoForACMERenewal)
|
|
if appConfig.MDM.AppleRequireHardwareAttestation {
|
|
// get info needed to determine whether ACME is required
|
|
di, err := ds.GetDeviceInfoForACMERenewal(ctx, maybeACMEUUIDs)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "getting device info for ACME renewal")
|
|
}
|
|
logErrs := []any{}
|
|
for _, info := range di {
|
|
if ok, err := isMacACMESupported(info.HardwareModel, info.OSVersion); err != nil {
|
|
logErrs = append(logErrs, "host_uuid", info.HostUUID, "error", err)
|
|
} else if ok {
|
|
acmeRequiredByHostUUID[info.HostUUID] = info
|
|
}
|
|
}
|
|
if len(logErrs) > 0 {
|
|
logger.ErrorContext(ctx, "checking ACME requirement for hosts renewing SCEP certificates", logErrs...)
|
|
}
|
|
}
|
|
|
|
assets, err := ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{
|
|
fleet.MDMAssetSCEPChallenge,
|
|
}, nil)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "loading SCEP challenge from the database")
|
|
}
|
|
scepChallenge := string(assets[fleet.MDMAssetSCEPChallenge].Value)
|
|
|
|
// acmeAssocsByHostUUID will store the associations for hosts that require ACME renewal, which
|
|
// will be handled separately since they require a different enrollment profile.
|
|
acmeAssocsByHostUUID := make(map[string]fleet.SCEPIdentityAssociation)
|
|
|
|
// Filter for ACME requirements then send a single command for all the hosts without references.
|
|
if len(assocsWithoutRefs) > 0 {
|
|
var filteredAssocs []fleet.SCEPIdentityAssociation
|
|
for _, assoc := range assocsWithoutRefs {
|
|
if _, ok := acmeRequiredByHostUUID[assoc.HostUUID]; ok {
|
|
acmeAssocsByHostUUID[assoc.HostUUID] = assoc
|
|
continue
|
|
}
|
|
filteredAssocs = append(filteredAssocs, assoc)
|
|
}
|
|
|
|
if len(filteredAssocs) > 0 {
|
|
// Bucket the renewals by their (personal, rights) tuple. Every host in
|
|
// a bucket gets a byte-identical enrollment profile, so we can collapse
|
|
// them into a single InstallProfile command instead of one per host.
|
|
// The nano command tables are hot, and in practice there are only two
|
|
// distinct buckets (company-owned vs. BYOD), so this keeps renewal write
|
|
// traffic close to the pre-BYOD single-command behaviour.
|
|
type renewalBucket struct {
|
|
personal bool
|
|
rights int
|
|
}
|
|
buckets := make(map[renewalBucket][]fleet.SCEPIdentityAssociation)
|
|
// Preserve a deterministic order so the commands we enqueue don't depend
|
|
// on Go's randomized map iteration.
|
|
bucketOrder := make([]renewalBucket, 0, 2)
|
|
for _, assoc := range filteredAssocs {
|
|
personal, rights, err := renewalEnrollmentParams(ctx, ds, assoc.HostUUID)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "getting stored enrollment permissions for renewal")
|
|
}
|
|
key := renewalBucket{personal: personal, rights: rights}
|
|
if _, ok := buckets[key]; !ok {
|
|
bucketOrder = append(bucketOrder, key)
|
|
}
|
|
buckets[key] = append(buckets[key], assoc)
|
|
}
|
|
|
|
for _, key := range bucketOrder {
|
|
assocs := buckets[key]
|
|
// Apple rejects ServerURL changes on profile replacement, so the
|
|
// renewed URL must match the URL the device was enrolled with.
|
|
// BYOD devices carry byod=1 in their initial ServerURL (set by
|
|
// AddPersonalEnrollmentToFleetURL on the OTA/EE path); reapply
|
|
// the same flag for personal enrollments. Pre-feature and
|
|
// company-owned devices have personal=false here, leaving
|
|
// MDMUrl unchanged.
|
|
renewURL, err := apple_mdm.AddPersonalEnrollmentToFleetURL(appConfig.MDMUrl(), key.personal)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "building renewal URL with personal flag")
|
|
}
|
|
|
|
profile, err := apple_mdm.GenerateEnrollmentProfileMobileconfig(
|
|
appConfig.OrgInfo.OrgName,
|
|
renewURL,
|
|
scepChallenge,
|
|
mdmPushCertTopic,
|
|
key.rights,
|
|
false, // renewal: must NOT carry the new-enrollment Subject marker
|
|
)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "generating enrollment profile for hosts without enroll reference")
|
|
}
|
|
if err := renewMDMAppleEnrollmentProfile(ctx, ds, commander, logger, assocs, profile, appConfig.OrgInfo.OrgName+" enrollment"); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "sending profile to hosts without associations")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Note we don't screen userDeviceAssocs for ACME requirements.
|
|
if len(userDeviceAssocs) > 0 {
|
|
hostUUIDs := make([]string, 0, len(userDeviceAssocs))
|
|
for i := 0; i < len(userDeviceAssocs); i++ {
|
|
hostUUIDs = append(hostUUIDs, userDeviceAssocs[i].HostUUID)
|
|
}
|
|
idpAccountsByHostUUID, err := ds.GetMDMIdPAccountsByHostUUIDs(ctx, hostUUIDs)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "getting IDP accounts for user device associations")
|
|
}
|
|
for _, assoc := range userDeviceAssocs {
|
|
idpAccount := idpAccountsByHostUUID[assoc.HostUUID]
|
|
|
|
// This will end up not passing an email which is not idea, Apple says it is required
|
|
// and cannot change however in testing an iOS 18 device still renewed in this case so
|
|
// it is probably our best option for now.
|
|
email := ""
|
|
if idpAccount != nil {
|
|
email = idpAccount.Email
|
|
} else {
|
|
logger.ErrorContext(ctx, "no IDP account associated with account driven user enrollment host, sending renewal without email",
|
|
"host_uuid", assoc.HostUUID)
|
|
}
|
|
profile, err := apple_mdm.GenerateAccountDrivenEnrollmentProfileMobileconfig(
|
|
appConfig.OrgInfo.OrgName,
|
|
appConfig.MDMUrl(),
|
|
scepChallenge,
|
|
mdmPushCertTopic,
|
|
email,
|
|
false, // renewal: must NOT carry the new-enrollment Subject marker
|
|
)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "generating enrollment profile for hosts with enroll reference")
|
|
}
|
|
|
|
// each host with association needs a different enrollment profile, and thus a different command.
|
|
if err := renewMDMAppleEnrollmentProfile(ctx, ds, commander, logger, []fleet.SCEPIdentityAssociation{assoc}, profile, appConfig.OrgInfo.OrgName+" account driven enrollment"); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "sending account driven enrollment profile renewal to hosts")
|
|
}
|
|
}
|
|
}
|
|
|
|
// Filter for ACME requirement, then send individual commands for each host with a reference
|
|
for _, assoc := range assocsWithRefs {
|
|
if _, ok := acmeRequiredByHostUUID[assoc.HostUUID]; ok {
|
|
acmeAssocsByHostUUID[assoc.HostUUID] = assoc
|
|
continue
|
|
}
|
|
|
|
enrollURL, err := apple_mdm.AddEnrollmentRefToFleetURL(appConfig.MDMUrl(), assoc.EnrollReference)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "adding reference to fleet URL")
|
|
}
|
|
|
|
personal, rights, err := renewalEnrollmentParams(ctx, ds, assoc.HostUUID)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "getting stored enrollment permissions for renewal with ref")
|
|
}
|
|
// Apple rejects ServerURL changes on profile replacement; preserve the
|
|
// byod=1 flag on the renewed URL for personal enrollments. See the
|
|
// matching block above (without ref) for the full rationale.
|
|
enrollURL, err = apple_mdm.AddPersonalEnrollmentToFleetURL(enrollURL, personal)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "building renewal URL with personal flag for ref renewal")
|
|
}
|
|
|
|
profile, err := apple_mdm.GenerateEnrollmentProfileMobileconfig(
|
|
appConfig.OrgInfo.OrgName,
|
|
enrollURL,
|
|
scepChallenge,
|
|
mdmPushCertTopic,
|
|
rights,
|
|
false, // renewal: must NOT carry the new-enrollment Subject marker
|
|
)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "generating enrollment profile for hosts with enroll reference")
|
|
}
|
|
|
|
// each host with association needs a different enrollment profile, and thus a different command.
|
|
if err := renewMDMAppleEnrollmentProfile(ctx, ds, commander, logger, []fleet.SCEPIdentityAssociation{assoc}, profile, appConfig.OrgInfo.OrgName+" enrollment"); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "sending profile to hosts without associations")
|
|
}
|
|
}
|
|
|
|
// Generate and send enrollment profiles for hosts that require ACME renewal
|
|
for hostUUID, assoc := range acmeAssocsByHostUUID {
|
|
enrollURL := appConfig.MDMUrl()
|
|
if assoc.EnrollReference != "" {
|
|
var err error
|
|
enrollURL, err = apple_mdm.AddEnrollmentRefToFleetURL(appConfig.MDMUrl(), assoc.EnrollReference)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "adding reference to fleet URL for ACME renewal")
|
|
}
|
|
}
|
|
|
|
di, ok := acmeRequiredByHostUUID[hostUUID]
|
|
if !ok {
|
|
logger.ErrorContext(ctx, "host missing from ACME renewal map, skipping ACME renewal for this host", "host_uuid", hostUUID)
|
|
continue
|
|
}
|
|
|
|
acmeIdent, err := acmeService.NewACMEEnrollment(ctx, di.HardwareSerial)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "creating new ACME enrollment")
|
|
}
|
|
|
|
personal, acmeRights, err := renewalEnrollmentParams(ctx, ds, hostUUID)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "getting stored enrollment permissions for ACME renewal")
|
|
}
|
|
// Defensive: BYOD enrolls only via OTA which doesn't use ACME, so this
|
|
// branch should never see a personal enrollment today. Still match the
|
|
// URL shape in case that combination becomes possible later.
|
|
enrollURL, err = apple_mdm.AddPersonalEnrollmentToFleetURL(enrollURL, personal)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "building renewal URL with personal flag for ACME renewal")
|
|
}
|
|
|
|
profile, err := apple_mdm.GenerateACMEEnrollmentProfileMobileconfig(
|
|
appConfig.OrgInfo.OrgName,
|
|
enrollURL,
|
|
acmeIdent,
|
|
di.HardwareSerial,
|
|
mdmPushCertTopic,
|
|
acmeRights,
|
|
false, // renewal: must NOT carry the new-enrollment Subject marker
|
|
)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "generating enrollment profile for hosts requiring ACME renewal")
|
|
}
|
|
|
|
if err := renewMDMAppleEnrollmentProfile(ctx, ds, commander, logger, []fleet.SCEPIdentityAssociation{assoc}, profile, appConfig.OrgInfo.OrgName+" ACME enrollment"); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "sending ACME enrollment profile to hosts")
|
|
}
|
|
}
|
|
|
|
// Note we don't screen assocsFromMigration for ACME requirements.
|
|
decodedMigrationEnrollmentProfile, err := base64.StdEncoding.DecodeString(os.Getenv("FLEET_SILENT_MIGRATION_ENROLLMENT_PROFILE"))
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "failed to decode silent migration enrollment profile")
|
|
}
|
|
hasAssocsFromMigration := len(assocsFromMigration) > 0
|
|
|
|
migrationEnrollmentProfile := string(decodedMigrationEnrollmentProfile)
|
|
if migrationEnrollmentProfile == "" && hasAssocsFromMigration {
|
|
logger.DebugContext(ctx, "found devices from migration that need SCEP renewals but FLEET_SILENT_MIGRATION_ENROLLMENT_PROFILE is empty")
|
|
}
|
|
if migrationEnrollmentProfile != "" && hasAssocsFromMigration {
|
|
profileBytes := []byte(migrationEnrollmentProfile)
|
|
if err := renewMDMAppleEnrollmentProfile(ctx, ds, commander, logger, assocsFromMigration, profileBytes, appConfig.OrgInfo.OrgName+" migration enrollment"); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "sending profile to hosts from migration")
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// renewalEnrollmentParams loads the personal-enrollment flag and the access
|
|
// rights bitmask that must be reused when generating a SCEP/ACME renewal
|
|
// profile. Apple does not allow ServerURL changes or access-rights widening
|
|
// on profile replacement, so the renewal must reuse the exact values the
|
|
// device was originally enrolled with.
|
|
//
|
|
// The personal flag is sourced from host_mdm.is_personal_enrollment (set
|
|
// during the initial Authenticate by the host upsert, which is fatal on
|
|
// failure). The access rights default to MDMAccessRightAll unless a stored
|
|
// row in host_mdm_apple_enrollment_permissions narrows them. As a safety
|
|
// net for the rare case where the rights persist failed during the initial
|
|
// Authenticate (it is logged but non-fatal), a personal device whose stored
|
|
// rights came back unrestricted is renarrowed here.
|
|
//
|
|
// Pre-feature devices have is_personal_enrollment=0 (column default) and no
|
|
// permissions row, so they return (personal=false, MDMAccessRightAll) —
|
|
// matching their original raw ServerURL and full rights.
|
|
func renewalEnrollmentParams(ctx context.Context, ds fleet.Datastore, hostUUID string) (personal bool, rights int, err error) {
|
|
stored, err := ds.GetHostMDMAppleEnrollmentPermissions(ctx, hostUUID)
|
|
if err != nil && !fleet.IsNotFound(err) {
|
|
return false, 0, err
|
|
}
|
|
rights = apple_mdm.MDMAccessRightAll
|
|
if stored != nil {
|
|
personal = stored.IsPersonalEnrollment
|
|
rights = stored.AccessRights
|
|
if personal && rights == apple_mdm.MDMAccessRightAll {
|
|
// Permissions row was missing or stale; re-derive the narrowed
|
|
// bitmask from the authoritative personal flag so the renewal
|
|
// doesn't attempt to widen.
|
|
rights = apple_mdm.AppleEnrollmentAccessRights(true)
|
|
}
|
|
}
|
|
return personal, rights, nil
|
|
}
|
|
|
|
func renewMDMAppleEnrollmentProfile(
|
|
ctx context.Context,
|
|
ds fleet.Datastore,
|
|
commander *apple_mdm.MDMAppleCommander,
|
|
logger *slog.Logger,
|
|
assocs []fleet.SCEPIdentityAssociation,
|
|
profile []byte,
|
|
profileName string,
|
|
) error {
|
|
cmdUUID := uuid.NewString()
|
|
var uuids []string
|
|
duplicateUUIDCheck := map[string]struct{}{}
|
|
for _, assoc := range assocs {
|
|
// this should never happen if our DB logic is on point.
|
|
// This sanity check is in place to prevent issues like
|
|
// https://github.com/fleetdm/fleet/issues/19311 where a
|
|
// single duplicated UUID prevents _all_ the commands from
|
|
// being enqueued.
|
|
if _, ok := duplicateUUIDCheck[assoc.HostUUID]; ok {
|
|
logger.InfoContext(ctx, "duplicated host UUID while renewing associations", "host_uuid", assoc.HostUUID)
|
|
continue
|
|
}
|
|
|
|
duplicateUUIDCheck[assoc.HostUUID] = struct{}{}
|
|
uuids = append(uuids, assoc.HostUUID)
|
|
}
|
|
|
|
if err := commander.InstallProfile(ctx, uuids, profile, cmdUUID, profileName); err != nil {
|
|
return ctxerr.Wrapf(ctx, err, "sending InstallProfile command for hosts %s", uuids)
|
|
}
|
|
|
|
if err := ds.SetCommandForPendingSCEPRenewal(ctx, assocs, cmdUUID); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "setting pending command associations")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// MDMAppleDDMService is the service that handles MDM [DeclarativeManagement][1] requests.
|
|
//
|
|
// [1]: https://developer.apple.com/documentation/devicemanagement/declarative_management_checkin
|
|
type MDMAppleDDMService struct {
|
|
ds fleet.Datastore
|
|
logger *slog.Logger
|
|
}
|
|
|
|
func NewMDMAppleDDMService(ds fleet.Datastore, logger *slog.Logger) *MDMAppleDDMService {
|
|
return &MDMAppleDDMService{
|
|
ds: ds,
|
|
logger: logger,
|
|
}
|
|
}
|
|
|
|
// DeclarativeManagement handles MDM [DeclarativeManagement][1] requests.
|
|
//
|
|
// This method is when the request has been handled by nanomdm.
|
|
//
|
|
// [1]: https://developer.apple.com/documentation/devicemanagement/declarative_management_checkin
|
|
func (svc *MDMAppleDDMService) DeclarativeManagement(r *mdm.Request, dm *mdm.DeclarativeManagement) ([]byte, error) {
|
|
if dm == nil {
|
|
svc.logger.DebugContext(r.Context, "ddm request received with nil payload")
|
|
return nil, nil
|
|
}
|
|
svc.logger.DebugContext(r.Context, "ddm request received", "endpoint", dm.Endpoint)
|
|
|
|
if err := svc.ds.InsertMDMAppleDDMRequest(r.Context, dm.Identifier(), dm.Endpoint, dm.Data); err != nil {
|
|
return nil, ctxerr.Wrap(r.Context, err, "insert ddm request history")
|
|
}
|
|
|
|
if dm.Identifier() == "" {
|
|
return nil, nano_service.NewHTTPStatusError(http.StatusBadRequest, ctxerr.New(r.Context, "missing UDID/EnrollmentID in request"))
|
|
}
|
|
|
|
// A DDM check-in can arrive on the device channel or the user channel. The
|
|
// host UUID is the same for both (dm.Identifier() is the device UDID), but
|
|
// the channel determines which declarations we serve, so declarations stay
|
|
// scoped to their channel.
|
|
hostUUID := dm.Identifier()
|
|
scope := ddmScopeForRequest(r)
|
|
|
|
switch {
|
|
case dm.Endpoint == "tokens":
|
|
svc.logger.DebugContext(r.Context, "received tokens request", "scope", scope)
|
|
return svc.handleTokens(r.Context, hostUUID, scope)
|
|
|
|
case dm.Endpoint == "declaration-items":
|
|
svc.logger.DebugContext(r.Context, "received declaration-items request", "scope", scope)
|
|
return svc.handleDeclarationItems(r.Context, hostUUID, scope)
|
|
|
|
case dm.Endpoint == "status":
|
|
svc.logger.DebugContext(r.Context, "received status request", "scope", scope)
|
|
return nil, svc.handleDeclarationStatus(r.Context, dm, hostUUID, scope)
|
|
|
|
case strings.HasPrefix(dm.Endpoint, "declaration/"):
|
|
svc.logger.DebugContext(r.Context, "received declarations request", "scope", scope)
|
|
return svc.handleDeclarationsResponse(r.Context, dm.Endpoint, hostUUID, scope)
|
|
|
|
default:
|
|
return nil, nano_service.NewHTTPStatusError(http.StatusBadRequest, ctxerr.New(r.Context, fmt.Sprintf("unrecognized declarations endpoint: %s", dm.Endpoint)))
|
|
}
|
|
}
|
|
|
|
// ddmScopeForRequest resolves the channel (scope) of a DDM check-in from the
|
|
// request's normalized enrollment id. nanomdm populates r.EnrollID before
|
|
// dispatching: a user-channel enrollment carries the device UUID in ParentID
|
|
// (its ID is "<deviceUUID>:<userID>"), while a device-channel enrollment has an
|
|
// empty ParentID. We can't tell the channels apart from dm.Identifier() alone
|
|
// because on macOS both channels report the device UDID.
|
|
func ddmScopeForRequest(r *mdm.Request) fleet.PayloadScope {
|
|
if r != nil && r.EnrollID != nil && r.ParentID != "" {
|
|
return fleet.PayloadScopeUser
|
|
}
|
|
return fleet.PayloadScopeSystem
|
|
}
|
|
|
|
func (svc *MDMAppleDDMService) handleTokens(ctx context.Context, hostUUID string, scope fleet.PayloadScope) ([]byte, error) {
|
|
tok, err := svc.ds.MDMAppleDDMDeclarationsToken(ctx, hostUUID, scope)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "getting synchronization tokens")
|
|
}
|
|
|
|
// Important: Timestamp must use format YYYY-mm-ddTHH:MM:SSZ (no milliseconds)
|
|
// Source: https://developer.apple.com/documentation/devicemanagement/synchronizationtokens?language=objc
|
|
tok.Timestamp = tok.Timestamp.Truncate(time.Second)
|
|
b, err := json.Marshal(fleet.MDMAppleDDMTokensResponse{
|
|
SyncTokens: *tok,
|
|
})
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "marshaling synchronization tokens")
|
|
}
|
|
|
|
return b, nil
|
|
}
|
|
|
|
// handleDeclarationItems retrieves the declaration items to send back to the client to update
|
|
func (svc *MDMAppleDDMService) handleDeclarationItems(ctx context.Context, hostUUID string, scope fleet.PayloadScope) ([]byte, error) {
|
|
di, err := svc.ds.MDMAppleDDMDeclarationItems(ctx, hostUUID, scope)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "getting synchronization tokens")
|
|
}
|
|
|
|
activations := []fleet.MDMAppleDDMManifest{}
|
|
configurations := []fleet.MDMAppleDDMManifest{}
|
|
configurationUUIDs := []string{}
|
|
var removeDeclarationUUIDsToUpdateToPending []string
|
|
for _, d := range di {
|
|
if d.OperationType == nil {
|
|
continue
|
|
}
|
|
if *d.OperationType != string(fleet.MDMOperationTypeInstall) {
|
|
if d.Status == nil && *d.OperationType == string(fleet.MDMOperationTypeRemove) {
|
|
removeDeclarationUUIDsToUpdateToPending = append(removeDeclarationUUIDsToUpdateToPending, d.DeclarationUUID)
|
|
}
|
|
continue
|
|
}
|
|
|
|
// For declarations with fleet variables, check if the variables can
|
|
// be resolved for this host. If not, mark as failed and skip the
|
|
// declaration from the manifest so the device does not attempt to
|
|
// fetch or apply it. NOTE: the declaration is still included in the token
|
|
// computation below so that the token matches the SQL-computed
|
|
// token from handleTokens.
|
|
if d.VariablesUpdatedAt != nil {
|
|
if d.RawJSON != nil {
|
|
if _, err := svc.replaceDeclarationFleetVariables(ctx, string(*d.RawJSON), hostUUID); err != nil {
|
|
if err := svc.markDeclarationFailed(ctx, hostUUID, d.DeclarationUUID, err.Error()); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "mark declaration as failed")
|
|
}
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
|
|
effectiveToken := fleet.EffectiveDDMToken(d.ServerToken, d.VariablesUpdatedAt, d.AssetsUpdatedAt)
|
|
configurations = append(configurations, fleet.MDMAppleDDMManifest{
|
|
Identifier: d.Identifier,
|
|
ServerToken: effectiveToken,
|
|
})
|
|
configurationUUIDs = append(configurationUUIDs, d.DeclarationUUID)
|
|
activations = append(activations, fleet.MDMAppleDDMManifest{
|
|
Identifier: fmt.Sprintf("%s.activation", d.Identifier),
|
|
ServerToken: effectiveToken,
|
|
})
|
|
}
|
|
|
|
referencedAssets, err := svc.ds.GetAppleDDMAssetsReferencedByDeclarations(ctx, configurationUUIDs)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "getting referenced assets")
|
|
}
|
|
|
|
ddmAssets := []fleet.MDMAppleDDMManifest{}
|
|
for _, asset := range referencedAssets {
|
|
ddmAssets = append(ddmAssets, fleet.MDMAppleDDMManifest{
|
|
Identifier: asset.Identifier,
|
|
// Match how configurations/activations serve their ServerToken: a hex
|
|
// string of the token. asset.Checksum holds the raw binary(16) token, so
|
|
// hex-encode it here rather than emitting raw bytes through JSON.
|
|
ServerToken: hex.EncodeToString(asset.Checksum),
|
|
})
|
|
}
|
|
|
|
// Calculate token based on count and concatenated tokens for install items
|
|
var count int
|
|
type tokenSorting struct {
|
|
token string
|
|
variablesUpdatedAt *time.Time
|
|
assetsUpdatedAt *time.Time
|
|
uploadedAt time.Time
|
|
declarationUUID string
|
|
}
|
|
var tokens []tokenSorting
|
|
for _, d := range di {
|
|
if d.OperationType != nil && *d.OperationType == string(fleet.MDMOperationTypeInstall) {
|
|
// Extract d.ServerToken and order by d.UploadedAt descending and then by d.DeclarationUUID ascending
|
|
sorting := tokenSorting{
|
|
token: d.ServerToken,
|
|
variablesUpdatedAt: d.VariablesUpdatedAt,
|
|
assetsUpdatedAt: d.AssetsUpdatedAt,
|
|
uploadedAt: d.UploadedAt,
|
|
declarationUUID: d.DeclarationUUID,
|
|
}
|
|
tokens = append(tokens, sorting)
|
|
count++
|
|
}
|
|
}
|
|
|
|
sort.SliceStable(tokens, func(i, j int) bool {
|
|
if tokens[i].uploadedAt.Equal(tokens[j].uploadedAt) {
|
|
return tokens[i].declarationUUID < tokens[j].declarationUUID
|
|
}
|
|
return tokens[i].uploadedAt.After(tokens[j].uploadedAt)
|
|
})
|
|
var tokenBuilder strings.Builder
|
|
for _, t := range tokens {
|
|
// Must match MySQL's CONCAT order and DATETIME(6) string representation
|
|
// used in MDMAppleDDMDeclarationsToken:
|
|
// HEX(mad.token) + IFNULL(variables_updated_at, '') + IFNULL(assets_updated_at, '')
|
|
tokenBuilder.WriteString(t.token)
|
|
if t.variablesUpdatedAt != nil {
|
|
tokenBuilder.WriteString(t.variablesUpdatedAt.Format("2006-01-02 15:04:05.000000"))
|
|
}
|
|
if t.assetsUpdatedAt != nil {
|
|
tokenBuilder.WriteString(t.assetsUpdatedAt.Format("2006-01-02 15:04:05.000000"))
|
|
}
|
|
}
|
|
|
|
var token string
|
|
if count > 0 {
|
|
// Generate MD5 hash token. It must match the token generated by MDMAppleDDMDeclarationsToken
|
|
hasher := md5.New() // nolint:gosec // used for declarative management token
|
|
hasher.Write([]byte(fmt.Sprintf("%d%s", count, tokenBuilder.String())))
|
|
token = hex.EncodeToString(hasher.Sum(nil))
|
|
}
|
|
|
|
b, err := json.Marshal(fleet.MDMAppleDDMDeclarationItemsResponse{
|
|
Declarations: fleet.MDMAppleDDMManifestItems{
|
|
Activations: activations,
|
|
Configurations: configurations,
|
|
Assets: ddmAssets,
|
|
Management: []fleet.MDMAppleDDMManifest{},
|
|
},
|
|
DeclarationsToken: token,
|
|
})
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "marshaling synchronization tokens")
|
|
}
|
|
|
|
// If any "remove" declarations have a NULL status, update them to a "pending" status
|
|
// so they can be cleared when the host sends back a status report.
|
|
// Otherwise they may get stuck in "pending" -- host already cleared them, but Fleet doesn't think so.
|
|
if len(removeDeclarationUUIDsToUpdateToPending) > 0 {
|
|
err = svc.ds.MDMAppleSetRemoveDeclarationsAsPending(ctx, hostUUID, removeDeclarationUUIDsToUpdateToPending)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "updating remove declarations to pending")
|
|
}
|
|
}
|
|
|
|
return b, nil
|
|
}
|
|
|
|
func (svc *MDMAppleDDMService) handleDeclarationsResponse(ctx context.Context, endpoint string, hostUUID string, scope fleet.PayloadScope) ([]byte, error) {
|
|
parts := strings.Split(endpoint, "/")
|
|
if len(parts) != 3 {
|
|
return nil, nano_service.NewHTTPStatusError(http.StatusBadRequest, ctxerr.Errorf(ctx, "unrecognized declarations endpoint: %s", endpoint))
|
|
}
|
|
svc.logger.DebugContext(ctx, "parsed declarations request", "type", parts[1], "identifier", parts[2])
|
|
|
|
switch parts[1] {
|
|
case "activation":
|
|
return svc.handleActivationDeclaration(ctx, parts, hostUUID, scope)
|
|
case "configuration":
|
|
return svc.handleConfigurationDeclaration(ctx, parts, hostUUID, scope)
|
|
case "asset":
|
|
return svc.handleDeclarationAsset(ctx, parts, hostUUID)
|
|
default:
|
|
return nil, nano_service.NewHTTPStatusError(http.StatusNotFound, ctxerr.Errorf(ctx, "declaration type not supported: %s", parts[1]))
|
|
}
|
|
}
|
|
|
|
func (svc *MDMAppleDDMService) handleDeclarationAsset(ctx context.Context, parts []string, hostUUID string) ([]byte, error) {
|
|
assetIdentifier := parts[2]
|
|
|
|
asset, err := svc.ds.GetAppleDDMAssetForDelivery(ctx, assetIdentifier, hostUUID)
|
|
if err != nil {
|
|
if fleet.IsNotFound(err) {
|
|
return nil, nano_service.NewHTTPStatusError(http.StatusNotFound, err)
|
|
}
|
|
return nil, ctxerr.Wrap(ctx, err, "getting asset by identifier")
|
|
}
|
|
|
|
expanded, err := svc.ds.ExpandEmbeddedSecrets(ctx, string(asset.Data))
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, fmt.Sprintf("expanding embedded secrets for identifier:%s", parts[2]))
|
|
}
|
|
|
|
var tempd map[string]any
|
|
if err := json.Unmarshal([]byte(expanded), &tempd); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "unmarshaling stored declaration")
|
|
}
|
|
|
|
// asset.Checksum is the generated token column, MD5(raw_json + secrets_updated_at),
|
|
// so it already reflects secret updates. Serve it hex-encoded to match how the
|
|
// manifest advertises this asset's ServerToken (see handleDeclarationItems).
|
|
tempd["ServerToken"] = hex.EncodeToString(asset.Checksum)
|
|
|
|
b, err := json.Marshal(tempd)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "marshaling declaration")
|
|
}
|
|
return b, nil
|
|
}
|
|
|
|
func (svc *MDMAppleDDMService) handleActivationDeclaration(ctx context.Context, parts []string, hostUUID string, scope fleet.PayloadScope) ([]byte, error) {
|
|
references := strings.TrimSuffix(parts[2], ".activation")
|
|
|
|
// ensure the declaration for the requested activation still exists
|
|
d, err := svc.ds.MDMAppleDDMDeclarationsResponse(ctx, references, hostUUID, scope)
|
|
if err != nil {
|
|
if fleet.IsNotFound(err) {
|
|
return nil, nano_service.NewHTTPStatusError(http.StatusNotFound, err)
|
|
}
|
|
return nil, ctxerr.Wrap(ctx, err, "getting linked configuration for activation declaration")
|
|
}
|
|
|
|
response := fmt.Sprintf(`
|
|
{
|
|
"Identifier": "%s",
|
|
"Payload": {
|
|
"StandardConfigurations": ["%s"]
|
|
},
|
|
"ServerToken": "%s",
|
|
"Type": "com.apple.activation.simple"
|
|
}`, parts[2], references, fleet.EffectiveDDMToken(d.Token, d.VariablesUpdatedAt, d.AssetsUpdatedAt))
|
|
|
|
return []byte(response), nil
|
|
}
|
|
|
|
func (svc *MDMAppleDDMService) handleConfigurationDeclaration(ctx context.Context, parts []string, hostUUID string, scope fleet.PayloadScope) ([]byte, error) {
|
|
d, err := svc.ds.MDMAppleDDMDeclarationsResponse(ctx, parts[2], hostUUID, scope)
|
|
if err != nil {
|
|
if fleet.IsNotFound(err) {
|
|
return nil, nano_service.NewHTTPStatusError(http.StatusNotFound, err)
|
|
}
|
|
return nil, ctxerr.Wrap(ctx, err, "getting declaration response")
|
|
}
|
|
|
|
expanded, err := svc.ds.ExpandEmbeddedSecrets(ctx, string(d.RawJSON))
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, fmt.Sprintf("expanding embedded secrets for identifier:%s hostUUID:%s", parts[2], hostUUID))
|
|
}
|
|
|
|
// Replace Fleet variables with host-specific values
|
|
expanded, err = svc.replaceDeclarationFleetVariables(ctx, expanded, hostUUID)
|
|
if err != nil {
|
|
// Mark this declaration as failed for this host, return empty 200
|
|
if err := svc.markDeclarationFailed(ctx, hostUUID, d.DeclarationUUID, err.Error()); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "mark declaration as failed")
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
var tempd map[string]any
|
|
if err := json.Unmarshal([]byte(expanded), &tempd); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "unmarshaling stored declaration")
|
|
}
|
|
// PayloadScope is a Fleet extension, not part of Apple's DDM schema. It's
|
|
// normally stripped at delivery time since it is not a part of the Apple
|
|
// schema and unused by the device.
|
|
delete(tempd, "PayloadScope")
|
|
tempd["ServerToken"] = fleet.EffectiveDDMToken(d.Token, d.VariablesUpdatedAt, d.AssetsUpdatedAt) //nolint:nilaway // tempd is non-nil after successful json.Unmarshal
|
|
|
|
b, err := json.Marshal(tempd)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "marshaling declaration")
|
|
}
|
|
return b, nil
|
|
}
|
|
|
|
func (svc *MDMAppleDDMService) handleDeclarationStatus(ctx context.Context, dm *mdm.DeclarativeManagement, hostUUID string, scope fleet.PayloadScope) error {
|
|
var statusReport fleet.MDMAppleDDMStatusReport
|
|
if err := json.Unmarshal(dm.Data, &statusReport); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "unmarshalling response")
|
|
}
|
|
|
|
configurationReports := statusReport.StatusItems.Management.Declarations.Configurations
|
|
updates := make([]*fleet.MDMAppleHostDeclaration, len(configurationReports))
|
|
for i, r := range configurationReports {
|
|
var status fleet.MDMDeliveryStatus
|
|
var detail string
|
|
switch {
|
|
case r.Active && r.Valid == fleet.MDMAppleDeclarationValid:
|
|
status = fleet.MDMDeliveryVerified
|
|
case r.Valid == fleet.MDMAppleDeclarationInvalid || isUnknownDeclarationType(r):
|
|
status = fleet.MDMDeliveryFailed
|
|
detail = apple_mdm.FmtDDMError(r.Reasons)
|
|
case r.Valid == fleet.MDMAppleDeclarationValid: // should be rare/never
|
|
// The debug messages here can be used to figure out why a DDM profile is stuck in a certain state on a device.
|
|
svc.logger.DebugContext(ctx, "valid but inactive declaration status",
|
|
"status", r.Valid, "active", r.Active, "host", dm.Identifier(), "declaration", r.Identifier)
|
|
status = fleet.MDMDeliveryVerifying
|
|
case r.Valid == fleet.MDMAppleDeclarationUnknown: // should be rare
|
|
svc.logger.DebugContext(ctx, "unknown declaration status",
|
|
"status", r.Valid, "active", r.Active, "host", dm.Identifier(), "declaration", r.Identifier)
|
|
status = fleet.MDMDeliveryVerifying
|
|
default:
|
|
// This should never happen. If we see this happening, we should handle it.
|
|
svc.logger.ErrorContext(ctx, "undefined declaration status",
|
|
"status", r.Valid, "active", r.Active, "host", dm.Identifier(), "declaration", r.Identifier)
|
|
status = fleet.MDMDeliveryFailed
|
|
detail = fmt.Sprintf("undefined declaration status: %s; %s", r.Valid, apple_mdm.FmtDDMError(r.Reasons))
|
|
}
|
|
|
|
updates[i] = &fleet.MDMAppleHostDeclaration{
|
|
Status: &status,
|
|
OperationType: fleet.MDMOperationTypeInstall,
|
|
Detail: detail,
|
|
Token: r.ServerToken,
|
|
}
|
|
}
|
|
|
|
// MDMAppleStoreDDMStatusReport takes care of cleaning ("pending", "remove")
|
|
// pairs for the host.
|
|
//
|
|
// TODO(roberto): in the DDM documentation, it's mentioned that status
|
|
// report will give you a "remove" status so the server can track
|
|
// removals. In my testing, I never saw this (after spending
|
|
// considerable time trying to make it work.)
|
|
//
|
|
// My current guess is that the documentation is implicitly referring
|
|
// to asset declarations (which deliver tangible "assets" to the host)
|
|
//
|
|
// The best indication I found so far, is that if the declaration is
|
|
// not in the report, then it's implicitly removed.
|
|
if err := svc.ds.MDMAppleStoreDDMStatusReport(ctx, hostUUID, scope, updates); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "updating host declaration status with reports")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Checks the active, valid and first reason to verify if it is an unknown declaration type error
|
|
func isUnknownDeclarationType(declarationResponse fleet.MDMAppleDDMStatusDeclaration) bool {
|
|
return !declarationResponse.Active &&
|
|
declarationResponse.Valid == fleet.MDMAppleDeclarationUnknown &&
|
|
len(declarationResponse.Reasons) > 0 &&
|
|
declarationResponse.Reasons[0].Code == "Error.UnknownDeclarationType"
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Generate ABM keypair endpoint
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type generateABMKeyPairResponse struct {
|
|
PublicKey []byte `json:"public_key,omitempty"`
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r generateABMKeyPairResponse) Error() error { return r.Err }
|
|
|
|
func generateABMKeyPairEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
keyPair, err := svc.GenerateABMKeyPair(ctx)
|
|
if err != nil {
|
|
return generateABMKeyPairResponse{
|
|
Err: err,
|
|
}, nil
|
|
}
|
|
|
|
return generateABMKeyPairResponse{
|
|
PublicKey: keyPair.PublicKey,
|
|
}, nil
|
|
}
|
|
|
|
func (svc *Service) GenerateABMKeyPair(ctx context.Context) (*fleet.MDMAppleDEPKeyPair, error) {
|
|
if err := svc.authz.Authorize(ctx, &fleet.AppleBM{}, 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 public key. Missing required private key. Learn how to configure the private key here: https://fleetdm.com/learn-more-about/fleet-server-private-key")
|
|
}
|
|
|
|
var publicKeyPEM, privateKeyPEM []byte
|
|
assets, err := svc.ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{
|
|
fleet.MDMAssetABMCert,
|
|
fleet.MDMAssetABMKey,
|
|
}, nil)
|
|
if err != nil {
|
|
// allow not found errors as it means that we're generating the
|
|
// keypair for the first time
|
|
if !fleet.IsNotFound(err) {
|
|
return nil, ctxerr.Wrap(ctx, err, "loading ABM keys from the database")
|
|
}
|
|
}
|
|
|
|
// if we don't have any certificates, create a new keypair, otherwise
|
|
// return the already stored values to allow for the renewal flow.
|
|
if len(assets) == 0 {
|
|
publicKeyPEM, privateKeyPEM, err = apple_mdm.NewDEPKeyPairPEM()
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "generate key pair")
|
|
}
|
|
|
|
err = svc.ds.InsertMDMConfigAssets(ctx, []fleet.MDMConfigAsset{
|
|
{Name: fleet.MDMAssetABMCert, Value: publicKeyPEM},
|
|
{Name: fleet.MDMAssetABMKey, Value: privateKeyPEM},
|
|
}, nil)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "saving ABM keypair in database")
|
|
}
|
|
} else {
|
|
// we can trust that the keys exist due to the contract specified by
|
|
// the datastore method
|
|
publicKeyPEM = assets[fleet.MDMAssetABMCert].Value
|
|
privateKeyPEM = assets[fleet.MDMAssetABMKey].Value
|
|
}
|
|
|
|
return &fleet.MDMAppleDEPKeyPair{
|
|
PublicKey: publicKeyPEM,
|
|
PrivateKey: privateKeyPEM,
|
|
}, nil
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Upload ABM token endpoint
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type uploadABMTokenRequest struct {
|
|
Token *multipart.FileHeader
|
|
}
|
|
|
|
func (uploadABMTokenRequest) DecodeRequest(ctx context.Context, r *http.Request) (interface{}, error) {
|
|
err := r.ParseMultipartForm(platform_http.MaxMultipartFormSize)
|
|
if err != nil {
|
|
return nil, &fleet.BadRequestError{
|
|
Message: "failed to parse multipart form",
|
|
InternalErr: err,
|
|
}
|
|
}
|
|
|
|
token, ok := r.MultipartForm.File["token"]
|
|
if !ok || len(token) < 1 {
|
|
return nil, &fleet.BadRequestError{Message: "no file headers for token"}
|
|
}
|
|
|
|
return &uploadABMTokenRequest{
|
|
Token: token[0],
|
|
}, nil
|
|
}
|
|
|
|
type uploadABMTokenResponse struct {
|
|
Token *fleet.ABMToken `json:"abm_token,omitempty" renameto:"ab_token,inline"`
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r uploadABMTokenResponse) Error() error { return r.Err }
|
|
|
|
func uploadABMTokenEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*uploadABMTokenRequest)
|
|
ff, err := req.Token.Open()
|
|
if err != nil {
|
|
return uploadABMTokenResponse{Err: err}, nil
|
|
}
|
|
defer ff.Close()
|
|
|
|
token, err := svc.UploadABMToken(ctx, ff)
|
|
if err != nil {
|
|
return uploadABMTokenResponse{
|
|
Err: err,
|
|
}, nil
|
|
}
|
|
|
|
return uploadABMTokenResponse{Token: token}, nil
|
|
}
|
|
|
|
func (svc *Service) UploadABMToken(ctx context.Context, token io.Reader) (*fleet.ABMToken, error) {
|
|
// skipauth: No authorization check needed due to implementation returning
|
|
// only license error.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
return nil, fleet.ErrMissingLicense
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Disable ABM endpoint
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type deleteABMTokenRequest struct {
|
|
TokenID uint `url:"id"`
|
|
}
|
|
|
|
type deleteABMTokenResponse struct {
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r deleteABMTokenResponse) Error() error { return r.Err }
|
|
func (r deleteABMTokenResponse) Status() int { return http.StatusNoContent }
|
|
|
|
func deleteABMTokenEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*deleteABMTokenRequest)
|
|
if err := svc.DeleteABMToken(ctx, req.TokenID); err != nil {
|
|
return deleteABMTokenResponse{Err: err}, nil
|
|
}
|
|
|
|
return deleteABMTokenResponse{}, nil
|
|
}
|
|
|
|
func (svc *Service) DeleteABMToken(ctx context.Context, tokenID uint) error {
|
|
// skipauth: No authorization check needed due to implementation returning
|
|
// only license error.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
return fleet.ErrMissingLicense
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// List ABM tokens endpoint
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type listABMTokensResponse struct {
|
|
Err error `json:"error,omitempty"`
|
|
Tokens []*fleet.ABMToken `json:"abm_tokens" renameto:"ab_tokens,inline"`
|
|
}
|
|
|
|
func (r listABMTokensResponse) Error() error { return r.Err }
|
|
|
|
func listABMTokensEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
tokens, err := svc.ListABMTokens(ctx)
|
|
if err != nil {
|
|
return &listABMTokensResponse{Err: err}, nil
|
|
}
|
|
|
|
if tokens == nil {
|
|
tokens = []*fleet.ABMToken{}
|
|
}
|
|
|
|
return &listABMTokensResponse{Tokens: tokens}, nil
|
|
}
|
|
|
|
func (svc *Service) ListABMTokens(ctx context.Context) ([]*fleet.ABMToken, error) {
|
|
// skipauth: No authorization check needed due to implementation returning
|
|
// only license error.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
return nil, fleet.ErrMissingLicense
|
|
}
|
|
|
|
// //////////////////////////////////////////////////////////////////////////////
|
|
// Count ABM tokens endpoint
|
|
// //////////////////////////////////////////////////////////////////////////////
|
|
|
|
type countABMTokensResponse struct {
|
|
Err error `json:"error,omitempty"`
|
|
Count int `json:"count"`
|
|
}
|
|
|
|
func (r countABMTokensResponse) Error() error { return r.Err }
|
|
|
|
func countABMTokensEndpoint(ctx context.Context, _ interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
tokenCount, err := svc.CountABMTokens(ctx)
|
|
if err != nil {
|
|
return &countABMTokensResponse{Err: err}, nil
|
|
}
|
|
|
|
return &countABMTokensResponse{Count: tokenCount}, nil
|
|
}
|
|
|
|
func (svc *Service) CountABMTokens(ctx context.Context) (int, error) {
|
|
// Automatic enrollment (ABM/ADE/DEP) is a feature that requires a license.
|
|
// skipauth: No authorization check needed due to implementation returning
|
|
// only license error.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
return 0, fleet.ErrMissingLicense
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Update ABM token teams endpoint
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type updateABMTokenTeamsRequest struct {
|
|
TokenID uint `url:"id"`
|
|
MacOSTeamID *uint `json:"macos_team_id" renameto:"macos_fleet_id"`
|
|
IOSTeamID *uint `json:"ios_team_id" renameto:"ios_fleet_id"`
|
|
IPadOSTeamID *uint `json:"ipados_team_id" renameto:"ipados_fleet_id"`
|
|
BYODTeamID *uint `json:"byod_team_id" renameto:"byod_fleet_id"`
|
|
}
|
|
|
|
type updateABMTokenTeamsResponse struct {
|
|
ABMToken *fleet.ABMToken `json:"abm_token,omitempty" renameto:"ab_token,inline"`
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r updateABMTokenTeamsResponse) Error() error { return r.Err }
|
|
|
|
func updateABMTokenTeamsEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*updateABMTokenTeamsRequest)
|
|
|
|
tok, err := svc.UpdateABMTokenTeams(ctx, req.TokenID, req.MacOSTeamID, req.IOSTeamID, req.IPadOSTeamID, req.BYODTeamID)
|
|
if err != nil {
|
|
return &updateABMTokenTeamsResponse{Err: err}, nil
|
|
}
|
|
|
|
return &updateABMTokenTeamsResponse{ABMToken: tok}, nil
|
|
}
|
|
|
|
func (svc *Service) UpdateABMTokenTeams(ctx context.Context, tokenID uint, macOSTeamID, iOSTeamID, iPadOSTeamID, byodTeamID *uint) (*fleet.ABMToken, error) {
|
|
// skipauth: No authorization check needed due to implementation returning
|
|
// only license error.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
return nil, fleet.ErrMissingLicense
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Renew ABM token endpoint
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type renewABMTokenRequest struct {
|
|
TokenID uint `url:"id"`
|
|
Token *multipart.FileHeader
|
|
}
|
|
|
|
func (renewABMTokenRequest) DecodeRequest(ctx context.Context, r *http.Request) (interface{}, error) {
|
|
err := r.ParseMultipartForm(platform_http.MaxMultipartFormSize)
|
|
if err != nil {
|
|
return nil, &fleet.BadRequestError{
|
|
Message: "failed to parse multipart form",
|
|
InternalErr: err,
|
|
}
|
|
}
|
|
|
|
token, ok := r.MultipartForm.File["token"]
|
|
if !ok || len(token) < 1 {
|
|
return nil, &fleet.BadRequestError{Message: "no file headers for token"}
|
|
}
|
|
|
|
// because we are in this method, we know that the path has 7 parts, e.g:
|
|
// /api/latest/fleet/abm_tokens/19/renew
|
|
|
|
id, err := endpointer.IntFromRequest(r, "id")
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "failed to parse abm token id")
|
|
}
|
|
|
|
return &renewABMTokenRequest{
|
|
Token: token[0],
|
|
TokenID: uint(id), //nolint:gosec // dismiss G115
|
|
}, nil
|
|
}
|
|
|
|
type renewABMTokenResponse struct {
|
|
ABMToken *fleet.ABMToken `json:"abm_token,omitempty" renameto:"ab_token,inline"`
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r renewABMTokenResponse) Error() error { return r.Err }
|
|
|
|
func renewABMTokenEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*renewABMTokenRequest)
|
|
ff, err := req.Token.Open()
|
|
if err != nil {
|
|
return &renewABMTokenResponse{Err: err}, nil
|
|
}
|
|
defer ff.Close()
|
|
|
|
tok, err := svc.RenewABMToken(ctx, ff, req.TokenID)
|
|
if err != nil {
|
|
return &renewABMTokenResponse{Err: err}, nil
|
|
}
|
|
|
|
return &renewABMTokenResponse{ABMToken: tok}, nil
|
|
}
|
|
|
|
func (svc *Service) RenewABMToken(ctx context.Context, token io.Reader, tokenID uint) (*fleet.ABMToken, error) {
|
|
// skipauth: No authorization check needed due to implementation returning
|
|
// only license error.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
return nil, fleet.ErrMissingLicense
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// GET /enrollment_profiles/ota
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type getOTAProfileRequest struct {
|
|
EnrollSecret string `query:"enroll_secret"`
|
|
// Personal indicates the end user chose "Personal (BYOD)" on the /enroll page.
|
|
// Defaults to false (company-owned) when omitted.
|
|
Personal bool `query:"byod"`
|
|
IdpUUID string // The UUID of the mdm_idp_account that was used if any, can be empty, will be taken from cookies
|
|
}
|
|
|
|
func (getOTAProfileRequest) DecodeRequest(ctx context.Context, r *http.Request) (interface{}, error) {
|
|
enrollSecret := r.URL.Query().Get("enroll_secret")
|
|
if enrollSecret == "" {
|
|
return nil, &fleet.BadRequestError{
|
|
Message: "enroll_secret is required",
|
|
}
|
|
}
|
|
|
|
personal := r.URL.Query().Get("byod") == "true" || r.URL.Query().Get("byod") == "1"
|
|
|
|
boydIdpCookie, err := r.Cookie(shared_mdm.BYODIdpCookieName)
|
|
if err != nil {
|
|
// r.Cookie only return ErrNoCookie and no other errors.
|
|
|
|
// We do not fail here if no cookie is found, we validate later down the line if it's required
|
|
return &getOTAProfileRequest{
|
|
EnrollSecret: enrollSecret,
|
|
Personal: personal,
|
|
IdpUUID: "",
|
|
}, nil
|
|
}
|
|
|
|
if err = boydIdpCookie.Valid(); err != nil {
|
|
return nil, &fleet.BadRequestError{
|
|
Message: "boyd idp cookie is not valid",
|
|
InternalErr: err,
|
|
}
|
|
}
|
|
|
|
return &getOTAProfileRequest{
|
|
EnrollSecret: enrollSecret,
|
|
Personal: personal,
|
|
IdpUUID: boydIdpCookie.Value,
|
|
}, nil
|
|
}
|
|
|
|
func getOTAProfileEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*getOTAProfileRequest)
|
|
profile, err := svc.GetOTAProfile(ctx, req.EnrollSecret, req.IdpUUID, req.Personal)
|
|
if err != nil {
|
|
return &getMDMAppleConfigProfileResponse{Err: err}, err
|
|
}
|
|
|
|
reader := bytes.NewReader(profile)
|
|
return &getMDMAppleConfigProfileResponse{fileReader: io.NopCloser(reader), fileLength: reader.Size(), fileName: "fleet-mdm-enrollment-profile"}, nil
|
|
}
|
|
|
|
func (svc *Service) GetOTAProfile(ctx context.Context, enrollSecret, idpUUID string, personal bool) ([]byte, error) {
|
|
// Skip authz as this endpoint is used by end users from their iPhones or iPads; authz is done
|
|
// by the enroll secret verification below
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
cfg, err := svc.ds.AppConfig(ctx)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "getting app config to get org name")
|
|
}
|
|
|
|
requiresIDPUUID, err := shared_mdm.RequiresEnrollOTAAuthentication(ctx, svc.ds, enrollSecret, cfg.MDM.MacOSSetup.EnableEndUserAuthentication)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "checking if IDP UUID is required for OTA enrollment")
|
|
}
|
|
if requiresIDPUUID && idpUUID == "" {
|
|
return nil, ctxerr.Wrap(
|
|
ctx,
|
|
authz.ForbiddenWithInternal("required idp uuid to be set, but none found", nil, nil, nil),
|
|
"missing required idp uuid",
|
|
)
|
|
}
|
|
|
|
profBytes, err := apple_mdm.GenerateOTAEnrollmentProfileMobileconfig(cfg.OrgInfo.OrgName, cfg.MDMUrl(), enrollSecret, idpUUID, personal)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "generating ota mobileconfig file")
|
|
}
|
|
|
|
signed, err := mdmcrypto.Sign(ctx, profBytes, svc.ds)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "signing profile")
|
|
}
|
|
|
|
return signed, nil
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// POST /ota_enrollment?enroll_secret=xyz
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type mdmAppleOTARequest struct {
|
|
EnrollSecret string `query:"enroll_secret"`
|
|
IdpUUID string `query:"idp_uuid"`
|
|
// Personal is set when the end user chose "Personal (BYOD)" on the /enroll page.
|
|
// It is propagated through the OTA mobileconfig POST-back URL by GetOTAProfile.
|
|
Personal bool
|
|
Certificates []*x509.Certificate
|
|
RootSigner *x509.Certificate
|
|
DeviceInfo fleet.MDMAppleMachineInfo
|
|
}
|
|
|
|
func (mdmAppleOTARequest) DecodeRequest(ctx context.Context, r *http.Request) (interface{}, error) {
|
|
enrollSecret := r.URL.Query().Get("enroll_secret")
|
|
if enrollSecret == "" {
|
|
return nil, &fleet.OTAForbiddenError{
|
|
InternalErr: errors.New("enroll_secret query parameter was empty"),
|
|
}
|
|
}
|
|
|
|
idpUUID := r.URL.Query().Get("idp_uuid") // Can be empty.
|
|
|
|
rawData, err := io.ReadAll(io.LimitReader(r.Body, limit10KiB))
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "reading body from request")
|
|
}
|
|
|
|
if err := cryptoutil.ValidateBERDepth(rawData, cryptoutil.MaxBERDepth); err != nil {
|
|
return nil, &fleet.BadRequestError{
|
|
Message: "invalid request body",
|
|
InternalErr: err,
|
|
}
|
|
}
|
|
|
|
p7, err := pkcs7.Parse(rawData)
|
|
if err != nil {
|
|
return nil, &fleet.BadRequestError{
|
|
Message: "invalid request body",
|
|
InternalErr: err,
|
|
}
|
|
}
|
|
|
|
var request mdmAppleOTARequest
|
|
err = apple_mdm.BoundedPlistUnmarshal(p7.Content, &request.DeviceInfo)
|
|
if err != nil {
|
|
return nil, &fleet.BadRequestError{
|
|
Message: "invalid request body",
|
|
InternalErr: err,
|
|
}
|
|
}
|
|
|
|
if request.DeviceInfo.Serial == "" {
|
|
return nil, &fleet.BadRequestError{
|
|
Message: "SERIAL is required",
|
|
}
|
|
}
|
|
|
|
request.EnrollSecret = enrollSecret
|
|
request.IdpUUID = idpUUID
|
|
request.Personal = r.URL.Query().Get("byod") == "true" || r.URL.Query().Get("byod") == "1"
|
|
request.Certificates = p7.Certificates
|
|
request.RootSigner = p7.GetOnlySigner()
|
|
return &request, nil
|
|
}
|
|
|
|
type mdmAppleOTAResponse struct {
|
|
Err error `json:"error,omitempty"`
|
|
xml []byte
|
|
}
|
|
|
|
func (r mdmAppleOTAResponse) Error() error { return r.Err }
|
|
|
|
func (r mdmAppleOTAResponse) HijackRender(ctx context.Context, w http.ResponseWriter) {
|
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(r.xml)))
|
|
w.Header().Set("Content-Type", "application/x-apple-aspen-config")
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
if _, err := w.Write(r.xml); err != nil {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
func mdmAppleOTAEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*mdmAppleOTARequest)
|
|
xml, err := svc.MDMAppleProcessOTAEnrollment(ctx, req.Certificates, req.RootSigner, req.EnrollSecret, req.IdpUUID, req.Personal, req.DeviceInfo)
|
|
if err != nil {
|
|
return mdmAppleGetInstallerResponse{Err: err}, nil
|
|
}
|
|
return mdmAppleOTAResponse{xml: xml}, nil
|
|
}
|
|
|
|
// NOTE: this method and how OTA works is documented in full in the interface definition.
|
|
func (svc *Service) MDMAppleProcessOTAEnrollment(
|
|
ctx context.Context,
|
|
certificates []*x509.Certificate,
|
|
rootSigner *x509.Certificate,
|
|
enrollSecret string,
|
|
idpUUID string,
|
|
personal bool,
|
|
deviceInfo fleet.MDMAppleMachineInfo,
|
|
) ([]byte, error) {
|
|
// authorization is performed via the enroll secret and the provided certificates
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
if len(certificates) == 0 {
|
|
return nil, authz.ForbiddenWithInternal("no certificates provided", nil, nil, nil)
|
|
}
|
|
|
|
// first check is for the enroll secret, we'll only let the host
|
|
// through if it has a valid secret.
|
|
enrollSecretInfo, err := svc.ds.VerifyEnrollSecret(ctx, enrollSecret)
|
|
if err != nil {
|
|
if fleet.IsNotFound(err) {
|
|
return nil, &fleet.OTAForbiddenError{
|
|
InternalErr: err,
|
|
}
|
|
}
|
|
|
|
return nil, ctxerr.Wrap(ctx, err, "validating enroll secret")
|
|
}
|
|
|
|
assets, err := svc.ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{
|
|
fleet.MDMAssetSCEPChallenge,
|
|
}, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("loading SCEP challenge from the database: %w", err)
|
|
}
|
|
scepChallenge := string(assets[fleet.MDMAssetSCEPChallenge].Value)
|
|
|
|
appCfg, err := svc.ds.AppConfig(ctx)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "reading app config")
|
|
}
|
|
|
|
mdmURL := appCfg.MDMUrl()
|
|
|
|
// if the root signer was issued by Apple's CA, it means we're in the
|
|
// first phase and we should return a SCEP payload.
|
|
if err := apple_mdm.VerifyFromAppleIphoneDeviceCA(rootSigner); err == nil {
|
|
scepURL, err := apple_mdm.ResolveAppleSCEPURL(mdmURL)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "resolve Apple SCEP url")
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
if err := apple_mdm.OTASCEPTemplate.Execute(&buf, struct {
|
|
SCEPURL string
|
|
SCEPChallenge string
|
|
}{
|
|
SCEPURL: scepURL,
|
|
SCEPChallenge: scepChallenge,
|
|
}); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "execute template")
|
|
}
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
// otherwise we might be in the second phase, check if the signing cert
|
|
// was issued by Fleet, only let the enrollment through if so.
|
|
certVerifier := mdmcrypto.NewSCEPVerifier(svc.ds)
|
|
if err := certVerifier.Verify(ctx, rootSigner); err != nil {
|
|
return nil, authz.ForbiddenWithInternal(fmt.Sprintf("payload signed with invalid certificate: %s", err), nil, nil, nil)
|
|
}
|
|
|
|
topic, err := apple_mdm.MDMPushCertTopic(ctx, svc.ds)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "extracting topic from APNs cert")
|
|
}
|
|
|
|
// NOTE: we don't offer ACME enrollment via OTA.
|
|
// Embed byod=1 in the MDM ServerURL so the Authenticate checkin handler
|
|
// can set is_personal_enrollment correctly on the host record.
|
|
enrollMDMURL, err := apple_mdm.AddPersonalEnrollmentToFleetURL(mdmURL, personal)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "building MDM URL with personal enrollment flag for OTA")
|
|
}
|
|
accessRights := apple_mdm.AppleEnrollmentAccessRights(personal)
|
|
enrollmentProf, err := apple_mdm.GenerateEnrollmentProfileMobileconfig(
|
|
appCfg.OrgInfo.OrgName,
|
|
enrollMDMURL,
|
|
string(assets[fleet.MDMAssetSCEPChallenge].Value),
|
|
topic,
|
|
accessRights,
|
|
true, // fresh enrollment
|
|
)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "generating manual enrollment profile")
|
|
}
|
|
|
|
requiresIdPUUID, err := shared_mdm.RequiresEnrollOTAAuthentication(ctx, svc.ds, enrollSecret, appCfg.MDM.MacOSSetup.EnableEndUserAuthentication)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "checking requirement of ota enrollment authentication")
|
|
}
|
|
|
|
if requiresIdPUUID && idpUUID == "" {
|
|
return nil, ctxerr.Wrap(
|
|
ctx,
|
|
authz.ForbiddenWithInternal("required idp uuid to be set, but none found", nil, nil, nil),
|
|
"missing required idp uuid",
|
|
)
|
|
}
|
|
|
|
if idpUUID != "" {
|
|
_, err := svc.ds.GetMDMIdPAccountByUUID(ctx, idpUUID)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "validating idp account existence")
|
|
}
|
|
}
|
|
|
|
// before responding, create a host record, and assign the host to the
|
|
// team that matches the enroll secret provided.
|
|
err = svc.ds.IngestMDMAppleDeviceFromOTAEnrollment(ctx, enrollSecretInfo.TeamID, idpUUID, deviceInfo)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "creating new host record")
|
|
}
|
|
|
|
// at this point we know the device can be enrolled, so we respond with
|
|
// a signed enrollment profile
|
|
signed, err := mdmcrypto.Sign(ctx, enrollmentProf, svc.ds)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "signing profile")
|
|
}
|
|
|
|
return signed, nil
|
|
}
|
|
|
|
// EnsureMDMAppleServiceDiscovery checks if the service discovery URL is set up correctly with Apple
|
|
// and assigns it if necessary.
|
|
func EnsureMDMAppleServiceDiscovery(ctx context.Context, ds fleet.Datastore, depStorage storage.AllDEPStorage, logger *slog.Logger,
|
|
urlPrefix string,
|
|
) error {
|
|
depSvc := apple_mdm.NewDEPService(ds, depStorage, logger)
|
|
|
|
ac, err := ds.AppConfig(ctx)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "checking account driven enrollment service discovery")
|
|
}
|
|
sdURL := ac.MDMUrl() + urlPrefix + apple_mdm.ServiceDiscoveryTokenPath
|
|
|
|
tokens, err := ds.ListABMTokens(ctx)
|
|
switch {
|
|
case err != nil:
|
|
return ctxerr.Wrap(ctx, err, "listing ABM tokens")
|
|
case len(tokens) == 0:
|
|
logger.InfoContext(ctx, "no ABM tokens found, skipping account driven enrollment service discovery")
|
|
return nil
|
|
}
|
|
|
|
for _, token := range tokens {
|
|
orgName := token.OrganizationName
|
|
|
|
details, err := depSvc.GetMDMAppleServiceDiscoveryDetails(ctx, orgName)
|
|
if err != nil {
|
|
switch {
|
|
case godep.IsServiceDiscoveryNotFound(err):
|
|
logger.InfoContext(ctx, "account driven enrollment profile not found") // proceed to assignment
|
|
case godep.IsServiceDiscoveryNotSupported(err):
|
|
logger.InfoContext(ctx, "account driven enrollment org not supported, skipping assignment")
|
|
continue // skip assignment
|
|
default:
|
|
logger.ErrorContext(ctx, "fetching account driven enrollment profile", "org_name", orgName, "err", err)
|
|
continue // skip assignment
|
|
}
|
|
}
|
|
|
|
sdURLWithToken := strings.Replace(sdURL, "{token}", string(token.EnrollmentURLToken), 1)
|
|
|
|
var gotURL string
|
|
var lastUpdated time.Time
|
|
if details != nil {
|
|
gotURL = details.MDMServiceDiscoveryURL
|
|
lastUpdated = details.LastUpdatedTimestamp
|
|
}
|
|
logger.InfoContext(ctx, "account driven enrollment service discovery url confirmed", "service_discovery_url", gotURL, "last_updated", lastUpdated)
|
|
|
|
if gotURL != sdURLWithToken {
|
|
logger.InfoContext(ctx, "account driven enrollment service discovery url needs update", "new_url", sdURLWithToken)
|
|
// proced to assignment
|
|
if err := depSvc.AssignMDMAppleServiceDiscoveryURL(ctx, orgName, sdURLWithToken); err != nil {
|
|
logger.ErrorContext(ctx, "assigning account driven enrollment service discovery URL", "org_name", orgName, "err", err)
|
|
}
|
|
continue
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
///////////////////////////////////////////////////////////////////////////////
|
|
// Apple MDM Recovery Lock Password
|
|
|
|
// recoveryLockResult wraps mdm.CommandResults to implement fleet.MDMCommandResults
|
|
type recoveryLockResult struct {
|
|
cmdResult *mdm.CommandResults
|
|
}
|
|
|
|
func (r *recoveryLockResult) Raw() []byte { return r.cmdResult.Raw }
|
|
func (r *recoveryLockResult) UUID() string { return r.cmdResult.CommandUUID }
|
|
func (r *recoveryLockResult) HostUUID() string { return r.cmdResult.UDID } // SetRecoveryLock is device-only, UDID is always present
|
|
|
|
// NewRecoveryLockResult wraps an mdm.CommandResults to implement fleet.MDMCommandResults
|
|
func NewRecoveryLockResult(cmdResult *mdm.CommandResults) fleet.MDMCommandResults {
|
|
return &recoveryLockResult{cmdResult: cmdResult}
|
|
}
|
|
|
|
// NewSetRecoveryLockResultsHandler processes SetRecoveryLock command results.
|
|
// It handles SET (install), CLEAR (remove), and ROTATE operations:
|
|
// - SET: When acknowledged, marks the recovery lock as verified. On error, marks as failed.
|
|
// - CLEAR: When acknowledged, deletes the recovery lock password record. On error, marks as failed.
|
|
// - ROTATE: When acknowledged, moves pending password to active. On error, marks rotation as failed.
|
|
func NewSetRecoveryLockResultsHandler(
|
|
ds fleet.Datastore,
|
|
logger *slog.Logger,
|
|
newActivityFn fleet.NewActivityFunc,
|
|
) fleet.MDMCommandResultsHandler {
|
|
return func(ctx context.Context, results fleet.MDMCommandResults) error {
|
|
// Get the underlying result to access status and error chain
|
|
rlResult, ok := results.(*recoveryLockResult)
|
|
if !ok {
|
|
return ctxerr.New(ctx, "SetRecoveryLock handler: unexpected results type")
|
|
}
|
|
|
|
hostUUID := results.HostUUID()
|
|
status := rlResult.cmdResult.Status
|
|
|
|
// Check if this is a rotation (has pending password)
|
|
hasPendingRotation, err := ds.HasPendingRecoveryLockRotation(ctx, hostUUID)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "SetRecoveryLock handler: check pending rotation")
|
|
}
|
|
|
|
if hasPendingRotation {
|
|
// This is a rotation result
|
|
logger.DebugContext(ctx, "SetRecoveryLock rotation result received",
|
|
"host_uuid", hostUUID,
|
|
"command_uuid", results.UUID(),
|
|
"status", status,
|
|
)
|
|
|
|
switch status {
|
|
case fleet.MDMAppleStatusAcknowledged:
|
|
// Rotation succeeded - move pending password to active
|
|
if err := ds.CompleteRecoveryLockRotation(ctx, hostUUID); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "SetRecoveryLock handler: complete rotation")
|
|
}
|
|
|
|
logger.InfoContext(ctx, "RotateRecoveryLock acknowledged, password rotated",
|
|
"host_uuid", hostUUID,
|
|
)
|
|
|
|
case fleet.MDMAppleStatusError, fleet.MDMAppleStatusCommandFormatError:
|
|
errorMsg := apple_mdm.FmtErrorChain(rlResult.cmdResult.ErrorChain)
|
|
if errorMsg == "" {
|
|
errorMsg = "RotateRecoveryLock command failed"
|
|
}
|
|
if err := ds.FailRecoveryLockRotation(ctx, hostUUID, errorMsg); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "SetRecoveryLock handler: fail rotation")
|
|
}
|
|
logger.WarnContext(ctx, "RotateRecoveryLock command failed",
|
|
"host_uuid", hostUUID,
|
|
"error", errorMsg,
|
|
)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Get the operation type to determine if this was a SET or CLEAR operation
|
|
opType, err := ds.GetRecoveryLockOperationType(ctx, hostUUID)
|
|
if err != nil {
|
|
// If the record doesn't exist, it may have been deleted already - nothing to do
|
|
if fleet.IsNotFound(err) {
|
|
logger.DebugContext(ctx, "SetRecoveryLock result received but no password record exists",
|
|
"host_uuid", hostUUID,
|
|
"status", status,
|
|
)
|
|
return nil
|
|
}
|
|
return ctxerr.Wrap(ctx, err, "SetRecoveryLock handler: get operation type")
|
|
}
|
|
|
|
logger.DebugContext(ctx, "SetRecoveryLock command result received",
|
|
"host_uuid", hostUUID,
|
|
"command_uuid", results.UUID(),
|
|
"status", status,
|
|
"operation_type", opType,
|
|
)
|
|
|
|
switch status {
|
|
case fleet.MDMAppleStatusAcknowledged:
|
|
if opType == fleet.MDMOperationTypeRemove {
|
|
// CLEAR succeeded - delete the password record
|
|
if err := ds.DeleteHostRecoveryLockPassword(ctx, hostUUID); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "SetRecoveryLock handler: delete recovery lock password")
|
|
}
|
|
logger.InfoContext(ctx, "ClearRecoveryLock acknowledged, password record deleted",
|
|
"host_uuid", hostUUID,
|
|
)
|
|
} else {
|
|
// SET succeeded - mark as verified
|
|
if err := ds.SetRecoveryLockVerified(ctx, hostUUID); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "SetRecoveryLock handler: set recovery lock verified")
|
|
}
|
|
|
|
// Get host info for activity logging - don't fail the operation if this fails
|
|
var hostID uint
|
|
var displayName string
|
|
host, err := ds.HostLiteByIdentifier(ctx, hostUUID)
|
|
if err != nil {
|
|
logger.WarnContext(ctx, "SetRecoveryLock handler: failed to get host for activity logging",
|
|
"host_uuid", hostUUID,
|
|
"err", err,
|
|
)
|
|
} else {
|
|
hostID = host.ID
|
|
displayName = host.Hostname
|
|
|
|
// Log the activity only if we could identify the host (fleet-initiated via WasFromAutomation)
|
|
if err := newActivityFn(ctx, nil, fleet.ActivityTypeSetHostRecoveryLockPassword{
|
|
HostID: hostID,
|
|
HostDisplayName: displayName,
|
|
}); err != nil {
|
|
logger.WarnContext(ctx, "SetRecoveryLock handler: failed to create activity",
|
|
"host_uuid", hostUUID,
|
|
"err", err,
|
|
)
|
|
}
|
|
}
|
|
|
|
logger.InfoContext(ctx, "SetRecoveryLock acknowledged, marked verified",
|
|
"host_uuid", hostUUID,
|
|
"host_id", hostID,
|
|
)
|
|
}
|
|
|
|
case fleet.MDMAppleStatusError, fleet.MDMAppleStatusCommandFormatError:
|
|
errorMsg := apple_mdm.FmtErrorChain(rlResult.cmdResult.ErrorChain)
|
|
if errorMsg == "" {
|
|
if opType == fleet.MDMOperationTypeRemove {
|
|
errorMsg = "ClearRecoveryLock command failed"
|
|
} else {
|
|
errorMsg = "SetRecoveryLock command failed"
|
|
}
|
|
}
|
|
|
|
if opType == fleet.MDMOperationTypeRemove {
|
|
// CLEAR operation failed
|
|
// Command format errors are terminal - command is malformed and won't succeed on retry.
|
|
// Password mismatch errors are also terminal - requires admin intervention.
|
|
if rlResult.cmdResult.Status == fleet.MDMAppleStatusCommandFormatError ||
|
|
apple_mdm.IsRecoveryLockPasswordMismatchError(rlResult.cmdResult.ErrorChain) {
|
|
if err := ds.SetRecoveryLockFailed(ctx, hostUUID, errorMsg); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "SetRecoveryLock handler: set recovery lock failed")
|
|
}
|
|
logger.WarnContext(ctx, "ClearRecoveryLock failed with terminal error",
|
|
"host_uuid", hostUUID,
|
|
"error", errorMsg,
|
|
)
|
|
} else {
|
|
// Transient error - reset to install/verified for retry on next cron cycle
|
|
if err := ds.ResetRecoveryLockForRetry(ctx, hostUUID); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "SetRecoveryLock handler: reset recovery lock for retry")
|
|
}
|
|
logger.InfoContext(ctx, "ClearRecoveryLock failed with transient error, will retry",
|
|
"host_uuid", hostUUID,
|
|
"error", errorMsg,
|
|
)
|
|
}
|
|
} else {
|
|
// SET operation failed - mark as failed
|
|
if err := ds.SetRecoveryLockFailed(ctx, hostUUID, errorMsg); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "SetRecoveryLock handler: set recovery lock failed")
|
|
}
|
|
logger.WarnContext(ctx, "SetRecoveryLock command failed",
|
|
"host_uuid", hostUUID,
|
|
"error", errorMsg,
|
|
)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
}
|