<!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** Resolves #42735 - Exposes max_software_package_size in the `GET /api/v1/fleet/config` endpoint - Add frontend logic to use it to deny files that are too big before they get uploaded # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files) for more information. - [ ] 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. - [ ] Timeouts are implemented and retries are limited to avoid infinite loops - [ ] If paths of existing endpoints are modified without backwards compatibility, checked the frontend/CLI for any necessary changes ## Testing - [x] Added/updated automated tests - [ ] Where appropriate, [automated tests simulate multiple hosts and test for host isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing) (updates to one hosts's records do not affect another) - [x] QA'd all new/changed functionality manually - Tested on Chrome, Safari, and Firefox on macOS <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added a configurable maximum software package size to application settings. * Software uploads exceeding the limit are rejected with a clear, size-specific error message. * Packages at or below the configured limit are accepted. * Added user-friendly file-size formatting across common units. * Upload validation accounts for the complete request size, including scripts and settings. * **Tests** * Added coverage for upload validation, boundary conditions, size formatting, and configuration responses. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
3044 lines
121 KiB
Go
3044 lines
121 KiB
Go
package service
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"crypto/tls"
|
||
"crypto/x509"
|
||
"encoding/json"
|
||
"encoding/pem"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"maps"
|
||
"net"
|
||
"net/http"
|
||
"net/url"
|
||
"regexp"
|
||
strconv "strconv"
|
||
"strings"
|
||
|
||
"github.com/fleetdm/fleet/v4/pkg/optjson"
|
||
"github.com/fleetdm/fleet/v4/pkg/rawjson"
|
||
"github.com/fleetdm/fleet/v4/server/authz"
|
||
authz_ctx "github.com/fleetdm/fleet/v4/server/contexts/authz"
|
||
"github.com/fleetdm/fleet/v4/server/contexts/ctxdb"
|
||
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
|
||
"github.com/fleetdm/fleet/v4/server/contexts/license"
|
||
"github.com/fleetdm/fleet/v4/server/contexts/viewer"
|
||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
|
||
"github.com/fleetdm/fleet/v4/server/platform/endpointer"
|
||
"github.com/fleetdm/fleet/v4/server/platform/logging"
|
||
"github.com/fleetdm/fleet/v4/server/ptr"
|
||
"github.com/fleetdm/fleet/v4/server/version"
|
||
"golang.org/x/text/unicode/norm"
|
||
)
|
||
|
||
////////////////////////////////////////////////////////////////////////////////
|
||
// Get AppConfig
|
||
////////////////////////////////////////////////////////////////////////////////
|
||
|
||
type appConfigResponse struct {
|
||
fleet.AppConfig
|
||
appConfigResponseFields
|
||
}
|
||
|
||
// appConfigResponseFields are grouped separately to aid with JSON unmarshaling
|
||
type appConfigResponseFields struct {
|
||
UpdateInterval *fleet.UpdateIntervalConfig `json:"update_interval"`
|
||
Vulnerabilities *fleet.VulnerabilitiesConfig `json:"vulnerabilities"`
|
||
|
||
// License is loaded from the service
|
||
License *fleet.LicenseInfo `json:"license,omitempty"`
|
||
// Logging is loaded on the fly rather than from the database.
|
||
Logging *fleet.Logging `json:"logging,omitempty"`
|
||
// Email is returned when the email backend is something other than SMTP, for example SES
|
||
Email *fleet.EmailConfig `json:"email,omitempty"`
|
||
// SandboxEnabled is true if fleet serve was ran with server.sandbox_enabled=true
|
||
SandboxEnabled bool `json:"sandbox_enabled,omitempty"`
|
||
Err error `json:"error,omitempty"`
|
||
Partnerships *fleet.Partnerships `json:"partnerships,omitempty"`
|
||
// Maximum software package size is loaded from the service.
|
||
MaxSoftwarePackageSize int64 `json:"max_software_package_size"`
|
||
}
|
||
|
||
// UnmarshalJSON implements the json.Unmarshaler interface to make sure we serialize
|
||
// both AppConfig and appConfigResponseFields properly:
|
||
//
|
||
// - If this function is not defined, AppConfig.UnmarshalJSON gets promoted and
|
||
// will be called instead.
|
||
// - If we try to unmarshal everything in one go, AppConfig.UnmarshalJSON doesn't get
|
||
// called.
|
||
func (r *appConfigResponse) UnmarshalJSON(data []byte) error {
|
||
if err := json.Unmarshal(data, &r.AppConfig); err != nil {
|
||
return err
|
||
}
|
||
if err := json.Unmarshal(data, &r.appConfigResponseFields); err != nil {
|
||
return err
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// MarshalJSON implements the json.Marshaler interface to make sure we serialize
|
||
// both AppConfig and responseFields properly:
|
||
//
|
||
// - If this function is not defined, AppConfig.MarshalJSON gets promoted and
|
||
// will be called instead.
|
||
// - If we try to unmarshal everything in one go, AppConfig.MarshalJSON doesn't get
|
||
// called.
|
||
func (r appConfigResponse) MarshalJSON() ([]byte, error) {
|
||
// Marshal only the response fields
|
||
responseData, err := json.Marshal(r.appConfigResponseFields)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// Marshal the base AppConfig
|
||
appConfigData, err := json.Marshal(r.AppConfig)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// we need to marshal and combine both groups separately because
|
||
// AppConfig has a custom marshaler.
|
||
return rawjson.CombineRoots(responseData, appConfigData)
|
||
}
|
||
|
||
func (r appConfigResponse) Error() error { return r.Err }
|
||
|
||
func getAppConfigEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
||
vc, ok := viewer.FromContext(ctx)
|
||
if !ok {
|
||
return nil, errors.New("could not fetch user")
|
||
}
|
||
appConfig, err := svc.AppConfigObfuscated(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
lic, err := svc.License(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
loggingConfig, err := svc.LoggingConfig(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
emailConfig, err := svc.EmailConfig(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
updateIntervalConfig, err := svc.UpdateIntervalConfig(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
vulnConfig, err := svc.VulnerabilitiesConfig(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
partnerships, err := svc.PartnershipsConfig(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// Add Microsoft Entra settings from the integration table to appConfig.ConditionalAccess
|
||
// (Okta settings are already in appConfig.ConditionalAccess from the database)
|
||
conditionalAccessIntegration, err := svc.ConditionalAccessMicrosoftGet(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// Always initialize ConditionalAccess so it's never nil (even when empty)
|
||
if appConfig.ConditionalAccess == nil {
|
||
appConfig.ConditionalAccess = &fleet.ConditionalAccessSettings{}
|
||
}
|
||
|
||
// Set or clear Microsoft Entra fields based on integration status
|
||
if conditionalAccessIntegration != nil {
|
||
appConfig.ConditionalAccess.MicrosoftEntraTenantID = conditionalAccessIntegration.TenantID
|
||
appConfig.ConditionalAccess.MicrosoftEntraConnectionConfigured = conditionalAccessIntegration.SetupDone
|
||
} else {
|
||
// Clear Entra fields when integration is deleted
|
||
appConfig.ConditionalAccess.MicrosoftEntraTenantID = ""
|
||
appConfig.ConditionalAccess.MicrosoftEntraConnectionConfigured = false
|
||
}
|
||
|
||
isGlobalAdmin := vc.User.GlobalRole != nil && *vc.User.GlobalRole == fleet.RoleAdmin
|
||
isAnyTeamAdmin := false
|
||
if vc.User.Teams != nil {
|
||
// check if the user is an admin for any team
|
||
for _, team := range vc.User.Teams {
|
||
if team.Role == fleet.RoleAdmin {
|
||
isAnyTeamAdmin = true
|
||
break
|
||
}
|
||
}
|
||
}
|
||
|
||
// Only admins should see SMTP and SSO settings
|
||
var smtpSettings *fleet.SMTPSettings
|
||
var ssoSettings *fleet.SSOSettings
|
||
if isGlobalAdmin || isAnyTeamAdmin {
|
||
smtpSettings = appConfig.SMTPSettings
|
||
ssoSettings = appConfig.SSOSettings
|
||
}
|
||
|
||
// Only global admins should see osquery agent settings.
|
||
var agentOptions *json.RawMessage
|
||
if isGlobalAdmin {
|
||
agentOptions = appConfig.AgentOptions
|
||
}
|
||
|
||
// Fleet Premium license is required for custom transparency url
|
||
transparencyURL := fleet.DefaultTransparencyURL
|
||
if lic.IsPremium() && appConfig.FleetDesktop.TransparencyURL != "" {
|
||
transparencyURL = appConfig.FleetDesktop.TransparencyURL
|
||
}
|
||
// Fleet Premium license is required for server side alternative browser host URL
|
||
var alternativeBrowserHost string
|
||
if lic.IsPremium() {
|
||
alternativeBrowserHost = appConfig.FleetDesktop.AlternativeBrowserHost
|
||
}
|
||
fleetDesktop := fleet.FleetDesktopSettings{
|
||
TransparencyURL: transparencyURL,
|
||
AlternativeBrowserHost: alternativeBrowserHost,
|
||
}
|
||
|
||
if appConfig.OrgInfo.ContactURL == "" {
|
||
appConfig.OrgInfo.ContactURL = fleet.DefaultOrgInfoContactURL
|
||
}
|
||
|
||
features := appConfig.Features
|
||
response := appConfigResponse{
|
||
AppConfig: fleet.AppConfig{
|
||
OrgInfo: appConfig.OrgInfo,
|
||
ServerSettings: appConfig.ServerSettings,
|
||
Features: features,
|
||
VulnerabilitySettings: appConfig.VulnerabilitySettings,
|
||
HostExpirySettings: appConfig.HostExpirySettings,
|
||
ActivityExpirySettings: appConfig.ActivityExpirySettings,
|
||
|
||
SMTPSettings: smtpSettings,
|
||
SSOSettings: ssoSettings,
|
||
AgentOptions: agentOptions,
|
||
|
||
FleetDesktop: fleetDesktop,
|
||
|
||
WebhookSettings: appConfig.WebhookSettings,
|
||
Integrations: appConfig.Integrations,
|
||
MDM: appConfig.MDM,
|
||
Scripts: appConfig.Scripts,
|
||
GitOpsConfig: appConfig.GitOpsConfig,
|
||
ConditionalAccess: appConfig.ConditionalAccess,
|
||
},
|
||
appConfigResponseFields: appConfigResponseFields{
|
||
UpdateInterval: updateIntervalConfig,
|
||
Vulnerabilities: vulnConfig,
|
||
License: lic,
|
||
Logging: loggingConfig,
|
||
Email: emailConfig,
|
||
SandboxEnabled: svc.SandboxEnabled(),
|
||
Partnerships: partnerships,
|
||
MaxSoftwarePackageSize: svc.MaxInstallerSizeBytes(),
|
||
},
|
||
}
|
||
return response, nil
|
||
}
|
||
|
||
func (svc *Service) SandboxEnabled() bool {
|
||
return svc.config.Server.SandboxEnabled
|
||
}
|
||
|
||
func (svc *Service) AppConfigObfuscated(ctx context.Context) (*fleet.AppConfig, error) {
|
||
if !svc.authz.IsAuthenticatedWith(ctx, authz_ctx.AuthnDeviceToken) &&
|
||
!svc.authz.IsAuthenticatedWith(ctx, authz_ctx.AuthnDeviceCertificate) &&
|
||
!svc.authz.IsAuthenticatedWith(ctx, authz_ctx.AuthnDeviceURL) {
|
||
if err := svc.authz.Authorize(ctx, &fleet.AppConfig{}, fleet.ActionRead); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
|
||
ac, err := svc.ds.AppConfig(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// Mirror deprecated logo URL fields to the new mode-aware fields (and
|
||
// vice versa) so every read returns a consistent view to clients.
|
||
// NormalizeLogoFields only errors when both forms are set with
|
||
// conflicting values — that shouldn't happen via the normal write path
|
||
// (which 422s on conflict), so reaching this branch implies the row
|
||
// was edited out-of-band. Log it so the inconsistency is visible, then
|
||
// canonicalize by preferring the mode-aware fields.
|
||
if invalid := ac.OrgInfo.NormalizeLogoFields(); invalid != nil {
|
||
svc.logger.WarnContext(ctx, "persisted org logo URL fields are inconsistent; canonicalizing to mode-aware values for response", "err", invalid.Error())
|
||
if ac.OrgInfo.OrgLogoURLDarkMode != "" {
|
||
ac.OrgInfo.OrgLogoURL = ac.OrgInfo.OrgLogoURLDarkMode
|
||
}
|
||
if ac.OrgInfo.OrgLogoURLLightMode != "" {
|
||
ac.OrgInfo.OrgLogoURLLightBackground = ac.OrgInfo.OrgLogoURLLightMode
|
||
}
|
||
}
|
||
// Rewrite Fleet-hosted relative logo URLs to absolute ones using the
|
||
// current ServerURL. Persisted form stays relative; this is purely a
|
||
// read-time view. Safe to do here because no SaveAppConfig caller
|
||
// consumes the result of AppConfigObfuscated — they all re-fetch via
|
||
// svc.ds.AppConfig directly.
|
||
ac.OrgInfo.AbsolutizeLogoURLs(ac.ServerSettings.ServerURL)
|
||
|
||
// The Windows enrollment default fleet's source of truth is GetWindowsEnrollmentDefaultFleet (also cached), so hydrate the
|
||
// response from it when it disagrees with the name stored in the app config JSON.
|
||
winDefaultTeamID, winDefaultFleetName, err := svc.ds.GetWindowsEnrollmentDefaultFleet(ctx)
|
||
if err != nil {
|
||
return nil, ctxerr.Wrap(ctx, err, "get windows enrollment default fleet")
|
||
}
|
||
winStoredName := ""
|
||
if ac.MDM.WindowsEnrollment.Set && ac.MDM.WindowsEnrollment.Valid {
|
||
winStoredName = ac.MDM.WindowsEnrollment.Value.DefaultFleet
|
||
}
|
||
if winDefaultTeamID != nil || winStoredName != winDefaultFleetName {
|
||
ac.MDM.WindowsEnrollment = optjson.Any[fleet.WindowsEnrollment]{
|
||
Set: true, Valid: true,
|
||
Value: fleet.WindowsEnrollment{DefaultFleet: winDefaultFleetName},
|
||
}
|
||
}
|
||
|
||
ac.Obfuscate()
|
||
|
||
return ac, nil
|
||
}
|
||
|
||
func (svc *Service) AppConfigUrls(ctx context.Context) (*fleet.AppConfigUrls, error) {
|
||
// We skip auhtorization, as this is used where we don't have access to it, but that is why we return a subset of AppConfig fields.
|
||
svc.authz.SkipAuthorization(ctx)
|
||
|
||
ac, err := svc.ds.AppConfigUrls(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return ac, nil
|
||
}
|
||
|
||
// //////////////////////////////////////////////////////////////////////////////
|
||
// Modify AppConfig
|
||
// //////////////////////////////////////////////////////////////////////////////
|
||
|
||
type modifyAppConfigRequest struct {
|
||
Force bool `json:"-" query:"force,optional"` // if true, bypass strict incoming json validation
|
||
DryRun bool `json:"-" query:"dry_run,optional"` // if true, apply validation but do not save changes
|
||
Overwrite bool `json:"-" query:"overwrite,optional"` // if true, overwrite any existing settings with the incoming ones
|
||
json.RawMessage
|
||
}
|
||
|
||
func modifyAppConfigEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
||
req := request.(*modifyAppConfigRequest)
|
||
appConfig, err := svc.ModifyAppConfig(ctx, req.RawMessage, fleet.ApplySpecOptions{
|
||
Force: req.Force,
|
||
DryRun: req.DryRun,
|
||
Overwrite: req.Overwrite,
|
||
})
|
||
if err != nil {
|
||
return appConfigResponse{appConfigResponseFields: appConfigResponseFields{Err: err}}, nil
|
||
}
|
||
|
||
// We do not use svc.License(ctx) to allow roles (like GitOps) write but not read access to AppConfig.
|
||
licChecker, _ := license.FromContext(ctx)
|
||
lic, _ := licChecker.(*fleet.LicenseInfo)
|
||
|
||
loggingConfig, err := svc.LoggingConfig(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
response := appConfigResponse{
|
||
AppConfig: *appConfig,
|
||
appConfigResponseFields: appConfigResponseFields{
|
||
License: lic,
|
||
Logging: loggingConfig,
|
||
MaxSoftwarePackageSize: svc.MaxInstallerSizeBytes(),
|
||
},
|
||
}
|
||
|
||
response.Obfuscate()
|
||
|
||
if lic == nil || (!lic.IsPremium()) || response.FleetDesktop.TransparencyURL == "" {
|
||
response.FleetDesktop.TransparencyURL = fleet.DefaultTransparencyURL
|
||
}
|
||
|
||
return response, nil
|
||
}
|
||
|
||
// applyAndValidateConditionalAccessOktaFields merges incoming Okta conditional-access fields
|
||
// into appConfig and validates the combination. Extracted from ModifyAppConfig so the parent
|
||
// function's CFG block count stays under nilaway's limit.
|
||
func applyAndValidateConditionalAccessOktaFields(
|
||
ctx context.Context,
|
||
appConfig *fleet.AppConfig,
|
||
newAppConfig *fleet.AppConfig,
|
||
invalid *fleet.InvalidArgumentError,
|
||
lic *fleet.LicenseInfo,
|
||
) error {
|
||
if appConfig.ConditionalAccess == nil {
|
||
appConfig.ConditionalAccess = &fleet.ConditionalAccessSettings{}
|
||
}
|
||
if newAppConfig.ConditionalAccess == nil {
|
||
newAppConfig.ConditionalAccess = &fleet.ConditionalAccessSettings{}
|
||
}
|
||
|
||
// Normalize incoming Okta fields (trim whitespace) BEFORE the premium-license gate so a
|
||
// whitespace-only input that would persist as empty does not trip the license check.
|
||
normalizeOptString := func(src optjson.String) optjson.String {
|
||
if src.Set && src.Valid {
|
||
src.Value = strings.TrimSpace(src.Value)
|
||
}
|
||
return src
|
||
}
|
||
applyOptString := func(dest *optjson.String, src optjson.String) {
|
||
if src.Set {
|
||
*dest = src
|
||
}
|
||
}
|
||
oktaIDPID := normalizeOptString(newAppConfig.ConditionalAccess.OktaIDPID)
|
||
oktaACSURL := normalizeOptString(newAppConfig.ConditionalAccess.OktaAssertionConsumerServiceURL)
|
||
oktaAudienceURI := normalizeOptString(newAppConfig.ConditionalAccess.OktaAudienceURI)
|
||
oktaCert := normalizeOptString(newAppConfig.ConditionalAccess.OktaCertificate)
|
||
applyOptString(&appConfig.ConditionalAccess.OktaIDPID, oktaIDPID)
|
||
applyOptString(&appConfig.ConditionalAccess.OktaAssertionConsumerServiceURL, oktaACSURL)
|
||
applyOptString(&appConfig.ConditionalAccess.OktaAudienceURI, oktaAudienceURI)
|
||
applyOptString(&appConfig.ConditionalAccess.OktaCertificate, oktaCert)
|
||
|
||
isNonEmpty := func(s optjson.String) bool {
|
||
return s.Set && s.Valid && s.Value != ""
|
||
}
|
||
oktaFieldsBeingSet := isNonEmpty(oktaIDPID) ||
|
||
isNonEmpty(oktaACSURL) ||
|
||
isNonEmpty(oktaAudienceURI) ||
|
||
isNonEmpty(oktaCert)
|
||
|
||
if oktaFieldsBeingSet && !lic.IsPremium() {
|
||
invalid.Append("conditional_access", ErrMissingLicense.Error())
|
||
return ctxerr.Wrap(ctx, invalid)
|
||
}
|
||
|
||
oktaFieldsSet := 0
|
||
if appConfig.ConditionalAccess.OktaIDPID.Valid && appConfig.ConditionalAccess.OktaIDPID.Value != "" {
|
||
oktaFieldsSet++
|
||
}
|
||
if appConfig.ConditionalAccess.OktaAssertionConsumerServiceURL.Valid &&
|
||
appConfig.ConditionalAccess.OktaAssertionConsumerServiceURL.Value != "" {
|
||
oktaFieldsSet++
|
||
}
|
||
if appConfig.ConditionalAccess.OktaAudienceURI.Valid &&
|
||
appConfig.ConditionalAccess.OktaAudienceURI.Value != "" {
|
||
oktaFieldsSet++
|
||
}
|
||
if appConfig.ConditionalAccess.OktaCertificate.Valid &&
|
||
appConfig.ConditionalAccess.OktaCertificate.Value != "" {
|
||
oktaFieldsSet++
|
||
}
|
||
|
||
if oktaFieldsSet > 0 && oktaFieldsSet < 4 {
|
||
invalid.Append("conditional_access",
|
||
"all Okta fields must be set together (okta_idp_id, okta_assertion_consumer_service_url, okta_audience_uri, okta_certificate) or all must be empty")
|
||
}
|
||
|
||
if oktaFieldsSet == 4 {
|
||
const (
|
||
maxURLLength = 2048
|
||
maxCertLength = 8192
|
||
)
|
||
|
||
if len(appConfig.ConditionalAccess.OktaIDPID.Value) > maxURLLength {
|
||
invalid.Append("conditional_access.okta_idp_id",
|
||
fmt.Sprintf("must be %d characters or less", maxURLLength))
|
||
}
|
||
if len(appConfig.ConditionalAccess.OktaAssertionConsumerServiceURL.Value) > maxURLLength {
|
||
invalid.Append("conditional_access.okta_assertion_consumer_service_url",
|
||
fmt.Sprintf("must be %d characters or less", maxURLLength))
|
||
}
|
||
if len(appConfig.ConditionalAccess.OktaAudienceURI.Value) > maxURLLength {
|
||
invalid.Append("conditional_access.okta_audience_uri",
|
||
fmt.Sprintf("must be %d characters or less", maxURLLength))
|
||
}
|
||
if len(appConfig.ConditionalAccess.OktaCertificate.Value) > maxCertLength {
|
||
invalid.Append("conditional_access.okta_certificate",
|
||
fmt.Sprintf("must be %d characters or less", maxCertLength))
|
||
}
|
||
|
||
acsURL, err := url.ParseRequestURI(appConfig.ConditionalAccess.OktaAssertionConsumerServiceURL.Value)
|
||
if err != nil || ((acsURL.Scheme != "http" && acsURL.Scheme != "https") || acsURL.Host == "") {
|
||
invalid.Append("conditional_access.okta_assertion_consumer_service_url",
|
||
"must be a valid URL with http or https scheme and a host")
|
||
}
|
||
|
||
rest := []byte(appConfig.ConditionalAccess.OktaCertificate.Value)
|
||
certCount := 0
|
||
for {
|
||
block, r := pem.Decode(rest)
|
||
if block == nil {
|
||
break
|
||
}
|
||
rest = r
|
||
if block.Type != "CERTIFICATE" {
|
||
invalid.Append("conditional_access.okta_certificate", "PEM block must be a CERTIFICATE")
|
||
break
|
||
}
|
||
if _, err := x509.ParseCertificate(block.Bytes); err != nil {
|
||
invalid.Append("conditional_access.okta_certificate", "must be a valid x509 certificate")
|
||
break
|
||
}
|
||
certCount++
|
||
}
|
||
if certCount == 0 {
|
||
invalid.Append("conditional_access.okta_certificate", "must contain at least one PEM-encoded certificate")
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// persistAppleAccountProvisioningSecret stores, preserves, or soft-deletes the
|
||
// Apple account provisioning IdP client secret in mdm_config_assets so it
|
||
// matches the (already-validated) incoming config. It reports whether the
|
||
// stored secret actually changed, so re-applying a GitOps config that resends an
|
||
// identical secret is a no-op and emits no activity.
|
||
// - configured + a new secret provided: store it (replacing any existing
|
||
// value), reporting changed only when the value actually differs.
|
||
// - configured + no new secret: preserve the existing secret (unchanged).
|
||
// - feature cleared (was configured, now isn't): soft-delete the secret.
|
||
func (svc *Service) persistAppleAccountProvisioningSecret(ctx context.Context, configured, wasConfigured, newSecretProvided bool, secret string) (changed bool, err error) {
|
||
switch {
|
||
case configured && newSecretProvided:
|
||
current, err := svc.appleAccountProvisioningSecret(ctx)
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
if current == secret {
|
||
return false, nil
|
||
}
|
||
if err := svc.ds.InsertOrReplaceMDMConfigAsset(ctx, fleet.MDMConfigAsset{
|
||
Name: fleet.MDMAssetAppleAccountProvisioningIdPClientSecret,
|
||
Value: []byte(secret),
|
||
}); err != nil {
|
||
return false, ctxerr.Wrap(ctx, err, "store apple account provisioning idp client secret")
|
||
}
|
||
return true, nil
|
||
case !configured && wasConfigured:
|
||
if err := svc.ds.DeleteMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{
|
||
fleet.MDMAssetAppleAccountProvisioningIdPClientSecret,
|
||
}); err != nil {
|
||
return false, ctxerr.Wrap(ctx, err, "delete apple account provisioning idp client secret")
|
||
}
|
||
return true, nil
|
||
}
|
||
return false, nil
|
||
}
|
||
|
||
// appleAccountProvisioningSecret returns the stored Apple account provisioning
|
||
// IdP client secret, or "" if none is stored.
|
||
func (svc *Service) appleAccountProvisioningSecret(ctx context.Context) (string, error) {
|
||
assets, err := svc.ds.GetAllMDMConfigAssetsByName(ctx,
|
||
[]fleet.MDMAssetName{fleet.MDMAssetAppleAccountProvisioningIdPClientSecret}, nil)
|
||
if err != nil {
|
||
if fleet.IsNotFound(err) {
|
||
return "", nil
|
||
}
|
||
return "", ctxerr.Wrap(ctx, err, "get apple account provisioning idp client secret")
|
||
}
|
||
return string(assets[fleet.MDMAssetAppleAccountProvisioningIdPClientSecret].Value), nil
|
||
}
|
||
|
||
func (svc *Service) ModifyAppConfig(ctx context.Context, p []byte, applyOpts fleet.ApplySpecOptions) (*fleet.AppConfig, error) {
|
||
if err := svc.authz.Authorize(ctx, &fleet.AppConfig{}, fleet.ActionWrite); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// we need the config from the datastore because API tokens are obfuscated at
|
||
// the service layer we will retrieve the obfuscated config before we return.
|
||
// We bypass the mysql cache because this is a read that will be followed by
|
||
// modifications and a save, so we need up-to-date data.
|
||
ctx = ctxdb.BypassCachedMysql(ctx, true)
|
||
appConfig, err := svc.ds.AppConfig(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
// the rest of the calls can use the cache safely (we read the AppConfig
|
||
// again before returning, either after a dry-run or after saving the
|
||
// AppConfig, in which case the cache will be up-to-date and safe to use).
|
||
ctx = ctxdb.BypassCachedMysql(ctx, false)
|
||
|
||
oldAppConfig := appConfig.Copy()
|
||
|
||
// We do not use svc.License(ctx) to allow roles (like GitOps) write but not read access to AppConfig.
|
||
licChecker, _ := license.FromContext(ctx)
|
||
lic, _ := licChecker.(*fleet.LicenseInfo)
|
||
|
||
var oldSMTPSettings fleet.SMTPSettings
|
||
if appConfig.SMTPSettings != nil {
|
||
oldSMTPSettings = *appConfig.SMTPSettings
|
||
} else {
|
||
// SMTPSettings used to be a non-pointer on previous iterations,
|
||
// so if current SMTPSettings are not present (with empty values),
|
||
// then this is a bug, let's log an error.
|
||
svc.logger.ErrorContext(ctx, "smtp_settings are not present")
|
||
}
|
||
|
||
oldAgentOptions := ""
|
||
if appConfig.AgentOptions != nil {
|
||
oldAgentOptions = string(*appConfig.AgentOptions)
|
||
}
|
||
|
||
oldConditionalAccessEnabled := appConfig.Integrations.ConditionalAccessEnabled
|
||
|
||
storedJiraByProjectKey, err := fleet.IndexJiraIntegrations(appConfig.Integrations.Jira)
|
||
if err != nil {
|
||
return nil, ctxerr.Wrap(ctx, err, "modify AppConfig")
|
||
}
|
||
|
||
storedZendeskByGroupID, err := fleet.IndexZendeskIntegrations(appConfig.Integrations.Zendesk)
|
||
if err != nil {
|
||
return nil, ctxerr.Wrap(ctx, err, "modify AppConfig")
|
||
}
|
||
|
||
// Rewrite deprecated JSON field names (e.g. team_id → fleet_id) before
|
||
// unmarshaling into AppConfig, since the request body was captured as
|
||
// json.RawMessage and wasn't processed by the request decoder's rewriter.
|
||
if rules := endpointer.ExtractAliasRules(fleet.AppConfig{}); len(rules) > 0 {
|
||
var err error
|
||
var deprecatedKeysMap map[string]string
|
||
if p, deprecatedKeysMap, err = endpointer.RewriteDeprecatedKeys(p, rules); err != nil {
|
||
msg := "failed to decode app config"
|
||
// If it's an alias conflict error, return a user-friendly message about deprecated fields.
|
||
var aliasConflictErr *endpointer.AliasConflictError
|
||
if errors.As(err, &aliasConflictErr) {
|
||
msg = err.Error()
|
||
}
|
||
return nil, ctxerr.Wrap(ctx, &fleet.BadRequestError{
|
||
Message: msg,
|
||
InternalErr: err,
|
||
})
|
||
}
|
||
if len(deprecatedKeysMap) > 0 {
|
||
for oldKey, newKey := range deprecatedKeysMap {
|
||
svc.logger.WarnContext(ctx, fmt.Sprintf("App config: `%s` is deprecated, please use `%s` instead", oldKey, newKey), "log_topic", logging.DeprecatedFieldTopic)
|
||
}
|
||
}
|
||
}
|
||
|
||
invalid := &fleet.InvalidArgumentError{}
|
||
var newAppConfig fleet.AppConfig
|
||
if err := json.Unmarshal(p, &newAppConfig); err != nil {
|
||
return nil, ctxerr.Wrap(ctx, &fleet.BadRequestError{
|
||
Message: "failed to decode app config",
|
||
InternalErr: err,
|
||
})
|
||
}
|
||
|
||
// In Overwrite mode (gitops), older clients (e.g. fleetctl <=4.84
|
||
// predating the field) omit features.historical_data entirely.
|
||
// Without defaulting here, the Overwrite branch below would persist
|
||
// those sub-keys as false because Go's bool zero value is
|
||
// indistinguishable from "absent".
|
||
if applyOpts.Overwrite {
|
||
applyHistoricalDataOverwriteDefaults(p, &newAppConfig)
|
||
}
|
||
|
||
fleetDesktopSettingsInvalidErr := validateFleetDesktopSettings(newAppConfig, lic)
|
||
if fleetDesktopSettingsInvalidErr.HasErrors() {
|
||
return nil, ctxerr.Wrap(ctx, fleetDesktopSettingsInvalidErr)
|
||
}
|
||
|
||
// Validate and premium-gate the vulnerability-exposure chart filter
|
||
// defaults. These are display-only defaults (they seed the dashboard
|
||
// chart's filter controls; they do not affect data collection) and are
|
||
// premium-only. Validation runs on the incoming payload with sparse/PATCH
|
||
// semantics: only fields explicitly present are checked.
|
||
if veFilters := newAppConfig.Features.VulnerabilityExposureHistoricalReporting; veFilters != nil {
|
||
if !lic.IsPremium() {
|
||
invalid.Append("org_settings.features.vulnerability_exposure_historical_reporting", ErrMissingLicense.Error())
|
||
} else {
|
||
veFilters.Validate("org_settings.features", invalid)
|
||
}
|
||
if invalid.HasErrors() {
|
||
return nil, ctxerr.Wrap(ctx, invalid)
|
||
}
|
||
}
|
||
|
||
// Reject conflicting deprecated/new logo URL pairs and mirror them so
|
||
// both forms are persisted with identical values. Done on the incoming
|
||
// payload before merge so we surface the conflict at the source field.
|
||
if normErr := newAppConfig.OrgInfo.NormalizeLogoFields(); normErr != nil {
|
||
return nil, ctxerr.Wrap(ctx, normErr)
|
||
}
|
||
|
||
if newAppConfig.SSOSettings != nil {
|
||
validateSSOSettings(newAppConfig, appConfig, invalid, lic, applyOpts.Overwrite)
|
||
if invalid.HasErrors() {
|
||
return nil, ctxerr.Wrap(ctx, invalid)
|
||
}
|
||
}
|
||
|
||
// If we're in overwrite mode, clear out any feautures that are not explicitly specified.
|
||
if applyOpts.Overwrite {
|
||
appConfig.Features = newAppConfig.Features
|
||
appConfig.SSOSettings = newAppConfig.SSOSettings
|
||
appConfig.MDM.EndUserAuthentication = newAppConfig.MDM.EndUserAuthentication
|
||
}
|
||
|
||
// We apply the config that is incoming to the old one
|
||
appConfig.EnableStrictDecoding()
|
||
if err := json.Unmarshal(p, &appConfig); err != nil {
|
||
err = fleet.NewUserMessageError(err, http.StatusBadRequest)
|
||
return nil, ctxerr.Wrap(ctx, err)
|
||
}
|
||
|
||
// AppleOSUpdateSettings.UpdateNewHosts only applies to macOS ... so just ignore w/e posted for iOS/iPadOS
|
||
appConfig.MDM.IOSUpdates.UpdateNewHosts = optjson.Bool{}
|
||
appConfig.MDM.IPadOSUpdates.UpdateNewHosts = optjson.Bool{}
|
||
|
||
clearStaleAppleOSUpdateDeadline(&appConfig.MDM.MacOSUpdates, newAppConfig.MDM.MacOSUpdates)
|
||
clearStaleAppleOSUpdateDeadline(&appConfig.MDM.IOSUpdates, newAppConfig.MDM.IOSUpdates)
|
||
clearStaleAppleOSUpdateDeadline(&appConfig.MDM.IPadOSUpdates, newAppConfig.MDM.IPadOSUpdates)
|
||
|
||
// Handle Google Calendar API key preservation/replacement.
|
||
// The custom GoogleCalendarApiKey type handles unmarshaling "********" as masked.
|
||
if newAppConfig.Integrations.GoogleCalendar != nil {
|
||
for i, newGC := range newAppConfig.Integrations.GoogleCalendar {
|
||
if i < len(appConfig.Integrations.GoogleCalendar) {
|
||
// If api_key_json was omitted (empty) or masked ("********"), preserve the existing value
|
||
if newGC.ApiKey.IsEmpty() || newGC.ApiKey.IsMasked() {
|
||
if len(oldAppConfig.Integrations.GoogleCalendar) > i {
|
||
appConfig.Integrations.GoogleCalendar[i].ApiKey = oldAppConfig.Integrations.GoogleCalendar[i].ApiKey
|
||
}
|
||
} else {
|
||
// api_key_json was provided with real values, use it
|
||
appConfig.Integrations.GoogleCalendar[i].ApiKey = newGC.ApiKey
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Google Workspace IdP is a premium-only feature.
|
||
if len(newAppConfig.Integrations.GoogleWorkspace) > 0 && !lic.IsPremium() {
|
||
invalid.Append("integrations.google_workspace", ErrMissingLicense.Error())
|
||
return nil, ctxerr.Wrap(ctx, invalid)
|
||
}
|
||
|
||
// Handle Google Workspace API key preservation/replacement (same masking
|
||
// semantics as Google Calendar): a masked or omitted api_key_json means
|
||
// "keep the existing service account credentials".
|
||
if newAppConfig.Integrations.GoogleWorkspace != nil {
|
||
for i, newGW := range newAppConfig.Integrations.GoogleWorkspace {
|
||
if i < len(appConfig.Integrations.GoogleWorkspace) {
|
||
if newGW.ApiKey.IsEmpty() || newGW.ApiKey.IsMasked() {
|
||
if len(oldAppConfig.Integrations.GoogleWorkspace) > i {
|
||
appConfig.Integrations.GoogleWorkspace[i].ApiKey = oldAppConfig.Integrations.GoogleWorkspace[i].ApiKey
|
||
}
|
||
} else {
|
||
appConfig.Integrations.GoogleWorkspace[i].ApiKey = newGW.ApiKey
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// if turning off Windows MDM and Windows Migration is not explicitly set to
|
||
// on in the same update, set it to off (otherwise, if it is explicitly set
|
||
// to true, return an error that it can't be done when MDM is off, this is
|
||
// addressed in validateMDM).
|
||
if oldAppConfig.MDM.WindowsEnabledAndConfigured != appConfig.MDM.WindowsEnabledAndConfigured &&
|
||
!appConfig.MDM.WindowsEnabledAndConfigured && !newAppConfig.MDM.WindowsMigrationEnabled {
|
||
appConfig.MDM.WindowsMigrationEnabled = false
|
||
}
|
||
|
||
// When Windows MDM is being turned off and the incoming payload doesn't set the Entra allowlists, clear them.
|
||
clearEntraIDsIfWindowsTurnedOff := func(field *optjson.Slice[string], incomingLen int) {
|
||
if oldAppConfig.MDM.WindowsEnabledAndConfigured != appConfig.MDM.WindowsEnabledAndConfigured &&
|
||
!appConfig.MDM.WindowsEnabledAndConfigured && incomingLen == 0 {
|
||
*field = optjson.SetSlice([]string{})
|
||
}
|
||
}
|
||
clearEntraIDsIfWindowsTurnedOff(&appConfig.MDM.WindowsEntraTenantIDs, len(newAppConfig.MDM.WindowsEntraTenantIDs.Value))
|
||
clearEntraIDsIfWindowsTurnedOff(&appConfig.MDM.WindowsEntraClientIDs, len(newAppConfig.MDM.WindowsEntraClientIDs.Value))
|
||
|
||
// Normalize Entra client IDs to canonical lower-case and de-duplicate them. They are authorized
|
||
// case-insensitively (see hasAuthorizedAzureAudience), so storing them canonically prevents
|
||
// functionally-identical duplicates that differ only in case (for example, an upper-case ID added via
|
||
// GitOps or the API alongside a lower-case one added through the UI).
|
||
if appConfig.MDM.WindowsEntraClientIDs.Set && appConfig.MDM.WindowsEntraClientIDs.Valid {
|
||
seen := make(map[string]struct{}, len(appConfig.MDM.WindowsEntraClientIDs.Value))
|
||
normalized := make([]string, 0, len(appConfig.MDM.WindowsEntraClientIDs.Value))
|
||
for _, clientID := range appConfig.MDM.WindowsEntraClientIDs.Value {
|
||
id := strings.ToLower(strings.TrimSpace(clientID))
|
||
if _, ok := seen[id]; ok {
|
||
continue
|
||
}
|
||
seen[id] = struct{}{}
|
||
normalized = append(normalized, id)
|
||
}
|
||
appConfig.MDM.WindowsEntraClientIDs.Value = normalized
|
||
}
|
||
|
||
// EnableDiskEncryption is an optjson.Bool field in order to support the
|
||
// legacy field under "mdm.macos_settings". If the field provided to the
|
||
// PATCH endpoint is set but invalid (that is, "enable_disk_encryption":
|
||
// null) and no legacy field overwrites it, leave it unchanged (as if not
|
||
// provided).
|
||
|
||
// TODO: move this logic to the AppConfig unmarshaller? we need to do
|
||
// this because we unmarshal twice into appConfig:
|
||
//
|
||
// 1. To get the JSON value from the database
|
||
// 2. To update fields with the incoming values
|
||
if newAppConfig.MDM.EnableDiskEncryption.Valid {
|
||
if newAppConfig.MDM.EnableDiskEncryption.Value && svc.config.Server.PrivateKey == "" {
|
||
return nil, 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")
|
||
}
|
||
appConfig.MDM.EnableDiskEncryption = newAppConfig.MDM.EnableDiskEncryption
|
||
} else if appConfig.MDM.EnableDiskEncryption.Set && !appConfig.MDM.EnableDiskEncryption.Valid {
|
||
appConfig.MDM.EnableDiskEncryption = oldAppConfig.MDM.EnableDiskEncryption
|
||
}
|
||
|
||
// Apple account provisioning (Platform SSO): the IdP client secret is never
|
||
// persisted in the AppConfig JSON — it's stored encrypted in
|
||
// mdm_config_assets. Capture the caller-supplied secret here, validate, then
|
||
// strip it from the config that gets saved.
|
||
oldAAP := oldAppConfig.MDM.AppleAccountProvisioning
|
||
incomingAAP := newAppConfig.MDM.AppleAccountProvisioning
|
||
|
||
if applyOpts.Overwrite {
|
||
appConfig.MDM.AppleAccountProvisioning = incomingAAP
|
||
}
|
||
|
||
incomingSecret := incomingAAP.OAuthIdPClientSecret
|
||
newAAPSecretProvided := incomingSecret.Valid && incomingSecret.Value != "" && incomingSecret.Value != fleet.MaskedPassword
|
||
newAAPSecret := incomingSecret.Value
|
||
|
||
mergedAAP := appConfig.MDM.AppleAccountProvisioning
|
||
appConfig.MDM.AppleAccountProvisioning.OAuthIdPClientSecret = optjson.String{}
|
||
|
||
// Apple account provisioning is all-or-nothing: the token URL, client ID, and
|
||
// client secret are only meaningful together, so the config must have all three
|
||
// set or all three empty — never a partial state that reports as "configured"
|
||
// but can't run the sign-in flow.
|
||
tokenURLSet := mergedAAP.OAuthIdPTokenURL.Value != ""
|
||
clientIDSet := mergedAAP.OAuthIdPClientID.Value != ""
|
||
switch {
|
||
case mergedAAP.Configured(): // both public fields set
|
||
aapProvided := incomingAAP.OAuthIdPTokenURL.Set || incomingAAP.OAuthIdPClientID.Set || incomingAAP.OAuthIdPClientSecret.Set
|
||
if aapProvided && !lic.IsPremium() {
|
||
invalid.Append("mdm.apple_account_provisioning", ErrMissingLicense.Error())
|
||
}
|
||
if newAAPSecretProvided && svc.config.Server.PrivateKey == "" {
|
||
invalid.Append("mdm.apple_account_provisioning",
|
||
"Missing required private key. Learn how to configure the private key here: https://fleetdm.com/learn-more-about/fleet-server-private-key")
|
||
}
|
||
if u, err := url.Parse(mergedAAP.OAuthIdPTokenURL.Value); err != nil || u.Host == "" || u.Scheme != "https" {
|
||
invalid.Append("mdm.apple_account_provisioning.oauth_idp_token_url", "must be a valid https URL")
|
||
}
|
||
switch {
|
||
case !newAAPSecretProvided && (applyOpts.Overwrite || !oldAAP.Configured()):
|
||
invalid.Append("mdm.apple_account_provisioning.oauth_idp_client_secret",
|
||
"oauth_idp_client_secret must be set together with oauth_idp_token_url and oauth_idp_client_id")
|
||
case !newAAPSecretProvided && mergedAAP.OAuthIdPTokenURL.Value != oldAAP.OAuthIdPTokenURL.Value:
|
||
// Reusing a stored secret while repointing the IdP token endpoint would
|
||
// leak it to the new (possibly hostile) URL, so require it be provided.
|
||
// Similar to CAs and their secrets.
|
||
invalid.Append("mdm.apple_account_provisioning.oauth_idp_client_secret",
|
||
"oauth_idp_client_secret must be provided when changing oauth_idp_token_url")
|
||
}
|
||
case tokenURLSet || clientIDSet || newAAPSecretProvided:
|
||
// Not fully configured, but a field was supplied (one public field without
|
||
// the other, or a secret on its own) — a partial config.
|
||
invalid.Append("mdm.apple_account_provisioning",
|
||
"oauth_idp_token_url, oauth_idp_client_id, and oauth_idp_client_secret must all be set together, or all be empty")
|
||
}
|
||
|
||
// this is to handle the case where `apple_enable_release_device_manually: null` is
|
||
// passed in the request payload, which should be treated as "not present/not
|
||
// changed" by the PATCH. We should really try to find a more general way to
|
||
// handle this.
|
||
if !oldAppConfig.MDM.MacOSSetup.EnableReleaseDeviceManually.Valid {
|
||
// this makes a DB migration unnecessary, will update the field to its default false value as necessary
|
||
oldAppConfig.MDM.MacOSSetup.EnableReleaseDeviceManually = optjson.SetBool(false)
|
||
}
|
||
if newAppConfig.MDM.MacOSSetup.EnableReleaseDeviceManually.Valid {
|
||
appConfig.MDM.MacOSSetup.EnableReleaseDeviceManually = newAppConfig.MDM.MacOSSetup.EnableReleaseDeviceManually
|
||
} else {
|
||
appConfig.MDM.MacOSSetup.EnableReleaseDeviceManually = oldAppConfig.MDM.MacOSSetup.EnableReleaseDeviceManually
|
||
}
|
||
|
||
// Apply a default value of false for LockEndUserInfo if not set
|
||
if !oldAppConfig.MDM.MacOSSetup.LockEndUserInfo.Valid {
|
||
oldAppConfig.MDM.MacOSSetup.LockEndUserInfo = optjson.SetBool(false)
|
||
}
|
||
if newAppConfig.MDM.MacOSSetup.LockEndUserInfo.Valid {
|
||
appConfig.MDM.MacOSSetup.LockEndUserInfo = newAppConfig.MDM.MacOSSetup.LockEndUserInfo
|
||
} else {
|
||
appConfig.MDM.MacOSSetup.LockEndUserInfo = oldAppConfig.MDM.MacOSSetup.LockEndUserInfo
|
||
}
|
||
|
||
// When EUA changes and LockEndUserInfo is not explicitly set, sync LockEndUserInfo to match EUA (Apple-only).
|
||
// Also sync when EUA was just disabled so the Lock-requires-EUA invariant stays satisfied.
|
||
if oldAppConfig.MDM.MacOSSetup.EnableEndUserAuthentication != appConfig.MDM.MacOSSetup.EnableEndUserAuthentication &&
|
||
!newAppConfig.MDM.MacOSSetup.LockEndUserInfo.Valid &&
|
||
(oldAppConfig.MDM.EnabledAndConfigured || !appConfig.MDM.MacOSSetup.EnableEndUserAuthentication) {
|
||
appConfig.MDM.MacOSSetup.LockEndUserInfo = optjson.SetBool(appConfig.MDM.MacOSSetup.EnableEndUserAuthentication)
|
||
}
|
||
|
||
if !oldAppConfig.MDM.MacOSSetup.EnableManagedLocalAccount.Valid {
|
||
oldAppConfig.MDM.MacOSSetup.EnableManagedLocalAccount = optjson.SetBool(false)
|
||
}
|
||
if newAppConfig.MDM.MacOSSetup.EnableManagedLocalAccount.Valid {
|
||
appConfig.MDM.MacOSSetup.EnableManagedLocalAccount = newAppConfig.MDM.MacOSSetup.EnableManagedLocalAccount
|
||
} else {
|
||
appConfig.MDM.MacOSSetup.EnableManagedLocalAccount = oldAppConfig.MDM.MacOSSetup.EnableManagedLocalAccount
|
||
}
|
||
|
||
if !oldAppConfig.MDM.MacOSSetup.EndUserLocalAccountType.Valid {
|
||
oldAppConfig.MDM.MacOSSetup.EndUserLocalAccountType = optjson.SetString("admin")
|
||
}
|
||
if newAppConfig.MDM.MacOSSetup.EndUserLocalAccountType.Valid {
|
||
appConfig.MDM.MacOSSetup.EndUserLocalAccountType = newAppConfig.MDM.MacOSSetup.EndUserLocalAccountType
|
||
} else {
|
||
appConfig.MDM.MacOSSetup.EndUserLocalAccountType = oldAppConfig.MDM.MacOSSetup.EndUserLocalAccountType
|
||
}
|
||
|
||
// windows_settings.managed_local_account_settings.enabled: like EnableDiskEncryption above, an explicit JSON null
|
||
// means "not provided": keep the old value rather than persisting an invalid optjson state.
|
||
if !oldAppConfig.MDM.WindowsSettings.ManagedLocalAccountSettings.Enabled.Valid {
|
||
oldAppConfig.MDM.WindowsSettings.ManagedLocalAccountSettings.Enabled = optjson.SetBool(false)
|
||
}
|
||
if newAppConfig.MDM.WindowsSettings.ManagedLocalAccountSettings.Enabled.Valid {
|
||
appConfig.MDM.WindowsSettings.ManagedLocalAccountSettings.Enabled = newAppConfig.MDM.WindowsSettings.ManagedLocalAccountSettings.Enabled
|
||
} else {
|
||
appConfig.MDM.WindowsSettings.ManagedLocalAccountSettings.Enabled = oldAppConfig.MDM.WindowsSettings.ManagedLocalAccountSettings.Enabled
|
||
}
|
||
|
||
if appConfig.MDM.MacOSSetup.ManualAgentInstall.Valid && appConfig.MDM.MacOSSetup.ManualAgentInstall.Value {
|
||
if !lic.IsPremium() {
|
||
invalid.Append("setup_experience.macos_manual_agent_install", ErrMissingLicense.Error())
|
||
return nil, ctxerr.Wrap(ctx, invalid)
|
||
}
|
||
}
|
||
|
||
if appConfig.MDM.MacOSUpdates.Configured() || appConfig.MDM.IOSUpdates.Configured() || appConfig.MDM.IPadOSUpdates.Configured() {
|
||
// Verify that we don't have a custom OS updates declaration
|
||
hasProfile, err := svc.ds.HasAppleUpdateConfigProfileConfigured(ctx, 0)
|
||
if err != nil {
|
||
return nil, ctxerr.Wrap(ctx, err, "check for existing custom OS updates declaration profile")
|
||
}
|
||
if hasProfile {
|
||
return nil, &fleet.BadRequestError{
|
||
Message: fleet.OSUpdatesAlreadyConfiguredErrorMessage,
|
||
}
|
||
}
|
||
}
|
||
|
||
if appConfig.MDM.WindowsUpdates.Configured() {
|
||
// Verify that we don't have a custom Windows updates profile
|
||
hasProfile, err := svc.ds.HasWindowsUpdateConfigProfileConfigured(ctx, 0)
|
||
if err != nil {
|
||
return nil, ctxerr.Wrap(ctx, err, "check for existing custom Windows updates profile")
|
||
}
|
||
if hasProfile {
|
||
return nil, &fleet.BadRequestError{
|
||
Message: fleet.OSUpdatesAlreadyConfiguredErrorMessage,
|
||
}
|
||
}
|
||
}
|
||
|
||
var legacyUsedWarning error
|
||
if legacyKeys := appConfig.DidUnmarshalLegacySettings(); len(legacyKeys) > 0 {
|
||
// this "warning" is returned only in dry-run mode, and if no other errors
|
||
// were encountered.
|
||
legacyUsedWarning = &fleet.BadRequestError{
|
||
Message: fmt.Sprintf("warning: deprecated settings were used in the configuration: %v; consider updating to the new settings: https://fleetdm.com/docs/using-fleet/configuration-files#settings",
|
||
legacyKeys),
|
||
}
|
||
}
|
||
|
||
// required fields must be set, ensure they haven't been removed by applying
|
||
// the new config
|
||
if appConfig.OrgInfo.OrgName == "" {
|
||
invalid.Append("org_name", "organization name must be present")
|
||
}
|
||
if appConfig.ServerSettings.ServerURL == "" {
|
||
invalid.Append("server_url", "Fleet server URL must be present")
|
||
} else {
|
||
if err := ValidateServerURL(appConfig.ServerSettings.ServerURL); err != nil {
|
||
invalid.Append("server_url", "Couldn't update settings: "+err.Error())
|
||
}
|
||
}
|
||
|
||
if appConfig.ActivityExpirySettings.ActivityExpiryEnabled && appConfig.ActivityExpirySettings.ActivityExpiryWindow < 1 {
|
||
invalid.Append("activity_expiry_settings.activity_expiry_window", "must be greater than 0")
|
||
}
|
||
|
||
if appConfig.OrgInfo.ContactURL == "" {
|
||
appConfig.OrgInfo.ContactURL = fleet.DefaultOrgInfoContactURL
|
||
}
|
||
|
||
// Validate logo fields immediately after the merge
|
||
// A persisted-row conflict the patch didn't touch surfaces here as a 422,
|
||
// instead of letting the request silently complete its side effects.
|
||
if normErr := appConfig.OrgInfo.NormalizeLogoFields(); normErr != nil {
|
||
return nil, ctxerr.Wrap(ctx, normErr)
|
||
}
|
||
|
||
if newAppConfig.AgentOptions != nil {
|
||
// if there were Agent Options in the new app config, then it replaced the
|
||
// agent options in the resulting app config, so validate those.
|
||
if err := fleet.ValidateJSONAgentOptions(ctx, svc.ds, *appConfig.AgentOptions, lic.IsPremium(), 0); err != nil {
|
||
err = fleet.SuggestAgentOptionsCorrection(err)
|
||
err = fleet.NewUserMessageError(err, http.StatusBadRequest)
|
||
if applyOpts.Force && !applyOpts.DryRun {
|
||
svc.logger.InfoContext(ctx, "force-apply appConfig agent options with validation errors", "err", err)
|
||
}
|
||
if !applyOpts.Force {
|
||
return nil, ctxerr.Wrap(ctx, err, "validate agent options")
|
||
}
|
||
}
|
||
}
|
||
|
||
// If the license is Premium, we should always send usage statisics.
|
||
if !lic.IsAllowDisableTelemetry() {
|
||
appConfig.ServerSettings.EnableAnalytics = true
|
||
}
|
||
|
||
fleet.ValidateGoogleCalendarIntegrations(appConfig.Integrations.GoogleCalendar, invalid)
|
||
fleet.ValidateGoogleWorkspaceIntegrations(appConfig.Integrations.GoogleWorkspace, invalid)
|
||
fleet.ValidateEnabledVulnerabilitiesIntegrations(appConfig.WebhookSettings.VulnerabilitiesWebhook, appConfig.Integrations, invalid)
|
||
fleet.ValidateEnabledFailingPoliciesIntegrations(appConfig.WebhookSettings.FailingPoliciesWebhook, appConfig.Integrations, invalid)
|
||
fleet.ValidateEnabledHostStatusIntegrations(appConfig.WebhookSettings.HostStatusWebhook, invalid)
|
||
fleet.ValidateEnabledActivitiesWebhook(appConfig.WebhookSettings.ActivitiesWebhook, invalid)
|
||
|
||
if err := applyAndValidateConditionalAccessOktaFields(ctx, appConfig, &newAppConfig, invalid, lic); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
var conditionalAccessNoTeamUpdated bool
|
||
if newAppConfig.Integrations.ConditionalAccessEnabled.Set {
|
||
if err := fleet.ValidateConditionalAccessIntegration(ctx, svc, appConfig.ConditionalAccess, oldConditionalAccessEnabled.Value, newAppConfig.Integrations.ConditionalAccessEnabled.Value); err != nil {
|
||
return nil, err
|
||
}
|
||
conditionalAccessNoTeamUpdated = oldConditionalAccessEnabled.Value != newAppConfig.Integrations.ConditionalAccessEnabled.Value
|
||
appConfig.Integrations.ConditionalAccessEnabled = newAppConfig.Integrations.ConditionalAccessEnabled
|
||
}
|
||
|
||
if err := svc.validateMDM(ctx, lic, &oldAppConfig.MDM, &appConfig.MDM, invalid, applyOpts.Overwrite); err != nil {
|
||
return nil, ctxerr.Wrap(ctx, err, "validating MDM config")
|
||
}
|
||
|
||
abmAssignments, err := svc.validateABMAssignments(ctx, &newAppConfig.MDM, &oldAppConfig.MDM, invalid, lic)
|
||
if err != nil {
|
||
return nil, ctxerr.Wrap(ctx, err, "validating ABM token assignments")
|
||
}
|
||
|
||
var vppAssignments map[uint][]uint
|
||
vppAssignmentsDefined := newAppConfig.MDM.VolumePurchasingProgram.Set && newAppConfig.MDM.VolumePurchasingProgram.Valid
|
||
if vppAssignmentsDefined {
|
||
vppAssignments, err = svc.validateVPPAssignments(ctx, newAppConfig.MDM.VolumePurchasingProgram.Value, invalid, lic)
|
||
if err != nil {
|
||
return nil, ctxerr.Wrap(ctx, err, "validating VPP token assignments")
|
||
}
|
||
}
|
||
|
||
windowsEnrollmentDefined, windowsEnrollmentTeamID, windowsEnrollmentFleetName, err := svc.validateWindowsEnrollment(ctx, &newAppConfig.MDM, invalid, lic)
|
||
if err != nil {
|
||
return nil, ctxerr.Wrap(ctx, err, "validating windows enrollment default fleet")
|
||
}
|
||
|
||
if invalid.HasErrors() {
|
||
return nil, ctxerr.Wrap(ctx, invalid)
|
||
}
|
||
|
||
// Normalize the stored JSON to the canonical fleet name when one was resolved.
|
||
if windowsEnrollmentDefined && windowsEnrollmentFleetName != "" {
|
||
appConfig.MDM.WindowsEnrollment = optjson.Any[fleet.WindowsEnrollment]{
|
||
Set: true, Valid: true,
|
||
Value: fleet.WindowsEnrollment{DefaultFleet: windowsEnrollmentFleetName},
|
||
}
|
||
} else if appConfig.MDM.WindowsEnrollment.Set && !appConfig.MDM.WindowsEnrollment.Valid {
|
||
// A null windows_enrollment keeps the persisted setting (validateWindowsEnrollment treated it as omitted), so restore the stored value.
|
||
appConfig.MDM.WindowsEnrollment = oldAppConfig.MDM.WindowsEnrollment
|
||
}
|
||
|
||
// ignore MDM.EnabledAndConfigured MDM.AppleBMTermsExpired, and MDM.AppleBMEnabledAndConfigured
|
||
// if provided in the modify payload we don't return an error in this case because it would
|
||
// prevent using the output of fleetctl get config as input to fleetctl apply or this endpoint.
|
||
appConfig.MDM.AppleBMTermsExpired = oldAppConfig.MDM.AppleBMTermsExpired
|
||
appConfig.MDM.AppleBMEnabledAndConfigured = oldAppConfig.MDM.AppleBMEnabledAndConfigured
|
||
appConfig.MDM.EnabledAndConfigured = oldAppConfig.MDM.EnabledAndConfigured
|
||
// ignore MDM.AndroidEnabledAndConfigured because it is set by the server only
|
||
appConfig.MDM.AndroidEnabledAndConfigured = oldAppConfig.MDM.AndroidEnabledAndConfigured
|
||
|
||
// do not send a test email in dry-run mode, so this is a good place to stop
|
||
// (we also delete the removed integrations after that, which we don't want
|
||
// to do in dry-run mode).
|
||
if applyOpts.DryRun {
|
||
if legacyUsedWarning != nil {
|
||
return nil, legacyUsedWarning
|
||
}
|
||
|
||
// must reload to get the unchanged app config (retrieve with obfuscated secrets)
|
||
obfuscatedAppConfig, err := svc.ds.AppConfig(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
obfuscatedAppConfig.Obfuscate()
|
||
return obfuscatedAppConfig, nil
|
||
}
|
||
|
||
// Perform validation of the applied SMTP settings.
|
||
if newAppConfig.SMTPSettings != nil {
|
||
// Ignore the values for SMTPEnabled and SMTPConfigured.
|
||
oldSMTPSettings.SMTPEnabled = appConfig.SMTPSettings.SMTPEnabled
|
||
oldSMTPSettings.SMTPConfigured = appConfig.SMTPSettings.SMTPConfigured
|
||
|
||
// If we enable SMTP and the settings have changed, then we send a test email.
|
||
if appConfig.SMTPSettings.SMTPEnabled {
|
||
if oldSMTPSettings != *appConfig.SMTPSettings || !appConfig.SMTPSettings.SMTPConfigured {
|
||
if err = svc.sendTestEmail(ctx, appConfig); err != nil {
|
||
return nil, fleet.NewInvalidArgumentError("SMTP Options", err.Error())
|
||
}
|
||
}
|
||
appConfig.SMTPSettings.SMTPConfigured = true
|
||
} else {
|
||
appConfig.SMTPSettings.SMTPConfigured = false
|
||
}
|
||
}
|
||
|
||
// NOTE: the frontend will always send all integrations back when making
|
||
// changes, so as soon as Jira or Zendesk has something set, it's fair to
|
||
// assume that integrations are being modified and we have the full set of
|
||
// those integrations. When deleting, it does send empty arrays (not nulls),
|
||
// so this is fine - e.g. when deleting the last integration it sends:
|
||
//
|
||
// {"integrations":{"zendesk":[],"jira":[]}}
|
||
//
|
||
if newAppConfig.Integrations.Jira != nil || newAppConfig.Integrations.Zendesk != nil {
|
||
delJira, err := fleet.ValidateJiraIntegrations(ctx, storedJiraByProjectKey, newAppConfig.Integrations.Jira)
|
||
if err != nil {
|
||
if errors.As(err, &fleet.IntegrationTestError{}) {
|
||
return nil, ctxerr.Wrap(ctx, &fleet.BadRequestError{
|
||
Message: err.Error(),
|
||
})
|
||
}
|
||
return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("Jira integration", err.Error()))
|
||
}
|
||
appConfig.Integrations.Jira = newAppConfig.Integrations.Jira
|
||
|
||
delZendesk, err := fleet.ValidateZendeskIntegrations(ctx, storedZendeskByGroupID, newAppConfig.Integrations.Zendesk)
|
||
if err != nil {
|
||
if errors.As(err, &fleet.IntegrationTestError{}) {
|
||
return nil, ctxerr.Wrap(ctx, &fleet.BadRequestError{
|
||
Message: err.Error(),
|
||
})
|
||
}
|
||
return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("Zendesk integration", err.Error()))
|
||
}
|
||
appConfig.Integrations.Zendesk = newAppConfig.Integrations.Zendesk
|
||
|
||
// if any integration was deleted, remove it from any team that uses it
|
||
if len(delJira)+len(delZendesk) > 0 {
|
||
if err := svc.ds.DeleteIntegrationsFromTeams(ctx, fleet.Integrations{Jira: delJira, Zendesk: delZendesk}); err != nil {
|
||
return nil, ctxerr.Wrap(ctx, err, "delete integrations from teams")
|
||
}
|
||
}
|
||
}
|
||
// If google_calendar is null, we keep the existing setting.
|
||
if newAppConfig.Integrations.GoogleCalendar == nil {
|
||
appConfig.Integrations.GoogleCalendar = oldAppConfig.Integrations.GoogleCalendar
|
||
}
|
||
// If google_workspace is null, we keep the existing setting.
|
||
if newAppConfig.Integrations.GoogleWorkspace == nil {
|
||
appConfig.Integrations.GoogleWorkspace = oldAppConfig.Integrations.GoogleWorkspace
|
||
}
|
||
|
||
gitopsModeEnabled, gitopsRepoURL := appConfig.GitOpsConfig.GitopsModeEnabled, appConfig.GitOpsConfig.RepositoryURL
|
||
if gitopsModeEnabled {
|
||
if !lic.IsPremium() {
|
||
return nil, fleet.NewInvalidArgumentError("UI GitOpsMode: ", ErrMissingLicense.Error())
|
||
}
|
||
if gitopsRepoURL == "" {
|
||
return nil, fleet.NewInvalidArgumentError("UI GitOps Mode: ", "Repository URL is required when GitOps mode is enabled")
|
||
}
|
||
parsedURL, err := url.Parse(gitopsRepoURL)
|
||
if err != nil {
|
||
return nil, fleet.NewInvalidArgumentError("UI Gitops Mode: ", "Repository URL is invalid")
|
||
}
|
||
if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
|
||
return nil, fleet.NewInvalidArgumentError("UI Gitops Mode: ", "Git repository URL must include protocol (e.g. https://)")
|
||
}
|
||
}
|
||
|
||
if oldAppConfig.GitOpsConfig.GitopsModeEnabled != appConfig.GitOpsConfig.GitopsModeEnabled {
|
||
// generate the activity
|
||
var act fleet.ActivityDetails
|
||
if gitopsModeEnabled {
|
||
act = fleet.ActivityTypeEnabledGitOpsMode{}
|
||
} else {
|
||
act = fleet.ActivityTypeDisabledGitOpsMode{}
|
||
}
|
||
if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), act); err != nil {
|
||
return nil, ctxerr.Wrapf(ctx, err, "create activity %s", act.ActivityName())
|
||
}
|
||
|
||
}
|
||
|
||
if !lic.IsPremium() {
|
||
// reset fleet desktop settings to empty values for downgraded licenses
|
||
appConfig.FleetDesktop.TransparencyURL = ""
|
||
appConfig.FleetDesktop.AlternativeBrowserHost = ""
|
||
// Clear a premium-only host name template so a value set while premium isn't
|
||
// retained (and enforced by the cron, which gates on MDM.EnabledAndConfigured
|
||
// rather than the license) on Free. Only touch it when non-empty so a no-op
|
||
// Free-tier save doesn't flip the field's optjson state (unset null → empty).
|
||
if appConfig.MDM.HostNameTemplate.Value != "" {
|
||
appConfig.MDM.HostNameTemplate = optjson.SetString("")
|
||
}
|
||
}
|
||
|
||
aapSecretChanged, err := svc.persistAppleAccountProvisioningSecret(ctx, mergedAAP.Configured(), oldAAP.Configured(), newAAPSecretProvided, newAAPSecret)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
// The IdP client secret never reaches the AppConfig JSON, so a secret-only
|
||
// change isn't visible in the saved config diff — track it separately.
|
||
aapChanged := aapSecretChanged ||
|
||
mergedAAP.OAuthIdPTokenURL.Value != oldAAP.OAuthIdPTokenURL.Value ||
|
||
mergedAAP.OAuthIdPClientID.Value != oldAAP.OAuthIdPClientID.Value
|
||
|
||
// Mint the PSSO signing key and CA the first time the feature is configured.
|
||
// Idempotent: existing assets are preserved (never recreated on reconfigure),
|
||
// and they are deliberately kept when the feature is disabled so a later
|
||
// re-enable reuses the same JWKS key and unlock-key CA.
|
||
if mergedAAP.Configured() {
|
||
if err := bootstrapPSSOAssets(ctx, svc.ds); err != nil {
|
||
return nil, ctxerr.Wrap(ctx, err, "bootstrap psso assets")
|
||
}
|
||
}
|
||
|
||
if err := svc.ds.SaveAppConfig(ctx, appConfig); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
if aapChanged {
|
||
if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), fleet.ActivityTypeEditedAccountProvisioning{}); err != nil {
|
||
return nil, ctxerr.Wrapf(ctx, err, "create activity %s", fleet.ActivityTypeEditedAccountProvisioning{}.ActivityName())
|
||
}
|
||
}
|
||
|
||
// Best-effort: drop orphan blobs whose URL was just replaced with an
|
||
// external or empty value. Mirrors the explicit DELETE /logo endpoint's
|
||
// audit signal by emitting a deleted_org_logo activity per mode that
|
||
// actually had a blob removed.
|
||
if svc.orgLogoStore != nil {
|
||
for _, m := range []fleet.OrgLogoMode{fleet.OrgLogoModeLight, fleet.OrgLogoModeDark} {
|
||
oldURL := getOrgLogoURL(&oldAppConfig.OrgInfo, m)
|
||
newURL := getOrgLogoURL(&appConfig.OrgInfo, m)
|
||
if !fleet.IsFleetHostedLogoURL(oldURL) || fleet.IsFleetHostedLogoURL(newURL) {
|
||
continue
|
||
}
|
||
if err := svc.orgLogoStore.Delete(ctx, m); err != nil {
|
||
svc.logger.WarnContext(ctx, "failed to delete orphan org logo blob",
|
||
"mode", string(m), "err", err.Error())
|
||
continue
|
||
}
|
||
if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), fleet.ActivityTypeDeletedOrgLogo{
|
||
Mode: string(m),
|
||
}); err != nil {
|
||
svc.logger.WarnContext(ctx, "failed to create deleted_org_logo activity for auto-cleanup",
|
||
"mode", string(m), "err", err.Error())
|
||
}
|
||
}
|
||
}
|
||
|
||
oldExceptions := oldAppConfig.GitOpsConfig.Exceptions
|
||
newExceptions := appConfig.GitOpsConfig.Exceptions
|
||
exceptionChanges := []struct {
|
||
name string
|
||
oldEnabled bool
|
||
newEnabled bool
|
||
}{
|
||
{"labels", oldExceptions.Labels, newExceptions.Labels},
|
||
{"software", oldExceptions.Software, newExceptions.Software},
|
||
{"secrets", oldExceptions.Secrets, newExceptions.Secrets},
|
||
}
|
||
for _, c := range exceptionChanges {
|
||
if c.oldEnabled == c.newEnabled {
|
||
continue
|
||
}
|
||
var act fleet.ActivityDetails
|
||
if c.newEnabled {
|
||
act = fleet.ActivityTypeEnabledGitOpsException{Exception: c.name}
|
||
} else {
|
||
act = fleet.ActivityTypeDisabledGitOpsException{Exception: c.name}
|
||
}
|
||
if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), act); err != nil {
|
||
return nil, ctxerr.Wrapf(ctx, err, "create activity %s", act.ActivityName())
|
||
}
|
||
}
|
||
|
||
// Emit activities and enqueue scrub jobs for any historical_data sub-key
|
||
// whose value flipped. SaveAppConfig (above) commits first; the worker
|
||
// that picks up scrub jobs will see the new config and the collection
|
||
// cron will already have stopped writing the disabled scope.
|
||
if err := fleet.OnHistoricalDataChanged(
|
||
ctx,
|
||
svc,
|
||
svc.ds,
|
||
authz.UserFromContext(ctx),
|
||
oldAppConfig.Features.HistoricalData,
|
||
appConfig.Features.HistoricalData,
|
||
nil, nil,
|
||
); err != nil {
|
||
err = ctxerr.Wrap(ctx, err, "OnHistoricalDataChanged")
|
||
ctxerr.Handle(ctx, err)
|
||
svc.logger.ErrorContext(ctx, "OnHistoricalDataChanged", "err", err)
|
||
}
|
||
|
||
// Emit one activity per Entra tenant ID / client ID added or removed. diffStringSlices deduplicates, so a payload
|
||
// that repeats an ID does not produce duplicate activities.
|
||
addedEntraTenantIDs, removedEntraTenantIDs := diffStringSlices(oldAppConfig.MDM.WindowsEntraTenantIDs.Value, appConfig.MDM.WindowsEntraTenantIDs.Value)
|
||
for _, tenantID := range addedEntraTenantIDs {
|
||
act := fleet.ActivityTypeAddedMicrosoftEntraTenant{TenantID: tenantID}
|
||
if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), act); err != nil {
|
||
return nil, ctxerr.Wrap(ctx, err, "create activity for added Microsoft Entra tenant")
|
||
}
|
||
}
|
||
for _, tenantID := range removedEntraTenantIDs {
|
||
act := fleet.ActivityTypeDeletedMicrosoftEntraTenant{TenantID: tenantID}
|
||
if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), act); err != nil {
|
||
return nil, ctxerr.Wrap(ctx, err, "create activity for deleted Microsoft Entra tenant")
|
||
}
|
||
}
|
||
|
||
// Emit an activity when the Google Workspace IdP integration is added, edited,
|
||
// or removed.
|
||
if act := googleWorkspaceActivity(oldAppConfig.Integrations.GoogleWorkspace, appConfig.Integrations.GoogleWorkspace); act != nil {
|
||
if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), act); err != nil {
|
||
return nil, ctxerr.Wrap(ctx, err, "create activity for google workspace integration change")
|
||
}
|
||
}
|
||
|
||
addedEntraClientIDs, removedEntraClientIDs := diffStringSlices(oldAppConfig.MDM.WindowsEntraClientIDs.Value, appConfig.MDM.WindowsEntraClientIDs.Value)
|
||
for _, clientID := range addedEntraClientIDs {
|
||
act := fleet.ActivityTypeAddedMicrosoftEntraClientID{ClientID: clientID}
|
||
if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), act); err != nil {
|
||
return nil, ctxerr.Wrap(ctx, err, "create activity for added Microsoft Entra client ID")
|
||
}
|
||
}
|
||
for _, clientID := range removedEntraClientIDs {
|
||
act := fleet.ActivityTypeDeletedMicrosoftEntraClientID{ClientID: clientID}
|
||
if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), act); err != nil {
|
||
return nil, ctxerr.Wrap(ctx, err, "create activity for deleted Microsoft Entra client ID")
|
||
}
|
||
}
|
||
|
||
// Persist the Windows enrollment default fleet to its config row and log the change.
|
||
if windowsEnrollmentDefined {
|
||
oldWindowsEnrollmentTeamID, _, err := svc.ds.GetWindowsEnrollmentDefaultFleet(ctx)
|
||
if err != nil {
|
||
return nil, ctxerr.Wrap(ctx, err, "get current windows enrollment default fleet")
|
||
}
|
||
if !ptr.Equal(oldWindowsEnrollmentTeamID, windowsEnrollmentTeamID) {
|
||
if err := svc.ds.SetWindowsEnrollmentDefaultFleet(ctx, windowsEnrollmentTeamID); err != nil {
|
||
return nil, ctxerr.Wrap(ctx, err, "saving windows enrollment default fleet")
|
||
}
|
||
var fleetName *string
|
||
if windowsEnrollmentTeamID != nil {
|
||
fleetName = &windowsEnrollmentFleetName
|
||
}
|
||
act := fleet.ActivityTypeEditedWindowsEnrollmentDefaultFleet{
|
||
FleetID: windowsEnrollmentTeamID,
|
||
FleetName: fleetName,
|
||
}
|
||
if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), act); err != nil {
|
||
return nil, ctxerr.Wrap(ctx, err, "create activity for edited windows enrollment default fleet")
|
||
}
|
||
}
|
||
}
|
||
|
||
// only create activities when config change has been persisted
|
||
|
||
switch {
|
||
case appConfig.WebhookSettings.ActivitiesWebhook.Enable && !oldAppConfig.WebhookSettings.ActivitiesWebhook.Enable:
|
||
act := fleet.ActivityTypeEnabledActivityAutomations{WebhookUrl: appConfig.WebhookSettings.ActivitiesWebhook.DestinationURL}
|
||
if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), act); err != nil {
|
||
return nil, ctxerr.Wrap(ctx, err, "create activity for enabled activity automations")
|
||
}
|
||
case !appConfig.WebhookSettings.ActivitiesWebhook.Enable && oldAppConfig.WebhookSettings.ActivitiesWebhook.Enable:
|
||
act := fleet.ActivityTypeDisabledActivityAutomations{}
|
||
if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), act); err != nil {
|
||
return nil, ctxerr.Wrap(ctx, err, "create activity for disabled activity automations")
|
||
}
|
||
case appConfig.WebhookSettings.ActivitiesWebhook.Enable &&
|
||
appConfig.WebhookSettings.ActivitiesWebhook.DestinationURL != oldAppConfig.WebhookSettings.ActivitiesWebhook.DestinationURL:
|
||
act := fleet.ActivityTypeEditedActivityAutomations{
|
||
WebhookUrl: appConfig.WebhookSettings.ActivitiesWebhook.DestinationURL,
|
||
}
|
||
if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), act); err != nil {
|
||
return nil, ctxerr.Wrap(ctx, err, "create activity for edited activity automations")
|
||
}
|
||
}
|
||
|
||
if oldAppConfig.MDM.MacOSSetup.MacOSSetupAssistant.Value != appConfig.MDM.MacOSSetup.MacOSSetupAssistant.Value &&
|
||
appConfig.MDM.MacOSSetup.MacOSSetupAssistant.Value == "" {
|
||
// clear macos setup assistant for no team - note that we cannot call
|
||
// svc.DeleteMDMAppleSetupAssistant here as it would call the (non-premium)
|
||
// current service implementation. We have to go through the Enterprise
|
||
// extensions.
|
||
if err := svc.EnterpriseOverrides.DeleteMDMAppleSetupAssistant(ctx, nil); err != nil {
|
||
return nil, ctxerr.Wrap(ctx, err, "delete macos setup assistant")
|
||
}
|
||
}
|
||
|
||
if oldAppConfig.MDM.MacOSSetup.BootstrapPackage.Value != appConfig.MDM.MacOSSetup.BootstrapPackage.Value &&
|
||
appConfig.MDM.MacOSSetup.BootstrapPackage.Value == "" {
|
||
// clear bootstrap package for no team - note that we cannot call
|
||
// svc.DeleteMDMAppleBootstrapPackage here as it would call the (non-premium)
|
||
// current service implementation. We have to go through the Enterprise
|
||
// extensions.
|
||
if err := svc.EnterpriseOverrides.DeleteMDMAppleBootstrapPackage(ctx, nil, applyOpts.DryRun); err != nil {
|
||
// The package may have already been deleted via the GUI while the
|
||
// appconfig JSON still had the stale URL; ignore not-found.
|
||
if !fleet.IsNotFound(err) {
|
||
return nil, ctxerr.Wrap(ctx, err, "delete Apple bootstrap package")
|
||
}
|
||
}
|
||
}
|
||
|
||
tokensInCfg := make(map[string]struct{})
|
||
for _, t := range newAppConfig.MDM.AppleBusinessManager.Value {
|
||
tokensInCfg[t.OrganizationName] = struct{}{}
|
||
}
|
||
|
||
toks, err := svc.ds.ListABMTokens(ctx)
|
||
if err != nil {
|
||
return nil, ctxerr.Wrap(ctx, err, "listing ABM tokens")
|
||
}
|
||
|
||
if newAppConfig.MDM.AppleBusinessManager.Set && len(newAppConfig.MDM.AppleBusinessManager.Value) == 0 {
|
||
for _, tok := range toks {
|
||
if _, ok := tokensInCfg[tok.OrganizationName]; !ok {
|
||
tok.MacOSDefaultTeamID = nil
|
||
tok.IOSDefaultTeamID = nil
|
||
tok.IPadOSDefaultTeamID = nil
|
||
tok.BYODDefaultTeamID = nil
|
||
if err := svc.ds.SaveABMToken(ctx, tok); err != nil {
|
||
return nil, ctxerr.Wrap(ctx, err, "saving ABM token assignments")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if (appConfig.MDM.AppleBusinessManager.Set && appConfig.MDM.AppleBusinessManager.Valid) || appConfig.MDM.DeprecatedAppleBMDefaultTeam != "" {
|
||
for _, tok := range abmAssignments {
|
||
if err := svc.ds.SaveABMToken(ctx, tok); err != nil {
|
||
return nil, ctxerr.Wrap(ctx, err, "saving ABM token assignments")
|
||
}
|
||
}
|
||
}
|
||
|
||
if vppAssignmentsDefined {
|
||
// 1. Reset teams for VPP tokens that exist in Fleet but aren't present in the config being passed
|
||
clear(tokensInCfg)
|
||
for _, t := range newAppConfig.MDM.VolumePurchasingProgram.Value {
|
||
tokensInCfg[t.Location] = struct{}{}
|
||
}
|
||
vppToks, err := svc.ds.ListVPPTokens(ctx)
|
||
if err != nil {
|
||
return nil, ctxerr.Wrap(ctx, err, "listing VPP tokens")
|
||
}
|
||
for _, tok := range vppToks {
|
||
if _, ok := tokensInCfg[tok.Location]; !ok {
|
||
tok.Teams = nil
|
||
if _, err := svc.ds.UpdateVPPTokenTeams(ctx, tok.ID, nil); err != nil {
|
||
return nil, ctxerr.Wrap(ctx, err, "saving VPP token teams")
|
||
}
|
||
}
|
||
}
|
||
// 2. Set VPP assignments that are defined in the config.
|
||
for tokenID, tokenTeams := range vppAssignments {
|
||
if _, err := svc.ds.UpdateVPPTokenTeams(ctx, tokenID, tokenTeams); err != nil {
|
||
var errTokConstraint fleet.ErrVPPTokenTeamConstraint
|
||
if errors.As(err, &errTokConstraint) {
|
||
return nil, ctxerr.Wrap(ctx, fleet.NewUserMessageError(errTokConstraint, http.StatusConflict))
|
||
}
|
||
return nil, ctxerr.Wrap(ctx, err, "saving ABM token assignments")
|
||
}
|
||
}
|
||
}
|
||
|
||
// retrieve new app config with obfuscated secrets
|
||
obfuscatedAppConfig, err := svc.ds.AppConfig(ctxdb.RequirePrimary(ctx, true))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
obfuscatedAppConfig.Obfuscate()
|
||
|
||
newAgentOptions := ""
|
||
if obfuscatedAppConfig.AgentOptions != nil {
|
||
newAgentOptions = string(*obfuscatedAppConfig.AgentOptions)
|
||
}
|
||
|
||
if err := svc.processSavedAppConfigChanges(ctx, oldAppConfig, appConfig, lic, oldAgentOptions, newAgentOptions,
|
||
conditionalAccessNoTeamUpdated); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
return obfuscatedAppConfig, nil
|
||
}
|
||
|
||
// processSavedAppConfigChanges runs the side effects of a completed app config change: it creates the activities for the settings
|
||
// that were modified and reconciles the downstream state that depends on them (OS updates, disk encryption, DEP profiles, host
|
||
// name templates, Windows MDM profile cleanup). It runs after SaveAppConfig has committed, so returning an error here leaves the
|
||
// new configuration persisted.
|
||
func (svc *Service) processSavedAppConfigChanges(
|
||
ctx context.Context,
|
||
oldAppConfig, appConfig *fleet.AppConfig,
|
||
lic *fleet.LicenseInfo,
|
||
oldAgentOptions, newAgentOptions string,
|
||
conditionalAccessNoTeamUpdated bool,
|
||
) error {
|
||
// if the agent options changed, create the corresponding activity
|
||
if oldAgentOptions != newAgentOptions {
|
||
if err := svc.NewActivity(
|
||
ctx,
|
||
authz.UserFromContext(ctx),
|
||
fleet.ActivityTypeEditedAgentOptions{
|
||
Global: true,
|
||
},
|
||
); err != nil {
|
||
return ctxerr.Wrap(ctx, err, "create activity for app config agent options modification")
|
||
}
|
||
}
|
||
|
||
//
|
||
// Process OS updates config changes for Apple devices.
|
||
//
|
||
if err := svc.processAppleOSUpdateSettings(ctx, lic, fleet.MacOS,
|
||
oldAppConfig.MDM.MacOSUpdates,
|
||
appConfig.MDM.MacOSUpdates,
|
||
); err != nil {
|
||
return ctxerr.Wrap(ctx, err, "process macOS OS updates config change")
|
||
}
|
||
if err := svc.processAppleOSUpdateSettings(ctx, lic, fleet.IOS,
|
||
oldAppConfig.MDM.IOSUpdates,
|
||
appConfig.MDM.IOSUpdates,
|
||
); err != nil {
|
||
return ctxerr.Wrap(ctx, err, "process iOS OS updates config change")
|
||
}
|
||
if err := svc.processAppleOSUpdateSettings(ctx, lic, fleet.IPadOS,
|
||
oldAppConfig.MDM.IPadOSUpdates,
|
||
appConfig.MDM.IPadOSUpdates,
|
||
); err != nil {
|
||
return ctxerr.Wrap(ctx, err, "process iPadOS OS updates config change")
|
||
}
|
||
|
||
if appConfig.YaraRules != nil {
|
||
if err := svc.ds.ApplyYaraRules(ctx, appConfig.YaraRules); err != nil {
|
||
return ctxerr.Wrap(ctx, err, "save yara rules for app config")
|
||
}
|
||
}
|
||
|
||
// if the Windows updates requirements changed, create the corresponding
|
||
// activity.
|
||
if !oldAppConfig.MDM.WindowsUpdates.Equal(appConfig.MDM.WindowsUpdates) {
|
||
var deadline, grace *int
|
||
if appConfig.MDM.WindowsUpdates.DeadlineDays.Valid {
|
||
deadline = &appConfig.MDM.WindowsUpdates.DeadlineDays.Value
|
||
}
|
||
if appConfig.MDM.WindowsUpdates.GracePeriodDays.Valid {
|
||
grace = &appConfig.MDM.WindowsUpdates.GracePeriodDays.Value
|
||
}
|
||
|
||
if deadline != nil {
|
||
if err := svc.EnterpriseOverrides.MDMWindowsEnableOSUpdates(ctx, nil, appConfig.MDM.WindowsUpdates); err != nil {
|
||
return ctxerr.Wrap(ctx, err, "enable no-team windows OS updates")
|
||
}
|
||
} else if err := svc.EnterpriseOverrides.MDMWindowsDisableOSUpdates(ctx, nil); err != nil {
|
||
return ctxerr.Wrap(ctx, err, "disable no-team windows OS updates")
|
||
}
|
||
|
||
if err := svc.NewActivity(
|
||
ctx,
|
||
authz.UserFromContext(ctx),
|
||
fleet.ActivityTypeEditedWindowsUpdates{
|
||
DeadlineDays: deadline,
|
||
GracePeriodDays: grace,
|
||
},
|
||
); err != nil {
|
||
return ctxerr.Wrap(ctx, err, "create activity for app config windows updates modification")
|
||
}
|
||
}
|
||
|
||
if appConfig.MDM.EnableDiskEncryption.Valid && oldAppConfig.MDM.EnableDiskEncryption.Value != appConfig.MDM.EnableDiskEncryption.Value {
|
||
if oldAppConfig.MDM.EnabledAndConfigured {
|
||
var act fleet.ActivityDetails
|
||
if appConfig.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")
|
||
}
|
||
}
|
||
}
|
||
|
||
// Only reconcile enforcement rows on Premium: EnterpriseOverrides is wired up
|
||
// only for Premium builds, so calling it here would panic on Free. The value
|
||
// can change on Free without a Premium re-save in two ways — the downgrade
|
||
// reset above, and clearing a previously-set template ("" skips the license
|
||
// check) — both of which just clear the stored value; the leftover rows are
|
||
// inert because the enforcement cron skips an empty template.
|
||
if lic.IsPremium() && oldAppConfig.MDM.HostNameTemplate.Value != appConfig.MDM.HostNameTemplate.Value {
|
||
if err := svc.EnterpriseOverrides.ApplyHostNameTemplateChange(ctx, nil, appConfig.MDM.HostNameTemplate.Value); err != nil {
|
||
return ctxerr.Wrap(ctx, err, "reconcile no-team host name template")
|
||
}
|
||
}
|
||
|
||
if appConfig.MDM.EnableRecoveryLockPassword.Valid &&
|
||
oldAppConfig.MDM.EnableRecoveryLockPassword.Value != appConfig.MDM.EnableRecoveryLockPassword.Value {
|
||
if oldAppConfig.MDM.EnabledAndConfigured {
|
||
var act fleet.ActivityDetails
|
||
if appConfig.MDM.EnableRecoveryLockPassword.Value {
|
||
act = fleet.ActivityTypeEnabledRecoveryLockPasswords{}
|
||
} else {
|
||
act = fleet.ActivityTypeDisabledRecoveryLockPasswords{}
|
||
}
|
||
if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), act); err != nil {
|
||
return ctxerr.Wrap(ctx, err, "create activity for app config recovery lock password")
|
||
}
|
||
}
|
||
}
|
||
|
||
mdmEnableEndUserAuthChanged := oldAppConfig.MDM.MacOSSetup.EnableEndUserAuthentication != appConfig.MDM.MacOSSetup.EnableEndUserAuthentication
|
||
if mdmEnableEndUserAuthChanged {
|
||
var act fleet.ActivityDetails
|
||
if appConfig.MDM.MacOSSetup.EnableEndUserAuthentication {
|
||
act = fleet.ActivityTypeEnabledMacosSetupEndUserAuth{}
|
||
} else {
|
||
act = fleet.ActivityTypeDisabledMacosSetupEndUserAuth{}
|
||
}
|
||
if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), act); err != nil {
|
||
return ctxerr.Wrap(ctx, err, "create activity for macos enable end user auth change")
|
||
}
|
||
}
|
||
|
||
if oldAppConfig.MDM.MacOSSetup.EnableManagedLocalAccount.Value != appConfig.MDM.MacOSSetup.EnableManagedLocalAccount.Value {
|
||
var act fleet.ActivityDetails
|
||
if appConfig.MDM.MacOSSetup.EnableManagedLocalAccount.Value {
|
||
act = fleet.ActivityTypeEnabledManagedLocalAccount{Platform: "darwin"}
|
||
} else {
|
||
act = fleet.ActivityTypeDisabledManagedLocalAccount{Platform: "darwin"}
|
||
}
|
||
if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), act); err != nil {
|
||
return ctxerr.Wrap(ctx, err, "create activity for macos enable managed local account change")
|
||
}
|
||
}
|
||
|
||
if oldAppConfig.MDM.WindowsSettings.ManagedLocalAccountSettings.Enabled.Value != appConfig.MDM.WindowsSettings.ManagedLocalAccountSettings.Enabled.Value {
|
||
var act fleet.ActivityDetails
|
||
if appConfig.MDM.WindowsSettings.ManagedLocalAccountSettings.Enabled.Value {
|
||
act = fleet.ActivityTypeEnabledManagedLocalAccount{Platform: "windows"}
|
||
} else {
|
||
act = fleet.ActivityTypeDisabledManagedLocalAccount{Platform: "windows"}
|
||
}
|
||
if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), act); err != nil {
|
||
return ctxerr.Wrap(ctx, err, "create activity for windows enable managed local account change")
|
||
}
|
||
}
|
||
|
||
mdmSSOSettingsChanged := oldAppConfig.MDM.EndUserAuthentication.SSOProviderSettings !=
|
||
appConfig.MDM.EndUserAuthentication.SSOProviderSettings
|
||
serverURLChanged := oldAppConfig.ServerSettings.ServerURL != appConfig.ServerSettings.ServerURL
|
||
appleMDMUrlChanged := oldAppConfig.MDMUrl() != appConfig.MDMUrl()
|
||
|
||
if appleMDMUrlChanged && appConfig.MDM.AppleServerURL != "" {
|
||
parsedURL, err := url.Parse(appConfig.MDM.AppleServerURL)
|
||
if err != nil {
|
||
return fleet.NewInvalidArgumentError("mdmAppleServerURL", "must be a valid URL")
|
||
}
|
||
scheme := strings.ToLower(parsedURL.Scheme)
|
||
if scheme == "" {
|
||
return fleet.NewInvalidArgumentError("mdmAppleServerURL", "must include a URL scheme (e.g. https://)")
|
||
}
|
||
|
||
if scheme != "http" && scheme != "https" {
|
||
return fleet.NewInvalidArgumentError("mdmAppleServerURL", "URL scheme must be http or https")
|
||
}
|
||
|
||
if parsedURL.Hostname() == "" {
|
||
return fleet.NewInvalidArgumentError("mdmAppleServerURL", "must include a host")
|
||
}
|
||
}
|
||
|
||
if (mdmEnableEndUserAuthChanged || mdmSSOSettingsChanged || serverURLChanged || appleMDMUrlChanged) && lic.IsPremium() {
|
||
if err := svc.EnterpriseOverrides.MDMAppleSyncDEPProfiles(ctx); err != nil {
|
||
return ctxerr.Wrap(ctx, err, "sync DEP profiles")
|
||
}
|
||
}
|
||
|
||
// if Windows MDM was enabled or disabled, create the corresponding activity
|
||
if oldAppConfig.MDM.WindowsEnabledAndConfigured != appConfig.MDM.WindowsEnabledAndConfigured {
|
||
var act fleet.ActivityDetails
|
||
if appConfig.MDM.WindowsEnabledAndConfigured {
|
||
act = fleet.ActivityTypeEnabledWindowsMDM{}
|
||
} else {
|
||
act = fleet.ActivityTypeDisabledWindowsMDM{}
|
||
|
||
// Clean up all pending Windows MDM profile rows since hosts can no longer receive MDM commands.
|
||
if err := svc.ds.CleanupAllHostMDMProfilesForPlatform(ctx, "windows"); err != nil {
|
||
return ctxerr.Wrap(ctx, err, "cleaning up Windows host MDM profiles")
|
||
}
|
||
}
|
||
if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), act); err != nil {
|
||
return ctxerr.Wrapf(ctx, err, "create activity %s", act.ActivityName())
|
||
}
|
||
}
|
||
|
||
if appConfig.MDM.WindowsEnabledAndConfigured && oldAppConfig.MDM.WindowsMigrationEnabled != appConfig.MDM.WindowsMigrationEnabled {
|
||
var act fleet.ActivityDetails
|
||
if appConfig.MDM.WindowsMigrationEnabled {
|
||
act = fleet.ActivityTypeEnabledWindowsMDMMigration{}
|
||
} else {
|
||
act = fleet.ActivityTypeDisabledWindowsMDMMigration{}
|
||
}
|
||
if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), act); err != nil {
|
||
return ctxerr.Wrapf(ctx, err, "create activity %s", act.ActivityName())
|
||
}
|
||
}
|
||
|
||
// Create activity if conditional access was enabled or disabled for "No team".
|
||
if conditionalAccessNoTeamUpdated {
|
||
if appConfig.Integrations.ConditionalAccessEnabled.Value {
|
||
if err := svc.NewActivity(
|
||
ctx,
|
||
authz.UserFromContext(ctx),
|
||
fleet.ActivityTypeEnabledConditionalAccessAutomations{
|
||
TeamID: nil,
|
||
TeamName: "",
|
||
},
|
||
); err != nil {
|
||
return ctxerr.Wrap(ctx, err, "create activity for enabling conditional access")
|
||
}
|
||
} else {
|
||
if err := svc.NewActivity(
|
||
ctx,
|
||
authz.UserFromContext(ctx),
|
||
fleet.ActivityTypeDisabledConditionalAccessAutomations{
|
||
TeamID: nil,
|
||
TeamName: "",
|
||
},
|
||
); err != nil {
|
||
return ctxerr.Wrap(ctx, err, "create activity for disabling conditional access")
|
||
}
|
||
}
|
||
}
|
||
|
||
// Create activity if Okta conditional access configuration was added, edited, or deleted
|
||
oldOktaConfigured := oldAppConfig.ConditionalAccess != nil && oldAppConfig.ConditionalAccess.OktaConfigured()
|
||
newOktaConfigured := appConfig.ConditionalAccess != nil && appConfig.ConditionalAccess.OktaConfigured()
|
||
|
||
// Check if Okta configuration values changed (for edited case)
|
||
oktaConfigChanged := false
|
||
oktaBypassChanged := false
|
||
if oldOktaConfigured && newOktaConfigured {
|
||
// Both old and new are configured - check if any values changed
|
||
oktaConfigChanged = oldAppConfig.ConditionalAccess.OktaIDPID.Value != appConfig.ConditionalAccess.OktaIDPID.Value ||
|
||
oldAppConfig.ConditionalAccess.OktaAssertionConsumerServiceURL.Value != appConfig.ConditionalAccess.OktaAssertionConsumerServiceURL.Value ||
|
||
oldAppConfig.ConditionalAccess.OktaAudienceURI.Value != appConfig.ConditionalAccess.OktaAudienceURI.Value ||
|
||
oldAppConfig.ConditionalAccess.OktaCertificate.Value != appConfig.ConditionalAccess.OktaCertificate.Value
|
||
|
||
// Only create an activity if bypass is actually changed
|
||
oktaBypassChanged = oldAppConfig.ConditionalAccess.BypassDisabled.Value != appConfig.ConditionalAccess.BypassDisabled.Value
|
||
}
|
||
|
||
if (!oldOktaConfigured && newOktaConfigured) || oktaConfigChanged {
|
||
// Okta configuration was added or edited
|
||
if err := svc.NewActivity(
|
||
ctx,
|
||
authz.UserFromContext(ctx),
|
||
fleet.ActivityTypeAddedConditionalAccessOkta{},
|
||
); err != nil {
|
||
return ctxerr.Wrap(ctx, err, "create activity for adding/editing Okta conditional access")
|
||
}
|
||
} else if oldOktaConfigured && !newOktaConfigured {
|
||
// Okta configuration was deleted
|
||
if err := svc.NewActivity(
|
||
ctx,
|
||
authz.UserFromContext(ctx),
|
||
fleet.ActivityTypeDeletedConditionalAccessOkta{},
|
||
); err != nil {
|
||
return ctxerr.Wrap(ctx, err, "create activity for deleting Okta conditional access")
|
||
}
|
||
}
|
||
|
||
if oktaBypassChanged {
|
||
if err := svc.ds.ConditionalAccessClearBypasses(ctx); err != nil {
|
||
return ctxerr.Wrap(ctx, err, "clearing existing conditional access bypasses")
|
||
}
|
||
|
||
if err := svc.NewActivity(
|
||
ctx,
|
||
authz.UserFromContext(ctx),
|
||
fleet.ActivityTypeUpdateConditionalAccessBypass{
|
||
BypassDisabled: appConfig.ConditionalAccess.BypassDisabled.Value,
|
||
},
|
||
); err != nil {
|
||
return ctxerr.Wrap(ctx, err, "create activity for updating conditional access bypass")
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
func validateFleetDesktopSettings(newAppConfig fleet.AppConfig, lic *fleet.LicenseInfo) *fleet.InvalidArgumentError {
|
||
// default transparency URL is https://fleetdm.com/transparency so you are allowed to apply as long as it's not changing
|
||
transparencyURLModified := newAppConfig.FleetDesktop.TransparencyURL != "" && newAppConfig.FleetDesktop.TransparencyURL != fleet.DefaultTransparencyURL
|
||
alternativeBrowserHostModified := newAppConfig.FleetDesktop.AlternativeBrowserHost != ""
|
||
|
||
fleetDesktopSettingsInvalidErr := &fleet.InvalidArgumentError{}
|
||
if !lic.IsPremium() {
|
||
if transparencyURLModified {
|
||
fleetDesktopSettingsInvalidErr.Append("transparency_url", ErrMissingLicense.Error())
|
||
}
|
||
if alternativeBrowserHostModified {
|
||
fleetDesktopSettingsInvalidErr.Append("alternative_browser_host", ErrMissingLicense.Error())
|
||
}
|
||
// No point in performing further validations if the license is not premium
|
||
return fleetDesktopSettingsInvalidErr
|
||
}
|
||
|
||
if transparencyURLModified {
|
||
if _, err := url.Parse(newAppConfig.FleetDesktop.TransparencyURL); err != nil {
|
||
fleetDesktopSettingsInvalidErr.Append("transparency_url", err.Error())
|
||
}
|
||
}
|
||
if alternativeBrowserHostModified {
|
||
if !validateAddress(newAppConfig.FleetDesktop.AlternativeBrowserHost) {
|
||
fleetDesktopSettingsInvalidErr.Append("alternative_browser_host", "must be a valid hostname or IP address")
|
||
}
|
||
}
|
||
return fleetDesktopSettingsInvalidErr
|
||
}
|
||
|
||
// processAppleOSUpdateSettings updates the OS updates configuration if the minimum version+deadline are updated.
|
||
func (svc *Service) processAppleOSUpdateSettings(
|
||
ctx context.Context,
|
||
lic *fleet.LicenseInfo,
|
||
appleDevice fleet.AppleDevice,
|
||
oldOSUpdateSettings fleet.AppleOSUpdateSettings,
|
||
newOSUpdateSettings fleet.AppleOSUpdateSettings,
|
||
) error {
|
||
if oldOSUpdateSettings.MinimumVersion.Value != newOSUpdateSettings.MinimumVersion.Value ||
|
||
oldOSUpdateSettings.Deadline.Value != newOSUpdateSettings.Deadline.Value ||
|
||
// Valid as well as Value: going from unset to 0, or 14 to unset, is a change.
|
||
oldOSUpdateSettings.DeadlineDays.Value != newOSUpdateSettings.DeadlineDays.Value ||
|
||
oldOSUpdateSettings.DeadlineDays.Valid != newOSUpdateSettings.DeadlineDays.Valid {
|
||
if lic.IsPremium() {
|
||
if err := svc.EnterpriseOverrides.MDMAppleEditedAppleOSUpdates(ctx, nil, appleDevice, newOSUpdateSettings); err != nil {
|
||
return ctxerr.Wrap(ctx, err, "update DDM profile after Apple OS updates change")
|
||
}
|
||
}
|
||
|
||
var activity fleet.ActivityDetails
|
||
switch appleDevice {
|
||
case fleet.MacOS:
|
||
activity = fleet.ActivityTypeEditedMacOSMinVersion{
|
||
MinimumVersion: newOSUpdateSettings.MinimumVersion.Value,
|
||
Deadline: newOSUpdateSettings.Deadline.Value,
|
||
}
|
||
case fleet.IOS:
|
||
activity = fleet.ActivityTypeEditedIOSMinVersion{
|
||
MinimumVersion: newOSUpdateSettings.MinimumVersion.Value,
|
||
Deadline: newOSUpdateSettings.Deadline.Value,
|
||
}
|
||
case fleet.IPadOS:
|
||
activity = fleet.ActivityTypeEditedIPadOSMinVersion{
|
||
MinimumVersion: newOSUpdateSettings.MinimumVersion.Value,
|
||
Deadline: newOSUpdateSettings.Deadline.Value,
|
||
}
|
||
}
|
||
if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), activity); err != nil {
|
||
return ctxerr.Wrap(ctx, err, "create activity for app config apple min version modification")
|
||
}
|
||
}
|
||
|
||
if oldOSUpdateSettings.UpdateNewHosts.Value != newOSUpdateSettings.UpdateNewHosts.Value && appleDevice == fleet.MacOS {
|
||
var activity fleet.ActivityDetails
|
||
activity = fleet.ActivityTypeEnabledMacosUpdateNewHosts{}
|
||
if !newOSUpdateSettings.UpdateNewHosts.Value {
|
||
activity = fleet.ActivityTypeDisabledMacosUpdateNewHosts{}
|
||
}
|
||
if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), activity); err != nil {
|
||
return ctxerr.Wrap(ctx, err, "create activity for app config apple min version modification")
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (svc *Service) HasCustomSetupAssistantConfigurationWebURL(ctx context.Context, teamID *uint) (bool, error) {
|
||
az, ok := authz_ctx.FromContext(ctx)
|
||
if !ok || !az.Checked() {
|
||
return false, fleet.NewAuthRequiredError("method requires previous authorization")
|
||
}
|
||
|
||
asst, err := svc.ds.GetMDMAppleSetupAssistant(ctx, teamID)
|
||
if err != nil {
|
||
if fleet.IsNotFound(err) {
|
||
return false, nil
|
||
}
|
||
return false, err
|
||
}
|
||
|
||
var m map[string]any
|
||
if err := json.Unmarshal(asst.Profile, &m); err != nil {
|
||
return false, err
|
||
}
|
||
|
||
_, ok = m["configuration_web_url"]
|
||
return ok, nil
|
||
}
|
||
|
||
// windowsEntraGUIDRegex matches an Azure/Entra GUID in 8-4-4-4-12 form, case-insensitively. Entra emits IDs in
|
||
// lower-case, but admins may paste them in upper-case, so we accept either case here and normalize at comparison time
|
||
// instead. We can't use the standard UUID parser here as it accepts non-standard forms; Entra tenant IDs and application
|
||
// client IDs are both validated against this so the two checks cannot drift.
|
||
var windowsEntraGUIDRegex = regexp.MustCompile("^[A-Fa-f0-9]{8}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{12}$")
|
||
|
||
// googleWorkspaceActivity returns the activity to record when the Google
|
||
// Workspace IdP integration is added, edited, or removed, or nil when it is
|
||
// unchanged. Only the (non-secret) domain is compared/recorded.
|
||
func googleWorkspaceActivity(old, current []*fleet.GoogleWorkspaceIntegration) fleet.ActivityDetails {
|
||
oldConfigured := len(old) > 0
|
||
newConfigured := len(current) > 0
|
||
switch {
|
||
case !oldConfigured && newConfigured:
|
||
return fleet.ActivityTypeAddedGoogleWorkspaceIntegration{Domain: current[0].Domain}
|
||
case oldConfigured && !newConfigured:
|
||
return fleet.ActivityTypeDeletedGoogleWorkspaceIntegration{Domain: old[0].Domain}
|
||
case oldConfigured && newConfigured:
|
||
if old[0].Domain != current[0].Domain || old[0].ImpersonatedUserEmail != current[0].ImpersonatedUserEmail {
|
||
return fleet.ActivityTypeEditedGoogleWorkspaceIntegration{Domain: current[0].Domain}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// diffStringSlices returns the elements added (present in current but not old) and removed (present in old but not
|
||
// current), each deduplicated and in first-seen order. Used to emit exactly one activity per changed value even when
|
||
// the incoming payload repeats an entry.
|
||
func diffStringSlices(old, current []string) (added, removed []string) {
|
||
oldSet := make(map[string]struct{}, len(old))
|
||
for _, v := range old {
|
||
oldSet[v] = struct{}{}
|
||
}
|
||
currentSet := make(map[string]struct{}, len(current))
|
||
for _, v := range current {
|
||
if _, seen := currentSet[v]; seen {
|
||
continue
|
||
}
|
||
currentSet[v] = struct{}{}
|
||
if _, found := oldSet[v]; !found {
|
||
added = append(added, v)
|
||
}
|
||
}
|
||
removedSeen := make(map[string]struct{})
|
||
for _, v := range old {
|
||
if _, found := currentSet[v]; found {
|
||
continue
|
||
}
|
||
if _, seen := removedSeen[v]; seen {
|
||
continue
|
||
}
|
||
removedSeen[v] = struct{}{}
|
||
removed = append(removed, v)
|
||
}
|
||
return added, removed
|
||
}
|
||
|
||
// clearStaleAppleOSUpdateDeadline drops whichever deadline field belongs to the
|
||
// mode a PATCH is leaving. The two modes are mutually exclusive — "latest"
|
||
// derives its deadline from deadline_days, a specific version uses deadline —
|
||
// and Validate rejects the wrong one being present. Because the payload is
|
||
// merged over the stored config, a mode switch that doesn't mention the old
|
||
// field keeps it and fails validation, forcing callers to send an explicit null
|
||
// or empty string just to change modes.
|
||
//
|
||
// merged is the stored config with the payload already applied; incoming is the
|
||
// payload on its own, so its Set flags say what the caller actually sent. A
|
||
// value the caller supplied is left alone, so a genuine mismatch still fails
|
||
// validation with the error that explains it.
|
||
func clearStaleAppleOSUpdateDeadline(merged *fleet.AppleOSUpdateSettings, incoming fleet.AppleOSUpdateSettings) {
|
||
if merged.EnforcesLatestVersion() {
|
||
if !incoming.Deadline.Set {
|
||
// SetString("") rather than the zero value so this still marshals as
|
||
// "" — deadline has always been a string on the wire, and null would
|
||
// be a breaking change for API consumers.
|
||
merged.Deadline = optjson.SetString("")
|
||
}
|
||
return
|
||
}
|
||
if !incoming.DeadlineDays.Set {
|
||
merged.DeadlineDays = optjson.Int{}
|
||
}
|
||
}
|
||
|
||
func (svc *Service) validateMDM(
|
||
ctx context.Context,
|
||
lic *fleet.LicenseInfo,
|
||
oldMdm *fleet.MDM,
|
||
mdm *fleet.MDM,
|
||
invalid *fleet.InvalidArgumentError,
|
||
overwrite bool,
|
||
) error {
|
||
if mdm.EnableDiskEncryption.Value && !lic.IsPremium() {
|
||
invalid.Append("apple_settings.enable_disk_encryption", ErrMissingLicense.Error())
|
||
}
|
||
if mdm.MacOSSetup.MacOSSetupAssistant.Value != "" && oldMdm.MacOSSetup.MacOSSetupAssistant.Value != mdm.MacOSSetup.MacOSSetupAssistant.Value && !lic.IsPremium() {
|
||
invalid.Append("setup_experience.apple_setup_assistant", ErrMissingLicense.Error())
|
||
}
|
||
if mdm.MacOSSetup.EnableReleaseDeviceManually.Value && oldMdm.MacOSSetup.EnableReleaseDeviceManually.Value != mdm.MacOSSetup.EnableReleaseDeviceManually.Value && !lic.IsPremium() {
|
||
invalid.Append("setup_experience.apple_enable_release_device_manually", ErrMissingLicense.Error())
|
||
}
|
||
if mdm.MacOSSetup.BootstrapPackage.Value != "" && oldMdm.MacOSSetup.BootstrapPackage.Value != mdm.MacOSSetup.BootstrapPackage.Value && !lic.IsPremium() {
|
||
invalid.Append("setup_experience.macos_bootstrap_package", ErrMissingLicense.Error())
|
||
}
|
||
if mdm.MacOSSetup.EnableEndUserAuthentication && oldMdm.MacOSSetup.EnableEndUserAuthentication != mdm.MacOSSetup.EnableEndUserAuthentication && !lic.IsPremium() {
|
||
invalid.Append("setup_experience.enable_end_user_authentication", ErrMissingLicense.Error())
|
||
}
|
||
if mdm.MacOSSetup.ManualAgentInstall.Valid && oldMdm.MacOSSetup.ManualAgentInstall.Value != mdm.MacOSSetup.ManualAgentInstall.Value && !lic.IsPremium() {
|
||
invalid.Append("setup_experience.macos_manual_agent_install", ErrMissingLicense.Error())
|
||
}
|
||
if mdm.WindowsSettings.ManagedLocalAccountSettings.Enabled.Value &&
|
||
mdm.WindowsSettings.ManagedLocalAccountSettings.Enabled.Value != oldMdm.WindowsSettings.ManagedLocalAccountSettings.Enabled.Value && !lic.IsPremium() {
|
||
invalid.Append("windows_settings.managed_local_account_settings.enabled", ErrMissingLicense.Error())
|
||
}
|
||
if mdm.WindowsMigrationEnabled && !lic.IsPremium() {
|
||
invalid.Append("windows_migration_enabled", ErrMissingLicense.Error())
|
||
}
|
||
if mdm.EnableTurnOnWindowsMDMManually && !lic.IsPremium() {
|
||
invalid.Append("enable_turn_on_windows_mdm_manually", ErrMissingLicense.Error())
|
||
}
|
||
if len(mdm.WindowsEntraTenantIDs.Value) > 0 && !lic.IsPremium() {
|
||
invalid.Append("windows_entra_tenant_ids", ErrMissingLicense.Error())
|
||
}
|
||
if len(mdm.WindowsEntraClientIDs.Value) > 0 && !lic.IsPremium() {
|
||
invalid.Append("windows_entra_client_ids", ErrMissingLicense.Error())
|
||
}
|
||
if mdm.AppleRequireHardwareAttestation && !lic.IsPremium() {
|
||
invalid.Append("apple_require_hardware_attestation", ErrMissingLicense.Error())
|
||
}
|
||
|
||
if mdm.HostNameTemplate.Value != "" && oldMdm.HostNameTemplate.Value != mdm.HostNameTemplate.Value {
|
||
if !lic.IsPremium() {
|
||
invalid.Append("mdm.name_template", ErrMissingLicense.Error())
|
||
} else if validated, err := fleet.ValidateHostNameTemplateWithSecrets(ctx, svc.ds, mdm.HostNameTemplate.Value); err != nil {
|
||
// A validation or missing-secret error is invalid user input (422); any
|
||
// other error (e.g. a datastore failure while checking secrets) must
|
||
// propagate as a server error rather than be misreported as invalid input.
|
||
var argErr *fleet.InvalidArgumentError
|
||
if !errors.As(err, &argErr) {
|
||
return ctxerr.Wrap(ctx, err, "validating host name template")
|
||
}
|
||
invalid.Append("mdm.name_template", err.Error())
|
||
} else {
|
||
mdm.HostNameTemplate = optjson.SetString(validated)
|
||
}
|
||
}
|
||
|
||
// we want to use `oldMdm` here as this boolean is set by the fleet
|
||
// server at startup and can't be modified by the user
|
||
if !oldMdm.EnabledAndConfigured {
|
||
if len(mdm.MacOSSettings.CustomSettings) > 0 && !fleet.MDMProfileSpecsMatch(mdm.MacOSSettings.CustomSettings, oldMdm.MacOSSettings.CustomSettings) {
|
||
invalid.Append("apple_settings.configuration_profiles",
|
||
`Couldn't update apple_settings because MDM features aren't turned on in Fleet. Use fleetctl generate mdm-apple and then fleet serve with mdm configuration to turn on MDM features.`)
|
||
}
|
||
|
||
if mdm.MacOSSetup.MacOSSetupAssistant.Value != "" && oldMdm.MacOSSetup.MacOSSetupAssistant.Value != mdm.MacOSSetup.MacOSSetupAssistant.Value {
|
||
invalid.Append("setup_experience.apple_setup_assistant",
|
||
`Couldn't update setup_experience because MDM features aren't turned on in Fleet. Use fleetctl generate mdm-apple and then fleet serve with mdm configuration to turn on MDM features.`)
|
||
}
|
||
|
||
if mdm.MacOSSetup.EnableReleaseDeviceManually.Value && oldMdm.MacOSSetup.EnableReleaseDeviceManually.Value != mdm.MacOSSetup.EnableReleaseDeviceManually.Value {
|
||
invalid.Append("setup_experience.apple_enable_release_device_manually",
|
||
`Couldn't update setup_experience because MDM features aren't turned on in Fleet. Use fleetctl generate mdm-apple and then fleet serve with mdm configuration to turn on MDM features.`)
|
||
}
|
||
|
||
if mdm.MacOSSetup.BootstrapPackage.Value != "" && oldMdm.MacOSSetup.BootstrapPackage.Value != mdm.MacOSSetup.BootstrapPackage.Value {
|
||
invalid.Append("setup_experience.macos_bootstrap_package",
|
||
`Couldn't update setup_experience because MDM features aren't turned on in Fleet. Use fleetctl generate mdm-apple and then fleet serve with mdm configuration to turn on MDM features.`)
|
||
}
|
||
|
||
if mdm.MacOSSetup.EnableManagedLocalAccount.Value && oldMdm.MacOSSetup.EnableManagedLocalAccount.Value != mdm.MacOSSetup.EnableManagedLocalAccount.Value {
|
||
invalid.Append("setup_experience.enable_managed_local_account",
|
||
`Couldn't update setup_experience because MDM features aren't turned on in Fleet. Use fleetctl generate mdm-apple and then fleet serve with mdm configuration to turn on MDM features.`)
|
||
}
|
||
}
|
||
fleet.ValidateMDMProfileSpecs(invalid, "macos", mdm.MacOSSettings.CustomSettings)
|
||
|
||
if !mdm.WindowsEnabledAndConfigured {
|
||
if mdm.WindowsSettings.CustomSettings.Set &&
|
||
len(mdm.WindowsSettings.CustomSettings.Value) > 0 &&
|
||
!fleet.MDMProfileSpecsMatch(mdm.WindowsSettings.CustomSettings.Value, oldMdm.WindowsSettings.CustomSettings.Value) {
|
||
invalid.Append("windows_settings.configuration_profiles",
|
||
"Couldn’t edit windows_settings.configuration_profiles. "+fleet.WindowsMDMNotTurnedOnMessage)
|
||
}
|
||
|
||
if mdm.WindowsSettings.ManagedLocalAccountSettings.Enabled.Value &&
|
||
!oldMdm.WindowsSettings.ManagedLocalAccountSettings.Enabled.Value {
|
||
invalid.Append("windows_settings.managed_local_account_settings.enabled",
|
||
"Couldn’t enable windows_settings.managed_local_account_settings. "+fleet.WindowsMDMNotTurnedOnMessage)
|
||
}
|
||
}
|
||
fleet.ValidateMDMProfileSpecs(invalid, "windows", mdm.WindowsSettings.CustomSettings.Value)
|
||
|
||
// Check oldMdm as we bypass the patching of this value, as it's enabled and disabled elsewhere.
|
||
if !oldMdm.AndroidEnabledAndConfigured {
|
||
if mdm.AndroidSettings.CustomSettings.Set &&
|
||
len(mdm.AndroidSettings.CustomSettings.Value) > 0 &&
|
||
!fleet.MDMProfileSpecsMatch(mdm.AndroidSettings.CustomSettings.Value, oldMdm.AndroidSettings.CustomSettings.Value) {
|
||
invalid.Append("android_settings.configuration_profiles",
|
||
`Couldn’t edit android_settings.configuration_profiles. Android MDM isn’t turned on. This can be enabled by setting "controls.android_enabled_and_configured: true" in the default configuration. Visit https://fleetdm.com/guides/android-mdm-setup and https://fleetdm.com/docs/configuration/yaml-files#controls to learn more about enabling MDM.`)
|
||
}
|
||
}
|
||
fleet.ValidateMDMProfileSpecs(invalid, "android", mdm.AndroidSettings.CustomSettings.Value)
|
||
|
||
// MacOSUpdates
|
||
updatingMacOSVersion := mdm.MacOSUpdates.MinimumVersion.Value != "" &&
|
||
mdm.MacOSUpdates.MinimumVersion != oldMdm.MacOSUpdates.MinimumVersion
|
||
updatingMacOSDeadline := mdm.MacOSUpdates.Deadline.Value != "" &&
|
||
mdm.MacOSUpdates.Deadline != oldMdm.MacOSUpdates.Deadline
|
||
// deadline_days is the "latest" mode counterpart of deadline, so it has to
|
||
// gate on the license too: without it a lapsed-premium instance that already
|
||
// enforces "latest" could still edit the deadline.
|
||
updatingMacOSDeadlineDays := mdm.MacOSUpdates.DeadlineDays.Valid &&
|
||
mdm.MacOSUpdates.DeadlineDays != oldMdm.MacOSUpdates.DeadlineDays
|
||
// IOSUpdates
|
||
updatingIOSVersion := mdm.IOSUpdates.MinimumVersion.Value != "" &&
|
||
mdm.IOSUpdates.MinimumVersion != oldMdm.IOSUpdates.MinimumVersion
|
||
updatingIOSDeadline := mdm.IOSUpdates.Deadline.Value != "" &&
|
||
mdm.IOSUpdates.Deadline != oldMdm.IOSUpdates.Deadline
|
||
updatingIOSDeadlineDays := mdm.IOSUpdates.DeadlineDays.Valid &&
|
||
mdm.IOSUpdates.DeadlineDays != oldMdm.IOSUpdates.DeadlineDays
|
||
// IPadOSUpdates
|
||
updatingIPadOSVersion := mdm.IPadOSUpdates.MinimumVersion.Value != "" &&
|
||
mdm.IPadOSUpdates.MinimumVersion != oldMdm.IPadOSUpdates.MinimumVersion
|
||
updatingIPadOSDeadline := mdm.IPadOSUpdates.Deadline.Value != "" &&
|
||
mdm.IPadOSUpdates.Deadline != oldMdm.IPadOSUpdates.Deadline
|
||
updatingIPadOSDeadlineDays := mdm.IPadOSUpdates.DeadlineDays.Valid &&
|
||
mdm.IPadOSUpdates.DeadlineDays != oldMdm.IPadOSUpdates.DeadlineDays
|
||
|
||
updatingMacOS := updatingMacOSVersion || updatingMacOSDeadline || updatingMacOSDeadlineDays
|
||
updatingIOS := updatingIOSVersion || updatingIOSDeadline || updatingIOSDeadlineDays
|
||
updatingIPadOS := updatingIPadOSVersion || updatingIPadOSDeadline || updatingIPadOSDeadlineDays
|
||
|
||
if updatingMacOS || updatingIOS || updatingIPadOS {
|
||
// TODO: Should we validate MDM configured on here too?
|
||
|
||
if !lic.IsPremium() {
|
||
// The gate is shared by all three platforms, so a fixed field name
|
||
// would report macOS for an iOS-only edit.
|
||
field := "macos_updates.minimum_version"
|
||
switch {
|
||
case updatingMacOS:
|
||
case updatingIOS:
|
||
field = "ios_updates.minimum_version"
|
||
default:
|
||
field = "ipados_updates.minimum_version"
|
||
}
|
||
invalid.Append(field, ErrMissingLicense.Error())
|
||
return nil
|
||
}
|
||
}
|
||
if err := mdm.MacOSUpdates.Validate(); err != nil {
|
||
invalid.Append("macos_updates", err.Error())
|
||
}
|
||
if err := mdm.IOSUpdates.Validate(); err != nil {
|
||
invalid.Append("ios_updates", err.Error())
|
||
}
|
||
if err := mdm.IPadOSUpdates.Validate(); err != nil {
|
||
invalid.Append("ipados_updates", err.Error())
|
||
}
|
||
|
||
// Only check whether specified versions are supported by Apple if they were updated in this request.
|
||
// Note that we're validating against the full, non-public asset set of OS versions here because
|
||
// in our DEP flow the minimum version just acts as the threshold for whether or not to update
|
||
// the host to the latest, public version. We don't need to install the specified version on the
|
||
// host during DEP so it doesn't need to be in the public asset set.
|
||
m, err := apple_mdm.ValidateMDMSettingsAppleSupportedOSVersion(*mdm, false)
|
||
if err != nil {
|
||
invalid.Append("mdm", fmt.Sprintf("validating Apple OS versions: %v", err))
|
||
return nil
|
||
}
|
||
if v, ok := m["macos"]; ok && updatingMacOSVersion {
|
||
invalid.Append("macos_updates.minimum_version", v)
|
||
}
|
||
if v, ok := m["ios"]; ok && updatingIOSVersion {
|
||
invalid.Append("ios_updates.minimum_version", v)
|
||
}
|
||
if v, ok := m["ipados"]; ok && updatingIPadOSVersion {
|
||
invalid.Append("ipados_updates.minimum_version", v)
|
||
}
|
||
|
||
if err := mdm.MacOSSetup.ValidateAgainst(oldMdm.MacOSSetup); err != nil {
|
||
var invalidArgErr *fleet.InvalidArgumentError
|
||
if errors.As(err, &invalidArgErr) {
|
||
firstInvalidErr := invalidArgErr.Errors[0] // We only expect one invalid argument error entry from the validate
|
||
invalid.AppendInvalidArgument(firstInvalidErr)
|
||
} else {
|
||
invalid.Append("macos_setup", err.Error())
|
||
}
|
||
}
|
||
|
||
// WindowsUpdates
|
||
updatingWindowsUpdates := !mdm.WindowsUpdates.Equal(oldMdm.WindowsUpdates)
|
||
if updatingWindowsUpdates {
|
||
// TODO: Should we validate MDM configured on here too?
|
||
|
||
if !lic.IsPremium() {
|
||
invalid.Append("windows_updates.deadline_days", ErrMissingLicense.Error())
|
||
return nil
|
||
}
|
||
}
|
||
if err := mdm.WindowsUpdates.Validate(); err != nil {
|
||
invalid.Append("windows_updates", err.Error())
|
||
}
|
||
|
||
// EndUserAuthentication
|
||
// only validate SSO settings if they changed
|
||
if mdm.EndUserAuthentication.SSOProviderSettings != oldMdm.EndUserAuthentication.SSOProviderSettings {
|
||
if !lic.IsPremium() {
|
||
invalid.Append("end_user_authentication", ErrMissingLicense.Error())
|
||
return nil
|
||
}
|
||
// In GitOps (overwrite=true), strict validation only fires when EUA is
|
||
// being enabled at the global/no-team level in this same request, because
|
||
// we can't tell if teams are changing their EUA settings in the same GitOps run.
|
||
// We rely on client-side validation in GitOps to catch cases of teams keeping EUA enabled
|
||
// while the global/no-team setting is disabled/cleared in the same run.
|
||
//
|
||
// TODO: look into blocking the case of a user-created API call that clears required EUA
|
||
// settings while a team still has EUA enabled.
|
||
euaStrict := overwrite && mdm.MacOSSetup.EnableEndUserAuthentication
|
||
validateSSOProviderSettings(&mdm.EndUserAuthentication.SSOProviderSettings, oldMdm.EndUserAuthentication.SSOProviderSettings, invalid, euaStrict)
|
||
}
|
||
|
||
// MacOSSetup validation
|
||
if mdm.EndUserAuthentication.IsEmpty() && !oldMdm.EndUserAuthentication.IsEmpty() {
|
||
// IdP is being cleared: block if global EUA will still be enabled after this update
|
||
// (mdm.MacOSSetup.EnableEndUserAuthentication reflects the incoming request's value),
|
||
// or if any team has EUA enabled. We only look at non-zero team IDs since global (id=0)
|
||
// is covered by the incoming request value.
|
||
teamIDs, err := svc.ds.TeamIDsWithSetupExperienceIdPEnabled(ctx)
|
||
if err != nil {
|
||
return ctxerr.Wrap(ctx, err, "checking teams with EUA enabled")
|
||
}
|
||
anyTeamEUAEnabled := false
|
||
for _, id := range teamIDs {
|
||
if id != 0 {
|
||
anyTeamEUAEnabled = true
|
||
break
|
||
}
|
||
}
|
||
if anyTeamEUAEnabled || mdm.MacOSSetup.EnableEndUserAuthentication {
|
||
invalid.Append("end_user_authentication",
|
||
`End user authentication is enabled. Please disable end user authentication in Controls > Setup experience and try again`)
|
||
}
|
||
} else if mdm.MacOSSetup.EnableEndUserAuthentication && mdm.EndUserAuthentication.IsEmpty() {
|
||
// TODO: update this error message to include steps to resolve the issue once docs for IdP
|
||
// config are available
|
||
invalid.Append("setup_experience.enable_end_user_authentication",
|
||
`Couldn't enable setup_experience.enable_end_user_authentication because no IdP is configured for MDM features.`)
|
||
}
|
||
|
||
if mdm.MacOSSetup.LockEndUserInfo.Value && !mdm.MacOSSetup.EnableEndUserAuthentication {
|
||
invalid.Append("setup_experience.lock_end_user_info",
|
||
`"enable_end_user_authentication" must be set to "true" in order to enable "lock_end_user_info".`)
|
||
}
|
||
|
||
if mdm.MacOSSetup.EnableEndUserAuthentication != oldMdm.MacOSSetup.EnableEndUserAuthentication {
|
||
hasCustomConfigurationWebURL, err := svc.HasCustomSetupAssistantConfigurationWebURL(ctx, nil)
|
||
if err != nil {
|
||
return ctxerr.Wrap(ctx, err, "checking setup assistant configuration web url")
|
||
}
|
||
if hasCustomConfigurationWebURL {
|
||
invalid.Append("end_user_authentication", fleet.EndUserAuthDEPWebURLConfiguredErrMsg)
|
||
}
|
||
}
|
||
|
||
updatingMacOSMigration := mdm.MacOSMigration.Enable != oldMdm.MacOSMigration.Enable ||
|
||
mdm.MacOSMigration.Mode != oldMdm.MacOSMigration.Mode ||
|
||
mdm.MacOSMigration.WebhookURL != oldMdm.MacOSMigration.WebhookURL
|
||
|
||
// MacOSMigration validation
|
||
if updatingMacOSMigration {
|
||
// TODO: Should we validate MDM configured on here too?
|
||
|
||
if mdm.MacOSMigration.Enable {
|
||
if !lic.IsPremium() {
|
||
invalid.Append("macos_migration.enable", ErrMissingLicense.Error())
|
||
return nil
|
||
}
|
||
if !mdm.MacOSMigration.Mode.IsValid() {
|
||
invalid.Append("macos_migration.mode", "mode must be one of 'voluntary' or 'forced'")
|
||
}
|
||
// TODO: improve url validation generally
|
||
if u, err := url.ParseRequestURI(mdm.MacOSMigration.WebhookURL); err != nil {
|
||
invalid.Append("macos_migration.webhook_url", err.Error())
|
||
} else if u.Scheme != "https" && u.Scheme != "http" {
|
||
invalid.Append("macos_migration.webhook_url", "webhook_url must be https or http")
|
||
}
|
||
}
|
||
}
|
||
|
||
// Windows validation
|
||
if !svc.config.MDM.IsMicrosoftWSTEPSet() {
|
||
if mdm.WindowsEnabledAndConfigured {
|
||
invalid.Append("mdm.windows_enabled_and_configured", "Couldn't turn on Windows MDM. Please configure Fleet with a certificate and key pair first.")
|
||
return nil
|
||
}
|
||
}
|
||
if !mdm.WindowsEnabledAndConfigured && mdm.WindowsMigrationEnabled {
|
||
invalid.Append("mdm.windows_migration_enabled", "Couldn't enable Windows MDM migration, Windows MDM is not enabled.")
|
||
}
|
||
|
||
if !mdm.WindowsEnabledAndConfigured && mdm.EnableTurnOnWindowsMDMManually {
|
||
invalid.Append("mdm.enable_turn_on_windows_mdm_manually", "Couldn't enable Turn on Windows MDM Manually, Windows MDM is not enabled.")
|
||
}
|
||
|
||
// Validate Windows Entra tenant IDs and application client IDs are in the correct GUID format.
|
||
for _, tenantID := range mdm.WindowsEntraTenantIDs.Value {
|
||
if !windowsEntraGUIDRegex.MatchString(tenantID) {
|
||
invalid.Append("mdm.windows_entra_tenant_ids", fmt.Sprintf("Invalid Entra tenant ID: %s", tenantID))
|
||
}
|
||
}
|
||
for _, clientID := range mdm.WindowsEntraClientIDs.Value {
|
||
if !windowsEntraGUIDRegex.MatchString(clientID) {
|
||
invalid.Append("mdm.windows_entra_client_ids", fmt.Sprintf("Invalid Entra client ID: %s", clientID))
|
||
}
|
||
}
|
||
|
||
if !mdm.WindowsEnabledAndConfigured && len(mdm.WindowsEntraTenantIDs.Value) > 0 {
|
||
invalid.Append("mdm.windows_entra_tenant_ids", "Couldn't set Windows Entra tenant IDs, Windows MDM is not enabled.")
|
||
}
|
||
|
||
if !mdm.WindowsEnabledAndConfigured && len(mdm.WindowsEntraClientIDs.Value) > 0 {
|
||
invalid.Append("mdm.windows_entra_client_ids", "Couldn't set Windows Entra client IDs, Windows MDM is not enabled.")
|
||
}
|
||
|
||
if mdm.WindowsMigrationEnabled && mdm.EnableTurnOnWindowsMDMManually {
|
||
invalid.Append("mdm.enable_turn_on_windows_mdm_manually", "Couldn't enable Turn on Windows MDM Manually, Windows MDM migration is also enabled. Please enable only one.")
|
||
}
|
||
|
||
if !mdm.EnableDiskEncryption.Value {
|
||
switch {
|
||
case !oldMdm.EnableDiskEncryption.Value && mdm.RequireBitLockerPIN.Value:
|
||
invalid.Append(
|
||
"mdm.windows_require_bitlocker_pin",
|
||
fleet.CantEnablePINRequiredIfDiskEncryptionEnabled,
|
||
)
|
||
case oldMdm.EnableDiskEncryption.Value && mdm.RequireBitLockerPIN.Value:
|
||
invalid.Append(
|
||
"mdm.enable_disk_encryption",
|
||
fleet.CantDisableDiskEncryptionIfPINRequiredErrMsg,
|
||
)
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// validateWindowsEnrollment validates the mdm.windows_enrollment section of a config modify payload and resolves its default
|
||
// fleet name to a team id. Returns defined=false when the section was omitted (no-op). When defined, teamID is the resolved team
|
||
// id (nil to clear) and fleetName is the canonical team name (empty when clearing).
|
||
func (svc *Service) validateWindowsEnrollment(
|
||
ctx context.Context,
|
||
newMDM *fleet.MDM,
|
||
invalid *fleet.InvalidArgumentError,
|
||
lic *fleet.LicenseInfo,
|
||
) (defined bool, teamID *uint, fleetName string, err error) {
|
||
if !newMDM.WindowsEnrollment.Set || !newMDM.WindowsEnrollment.Valid {
|
||
// Omitted key or explicit null: keep the persisted setting (same convention as
|
||
// enable_disk_encryption). Only an object clears or changes it.
|
||
return false, nil, "", nil
|
||
}
|
||
|
||
name := newMDM.WindowsEnrollment.Value.DefaultFleet
|
||
if name == "" {
|
||
// Explicitly clearing the default; allowed on any tier.
|
||
return true, nil, "", nil
|
||
}
|
||
|
||
if lic == nil || !lic.IsPremium() {
|
||
// Tolerate an unchanged value re-sent without Premium (e.g. gitops re-applying exported config after a license downgrade); only
|
||
// reject attempts to change it.
|
||
curTeamID, curName, dsErr := svc.ds.GetWindowsEnrollmentDefaultFleet(ctx)
|
||
if dsErr != nil {
|
||
return true, nil, "", ctxerr.Wrap(ctx, dsErr, "get current windows enrollment default fleet")
|
||
}
|
||
if name == curName {
|
||
return true, curTeamID, curName, nil
|
||
}
|
||
invalid.Append("mdm.windows_enrollment.default_fleet", ErrMissingLicense.Error())
|
||
return true, nil, "", nil
|
||
}
|
||
|
||
tm, err := svc.ds.TeamByName(ctx, name)
|
||
if err != nil {
|
||
if fleet.IsNotFound(err) {
|
||
invalid.Append("mdm.windows_enrollment.default_fleet", fmt.Sprintf("fleet %q doesn't exist", name))
|
||
return true, nil, "", nil
|
||
}
|
||
return true, nil, "", ctxerr.Wrap(ctx, err, "get team by name for windows enrollment default fleet")
|
||
}
|
||
return true, &tm.ID, tm.Name, nil
|
||
}
|
||
|
||
func (svc *Service) validateABMAssignments(
|
||
ctx context.Context,
|
||
mdm, oldMdm *fleet.MDM,
|
||
invalid *fleet.InvalidArgumentError,
|
||
lic *fleet.LicenseInfo,
|
||
) ([]*fleet.ABMToken, error) {
|
||
if mdm.DeprecatedAppleBMDefaultTeam != "" && mdm.AppleBusinessManager.Set && mdm.AppleBusinessManager.Valid {
|
||
invalid.Append("mdm.apple_bm_default_team", fleet.AppleABMDefaultTeamDeprecatedMessage)
|
||
return nil, nil
|
||
}
|
||
|
||
if name := mdm.DeprecatedAppleBMDefaultTeam; name != "" && name != oldMdm.DeprecatedAppleBMDefaultTeam {
|
||
if !lic.IsPremium() {
|
||
invalid.Append("mdm.apple_bm_default_team", ErrMissingLicense.Error())
|
||
return nil, nil
|
||
}
|
||
team, err := svc.ds.TeamByName(ctx, name)
|
||
if err != nil {
|
||
invalid.Append("mdm.apple_bm_default_team", "team name not found")
|
||
return nil, nil
|
||
}
|
||
tokens, err := svc.ds.ListABMTokens(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
if len(tokens) > 1 {
|
||
invalid.Append("mdm.apple_bm_default_team", fleet.AppleABMDefaultTeamDeprecatedMessage)
|
||
return nil, nil
|
||
}
|
||
|
||
if len(tokens) == 0 {
|
||
invalid.Append("mdm.apple_bm_default_team", "no ABM tokens found")
|
||
return nil, nil
|
||
}
|
||
|
||
tok := tokens[0]
|
||
tok.MacOSDefaultTeamID = &team.ID
|
||
tok.IOSDefaultTeamID = &team.ID
|
||
tok.IPadOSDefaultTeamID = &team.ID
|
||
tok.BYODDefaultTeamID = &team.ID
|
||
return []*fleet.ABMToken{tok}, nil
|
||
}
|
||
|
||
if mdm.AppleBusinessManager.Set && len(mdm.AppleBusinessManager.Value) > 0 {
|
||
if !lic.IsPremium() {
|
||
invalid.Append("mdm.apple_business", ErrMissingLicense.Error())
|
||
return nil, nil
|
||
}
|
||
|
||
teams, err := svc.ds.TeamsSummary(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
teamsByName := map[string]*uint{"": nil, "No team": nil}
|
||
for _, tm := range teams {
|
||
teamsByName[tm.Name] = &tm.ID
|
||
}
|
||
tokens, err := svc.ds.ListABMTokens(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
tokensByName := map[string]*fleet.ABMToken{}
|
||
for _, token := range tokens {
|
||
// The default assignments for all tokens is "no team"
|
||
// (ie: team_id IS NULL), here we reset the assignments
|
||
// for all tokens, those will be re-added below.
|
||
//
|
||
// This ensures any unassignments are properly handled.
|
||
token.MacOSDefaultTeamID = nil
|
||
token.IOSDefaultTeamID = nil
|
||
token.IPadOSDefaultTeamID = nil
|
||
token.BYODDefaultTeamID = nil
|
||
tokensByName[token.OrganizationName] = token
|
||
}
|
||
|
||
var tokensToSave []*fleet.ABMToken
|
||
for _, bm := range mdm.AppleBusinessManager.Value {
|
||
for _, tmName := range []string{bm.MacOSTeam, bm.IOSTeam, bm.IpadOSTeam, bm.BYODTeam} {
|
||
if _, ok := teamsByName[norm.NFC.String(tmName)]; !ok {
|
||
invalid.Appendf("mdm.apple_business", "team %s doesn't exist", tmName)
|
||
return nil, nil
|
||
}
|
||
}
|
||
|
||
if _, ok := tokensByName[norm.NFC.String(bm.OrganizationName)]; !ok {
|
||
invalid.Appendf("mdm.apple_business", "token with organization name %s doesn't exist", bm.OrganizationName)
|
||
return nil, nil
|
||
}
|
||
|
||
tok := tokensByName[bm.OrganizationName]
|
||
tok.MacOSDefaultTeamID = teamsByName[bm.MacOSTeam]
|
||
tok.IOSDefaultTeamID = teamsByName[bm.IOSTeam]
|
||
tok.IPadOSDefaultTeamID = teamsByName[bm.IpadOSTeam]
|
||
tok.BYODDefaultTeamID = teamsByName[bm.BYODTeam]
|
||
tokensToSave = append(tokensToSave, tok)
|
||
}
|
||
|
||
return tokensToSave, nil
|
||
}
|
||
|
||
return nil, nil
|
||
}
|
||
|
||
func (svc *Service) validateVPPAssignments(
|
||
ctx context.Context,
|
||
volumePurchasingProgramInfo []fleet.MDMAppleVolumePurchasingProgramInfo,
|
||
invalid *fleet.InvalidArgumentError,
|
||
lic *fleet.LicenseInfo,
|
||
) (map[uint][]uint, error) {
|
||
// Allow clearing VPP assignments in free and premium.
|
||
if len(volumePurchasingProgramInfo) == 0 {
|
||
return nil, nil
|
||
}
|
||
|
||
if !lic.IsPremium() {
|
||
invalid.Append("mdm.volume_purchasing_program", ErrMissingLicense.Error())
|
||
return nil, nil
|
||
}
|
||
|
||
teams, err := svc.ds.TeamsSummary(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
teamsByName := map[string]uint{fleet.TeamNameNoTeam: 0}
|
||
for _, tm := range teams {
|
||
teamsByName[tm.Name] = tm.ID
|
||
}
|
||
tokens, err := svc.ds.ListVPPTokens(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
tokensByLocation := map[string]*fleet.VPPTokenDB{}
|
||
for _, token := range tokens {
|
||
// The default assignments for all tokens is "no team"
|
||
// (ie: team_id IS NULL), here we reset the assignments
|
||
// for all tokens, those will be re-added below.
|
||
//
|
||
// This ensures any unassignments are properly handled.
|
||
tokensByLocation[token.Location] = token
|
||
token.Teams = nil
|
||
}
|
||
|
||
tokensToSave := make(map[uint][]uint, len(volumePurchasingProgramInfo))
|
||
for _, vpp := range volumePurchasingProgramInfo {
|
||
for _, tmName := range vpp.Teams {
|
||
if tmName == fleet.DisplayNameAllTeams {
|
||
tmName = fleet.TeamNameAllTeams
|
||
}
|
||
if _, ok := teamsByName[norm.NFC.String(tmName)]; !ok && tmName != fleet.TeamNameAllTeams {
|
||
invalid.Appendf("mdm.volume_purchasing_program", "team %s doesn't exist", tmName)
|
||
return nil, nil
|
||
}
|
||
}
|
||
|
||
loc := norm.NFC.String(vpp.Location)
|
||
if _, ok := tokensByLocation[loc]; !ok {
|
||
invalid.Appendf("mdm.volume_purchasing_program", "token with organization unit %s doesn't exist", vpp.Location)
|
||
return nil, nil
|
||
}
|
||
|
||
var tokenTeams []uint
|
||
for _, teamName := range vpp.Teams {
|
||
if teamName == fleet.DisplayNameAllTeams {
|
||
teamName = fleet.TeamNameAllTeams
|
||
}
|
||
if teamName == fleet.TeamNameAllTeams {
|
||
if len(vpp.Teams) > 1 {
|
||
invalid.Appendf("mdm.volume_purchasing_program", "token cannot belong to %s and other fleets", fleet.DisplayNameAllTeams)
|
||
return nil, nil
|
||
}
|
||
tokenTeams = []uint{}
|
||
break
|
||
}
|
||
teamID := teamsByName[teamName]
|
||
tokenTeams = append(tokenTeams, teamID)
|
||
}
|
||
|
||
tok := tokensByLocation[loc]
|
||
tokensToSave[tok.ID] = tokenTeams
|
||
}
|
||
|
||
return tokensToSave, nil
|
||
}
|
||
|
||
// Validate incoming SSO provider settings.
|
||
// If this is a GitOps run (overwrite=true), all required fields must be present.
|
||
// Otherwise we're doing a patch, so it's ok for fields to be missing as long
|
||
// as we have persisted values for them.
|
||
func validateSSOProviderSettings(incoming *fleet.SSOProviderSettings, existing fleet.SSOProviderSettings, invalid *fleet.InvalidArgumentError, overwrite bool) {
|
||
// trim whitespace from the incoming values so that we don't persist them with leading/trailing whitespace
|
||
incoming.Metadata = strings.TrimSpace(incoming.Metadata)
|
||
incoming.MetadataURL = strings.TrimSpace(incoming.MetadataURL)
|
||
incoming.EntityID = strings.TrimSpace(incoming.EntityID)
|
||
incoming.IDPName = strings.TrimSpace(incoming.IDPName)
|
||
|
||
if incoming.Metadata == "" && incoming.MetadataURL == "" {
|
||
if overwrite || (existing.Metadata == "" && existing.MetadataURL == "") {
|
||
invalid.Append("metadata", "either metadata or metadata_url must be defined")
|
||
}
|
||
}
|
||
if incoming.EntityID == "" {
|
||
if overwrite || existing.EntityID == "" {
|
||
invalid.Append("entity_id", "required")
|
||
}
|
||
}
|
||
if incoming.IDPName == "" {
|
||
if overwrite || existing.IDPName == "" {
|
||
invalid.Append("idp_name", "required")
|
||
}
|
||
}
|
||
|
||
if incoming.MetadataURL != "" {
|
||
if u, err := url.ParseRequestURI(incoming.MetadataURL); err != nil {
|
||
invalid.Append("metadata_url", err.Error())
|
||
} else if u.Scheme != "https" && u.Scheme != "http" {
|
||
invalid.Append("metadata_url", "must be either https or http")
|
||
}
|
||
}
|
||
}
|
||
|
||
func validateSSOSettings(p fleet.AppConfig, existing *fleet.AppConfig, invalid *fleet.InvalidArgumentError, lic *fleet.LicenseInfo, overwrite bool) {
|
||
if p.SSOSettings != nil && p.SSOSettings.EnableSSO {
|
||
|
||
var existingSSOProviderSettings fleet.SSOProviderSettings
|
||
if existing.SSOSettings != nil {
|
||
existingSSOProviderSettings = existing.SSOSettings.SSOProviderSettings
|
||
}
|
||
validateSSOProviderSettings(&p.SSOSettings.SSOProviderSettings, existingSSOProviderSettings, invalid, overwrite)
|
||
|
||
if !lic.IsPremium() {
|
||
if p.SSOSettings.EnableJITProvisioning {
|
||
invalid.Append("enable_jit_provisioning", ErrMissingLicense.Error())
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// gitopsHistoricalDataView is the narrow tri-state view of
|
||
// features.historical_data used to detect which sub-keys were absent
|
||
// from a gitops payload. Pointer fields distinguish "absent" from
|
||
// "false" — something the fleet.HistoricalDataSettings plain bools
|
||
// cannot do once unmarshaled.
|
||
//
|
||
// Only used by applyHistoricalDataOverwriteDefaults below.
|
||
type gitopsHistoricalDataView struct {
|
||
Features struct {
|
||
HistoricalData struct {
|
||
Uptime *bool `json:"uptime"`
|
||
Vulnerabilities *bool `json:"vulnerabilities"`
|
||
} `json:"historical_data"`
|
||
} `json:"features"`
|
||
}
|
||
|
||
// applyHistoricalDataOverwriteDefaults defaults absent
|
||
// features.historical_data sub-keys to true.
|
||
// Older clients will not send values for these keys,
|
||
// and in Overwrite mode we want to preserve the default
|
||
// "enabled" state so that we don't disable data collection
|
||
// and wipe data incorrectly.
|
||
func applyHistoricalDataOverwriteDefaults(p []byte, cfg *fleet.AppConfig) {
|
||
var view gitopsHistoricalDataView
|
||
if err := json.Unmarshal(p, &view); err != nil {
|
||
return // main decode path will surface the typed error
|
||
}
|
||
var defaults fleet.Features
|
||
defaults.ApplyDefaults()
|
||
// For each sub-key, check if the incoming data provided a value (true or false).
|
||
// If not, then set the config to the default value we want rather than
|
||
// the Go default for bools (false).
|
||
cfg.Features.HistoricalData.Uptime = defaults.HistoricalData.Uptime
|
||
if v := view.Features.HistoricalData.Uptime; v != nil {
|
||
cfg.Features.HistoricalData.Uptime = *v
|
||
}
|
||
cfg.Features.HistoricalData.Vulnerabilities = defaults.HistoricalData.Vulnerabilities
|
||
if v := view.Features.HistoricalData.Vulnerabilities; v != nil {
|
||
cfg.Features.HistoricalData.Vulnerabilities = *v
|
||
}
|
||
}
|
||
|
||
// //////////////////////////////////////////////////////////////////////////////
|
||
// Apply enroll secret spec
|
||
// //////////////////////////////////////////////////////////////////////////////
|
||
|
||
type applyEnrollSecretSpecRequest struct {
|
||
Spec *fleet.EnrollSecretSpec `json:"spec"`
|
||
DryRun bool `json:"-" query:"dry_run,optional"` // if true, apply validation but do not save changes
|
||
}
|
||
|
||
type applyEnrollSecretSpecResponse struct {
|
||
Err error `json:"error,omitempty"`
|
||
}
|
||
|
||
func (r applyEnrollSecretSpecResponse) Error() error { return r.Err }
|
||
|
||
func applyEnrollSecretSpecEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
||
req := request.(*applyEnrollSecretSpecRequest)
|
||
err := svc.ApplyEnrollSecretSpec(
|
||
ctx, req.Spec, fleet.ApplySpecOptions{
|
||
DryRun: req.DryRun,
|
||
},
|
||
)
|
||
if err != nil {
|
||
return applyEnrollSecretSpecResponse{Err: err}, nil
|
||
}
|
||
return applyEnrollSecretSpecResponse{}, nil
|
||
}
|
||
|
||
func (svc *Service) ApplyEnrollSecretSpec(ctx context.Context, spec *fleet.EnrollSecretSpec, applyOpts fleet.ApplySpecOptions) error {
|
||
if err := svc.authz.Authorize(ctx, &fleet.EnrollSecret{}, fleet.ActionWrite); err != nil {
|
||
return err
|
||
}
|
||
if len(spec.Secrets) > fleet.MaxEnrollSecretsCount {
|
||
return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("secrets", "too many secrets"))
|
||
}
|
||
|
||
for _, s := range spec.Secrets {
|
||
if s == nil || strings.TrimSpace(s.Secret) == "" {
|
||
return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("secrets", "enroll secret must not be empty"))
|
||
}
|
||
}
|
||
|
||
if applyOpts.DryRun {
|
||
for _, s := range spec.Secrets {
|
||
available, err := svc.ds.IsEnrollSecretAvailable(ctx, s.Secret, false, nil)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !available {
|
||
return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("secrets", "a provided global enroll secret is already being used"))
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
oldSecrets, err := svc.ds.GetEnrollSecrets(ctx, nil)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
if err := svc.ds.ApplyEnrollSecrets(ctx, nil, spec.Secrets); err != nil {
|
||
return err
|
||
}
|
||
|
||
// Check whether there were any mutations around the provided secrets ... if true, then register
|
||
// an activity.
|
||
oldSecretValues := make(map[string]struct{}, len(oldSecrets))
|
||
for _, s := range oldSecrets {
|
||
oldSecretValues[s.Secret] = struct{}{}
|
||
}
|
||
newSecretsValues := make(map[string]struct{}, len(spec.Secrets))
|
||
for _, s := range spec.Secrets {
|
||
newSecretsValues[s.Secret] = struct{}{}
|
||
}
|
||
if !maps.Equal(oldSecretValues, newSecretsValues) {
|
||
activity := fleet.ActivityTypeEditedEnrollSecrets{}
|
||
if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), activity); err != nil {
|
||
return ctxerr.Wrap(ctx, err, "creating activity for edited enroll secret")
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// //////////////////////////////////////////////////////////////////////////////
|
||
// Get enroll secret spec
|
||
// //////////////////////////////////////////////////////////////////////////////
|
||
|
||
type getEnrollSecretSpecResponse struct {
|
||
Spec *fleet.EnrollSecretSpec `json:"spec"`
|
||
Err error `json:"error,omitempty"`
|
||
}
|
||
|
||
func (r getEnrollSecretSpecResponse) Error() error { return r.Err }
|
||
|
||
func getEnrollSecretSpecEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
||
specs, err := svc.GetEnrollSecretSpec(ctx)
|
||
if err != nil {
|
||
return getEnrollSecretSpecResponse{Err: err}, nil
|
||
}
|
||
return getEnrollSecretSpecResponse{Spec: specs}, nil
|
||
}
|
||
|
||
func (svc *Service) GetEnrollSecretSpec(ctx context.Context) (*fleet.EnrollSecretSpec, error) {
|
||
if err := svc.authz.Authorize(ctx, &fleet.EnrollSecret{}, fleet.ActionRead); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
secrets, err := svc.ds.GetEnrollSecrets(ctx, nil)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return &fleet.EnrollSecretSpec{Secrets: secrets}, nil
|
||
}
|
||
|
||
// //////////////////////////////////////////////////////////////////////////////
|
||
// Version
|
||
// //////////////////////////////////////////////////////////////////////////////
|
||
|
||
type versionResponse struct {
|
||
*version.Info
|
||
Err error `json:"error,omitempty"`
|
||
}
|
||
|
||
func (r versionResponse) Error() error { return r.Err }
|
||
|
||
func versionEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
||
info, err := svc.Version(ctx)
|
||
if err != nil {
|
||
return versionResponse{Err: err}, nil
|
||
}
|
||
return versionResponse{Info: info}, nil
|
||
}
|
||
|
||
func (svc *Service) Version(ctx context.Context) (*version.Info, error) {
|
||
if err := svc.authz.Authorize(ctx, &fleet.Version{}, fleet.ActionRead); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
info := version.Version()
|
||
return &info, nil
|
||
}
|
||
|
||
// //////////////////////////////////////////////////////////////////////////////
|
||
// Get Certificate Chain
|
||
// //////////////////////////////////////////////////////////////////////////////
|
||
|
||
type getCertificateResponse struct {
|
||
CertificateChain []byte `json:"certificate_chain"`
|
||
Err error `json:"error,omitempty"`
|
||
}
|
||
|
||
func (r getCertificateResponse) Error() error { return r.Err }
|
||
|
||
func getCertificateEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
||
chain, err := svc.CertificateChain(ctx)
|
||
if err != nil {
|
||
return getCertificateResponse{Err: err}, nil
|
||
}
|
||
return getCertificateResponse{CertificateChain: chain}, nil
|
||
}
|
||
|
||
// Certificate returns the PEM encoded certificate chain for osqueryd TLS termination.
|
||
func (svc *Service) CertificateChain(ctx context.Context) ([]byte, error) {
|
||
config, err := svc.AppConfigObfuscated(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
u, err := url.Parse(config.ServerSettings.ServerURL)
|
||
if err != nil {
|
||
return nil, ctxerr.Wrap(ctx, err, "parsing serverURL")
|
||
}
|
||
|
||
conn, err := connectTLS(ctx, u)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
return chain(ctx, conn.ConnectionState(), u.Hostname())
|
||
}
|
||
|
||
func connectTLS(ctx context.Context, serverURL *url.URL) (*tls.Conn, error) {
|
||
var hostport string
|
||
if serverURL.Port() == "" {
|
||
hostport = net.JoinHostPort(serverURL.Host, "443")
|
||
} else {
|
||
hostport = serverURL.Host
|
||
}
|
||
|
||
// attempt dialing twice, first with a secure conn, and then
|
||
// if that fails, use insecure
|
||
dial := func(insecure bool) (*tls.Conn, error) {
|
||
conn, err := tls.Dial("tcp", hostport, &tls.Config{
|
||
InsecureSkipVerify: insecure,
|
||
})
|
||
if err != nil {
|
||
return nil, ctxerr.Wrap(ctx, err, "dial tls")
|
||
}
|
||
defer conn.Close()
|
||
return conn, nil
|
||
}
|
||
|
||
var (
|
||
conn *tls.Conn
|
||
err error
|
||
)
|
||
|
||
conn, err = dial(false)
|
||
if err == nil {
|
||
return conn, nil
|
||
}
|
||
conn, err = dial(true)
|
||
return conn, err
|
||
}
|
||
|
||
// chain builds a PEM encoded certificate chain using the PeerCertificates
|
||
// in tls.ConnectionState. chain uses the hostname to omit the Leaf certificate
|
||
// from the chain.
|
||
func chain(ctx context.Context, cs tls.ConnectionState, hostname string) ([]byte, error) {
|
||
buf := bytes.NewBuffer([]byte(""))
|
||
|
||
verifyEncode := func(chain []*x509.Certificate) error {
|
||
for _, cert := range chain {
|
||
if len(chain) > 1 {
|
||
// drop the leaf certificate from the chain. osqueryd does not
|
||
// need it to establish a secure connection
|
||
if err := cert.VerifyHostname(hostname); err == nil {
|
||
continue
|
||
}
|
||
}
|
||
if err := encodePEMCertificate(buf, cert); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// use verified chains if available(which adds the root CA), otherwise
|
||
// use the certificate chain offered by the server (if terminated with
|
||
// self-signed certs)
|
||
if len(cs.VerifiedChains) != 0 {
|
||
for _, chain := range cs.VerifiedChains {
|
||
if err := verifyEncode(chain); err != nil {
|
||
return nil, ctxerr.Wrap(ctx, err, "encode verified chains pem")
|
||
}
|
||
}
|
||
} else {
|
||
if err := verifyEncode(cs.PeerCertificates); err != nil {
|
||
return nil, ctxerr.Wrap(ctx, err, "encode peer certificates pem")
|
||
}
|
||
}
|
||
return buf.Bytes(), nil
|
||
}
|
||
|
||
func encodePEMCertificate(buf io.Writer, cert *x509.Certificate) error {
|
||
block := &pem.Block{
|
||
Type: "CERTIFICATE",
|
||
Bytes: cert.Raw,
|
||
}
|
||
return pem.Encode(buf, block)
|
||
}
|
||
|
||
func (svc *Service) HostFeatures(ctx context.Context, host *fleet.Host) (*fleet.Features, error) {
|
||
if svc.EnterpriseOverrides != nil {
|
||
return svc.EnterpriseOverrides.HostFeatures(ctx, host)
|
||
}
|
||
|
||
appConfig, err := svc.ds.AppConfig(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return &appConfig.Features, nil
|
||
}
|
||
|
||
// validateAddress validates that the provided address is usable for Fleet operations.
|
||
func validateAddress(addr string) bool {
|
||
host, portStr, err := net.SplitHostPort(addr)
|
||
|
||
if err != nil {
|
||
// Missing port is OK...
|
||
if strings.Contains(err.Error(), "missing port") {
|
||
host = addr
|
||
} else {
|
||
// Bare IPv6 address will make the call to SplitHostPort to fail,
|
||
// in which case we just need to validate that the address is usable.
|
||
ip := net.ParseIP(addr)
|
||
return isUsableIPAddr(ip)
|
||
}
|
||
} else {
|
||
port, err := strconv.Atoi(portStr)
|
||
if err != nil || port < 0 || port > 65535 {
|
||
return false
|
||
}
|
||
}
|
||
|
||
return isValidHostname(host)
|
||
}
|
||
|
||
// isUsableIPAddr validates that the provided IP address is usable for Fleet operations.
|
||
func isUsableIPAddr(addr net.IP) bool {
|
||
if addr == nil {
|
||
return false
|
||
}
|
||
if ip4 := addr.To4(); ip4 != nil && ip4.Equal(net.IPv4zero) {
|
||
return false
|
||
}
|
||
if len(addr) == net.IPv6len && addr.Equal(net.IPv6zero) {
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
// isValidHostname validates that h is a valid hostname per RFC 1123. It allows IP addresses and DNS names.
|
||
func isValidHostname(h string) bool {
|
||
if h == "" {
|
||
return false
|
||
}
|
||
|
||
// For IPv6 in brackets, strip them
|
||
if strings.HasPrefix(h, "[") && strings.HasSuffix(h, "]") {
|
||
h = h[1 : len(h)-1]
|
||
}
|
||
|
||
// Check if it's a valid IP address (IPv4 or IPv6)
|
||
if ip := net.ParseIP(h); ip != nil {
|
||
return isUsableIPAddr(ip)
|
||
}
|
||
|
||
// Validate as DNS hostname (RFC 1123)
|
||
// - Max 253 characters total
|
||
// - Each label max 63 characters
|
||
// - Labels contain only alphanumeric and hyphens
|
||
// - Labels cannot start or end with hyphens
|
||
if len(h) > 253 {
|
||
return false
|
||
}
|
||
|
||
// Regex for valid DNS hostname labels
|
||
validLabel := regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$`)
|
||
|
||
for label := range strings.SplitSeq(h, ".") {
|
||
if len(label) == 0 || len(label) > 63 {
|
||
return false
|
||
}
|
||
if !validLabel.MatchString(label) {
|
||
return false
|
||
}
|
||
}
|
||
|
||
return true
|
||
}
|
||
|
||
func (svc *Service) MaxInstallerSizeBytes() int64 {
|
||
return svc.config.Server.MaxInstallerSizeBytes
|
||
}
|