Files
fleet/ee/server/service/teams.go
T
Nico 7f1b330c90 Restrict deleting a fleet to global admins (#50271)
# Checklist for submitter

- [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.
## Testing

- [x] Added/updated automated tests
- [x] QA'd all new/changed functionality manually


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Restricted fleet deletion to users with global write permissions,
including global administrators and GitOps.
* Corrected team deletion authorization to require global write access.
* Prevented global technicians, team technicians, and observer-level
users from deleting teams.
* Updated authorization behavior to consistently enforce the required
access level.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-31 08:43:51 -03:00

2697 lines
99 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"maps"
"net/http"
"net/url"
"strings"
"unicode/utf8"
"golang.org/x/text/unicode/norm"
"github.com/fleetdm/fleet/v4/pkg/optjson"
"github.com/fleetdm/fleet/v4/server"
"github.com/fleetdm/fleet/v4/server/authz"
authz_ctx "github.com/fleetdm/fleet/v4/server/contexts/authz"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
hostctx "github.com/fleetdm/fleet/v4/server/contexts/host"
"github.com/fleetdm/fleet/v4/server/contexts/logging"
"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/ptr"
"github.com/fleetdm/fleet/v4/server/worker"
)
const teamNameConflictErrMsg = "Fleet names must differ by more than letter case."
func obfuscateSecrets(user *fleet.User, teams []*fleet.Team) error {
if user == nil {
return &authz.Forbidden{}
}
// Only global admins/maintainers and team admins/maintainers are allowed to
// read enroll secrets (see the enroll_secret authorization policy). We mask
// the secret for every other user, including gitops, observers, observer+,
// and technicians, so that being able to modify a team does not imply being
// able to read its enroll secrets.
canReadGlobal := user.GlobalRole != nil &&
(*user.GlobalRole == fleet.RoleAdmin || *user.GlobalRole == fleet.RoleMaintainer)
canReadTeam := user.TeamMembership(func(t fleet.UserTeam) bool {
return t.Role == fleet.RoleAdmin || t.Role == fleet.RoleMaintainer
})
for _, t := range teams {
if t == nil {
continue
}
if canReadGlobal || canReadTeam[t.ID] {
continue
}
for _, s := range t.Secrets {
s.Secret = fleet.MaskedPassword
}
}
return nil
}
// maskTeamSecretsForViewer masks the enroll secrets of the given teams for the
// current viewer, unless they are allowed to read them. It is used on team
// write endpoints so that being able to modify a team does not imply being able
// to read its enroll secrets (e.g. gitops).
func (svc *Service) maskTeamSecretsForViewer(ctx context.Context, teams ...*fleet.Team) error {
vc, ok := viewer.FromContext(ctx)
if !ok {
return fleet.ErrNoContext
}
return obfuscateSecrets(vc.User, teams)
}
func (svc *Service) NewTeam(ctx context.Context, p fleet.TeamPayload) (*fleet.Team, error) {
if err := svc.authz.Authorize(ctx, &fleet.Team{}, fleet.ActionWrite); err != nil {
return nil, err
}
// Copy team options from global options
globalConfig, err := svc.ds.AppConfig(ctx)
if err != nil {
return nil, err
}
team := &fleet.Team{
Config: fleet.TeamConfig{
AgentOptions: globalConfig.AgentOptions,
Features: globalConfig.Features,
},
}
if p.Name == nil {
return nil, fleet.NewInvalidArgumentError("name", "missing required argument")
}
*p.Name = strings.TrimSpace(*p.Name)
if *p.Name == "" {
return nil, fleet.NewInvalidArgumentError("name", "may not be empty")
}
if utf8.RuneCountInString(*p.Name) > fleet.MaxTeamNameLength {
return nil, fleet.NewInvalidArgumentError("name", fmt.Sprintf("may not exceed %d characters", fleet.MaxTeamNameLength))
}
if fleet.IsReservedTeamName(*p.Name) {
return nil, fleet.NewInvalidArgumentError("name", fmt.Sprintf("%q is a reserved fleet name", *p.Name))
}
conflict, err := svc.ds.TeamConflictsWithName(ctx, *p.Name, 0)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "check team name uniqueness")
}
if conflict != nil {
return nil, ctxerr.Wrap(ctx, &fleet.ConflictError{
Message: fmt.Sprintf("A fleet named %q already exists. %s", conflict.Name, teamNameConflictErrMsg),
})
}
team.Name = *p.Name
if p.Description != nil {
team.Description = *p.Description
}
if p.Secrets != nil {
if len(p.Secrets) > fleet.MaxEnrollSecretsCount {
return nil, fleet.NewInvalidArgumentError("secrets", "too many secrets")
}
for _, s := range p.Secrets {
if s == nil || strings.TrimSpace(s.Secret) == "" {
return nil, fleet.NewInvalidArgumentError("secrets", "enroll secret must not be empty")
}
}
team.Secrets = p.Secrets
} else {
// Set up a default enroll secret
secret, err := server.GenerateRandomText(fleet.EnrollSecretDefaultLength)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "generate enroll secret string")
}
team.Secrets = []*fleet.EnrollSecret{{Secret: secret}}
}
if p.HostExpirySettings != nil && p.HostExpirySettings.HostExpiryEnabled && p.HostExpirySettings.HostExpiryWindow <= 0 {
return nil, fleet.NewInvalidArgumentError("host_expiry_window", "must be greater than 0")
}
team, err = svc.ds.NewTeam(ctx, team)
if err != nil {
return nil, err
}
if err := svc.NewActivity(
ctx,
authz.UserFromContext(ctx),
fleet.ActivityTypeCreatedTeam{
ID: team.ID,
Name: team.Name,
},
); err != nil {
return nil, ctxerr.Wrap(ctx, err, "create activity for team creation")
}
// Mask enroll secrets for users that are not allowed to read them (e.g.
// gitops), so that creating a team does not leak the (possibly
// server-generated) plaintext enroll secret to a role that cannot read it.
if err := svc.maskTeamSecretsForViewer(ctx, team); err != nil {
return nil, err
}
return team, nil
}
func (svc *Service) ModifyTeam(ctx context.Context, teamID uint, payload fleet.TeamPayload) (*fleet.Team, error) {
// Special handling for team ID 0 (No team)
if teamID == 0 {
return svc.modifyDefaultTeamConfig(ctx, payload)
}
if err := svc.authz.Authorize(ctx, &fleet.Team{ID: teamID}, fleet.ActionWrite); err != nil {
return nil, err
}
team, err := svc.ds.TeamWithExtras(ctx, teamID) // TODO see if we can convert to TeamLite (will require a new save DS method)
if err != nil {
return nil, err
}
if payload.Name != nil {
*payload.Name = strings.TrimSpace(*payload.Name)
if *payload.Name == "" {
return nil, fleet.NewInvalidArgumentError("name", "may not be empty")
}
if utf8.RuneCountInString(*payload.Name) > fleet.MaxTeamNameLength {
return nil, fleet.NewInvalidArgumentError("name", fmt.Sprintf("may not exceed %d characters", fleet.MaxTeamNameLength))
}
if fleet.IsReservedTeamName(*payload.Name) {
return nil, fleet.NewInvalidArgumentError("name", fmt.Sprintf("%q is a reserved fleet name", *payload.Name))
}
conflict, err := svc.ds.TeamConflictsWithName(ctx, *payload.Name, teamID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "check team name uniqueness")
}
if conflict != nil {
return nil, ctxerr.Wrap(ctx, &fleet.ConflictError{
Message: fmt.Sprintf("A fleet named %q already exists. %s", conflict.Name, teamNameConflictErrMsg),
})
}
team.Name = *payload.Name
}
if payload.Description != nil {
team.Description = *payload.Description
}
if payload.WebhookSettings != nil {
if err := validateTeamWebhookSettings(ctx, payload.WebhookSettings); err != nil {
return nil, err
}
team.Config.WebhookSettings = *payload.WebhookSettings
}
appCfg, err := svc.ds.AppConfig(ctx)
if err != nil {
return nil, err
}
var (
macOSMinVersionUpdated bool
updateNewHostsChanged bool
iOSMinVersionUpdated bool
iPadOSMinVersionUpdated bool
windowsUpdatesUpdated bool
macOSDiskEncryptionUpdated bool
recoveryLockPasswordUpdated bool
macOSEnableEndUserAuthUpdated bool
macOSManagedLocalAccountUpdated bool
conditionalAccessUpdated bool
nameTemplateUpdated bool
)
var windowsManagedLocalAccountUpdated bool
if payload.MDM != nil {
if payload.MDM.MacOSUpdates != nil {
if err := payload.MDM.MacOSUpdates.Validate(); err != nil {
return nil, fleet.NewInvalidArgumentError("macos_updates", err.Error())
}
if payload.MDM.MacOSUpdates.MinimumVersion.Set || payload.MDM.MacOSUpdates.Deadline.Set || payload.MDM.MacOSUpdates.UpdateNewHosts.Set {
macOSMinVersionUpdated = team.Config.MDM.MacOSUpdates.MinimumVersion.Value != payload.MDM.MacOSUpdates.MinimumVersion.Value ||
team.Config.MDM.MacOSUpdates.Deadline.Value != payload.MDM.MacOSUpdates.Deadline.Value
updateNewHostsChanged = team.Config.MDM.MacOSUpdates.UpdateNewHosts.Value != payload.MDM.MacOSUpdates.UpdateNewHosts.Value
team.Config.MDM.MacOSUpdates = *payload.MDM.MacOSUpdates
}
}
if payload.MDM.IOSUpdates != nil {
if err := payload.MDM.IOSUpdates.Validate(); err != nil {
return nil, fleet.NewInvalidArgumentError("ios_updates", err.Error())
}
if payload.MDM.IOSUpdates.MinimumVersion.Set || payload.MDM.IOSUpdates.Deadline.Set {
iOSMinVersionUpdated = team.Config.MDM.IOSUpdates.MinimumVersion.Value != payload.MDM.IOSUpdates.MinimumVersion.Value ||
team.Config.MDM.IOSUpdates.Deadline.Value != payload.MDM.IOSUpdates.Deadline.Value
team.Config.MDM.IOSUpdates = *payload.MDM.IOSUpdates
}
}
if payload.MDM.IPadOSUpdates != nil {
if err := payload.MDM.IPadOSUpdates.Validate(); err != nil {
return nil, fleet.NewInvalidArgumentError("ipados_updates", err.Error())
}
if payload.MDM.IPadOSUpdates.MinimumVersion.Set || payload.MDM.IPadOSUpdates.Deadline.Set {
iPadOSMinVersionUpdated = team.Config.MDM.IPadOSUpdates.MinimumVersion.Value != payload.MDM.IPadOSUpdates.MinimumVersion.Value ||
team.Config.MDM.IPadOSUpdates.Deadline.Value != payload.MDM.IPadOSUpdates.Deadline.Value
team.Config.MDM.IPadOSUpdates = *payload.MDM.IPadOSUpdates
}
}
// 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(team.Config.MDM, false)
if err != nil {
return nil, fleet.NewInvalidArgumentError("mdm", err.Error())
}
if v, ok := m["macos"]; ok && macOSMinVersionUpdated {
return nil, fleet.NewInvalidArgumentError("macos_updates.minimum_version", v)
}
if v, ok := m["ios"]; ok && iOSMinVersionUpdated {
return nil, fleet.NewInvalidArgumentError("ios_updates.minimum_version", v)
}
if v, ok := m["ipados"]; ok && iPadOSMinVersionUpdated {
return nil, fleet.NewInvalidArgumentError("ipados_updates.minimum_version", v)
}
if payload.MDM != nil && (payload.MDM.MacOSUpdates != nil && payload.MDM.MacOSUpdates.Configured()) || (payload.MDM.IOSUpdates != nil && payload.MDM.IOSUpdates.Configured()) || (payload.MDM.IPadOSUpdates != nil && payload.MDM.IPadOSUpdates.Configured()) {
// Verify that we don't have a custom OS updates declaration
hasProfile, err := svc.ds.HasAppleUpdateConfigProfileConfigured(ctx, teamID)
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.CouldNotUpdateAppleOSSettingsWithCustomProfileErrorMessage,
}
}
}
if payload.MDM.WindowsUpdates != nil {
if err := payload.MDM.WindowsUpdates.Validate(); err != nil {
return nil, fleet.NewInvalidArgumentError("windows_updates", err.Error())
}
if payload.MDM.WindowsUpdates.DeadlineDays.Set || payload.MDM.WindowsUpdates.GracePeriodDays.Set {
windowsUpdatesUpdated = !team.Config.MDM.WindowsUpdates.Equal(*payload.MDM.WindowsUpdates)
team.Config.MDM.WindowsUpdates = *payload.MDM.WindowsUpdates
}
}
if payload.MDM != nil && payload.MDM.WindowsUpdates != nil && payload.MDM.WindowsUpdates.Configured() {
// Verify that we don't have a custom OS updates profile
hasProfile, err := svc.ds.HasWindowsUpdateConfigProfileConfigured(ctx, teamID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "check for existing custom OS updates profile")
}
if hasProfile {
return nil, &fleet.BadRequestError{
Message: fleet.CouldNotUpdateWindowsOSSettingsWithCustomProfileErrorMessage,
}
}
}
if payload.MDM.EnableDiskEncryption.Valid {
diskEncryptionUpdated := team.Config.MDM.EnableDiskEncryption != payload.MDM.EnableDiskEncryption.Value
if diskEncryptionUpdated && !appCfg.MDM.EnabledAndConfigured && !appCfg.MDM.WindowsEnabledAndConfigured {
return nil, fleet.NewInvalidArgumentError("mdm.enable_disk_encryption",
"Couldn't update mdm.enable_disk_encryption because neither Apple MDM nor Windows MDM is turned on in Fleet.")
}
// The Apple FileVault profile and macOS-named activity only apply when Apple MDM is configured.
// On Windows-only deployments the flag still controls BitLocker enforcement via the Windows MDM path.
macOSDiskEncryptionUpdated = diskEncryptionUpdated && appCfg.MDM.EnabledAndConfigured
team.Config.MDM.EnableDiskEncryption = payload.MDM.EnableDiskEncryption.Value
}
if payload.MDM.EnableRecoveryLockPassword.Valid {
recoveryLockPasswordUpdated = team.Config.MDM.EnableRecoveryLockPassword != payload.MDM.EnableRecoveryLockPassword.Value
if recoveryLockPasswordUpdated && !appCfg.MDM.EnabledAndConfigured {
return nil, fleet.NewInvalidArgumentError("mdm.enable_recovery_lock_password",
`Couldn't update enable_recovery_lock_password because MDM features aren't turned on in Fleet.`)
}
team.Config.MDM.EnableRecoveryLockPassword = payload.MDM.EnableRecoveryLockPassword.Value
}
if payload.MDM.RequireBitLockerPIN.Valid {
team.Config.MDM.RequireBitLockerPIN = payload.MDM.RequireBitLockerPIN.Value
}
if payload.MDM.HostNameTemplate.Set {
nameTemplate := payload.MDM.HostNameTemplate.Value
// Only validate (a DB round-trip to confirm referenced secrets exist)
// when the template actually changed, mirroring the app-config path.
if nameTemplate != "" && nameTemplate != team.Config.MDM.HostNameTemplate {
validated, err := fleet.ValidateHostNameTemplateWithSecrets(ctx, svc.ds, nameTemplate)
if err != nil {
return nil, ctxerr.Wrap(ctx, err)
}
nameTemplate = validated
}
nameTemplateUpdated = team.Config.MDM.HostNameTemplate != nameTemplate
team.Config.MDM.HostNameTemplate = nameTemplate
}
if payload.MDM.MacOSSetup != nil {
macOSEnableEndUserAuthUpdated = team.Config.MDM.MacOSSetup.EnableEndUserAuthentication != payload.MDM.MacOSSetup.EnableEndUserAuthentication
if macOSEnableEndUserAuthUpdated && payload.MDM.MacOSSetup.EnableEndUserAuthentication && appCfg.MDM.EndUserAuthentication.IsEmpty() {
// TODO: update this error message to include steps to resolve the issue once docs for IdP
// config are available
return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("setup_experience.enable_end_user_authentication",
`Couldn't enable setup_experience.enable_end_user_authentication because no IdP is configured for MDM features.`))
}
if err := svc.validateEndUserAuthenticationAndSetupAssistant(ctx, &team.ID); err != nil {
return nil, err
}
team.Config.MDM.MacOSSetup.EnableEndUserAuthentication = payload.MDM.MacOSSetup.EnableEndUserAuthentication
// 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 below stays satisfied.
if macOSEnableEndUserAuthUpdated && !payload.MDM.MacOSSetup.LockEndUserInfo.Valid &&
(appCfg.MDM.EnabledAndConfigured || !team.Config.MDM.MacOSSetup.EnableEndUserAuthentication) {
team.Config.MDM.MacOSSetup.LockEndUserInfo = optjson.SetBool(team.Config.MDM.MacOSSetup.EnableEndUserAuthentication)
}
if payload.MDM.MacOSSetup.LockEndUserInfo.Valid {
team.Config.MDM.MacOSSetup.LockEndUserInfo = payload.MDM.MacOSSetup.LockEndUserInfo
}
if !team.Config.MDM.MacOSSetup.EnableEndUserAuthentication && team.Config.MDM.MacOSSetup.LockEndUserInfo.Value {
return nil, fleet.NewInvalidArgumentError("setup_experience.lock_end_user_info", `"enable_end_user_authentication" must be set to "true" in order to enable "lock_end_user_info".`)
}
if err := payload.MDM.MacOSSetup.ValidateAgainst(team.Config.MDM.MacOSSetup); err != nil {
return nil, err
}
macOSManagedLocalAccountUpdated = payload.MDM.MacOSSetup.EnableManagedLocalAccount.Set &&
team.Config.MDM.MacOSSetup.EnableManagedLocalAccount.Value != payload.MDM.MacOSSetup.EnableManagedLocalAccount.Value
if macOSManagedLocalAccountUpdated && payload.MDM.MacOSSetup.EnableManagedLocalAccount.Value && !appCfg.MDM.EnabledAndConfigured {
return nil, fleet.NewInvalidArgumentError("setup_experience.enable_managed_local_account",
`Couldn't update setup_experience.enable_managed_local_account because MDM features aren't turned on in Fleet.`)
}
// move over values that we just validated, so they get updated, but only if set since this is partial patch.
if payload.MDM.MacOSSetup.EnableManagedLocalAccount.Set {
team.Config.MDM.MacOSSetup.EnableManagedLocalAccount = payload.MDM.MacOSSetup.EnableManagedLocalAccount
}
if payload.MDM.MacOSSetup.EndUserLocalAccountType.Set {
team.Config.MDM.MacOSSetup.EndUserLocalAccountType = payload.MDM.MacOSSetup.EndUserLocalAccountType
}
}
if payload.MDM.WindowsSettings != nil && payload.MDM.WindowsSettings.ManagedLocalAccountSettings.Enabled.Valid {
newEnabled := payload.MDM.WindowsSettings.ManagedLocalAccountSettings.Enabled
windowsManagedLocalAccountUpdated = team.Config.MDM.WindowsSettings.ManagedLocalAccountSettings.Enabled.Value != newEnabled.Value
if windowsManagedLocalAccountUpdated && newEnabled.Value && !appCfg.MDM.WindowsEnabledAndConfigured {
return nil, fleet.NewInvalidArgumentError("windows_settings.managed_local_account_settings.enabled",
"Couldn't update windows_settings.managed_local_account_settings because Windows MDM isn't turned on in Fleet.")
}
team.Config.MDM.WindowsSettings.ManagedLocalAccountSettings.Enabled = newEnabled
}
}
if payload.Integrations != nil {
if payload.Integrations.Jira != nil || payload.Integrations.Zendesk != nil {
// the team integrations must reference an existing global config integration.
if _, err := payload.Integrations.MatchWithIntegrations(appCfg.Integrations); err != nil {
return nil, fleet.NewInvalidArgumentError("integrations", err.Error())
}
// integrations must be unique
if err := payload.Integrations.Validate(); err != nil {
return nil, fleet.NewInvalidArgumentError("integrations", err.Error())
}
team.Config.Integrations.Jira = payload.Integrations.Jira
team.Config.Integrations.Zendesk = payload.Integrations.Zendesk
}
// Only update the calendar integration if it's not nil.
if payload.Integrations.GoogleCalendar != nil {
invalid := &fleet.InvalidArgumentError{}
_ = svc.validateTeamCalendarIntegrations(payload.Integrations.GoogleCalendar, appCfg, false, invalid)
if invalid.HasErrors() {
return nil, ctxerr.Wrap(ctx, invalid)
}
team.Config.Integrations.GoogleCalendar = payload.Integrations.GoogleCalendar
}
// Only update conditional_access_enabled if it's not nil.
if payload.Integrations.ConditionalAccessEnabled.Set {
if err := fleet.ValidateConditionalAccessIntegration(ctx,
svc,
appCfg.ConditionalAccess,
team.Config.Integrations.ConditionalAccessEnabled.Value,
payload.Integrations.ConditionalAccessEnabled.Value,
); err != nil {
return nil, ctxerr.Wrap(ctx, err)
}
conditionalAccessUpdated = team.Config.Integrations.ConditionalAccessEnabled.Value != payload.Integrations.ConditionalAccessEnabled.Value
team.Config.Integrations.ConditionalAccessEnabled = payload.Integrations.ConditionalAccessEnabled
}
}
if payload.WebhookSettings != nil || payload.Integrations != nil {
// must validate that at most only one automation is enabled for each
// supported feature - by now the updated payload has been applied to
// team.Config.
invalid := &fleet.InvalidArgumentError{}
fleet.ValidateEnabledFailingPoliciesTeamIntegrations(
team.Config.WebhookSettings.FailingPoliciesWebhook,
team.Config.Integrations,
invalid,
)
if invalid.HasErrors() {
return nil, ctxerr.Wrap(ctx, invalid)
}
}
if payload.HostExpirySettings != nil {
if payload.HostExpirySettings.HostExpiryEnabled && payload.HostExpirySettings.HostExpiryWindow <= 0 {
return nil, fleet.NewInvalidArgumentError("host_expiry_window", "must be greater than 0")
}
team.Config.HostExpirySettings = *payload.HostExpirySettings
}
// Snapshot the old historical-data state so we can emit activities for any
// sub-keys that flip during this PATCH. Apply per-sub-key partial
// overrides from payload.Features.HistoricalData; sub-keys with
// `Valid == false` are left untouched (PATCH-merge semantics).
oldHistoricalData := team.Config.Features.HistoricalData
if payload.Features != nil && payload.Features.HistoricalData != nil {
if payload.Features.HistoricalData.Uptime.Valid {
team.Config.Features.HistoricalData.Uptime = payload.Features.HistoricalData.Uptime.Value
}
if payload.Features.HistoricalData.Vulnerabilities.Valid {
team.Config.Features.HistoricalData.Vulnerabilities = payload.Features.HistoricalData.Vulnerabilities.Value
}
}
team, err = svc.ds.SaveTeam(ctx, team)
if err != nil {
return nil, err
}
// Emit activities and enqueue scrub jobs for any historical_data sub-key
// whose value flipped on this team. SaveTeam (above) has already
// committed; the worker will see the new config when it picks up the
// job.
//
// Log-and-continue on failure: the joined error covers both activity
// emit and scrub enqueue, both non-fatal individually. See design
// decision 8a of chart-disabling-collection-scrub.
if err := fleet.OnHistoricalDataChanged(
ctx,
svc,
svc.ds,
authz.UserFromContext(ctx),
oldHistoricalData,
team.Config.Features.HistoricalData,
&team.ID, &team.Name,
); err != nil {
err = ctxerr.Wrap(ctx, err, "OnHistoricalDataChanged")
ctxerr.Handle(ctx, err)
svc.logger.ErrorContext(ctx, "OnHistoricalDataChanged", "err", err, "team_id", team.ID)
}
if macOSMinVersionUpdated {
if err := svc.mdmAppleEditedAppleOSUpdates(ctx, &team.ID, fleet.MacOS, team.Config.MDM.MacOSUpdates); err != nil {
return nil, ctxerr.Wrap(ctx, err, "update DDM profile on macOS updates change")
}
if err := svc.NewActivity(
ctx,
authz.UserFromContext(ctx),
fleet.ActivityTypeEditedMacOSMinVersion{
TeamID: &team.ID,
TeamName: &team.Name,
MinimumVersion: team.Config.MDM.MacOSUpdates.MinimumVersion.Value,
Deadline: team.Config.MDM.MacOSUpdates.Deadline.Value,
},
); err != nil {
return nil, ctxerr.Wrap(ctx, err, "create activity for team macOS min version edited")
}
}
if iOSMinVersionUpdated {
if err := svc.mdmAppleEditedAppleOSUpdates(ctx, &team.ID, fleet.IOS, team.Config.MDM.IOSUpdates); err != nil {
return nil, ctxerr.Wrap(ctx, err, "update DDM profile on iOS updates change")
}
if err := svc.NewActivity(
ctx,
authz.UserFromContext(ctx),
fleet.ActivityTypeEditedIOSMinVersion{
TeamID: &team.ID,
TeamName: &team.Name,
MinimumVersion: team.Config.MDM.IOSUpdates.MinimumVersion.Value,
Deadline: team.Config.MDM.IOSUpdates.Deadline.Value,
},
); err != nil {
return nil, ctxerr.Wrap(ctx, err, "create activity for team iOS min version edited")
}
}
if iPadOSMinVersionUpdated {
if err := svc.mdmAppleEditedAppleOSUpdates(ctx, &team.ID, fleet.IPadOS, team.Config.MDM.IPadOSUpdates); err != nil {
return nil, ctxerr.Wrap(ctx, err, "update DDM profile on iPadOS updates change")
}
if err := svc.NewActivity(
ctx,
authz.UserFromContext(ctx),
fleet.ActivityTypeEditedIPadOSMinVersion{
TeamID: &team.ID,
TeamName: &team.Name,
MinimumVersion: team.Config.MDM.IPadOSUpdates.MinimumVersion.Value,
Deadline: team.Config.MDM.IPadOSUpdates.Deadline.Value,
},
); err != nil {
return nil, ctxerr.Wrap(ctx, err, "create activity for team iPadOS min version edited")
}
}
if updateNewHostsChanged {
var activity fleet.ActivityDetails
activity = fleet.ActivityTypeEnabledMacosUpdateNewHosts{
TeamID: &team.ID,
TeamName: &team.Name,
}
if payload.MDM != nil && payload.MDM.MacOSUpdates != nil && !payload.MDM.MacOSUpdates.UpdateNewHosts.Value {
activity = fleet.ActivityTypeDisabledMacosUpdateNewHosts{
TeamID: &team.ID,
TeamName: &team.Name,
}
}
if err := svc.NewActivity(
ctx,
authz.UserFromContext(ctx),
activity,
); err != nil {
return nil, ctxerr.Wrap(ctx, err, "create activity for team macOS update new hosts edited")
}
}
if windowsUpdatesUpdated {
var deadline, grace *int
if team.Config.MDM.WindowsUpdates.DeadlineDays.Valid {
deadline = &team.Config.MDM.WindowsUpdates.DeadlineDays.Value
}
if team.Config.MDM.WindowsUpdates.GracePeriodDays.Valid {
grace = &team.Config.MDM.WindowsUpdates.GracePeriodDays.Value
}
if deadline != nil {
if err := svc.mdmWindowsEnableOSUpdates(ctx, &team.ID, team.Config.MDM.WindowsUpdates); err != nil {
return nil, ctxerr.Wrap(ctx, err, "enable team windows OS updates")
}
} else if err := svc.mdmWindowsDisableOSUpdates(ctx, &team.ID); err != nil {
return nil, ctxerr.Wrap(ctx, err, "disable team windows OS updates")
}
if err := svc.NewActivity(
ctx,
authz.UserFromContext(ctx),
fleet.ActivityTypeEditedWindowsUpdates{
TeamID: &team.ID,
TeamName: &team.Name,
DeadlineDays: deadline,
GracePeriodDays: grace,
},
); err != nil {
return nil, ctxerr.Wrap(ctx, err, "create activity for team macos min version edited")
}
}
if macOSDiskEncryptionUpdated {
var act fleet.ActivityDetails
if team.Config.MDM.EnableDiskEncryption {
act = fleet.ActivityTypeEnabledMacosDiskEncryption{TeamID: &team.ID, TeamName: &team.Name}
if err := svc.MDMAppleEnableFileVaultAndEscrow(ctx, &team.ID); err != nil {
return nil, ctxerr.Wrap(ctx, err, "enable team filevault and escrow")
}
} else {
act = fleet.ActivityTypeDisabledMacosDiskEncryption{TeamID: &team.ID, TeamName: &team.Name}
if err := svc.MDMAppleDisableFileVaultAndEscrow(ctx, &team.ID); err != nil {
return nil, ctxerr.Wrap(ctx, err, "disable team filevault and escrow")
}
}
if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), act); err != nil {
return nil, ctxerr.Wrap(ctx, err, "create activity for team macos disk encryption")
}
}
if recoveryLockPasswordUpdated {
var act fleet.ActivityDetails
if team.Config.MDM.EnableRecoveryLockPassword {
act = fleet.ActivityTypeEnabledRecoveryLockPasswords{TeamID: &team.ID, TeamName: &team.Name}
} else {
act = fleet.ActivityTypeDisabledRecoveryLockPasswords{TeamID: &team.ID, TeamName: &team.Name}
}
if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), act); err != nil {
return nil, ctxerr.Wrap(ctx, err, "create activity for team recovery lock password")
}
}
if nameTemplateUpdated {
if err := svc.applyHostNameTemplateChange(ctx, team, team.Config.MDM.HostNameTemplate); err != nil {
return nil, err
}
}
if macOSEnableEndUserAuthUpdated {
if err := svc.updateMacOSSetupEnableEndUserAuth(ctx, team.Config.MDM.MacOSSetup.EnableEndUserAuthentication, &team.ID, &team.Name); err != nil {
return nil, ctxerr.Wrap(ctx, err, "update macos setup enable end user auth")
}
}
if macOSManagedLocalAccountUpdated {
if err := svc.logEnableManagedLocalAccountActivity(ctx, team.Config.MDM.MacOSSetup.EnableManagedLocalAccount.Value, "darwin", &team.ID, &team.Name); err != nil {
return nil, ctxerr.Wrap(ctx, err, "update macos setup enable managed local account")
}
}
if windowsManagedLocalAccountUpdated {
if err := svc.logEnableManagedLocalAccountActivity(ctx, team.Config.MDM.WindowsSettings.ManagedLocalAccountSettings.Enabled.Value, "windows", &team.ID, &team.Name); err != nil {
return nil, ctxerr.Wrap(ctx, err, "update windows enable managed local account")
}
}
// Create activity if conditional access was enabled or disabled for the team.
if conditionalAccessUpdated {
if team.Config.Integrations.ConditionalAccessEnabled.Value {
if err := svc.NewActivity(
ctx,
authz.UserFromContext(ctx),
fleet.ActivityTypeEnabledConditionalAccessAutomations{
TeamID: &team.ID,
TeamName: team.Name,
},
); err != nil {
return nil, ctxerr.Wrap(ctx, err, "create activity for enabling conditional access")
}
} else {
if err := svc.NewActivity(
ctx,
authz.UserFromContext(ctx),
fleet.ActivityTypeDisabledConditionalAccessAutomations{
TeamID: &team.ID,
TeamName: team.Name,
},
); err != nil {
return nil, ctxerr.Wrap(ctx, err, "create activity for disabling conditional access")
}
}
}
// Mask enroll secrets for users that are not allowed to read them (e.g.
// gitops), so that the write response does not leak plaintext secrets to a
// role that cannot read them via the GET endpoints.
if err := svc.maskTeamSecretsForViewer(ctx, team); err != nil {
return nil, err
}
return team, err
}
func (svc *Service) ModifyTeamAgentOptions(ctx context.Context, teamID uint, teamOptions json.RawMessage, applyOptions fleet.ApplySpecOptions) (*fleet.Team, error) {
if err := svc.authz.Authorize(ctx, &fleet.Team{ID: teamID}, fleet.ActionWrite); err != nil {
return nil, err
}
team, err := svc.ds.TeamWithExtras(ctx, teamID) // TODO see if we can convert to TeamLite (will require a new save DS method)
if err != nil {
return nil, err
}
if teamOptions != nil {
if err := fleet.ValidateJSONAgentOptions(ctx, svc.ds, teamOptions, true, teamID); err != nil {
err = fleet.SuggestAgentOptionsCorrection(err)
err = fleet.NewUserMessageError(err, http.StatusBadRequest)
if applyOptions.Force && !applyOptions.DryRun {
svc.logger.InfoContext(ctx, "force-apply team agent options with validation errors", "err", err)
}
if !applyOptions.Force {
return nil, ctxerr.Wrap(ctx, err, "validate agent options")
}
}
}
if applyOptions.DryRun {
// Mask enroll secrets so the write response does not leak plaintext
// secrets to a role that cannot read them (e.g. gitops).
if err := svc.maskTeamSecretsForViewer(ctx, team); err != nil {
return nil, err
}
return team, nil
}
if teamOptions != nil {
team.Config.AgentOptions = &teamOptions
} else {
team.Config.AgentOptions = nil
}
tm, err := svc.ds.SaveTeam(ctx, team)
if err != nil {
return nil, err
}
if err := svc.NewActivity(
ctx,
authz.UserFromContext(ctx),
fleet.ActivityTypeEditedAgentOptions{
Global: false,
TeamID: &team.ID,
TeamName: &team.Name,
},
); err != nil {
return nil, ctxerr.Wrap(ctx, err, "create edited agent options activity")
}
// Mask enroll secrets so the write response does not leak plaintext secrets
// to a role that cannot read them (e.g. gitops).
if err := svc.maskTeamSecretsForViewer(ctx, tm); err != nil {
return nil, err
}
return tm, nil
}
func (svc *Service) AddTeamUsers(ctx context.Context, teamID uint, users []fleet.TeamUser) (*fleet.Team, error) {
if err := svc.authz.Authorize(ctx, &fleet.Team{ID: teamID}, fleet.ActionWriteMembers); err != nil {
return nil, err
}
currentUser := authz.UserFromContext(ctx)
idMap := make(map[uint]fleet.TeamUser)
for _, user := range users {
if !fleet.ValidTeamRole(user.Role) {
return nil, fleet.NewInvalidArgumentError("users", fmt.Sprintf("%s is not a valid user role", user.Role))
}
idMap[user.ID] = user
fullUser, err := svc.ds.UserByID(ctx, user.ID)
if err != nil {
return nil, ctxerr.Wrapf(ctx, err, "getting full user with id %d", user.ID)
}
if fullUser.GlobalRole != nil && currentUser.GlobalRole == nil {
return nil, ctxerr.New(ctx, "A user with a global role cannot be added to a team by a non global user.")
}
}
team, err := svc.ds.TeamWithExtras(ctx, teamID)
if err != nil {
return nil, err
}
// Replace existing
for i, existingUser := range team.Users {
if user, ok := idMap[existingUser.ID]; ok {
team.Users[i] = user
delete(idMap, user.ID)
}
}
// Add new (that have not already been replaced)
for _, user := range idMap {
team.Users = append(team.Users, user)
}
logging.WithExtras(ctx, "users", team.Users)
return svc.ds.SaveTeam(ctx, team)
}
func (svc *Service) DeleteTeamUsers(ctx context.Context, teamID uint, users []fleet.TeamUser) (*fleet.Team, error) {
if err := svc.authz.Authorize(ctx, &fleet.Team{ID: teamID}, fleet.ActionWriteMembers); err != nil {
return nil, err
}
idMap := make(map[uint]bool)
for _, user := range users {
idMap[user.ID] = true
}
team, err := svc.ds.TeamWithExtras(ctx, teamID)
if err != nil {
return nil, err
}
newUsers := []fleet.TeamUser{}
// Delete existing
for _, existingUser := range team.Users {
if _, ok := idMap[existingUser.ID]; !ok {
// Only add non-deleted users
newUsers = append(newUsers, existingUser)
}
}
team.Users = newUsers
logging.WithExtras(ctx, "users", team.Users)
return svc.ds.SaveTeam(ctx, team)
}
func (svc *Service) ListTeamUsers(ctx context.Context, teamID uint, opt fleet.ListOptions) ([]*fleet.User, error) {
if err := svc.authz.Authorize(ctx, &fleet.Team{ID: teamID}, fleet.ActionRead); err != nil {
return nil, err
}
team, err := svc.ds.TeamLite(ctx, teamID)
if err != nil {
return nil, err
}
return svc.ds.ListUsers(ctx, fleet.UserListOptions{ListOptions: opt, TeamID: team.ID})
}
func (svc *Service) ListTeams(ctx context.Context, opt fleet.ListOptions) ([]*fleet.Team, error) {
if err := svc.authz.Authorize(ctx, &fleet.Team{}, fleet.ActionRead); err != nil {
return nil, err
}
vc, ok := viewer.FromContext(ctx)
if !ok {
return nil, fleet.ErrNoContext
}
filter := fleet.TeamFilter{User: vc.User, IncludeObserver: true}
teams, err := svc.ds.ListTeams(ctx, filter, opt)
if err != nil {
return nil, err
}
if err = obfuscateSecrets(vc.User, teams); err != nil {
return nil, err
}
return teams, nil
}
func (svc *Service) ListAvailableTeamsForUser(ctx context.Context, user *fleet.User) ([]*fleet.TeamSummary, error) {
if err := svc.authz.Authorize(ctx, &fleet.Team{}, fleet.ActionRead); err != nil {
return nil, err
}
availableTeams := []*fleet.TeamSummary{}
if user.GlobalRole != nil {
ts, err := svc.ds.TeamsSummary(ctx)
if err != nil {
return nil, err
}
availableTeams = append(availableTeams, ts...)
} else {
for _, t := range user.Teams {
// Convert from UserTeam to TeamSummary (i.e. omit the role, counts, agent options)
availableTeams = append(availableTeams, &fleet.TeamSummary{ID: t.ID, Name: t.Name, Description: t.Description})
}
}
return availableTeams, nil
}
func (svc *Service) DeleteTeam(ctx context.Context, teamID uint) error {
if err := svc.authz.Authorize(ctx, &fleet.Team{}, fleet.ActionWrite); err != nil {
return err
}
team, err := svc.ds.TeamLite(ctx, teamID)
if err != nil {
return err
}
name := team.Name
vc, ok := viewer.FromContext(ctx)
if !ok {
return fleet.ErrNoContext
}
filter := fleet.TeamFilter{User: vc.User, IncludeObserver: true}
opts := fleet.HostListOptions{
TeamFilter: &teamID,
DisableIssues: true, // don't need to check policies for hosts that are being deleted
}
hosts, err := svc.ds.ListHosts(ctx, filter, opts)
if err != nil {
return ctxerr.Wrap(ctx, err, "list hosts for reconcile profiles on team change")
}
hostIDs := make([]uint, 0, len(hosts))
mdmHostSerials := make([]string, 0, len(hosts))
for _, host := range hosts {
hostIDs = append(hostIDs, host.ID)
if host.IsDEPAssignedToFleet() {
mdmHostSerials = append(mdmHostSerials, host.HardwareSerial)
}
}
// Handle certificate templates associated with the team
certTemplates, _, err := svc.ds.GetCertificateTemplatesByTeamID(ctx, teamID, fleet.ListOptions{})
if err != nil {
return ctxerr.Wrap(ctx, err, "get certificate templates for team")
}
for _, ct := range certTemplates {
if err := svc.ds.SetHostCertificateTemplatesToPendingRemove(ctx, ct.ID); err != nil {
return ctxerr.Wrapf(ctx, err, "set hosts to pending remove for certificate template %d", ct.ID)
}
}
orgNames, err := svc.ds.GetABMTokenOrgNamesAssociatedByDefaultTeams(ctx, &teamID)
if err != nil {
return ctxerr.Wrap(ctx, err, "get ABM token org names associated by default teams")
}
// cleanup app config references for the team being deleted
if len(orgNames) > 0 {
appCfg, err := svc.ds.AppConfig(ctx)
if err != nil {
return ctxerr.Wrap(ctx, err, "get app config")
}
updated := false
for _, orgName := range orgNames {
for i, token := range appCfg.MDM.AppleBusinessManager.Value {
if token.OrganizationName != orgName {
// no-op for this org name/token combo
continue
}
token.CleanRemovedTeam(name)
appCfg.MDM.AppleBusinessManager.Value[i] = token
updated = true
}
}
if updated {
if err := svc.ds.SaveAppConfig(ctx, appCfg); err != nil {
return ctxerr.Wrap(ctx, err, "save app config")
}
}
}
if err := svc.ds.DeleteTeam(ctx, teamID); err != nil {
return err
}
if len(hostIDs) > 0 {
if _, err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, hostIDs, nil, nil, nil); err != nil {
return ctxerr.Wrap(ctx, err, "bulk set pending host profiles")
}
if err := svc.ds.CleanupDiskEncryptionKeysOnTeamChange(ctx, hostIDs, ptr.Uint(0)); err != nil {
return ctxerr.Wrap(ctx, err, "reconcile profiles on team change cleanup disk encryption keys")
}
if len(mdmHostSerials) > 0 {
if _, err := worker.QueueMacosSetupAssistantJob(
ctx,
svc.ds,
svc.logger,
worker.MacosSetupAssistantTeamDeleted,
nil,
mdmHostSerials...); err != nil {
return ctxerr.Wrap(ctx, err, "queue macos setup assistant team deleted job")
}
}
}
logging.WithExtras(ctx, "id", teamID)
if err := svc.NewActivity(
ctx,
authz.UserFromContext(ctx),
fleet.ActivityTypeDeletedTeam{
ID: teamID,
Name: name,
},
); err != nil {
return ctxerr.Wrap(ctx, err, "create activity for team deletion")
}
return nil
}
func (svc *Service) GetTeam(ctx context.Context, teamID uint) (*fleet.Team, error) {
// Special handling for team ID 0 - return default team config
if teamID == 0 {
// Use same authorization as AppConfig reads
if err := svc.authz.Authorize(ctx, &fleet.AppConfig{}, fleet.ActionRead); err != nil {
return nil, err
}
config, err := svc.ds.DefaultTeamConfig(ctx)
if err != nil {
return nil, err
}
// Convert TeamConfig to Team for API compatibility
// Team ID 0 represents "No Team"
team := &fleet.Team{
ID: 0,
Name: fleet.ReservedNameNoTeam,
Config: *config,
}
return team, nil
}
alreadyAuthd := svc.authz.IsAuthenticatedWith(ctx, authz_ctx.AuthnDeviceToken) ||
svc.authz.IsAuthenticatedWith(ctx, authz_ctx.AuthnDeviceCertificate) ||
svc.authz.IsAuthenticatedWith(ctx, authz_ctx.AuthnDeviceURL)
if alreadyAuthd {
// device-authenticated request can only get the device's team
host, ok := hostctx.FromContext(ctx)
if !ok {
err := ctxerr.Wrap(ctx, fleet.NewAuthRequiredError("internal error: missing host from request context"))
return nil, err
}
if host.TeamID == nil || *host.TeamID != teamID {
return nil, authz.ForbiddenWithInternal("device-authenticated host does not belong to requested team", nil, "team", "read")
}
} else {
if err := svc.authz.Authorize(ctx, &fleet.Team{ID: teamID}, fleet.ActionRead); err != nil {
return nil, err
}
}
logging.WithExtras(ctx, "id", teamID)
var user *fleet.User
if alreadyAuthd {
// device-authenticated, there is no user in the context, use a global
// observer with no special permissions
user = &fleet.User{GlobalRole: ptr.String(fleet.RoleObserver)}
} else {
vc, ok := viewer.FromContext(ctx)
if !ok {
return nil, fleet.ErrNoContext
}
user = vc.User
}
team, err := svc.ds.TeamWithExtras(ctx, teamID)
if err != nil {
return nil, err
}
if err = obfuscateSecrets(user, []*fleet.Team{team}); err != nil {
return nil, err
}
return team, nil
}
func (svc *Service) TeamEnrollSecrets(ctx context.Context, teamID uint) ([]*fleet.EnrollSecret, error) {
if err := svc.authz.Authorize(ctx, &fleet.Team{ID: teamID}, fleet.ActionRead); err != nil {
return nil, err
}
vc, ok := viewer.FromContext(ctx)
if !ok {
return nil, fleet.ErrNoContext
}
secrets, err := svc.ds.TeamEnrollSecrets(ctx, teamID)
if err != nil {
return nil, err
}
isGlobalObs := vc.User.IsGlobalObserver()
isGlobalTechnician := vc.User.GlobalRole != nil && *vc.User.GlobalRole == fleet.RoleTechnician
teamMemberships := vc.User.TeamMembership(func(t fleet.UserTeam) bool {
return true
})
// These roles are not allowed to see enroll secrets.
notAllowedTeamMembership := vc.User.TeamMembership(func(t fleet.UserTeam) bool {
return t.Role == fleet.RoleObserver || t.Role == fleet.RoleObserverPlus || t.Role == fleet.RoleTechnician
})
for _, s := range secrets {
if s == nil {
continue
}
if isGlobalObs || isGlobalTechnician || (vc.User.GlobalRole == nil && (!teamMemberships[*s.TeamID] || notAllowedTeamMembership[*s.TeamID])) {
s.Secret = fleet.MaskedPassword
}
}
return secrets, nil
}
func (svc *Service) ModifyTeamEnrollSecrets(ctx context.Context, teamID uint, secrets []fleet.EnrollSecret) ([]*fleet.EnrollSecret, error) {
if err := svc.authz.Authorize(ctx, &fleet.EnrollSecret{TeamID: ptr.Uint(teamID)}, fleet.ActionWrite); err != nil {
return nil, err
}
if secrets == nil {
return nil, fleet.NewInvalidArgumentError("secrets", "missing required argument")
}
if len(secrets) > fleet.MaxEnrollSecretsCount {
return nil, fleet.NewInvalidArgumentError("secrets", "too many secrets")
}
oldSecrets, err := svc.ds.GetEnrollSecrets(ctx, ptr.Uint(teamID))
if err != nil {
return nil, err
}
newSecretsValues := make(map[string]struct{}, len(secrets))
var newSecrets []*fleet.EnrollSecret
for _, secret := range secrets {
if strings.TrimSpace(secret.Secret) == "" {
return nil, fleet.NewInvalidArgumentError("secrets", "enroll secret must not be empty")
}
newSecretsValues[secret.Secret] = struct{}{}
newSecrets = append(newSecrets, &fleet.EnrollSecret{
Secret: secret.Secret,
})
}
if err := svc.ds.ApplyEnrollSecrets(ctx, ptr.Uint(teamID), newSecrets); err != nil {
return nil, 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{}{}
}
if !maps.Equal(oldSecretValues, newSecretsValues) {
activity := fleet.ActivityTypeEditedEnrollSecrets{}
team, err := svc.ds.TeamLite(ctx, teamID)
if err != nil {
svc.logger.ErrorContext(ctx, "error while fetching team for edited enroll secret activity", "err", err, "teamID", teamID)
}
if team != nil {
activity = fleet.ActivityTypeEditedEnrollSecrets{
TeamID: &team.ID,
TeamName: &team.Name,
}
} else {
svc.logger.ErrorContext(ctx, "team not found for edited enroll secret activity", "teamID", teamID)
}
if err := svc.NewActivity(
ctx,
authz.UserFromContext(ctx),
activity,
); err != nil {
errMsg := fmt.Sprintf("creating edited enroll secret activity for team %d", teamID)
return nil, ctxerr.Wrap(ctx, err, errMsg)
}
}
return newSecrets, nil
}
func (svc *Service) teamByIDOrName(ctx context.Context, id *uint, name *string) (*fleet.Team, error) {
if err := svc.authz.Authorize(ctx, &fleet.Team{}, fleet.ActionRead); err != nil {
return nil, err
}
var (
tm *fleet.Team
err error
)
if id != nil {
tm, err = svc.ds.TeamWithExtras(ctx, *id)
if err != nil {
return nil, err
}
} else if name != nil {
tm, err = svc.ds.TeamByName(ctx, *name)
if err != nil {
return nil, err
}
}
return tm, nil
}
var jsonNull = json.RawMessage(`null`)
// setAuthCheckedOnPreAuthErr can be used to set the authentication as checked
// in case of errors that happened before an auth check can be performed.
// Otherwise the endpoints return a "authentication skipped" error instead of
// the actual returned error.
func setAuthCheckedOnPreAuthErr(ctx context.Context) {
if az, ok := authz_ctx.FromContext(ctx); ok {
az.SetChecked()
}
}
func (svc *Service) checkAuthorizationForTeams(ctx context.Context, specs []*fleet.TeamSpec) error {
for _, spec := range specs {
var team *fleet.Team
var err error
// If filename is provided, try to find the team by filename first.
// This is needed in case user is trying to modify the team name.
if spec.Filename != nil && *spec.Filename != "" {
team, err = svc.ds.TeamByFilename(ctx, *spec.Filename)
if err != nil && !fleet.IsNotFound(err) {
return err
}
}
if team == nil {
team, err = svc.ds.TeamByName(ctx, spec.Name)
if err != nil {
if fleet.IsNotFound(err) {
// Can the user create a new team?
if err := svc.authz.Authorize(ctx, &fleet.Team{}, fleet.ActionWrite); err != nil {
return err
}
continue
}
// Set authorization as checked to return a proper error.
setAuthCheckedOnPreAuthErr(ctx)
return err
}
}
// can the user modify each team it's trying to modify
if err := svc.authz.Authorize(ctx, team, fleet.ActionWrite); err != nil {
return err
}
}
return nil
}
func (svc *Service) ApplyTeamSpecs(ctx context.Context, specs []*fleet.TeamSpec, applyOpts fleet.ApplyTeamSpecOptions) (
map[string]uint, error,
) {
if len(specs) == 0 {
setAuthCheckedOnPreAuthErr(ctx)
// Nothing to do.
return map[string]uint{}, nil
}
if err := svc.checkAuthorizationForTeams(ctx, specs); err != nil {
return nil, err
}
// Try to detect conflicting names within the incoming batch before hitting the DB.
type seenSpec struct {
name string
filename string
}
seenNames := make(map[string]seenSpec, len(specs))
for _, spec := range specs {
trimmedName := strings.TrimSpace(spec.Name)
key := norm.NFC.String(strings.ToLower(trimmedName))
if key == "" {
continue
}
filename := ""
if spec.Filename != nil {
filename = *spec.Filename
}
if prev, ok := seenNames[key]; ok {
return nil, ctxerr.Wrap(ctx, &fleet.ConflictError{Message: fmt.Sprintf(
"duplicate fleet names in request: %q (filename %q) and %q (filename %q). %s",
prev.name, prev.filename, trimmedName, filename, teamNameConflictErrMsg,
)})
}
seenNames[key] = seenSpec{name: trimmedName, filename: filename}
}
appConfig, err := svc.ds.AppConfig(ctx)
if err != nil {
return nil, err
}
appConfig.Obfuscate()
var details []fleet.TeamActivityDetail
for _, spec := range specs {
var secrets []*fleet.EnrollSecret
// When secrets slice is empty, all secrets are removed.
// When secrets slice is nil, existing secrets are kept.
if spec.Secrets != nil {
secrets = make([]*fleet.EnrollSecret, 0, len(*spec.Secrets))
for _, secret := range *spec.Secrets {
secrets = append(
secrets, &fleet.EnrollSecret{
Secret: secret.Secret,
},
)
}
}
spec.Name = strings.TrimSpace(spec.Name)
if spec.Name == "" {
return nil, fleet.NewInvalidArgumentError("name", "name may not be empty")
}
if utf8.RuneCountInString(spec.Name) > fleet.MaxTeamNameLength {
return nil, fleet.NewInvalidArgumentError("name", fmt.Sprintf("may not exceed %d characters", fleet.MaxTeamNameLength))
}
if fleet.IsReservedTeamName(spec.Name) {
return nil, fleet.NewInvalidArgumentError("name", fmt.Sprintf("%q is a reserved fleet name", spec.Name))
}
var team *fleet.Team
filenameProvided := spec.Filename != nil && *spec.Filename != ""
// Primary key for a GitOps-managed team is its filename. Try that
// lookup first.
if filenameProvided {
team, err = svc.ds.TeamByFilename(ctx, *spec.Filename)
if err != nil && !fleet.IsNotFound(err) {
return nil, err
}
}
// If we matched by filename, enforce uniqueness against *other*
// teams. Excluding the matched team's id lets a case-only self-rename
// ("ABC" → "abc") succeed while still catching renames that collide
// with a different team.
if team != nil {
conflict, err := svc.ds.TeamConflictsWithName(ctx, spec.Name, team.ID)
if err != nil {
return nil, err
}
if conflict != nil {
return nil, ctxerr.Wrap(ctx, &fleet.ConflictError{Message: fmt.Sprintf(
"fleet name %q conflicts with existing fleet %q. %s",
spec.Name, conflict.Name, teamNameConflictErrMsg,
)})
}
}
var create bool
if team == nil {
team, err = svc.ds.TeamByName(ctx, spec.Name)
switch {
case err == nil:
// Matched by collation-aware name lookup. Without a filename
// anchoring this spec to a specific file, adopting the
// spec's exact-case form would silently case-rename the team
// (e.g., "ABC" → "abc") so we need to preserve the DB's canonical name unless
// the user supplied a filename, which is an explicit claim
// that they want to manage (and possibly rename) this team.
if !filenameProvided {
spec.Name = team.Name
} else if team.Filename != nil && *team.Filename != "" && *team.Filename != *spec.Filename {
// The spec supplied a filename that matched no existing
// team, but the team matched by name is already managed
// by a different filename.
svc.logger.InfoContext(ctx, "GitOps filename changed for existing team",
"team_id", team.ID,
"team_name", team.Name,
"old_filename", *team.Filename,
"new_filename", *spec.Filename,
)
}
case fleet.IsNotFound(err):
create = true
default:
return nil, err
}
}
var tmID uint
if team != nil {
tmID = team.ID
}
if len(spec.AgentOptions) > 0 && !bytes.Equal(spec.AgentOptions, jsonNull) {
if err := fleet.ValidateJSONAgentOptions(ctx, svc.ds, spec.AgentOptions, true, tmID); err != nil {
err = fleet.SuggestAgentOptionsCorrection(err)
err = fleet.NewUserMessageError(err, http.StatusBadRequest)
if applyOpts.Force && !applyOpts.DryRun {
svc.logger.InfoContext(ctx, "force-apply team agent options with validation errors", "err", err)
}
if !applyOpts.Force {
return nil, ctxerr.Wrap(ctx, err, "validate agent options")
}
}
}
if len(secrets) > fleet.MaxEnrollSecretsCount {
return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("secrets", "too many secrets"), "validate secrets")
}
for _, s := range secrets {
if s == nil || strings.TrimSpace(s.Secret) == "" {
return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("secrets", "enroll secret must not be empty"), "validate secrets")
}
}
// TODO: should we be we validating the other Apple platforms? if so, we should also include
// ValidateMDMSettingsAppleSupportedOSVersion for each platform
if err := spec.MDM.MacOSUpdates.Validate(); err != nil {
return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("macos_updates", err.Error()))
}
if err := spec.MDM.WindowsUpdates.Validate(); err != nil {
return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("windows_updates", err.Error()))
}
if err := spec.MDM.MacOSSetup.Validate(); err != nil {
return nil, ctxerr.Wrap(ctx, err) // Error message coming from validate is already invalid argument
}
if create {
// create a new team enroll secret if none is provided for a new team,
// unless the user explicitly passed in an empty array
if secrets == nil {
secret, err := server.GenerateRandomText(fleet.EnrollSecretDefaultLength)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "generate enroll secret string")
}
secrets = append(secrets, &fleet.EnrollSecret{
Secret: secret,
})
}
team, err := svc.createTeamFromSpec(ctx, spec, appConfig, secrets, applyOpts.DryRun)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "creating team from spec")
}
details = append(details, fleet.TeamActivityDetail{
ID: team.ID,
Name: team.Name,
})
continue
}
if err := svc.editTeamFromSpec(ctx, team, spec, appConfig, secrets, applyOpts); err != nil {
return nil, ctxerr.Wrap(ctx, err, "editing team from spec")
}
details = append(details, fleet.TeamActivityDetail{
ID: team.ID,
Name: team.Name,
})
}
idsByName := make(map[string]uint, len(details))
if len(details) > 0 {
for _, tm := range details {
idsByName[tm.Name] = tm.ID
}
if !applyOpts.DryRun {
if err := svc.NewActivity(
ctx,
authz.UserFromContext(ctx),
fleet.ActivityTypeAppliedSpecTeam{
Teams: details,
},
); err != nil {
return nil, ctxerr.Wrap(ctx, err, "create activity for team spec")
}
}
}
return idsByName, nil
}
// validateVulnExposureFilters validates a team's vulnerability-exposure chart
// filter defaults (display-only defaults that seed the dashboard chart's
// filter controls; they do not affect data collection). Sparse/PATCH
// semantics: only present fields are checked. Teams are premium-only, so no
// separate license gate is required here.
func validateVulnExposureFilters(ctx context.Context, veFilters *fleet.VulnExposureFilterSettings) error {
if veFilters == nil {
return nil
}
invalid := &fleet.InvalidArgumentError{}
veFilters.Validate("team.settings.features", invalid)
if invalid.HasErrors() {
return ctxerr.Wrap(ctx, invalid)
}
return nil
}
func (svc *Service) createTeamFromSpec(
ctx context.Context,
spec *fleet.TeamSpec,
appCfg *fleet.AppConfig,
secrets []*fleet.EnrollSecret,
dryRun bool,
) (*fleet.Team, error) {
agentOptions := &spec.AgentOptions
if len(spec.AgentOptions) == 0 {
agentOptions = appCfg.AgentOptions
}
// if a team spec is not provided, use the global features, otherwise
// build a new config from the spec with default values applied.
var err error
features := appCfg.Features
if spec.Features != nil {
features, err = unmarshalWithGlobalDefaults(spec.Features)
if err != nil {
return nil, err
}
}
if err := validateVulnExposureFilters(ctx, features.VulnerabilityExposureHistoricalReporting); err != nil {
return nil, err
}
var macOSSettings fleet.MacOSSettings
if err := svc.applyTeamMacOSSettings(ctx, spec, &macOSSettings); err != nil {
return nil, err
}
macOSSetup := spec.MDM.MacOSSetup
if !macOSSetup.EnableReleaseDeviceManually.Valid {
macOSSetup.EnableReleaseDeviceManually = optjson.SetBool(false)
}
if !macOSSetup.EnableManagedLocalAccount.Valid {
macOSSetup.EnableManagedLocalAccount = optjson.SetBool(false)
}
if !macOSSetup.EndUserLocalAccountType.Valid {
macOSSetup.EndUserLocalAccountType = optjson.SetString("admin")
}
if macOSSetup.MacOSSetupAssistant.Value != "" || macOSSetup.BootstrapPackage.Value != "" ||
macOSSetup.EnableReleaseDeviceManually.Value || macOSSetup.ManualAgentInstall.Value {
if !appCfg.MDM.EnabledAndConfigured {
return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("setup_experience",
`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 macOSSetup.LockEndUserInfo.Value && !macOSSetup.EnableEndUserAuthentication {
return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("setup_experience.lock_end_user_info", `"enable_end_user_authentication" must be set to "true" in order to enable "lock_end_user_info"`))
}
if macOSSetup.RequireAllSoftwareWindows && !appCfg.MDM.WindowsEnabledAndConfigured {
return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("setup_experience.require_all_software_windows",
`Couldn't update setup_experience.require_all_software_windows. `+fleet.ErrWindowsMDMNotConfigured.Error()))
}
// Default the value of "lock_end_user_info" to the value of "enable_end_user_authentication" if not explicitly set in the spec to keep prior
// behavior.
if !macOSSetup.LockEndUserInfo.Valid {
macOSSetup.LockEndUserInfo = optjson.SetBool(macOSSetup.EnableEndUserAuthentication)
}
enableDiskEncryption := spec.MDM.EnableDiskEncryption.Value
if !spec.MDM.EnableDiskEncryption.Valid {
if de := macOSSettings.DeprecatedEnableDiskEncryption; de != nil {
enableDiskEncryption = *de
}
}
nameTemplate := spec.MDM.HostNameTemplate.Value
if nameTemplate != "" {
validated, err := fleet.ValidateHostNameTemplateWithSecrets(ctx, svc.ds, nameTemplate)
if err != nil {
return nil, ctxerr.Wrap(ctx, err)
}
nameTemplate = validated
}
invalid := &fleet.InvalidArgumentError{}
if enableDiskEncryption && 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")
}
fleet.ValidateMDMProfileSpecs(invalid, "macos", macOSSettings.CustomSettings)
fleet.ValidateMDMProfileSpecs(invalid, "windows", spec.MDM.WindowsSettings.CustomSettings.Value)
fleet.ValidateMDMProfileSpecs(invalid, "android", spec.MDM.AndroidSettings.CustomSettings.Value)
var hostExpirySettings fleet.HostExpirySettings
if spec.HostExpirySettings != nil {
if spec.HostExpirySettings.HostExpiryEnabled && spec.HostExpirySettings.HostExpiryWindow <= 0 {
invalid.Append(
"host_expiry_settings.host_expiry_window", "When enabling host expiry, host expiry window must be a positive number.",
)
}
hostExpirySettings = *spec.HostExpirySettings
}
var hostStatusWebhook *fleet.HostStatusWebhookSettings
if spec.WebhookSettings.HostStatusWebhook != nil {
fleet.ValidateEnabledHostStatusIntegrations(*spec.WebhookSettings.HostStatusWebhook, invalid)
hostStatusWebhook = spec.WebhookSettings.HostStatusWebhook
}
if spec.Integrations.GoogleCalendar != nil {
err = svc.validateTeamCalendarIntegrations(spec.Integrations.GoogleCalendar, appCfg, dryRun, invalid)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "validate team calendar integrations")
}
}
var conditionalAccessEnabled optjson.Bool
if spec.Integrations.ConditionalAccessEnabled != nil {
if err := fleet.ValidateConditionalAccessIntegration(ctx,
svc,
appCfg.ConditionalAccess,
false,
*spec.Integrations.ConditionalAccessEnabled,
); err != nil {
return nil, ctxerr.Wrap(ctx, err)
}
conditionalAccessEnabled = optjson.SetBool(*spec.Integrations.ConditionalAccessEnabled)
}
if dryRun {
for _, secret := range secrets {
available, err := svc.ds.IsEnrollSecretAvailable(ctx, secret.Secret, true, nil)
if err != nil {
return nil, err
}
if !available {
invalid.Append("secrets", fmt.Sprintf("a provided enroll secret for team '%s' is already being used", spec.Name))
break
}
}
}
if invalid.HasErrors() {
return nil, ctxerr.Wrap(ctx, invalid)
}
if dryRun {
return &fleet.Team{Name: spec.Name}, nil
}
tm, err := svc.ds.NewTeam(ctx, &fleet.Team{
Name: spec.Name,
Filename: spec.Filename,
Config: fleet.TeamConfig{
AgentOptions: agentOptions,
Features: features,
MDM: fleet.TeamMDM{
EnableDiskEncryption: enableDiskEncryption,
EnableRecoveryLockPassword: spec.MDM.EnableRecoveryLockPassword.Value,
RequireBitLockerPIN: spec.MDM.RequireBitLockerPIN.Value,
MacOSUpdates: spec.MDM.MacOSUpdates,
WindowsUpdates: spec.MDM.WindowsUpdates,
MacOSSettings: macOSSettings,
MacOSSetup: macOSSetup,
WindowsSettings: spec.MDM.WindowsSettings,
AndroidSettings: spec.MDM.AndroidSettings,
HostNameTemplate: nameTemplate,
},
HostExpirySettings: hostExpirySettings,
WebhookSettings: fleet.TeamWebhookSettings{
HostStatusWebhook: hostStatusWebhook,
},
Integrations: fleet.TeamIntegrations{
GoogleCalendar: spec.Integrations.GoogleCalendar,
ConditionalAccessEnabled: conditionalAccessEnabled,
},
Software: spec.Software,
},
Secrets: secrets,
})
if err != nil {
return nil, err
}
if tm.Config.MDM.WindowsUpdates.DeadlineDays.Valid {
if err := svc.mdmWindowsEnableOSUpdates(ctx, &tm.ID, tm.Config.MDM.WindowsUpdates); err != nil {
return nil, ctxerr.Wrap(ctx, err, "enable team windows OS updates")
}
}
if conditionalAccessEnabled.Set && conditionalAccessEnabled.Value {
if err := svc.NewActivity(
ctx,
authz.UserFromContext(ctx),
fleet.ActivityTypeEnabledConditionalAccessAutomations{
TeamID: &tm.ID,
TeamName: tm.Name,
},
); err != nil {
return nil, ctxerr.Wrap(ctx, err, "create activity for conditional access")
}
}
if enableDiskEncryption && appCfg.MDM.EnabledAndConfigured {
// TODO: Are we missing an activity or anything else for BitLocker here?
if err := svc.MDMAppleEnableFileVaultAndEscrow(ctx, &tm.ID); err != nil {
return nil, ctxerr.Wrap(ctx, err, "enable team filevault and escrow")
}
if err := svc.NewActivity(
ctx,
authz.UserFromContext(ctx),
fleet.ActivityTypeEnabledMacosDiskEncryption{TeamID: &tm.ID, TeamName: &tm.Name},
); err != nil {
return nil, ctxerr.Wrap(ctx, err, "create activity for team macos disk encryption")
}
}
if spec.MDM.EnableRecoveryLockPassword.Value && appCfg.MDM.EnabledAndConfigured {
if err := svc.NewActivity(
ctx,
authz.UserFromContext(ctx),
fleet.ActivityTypeEnabledRecoveryLockPasswords{TeamID: &tm.ID, TeamName: &tm.Name},
); err != nil {
return nil, ctxerr.Wrap(ctx, err, "create activity for team recovery lock password")
}
}
if nameTemplate != "" {
if err := svc.applyHostNameTemplateChange(ctx, tm, nameTemplate); err != nil {
return nil, err
}
}
return tm, nil
}
func (svc *Service) editTeamFromSpec(
ctx context.Context,
team *fleet.Team,
spec *fleet.TeamSpec,
appCfg *fleet.AppConfig,
secrets []*fleet.EnrollSecret,
opts fleet.ApplyTeamSpecOptions,
) error {
if !opts.DryRun {
// We keep the original name for dry run because subsequent dry run calls may need the original name to fetch the team
team.Name = spec.Name
}
team.Filename = spec.Filename
// if agent options are not provided, do not change them
if len(spec.AgentOptions) > 0 {
if bytes.Equal(spec.AgentOptions, jsonNull) {
// agent options provided but null, clear existing agent option
team.Config.AgentOptions = nil
} else {
team.Config.AgentOptions = &spec.AgentOptions
}
}
// replace (don't merge) the features with the new ones, using a config
// that has the global defaults applied.
oldHistoricalData := team.Config.Features.HistoricalData
features, err := unmarshalWithGlobalDefaults(spec.Features)
if err != nil {
return err
}
team.Config.Features = features
if err := validateVulnExposureFilters(ctx, team.Config.Features.VulnerabilityExposureHistoricalReporting); err != nil {
return err
}
// Check OS update settings.
var (
mdmMacOSUpdatesEdited bool
mdmIOSUpdatesEdited bool
mdmIPadOSUpdatesEdited bool
mdmWindowsUpdatesEdited bool
)
if spec.MDM.MacOSUpdates.Deadline.Set || spec.MDM.MacOSUpdates.MinimumVersion.Set || spec.MDM.MacOSUpdates.UpdateNewHosts.Set {
mdmMacOSUpdatesEdited = team.Config.MDM.MacOSUpdates.MinimumVersion.Value != spec.MDM.MacOSUpdates.MinimumVersion.Value ||
team.Config.MDM.MacOSUpdates.Deadline.Value != spec.MDM.MacOSUpdates.Deadline.Value
team.Config.MDM.MacOSUpdates = spec.MDM.MacOSUpdates
}
if spec.MDM.IOSUpdates.Deadline.Set || spec.MDM.IOSUpdates.MinimumVersion.Set {
mdmIOSUpdatesEdited = team.Config.MDM.IOSUpdates.MinimumVersion.Value != spec.MDM.IOSUpdates.MinimumVersion.Value ||
team.Config.MDM.IOSUpdates.Deadline.Value != spec.MDM.IOSUpdates.Deadline.Value
team.Config.MDM.IOSUpdates = spec.MDM.IOSUpdates
}
if spec.MDM.IPadOSUpdates.Deadline.Set || spec.MDM.IPadOSUpdates.MinimumVersion.Set {
mdmIPadOSUpdatesEdited = team.Config.MDM.IPadOSUpdates.MinimumVersion.Value != spec.MDM.IPadOSUpdates.MinimumVersion.Value ||
team.Config.MDM.IPadOSUpdates.Deadline.Value != spec.MDM.IPadOSUpdates.Deadline.Value
team.Config.MDM.IPadOSUpdates = spec.MDM.IPadOSUpdates
}
if spec.MDM.MacOSUpdates.Configured() || spec.MDM.IOSUpdates.Configured() || spec.MDM.IPadOSUpdates.Configured() {
// Verify that we don't have a custom OS updates declaration
hasProfile, err := svc.ds.HasAppleUpdateConfigProfileConfigured(ctx, team.ID)
if err != nil {
return ctxerr.Wrap(ctx, err, "check for existing custom OS updates declaration profile")
}
if hasProfile {
return &fleet.BadRequestError{
Message: fleet.CouldNotUpdateAppleOSSettingsWithCustomProfileErrorMessage,
}
}
}
if spec.MDM.WindowsUpdates.DeadlineDays.Set || spec.MDM.WindowsUpdates.GracePeriodDays.Set {
mdmWindowsUpdatesEdited = team.Config.MDM.WindowsUpdates.DeadlineDays.Value != spec.MDM.WindowsUpdates.DeadlineDays.Value ||
team.Config.MDM.WindowsUpdates.GracePeriodDays.Value != spec.MDM.WindowsUpdates.GracePeriodDays.Value
team.Config.MDM.WindowsUpdates = spec.MDM.WindowsUpdates
}
if spec.MDM.WindowsUpdates.Configured() {
// Verify that we don't have a custom OS updates profile
hasProfile, err := svc.ds.HasWindowsUpdateConfigProfileConfigured(ctx, team.ID)
if err != nil {
return ctxerr.Wrap(ctx, err, "check for existing custom OS updates profile")
}
if hasProfile {
return &fleet.BadRequestError{
Message: fleet.CouldNotUpdateWindowsOSSettingsWithCustomProfileErrorMessage,
}
}
}
oldEnableDiskEncryption := team.Config.MDM.EnableDiskEncryption
if err := svc.applyTeamMacOSSettings(ctx, spec, &team.Config.MDM.MacOSSettings); err != nil {
return err
}
// 1. if the spec has the new setting, use that
// 2. else if the spec has the deprecated setting, use that
// 3. otherwise, leave the setting untouched
if spec.MDM.EnableDiskEncryption.Valid {
team.Config.MDM.EnableDiskEncryption = spec.MDM.EnableDiskEncryption.Value
} else if de := team.Config.MDM.MacOSSettings.DeprecatedEnableDiskEncryption; de != nil {
team.Config.MDM.EnableDiskEncryption = *de
}
didUpdateDiskEncryption := team.Config.MDM.EnableDiskEncryption != oldEnableDiskEncryption
if didUpdateDiskEncryption && team.Config.MDM.EnableDiskEncryption && svc.config.Server.PrivateKey == "" {
return ctxerr.New(ctx, "Missing required private key. Learn how to configure the private key here: https://fleetdm.com/learn-more-about/fleet-server-private-key")
}
if spec.MDM.RequireBitLockerPIN.Valid {
team.Config.MDM.RequireBitLockerPIN = spec.MDM.RequireBitLockerPIN.Value
}
var didUpdateRecoveryLockPassword bool
if spec.MDM.EnableRecoveryLockPassword.Valid {
didUpdateRecoveryLockPassword = team.Config.MDM.EnableRecoveryLockPassword != spec.MDM.EnableRecoveryLockPassword.Value
if didUpdateRecoveryLockPassword && !appCfg.MDM.EnabledAndConfigured {
return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("mdm.enable_recovery_lock_password",
`Couldn't update enable_recovery_lock_password because MDM features aren't turned on in Fleet.`))
}
team.Config.MDM.EnableRecoveryLockPassword = spec.MDM.EnableRecoveryLockPassword.Value
}
var didUpdateHostNameTemplate bool
if spec.MDM.HostNameTemplate.Set {
nameTemplate := spec.MDM.HostNameTemplate.Value
// Only validate (a DB round-trip to confirm referenced secrets exist) when
// the template actually changed — GitOps re-applies the spec on every run.
if nameTemplate != "" && nameTemplate != team.Config.MDM.HostNameTemplate {
validated, err := fleet.ValidateHostNameTemplateWithSecrets(ctx, svc.ds, nameTemplate)
if err != nil {
return ctxerr.Wrap(ctx, err)
}
nameTemplate = validated
}
didUpdateHostNameTemplate = team.Config.MDM.HostNameTemplate != nameTemplate
team.Config.MDM.HostNameTemplate = nameTemplate
}
if !team.Config.MDM.MacOSSetup.EnableReleaseDeviceManually.Valid {
team.Config.MDM.MacOSSetup.EnableReleaseDeviceManually = optjson.SetBool(false)
}
if !team.Config.MDM.MacOSSetup.EnableManagedLocalAccount.Valid {
team.Config.MDM.MacOSSetup.EnableManagedLocalAccount = optjson.SetBool(false)
}
if !team.Config.MDM.MacOSSetup.EndUserLocalAccountType.Valid {
team.Config.MDM.MacOSSetup.EndUserLocalAccountType = optjson.SetString("admin")
}
oldMacOSSetup := team.Config.MDM.MacOSSetup
var didUpdateSetupAssistant, didUpdateBootstrapPackage, didUpdateEnableReleaseManually, didUpdateManualAgentInstall, didUpdateEnableManagedLocalAccount, didUpdateEndUserLocalAccountType bool
if spec.MDM.MacOSSetup.MacOSSetupAssistant.Set {
didUpdateSetupAssistant = oldMacOSSetup.MacOSSetupAssistant.Value != spec.MDM.MacOSSetup.MacOSSetupAssistant.Value
team.Config.MDM.MacOSSetup.MacOSSetupAssistant = spec.MDM.MacOSSetup.MacOSSetupAssistant
}
if spec.MDM.MacOSSetup.BootstrapPackage.Set {
didUpdateBootstrapPackage = oldMacOSSetup.BootstrapPackage.Value != spec.MDM.MacOSSetup.BootstrapPackage.Value
team.Config.MDM.MacOSSetup.BootstrapPackage = spec.MDM.MacOSSetup.BootstrapPackage
}
if spec.MDM.MacOSSetup.EnableReleaseDeviceManually.Valid {
didUpdateEnableReleaseManually = oldMacOSSetup.EnableReleaseDeviceManually.Value != spec.MDM.MacOSSetup.EnableReleaseDeviceManually.Value
team.Config.MDM.MacOSSetup.EnableReleaseDeviceManually = spec.MDM.MacOSSetup.EnableReleaseDeviceManually
}
if spec.MDM.MacOSSetup.ManualAgentInstall.Valid {
didUpdateManualAgentInstall = oldMacOSSetup.ManualAgentInstall.Value != spec.MDM.MacOSSetup.ManualAgentInstall.Value
team.Config.MDM.MacOSSetup.ManualAgentInstall = spec.MDM.MacOSSetup.ManualAgentInstall
}
if spec.MDM.MacOSSetup.EnableManagedLocalAccount.Valid {
didUpdateEnableManagedLocalAccount = oldMacOSSetup.EnableManagedLocalAccount.Value != spec.MDM.MacOSSetup.EnableManagedLocalAccount.Value
team.Config.MDM.MacOSSetup.EnableManagedLocalAccount = spec.MDM.MacOSSetup.EnableManagedLocalAccount
}
if spec.MDM.MacOSSetup.EndUserLocalAccountType.Valid {
didUpdateEndUserLocalAccountType = oldMacOSSetup.EndUserLocalAccountType.Value != spec.MDM.MacOSSetup.EndUserLocalAccountType.Value
team.Config.MDM.MacOSSetup.EndUserLocalAccountType = spec.MDM.MacOSSetup.EndUserLocalAccountType
}
// TODO(mna): doesn't look like we create an activity for macos updates when
// modified via spec? Doing the same for Windows, but should we?
if !appCfg.MDM.EnabledAndConfigured &&
((didUpdateSetupAssistant && team.Config.MDM.MacOSSetup.MacOSSetupAssistant.Value != "") ||
(didUpdateBootstrapPackage && team.Config.MDM.MacOSSetup.BootstrapPackage.Value != "") ||
(didUpdateEnableReleaseManually && team.Config.MDM.MacOSSetup.EnableReleaseDeviceManually.Value) ||
(didUpdateManualAgentInstall && team.Config.MDM.MacOSSetup.ManualAgentInstall.Value) ||
(didUpdateEnableManagedLocalAccount && team.Config.MDM.MacOSSetup.EnableManagedLocalAccount.Value) ||
(didUpdateEndUserLocalAccountType && team.Config.MDM.MacOSSetup.EndUserLocalAccountType.Value != "")) {
return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("setup_experience",
`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.`))
}
didUpdateMacOSEndUserAuth := spec.MDM.MacOSSetup.EnableEndUserAuthentication != oldMacOSSetup.EnableEndUserAuthentication
if didUpdateMacOSEndUserAuth && spec.MDM.MacOSSetup.EnableEndUserAuthentication {
// Skip the precondition during dry-run that end-user auth SSO must be configured,
// since we can't tell here if the GitOps run is also doing that configuration.
if !opts.DryRun && appCfg.MDM.EndUserAuthentication.IsEmpty() {
// TODO: update this error message to include steps to resolve the issue once docs for IdP
// config are available
return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("setup_experience.enable_end_user_authentication",
`Couldn't enable setup_experience.enable_end_user_authentication because no IdP is configured for MDM features.`))
}
}
if didUpdateMacOSEndUserAuth {
if err := svc.validateEndUserAuthenticationAndSetupAssistant(ctx, &team.ID); err != nil {
return err
}
}
team.Config.MDM.MacOSSetup.EnableEndUserAuthentication = spec.MDM.MacOSSetup.EnableEndUserAuthentication
if spec.MDM.MacOSSetup.LockEndUserInfo.Valid {
// User explicitly set LockEndUserInfo - use that value.
team.Config.MDM.MacOSSetup.LockEndUserInfo = spec.MDM.MacOSSetup.LockEndUserInfo
} else {
// Otherwise use the value of End User Authentication to maintain previous(unconfigurable) behavior where it was turned on when EUA was enabled
team.Config.MDM.MacOSSetup.LockEndUserInfo = optjson.SetBool(spec.MDM.MacOSSetup.EnableEndUserAuthentication)
}
invalid := &fleet.InvalidArgumentError{}
if !team.Config.MDM.MacOSSetup.EnableEndUserAuthentication && team.Config.MDM.MacOSSetup.LockEndUserInfo.Value {
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"`)
}
didUpdateMacOSRequireAllSoftware := spec.MDM.MacOSSetup.RequireAllSoftware != oldMacOSSetup.RequireAllSoftware
if didUpdateMacOSRequireAllSoftware && spec.MDM.MacOSSetup.RequireAllSoftware {
if !appCfg.MDM.EnabledAndConfigured {
return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("setup_experience.require_all_software",
`Couldn't update setup_experience.require_all_software 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.`))
}
}
team.Config.MDM.MacOSSetup.RequireAllSoftware = spec.MDM.MacOSSetup.RequireAllSoftware
didUpdateWindowsRequireAllSoftware := spec.MDM.MacOSSetup.RequireAllSoftwareWindows != oldMacOSSetup.RequireAllSoftwareWindows
windowsEnabledAndConfigured := appCfg.MDM.WindowsEnabledAndConfigured
if opts.DryRunAssumptions != nil && opts.DryRunAssumptions.WindowsEnabledAndConfigured.Valid {
windowsEnabledAndConfigured = opts.DryRunAssumptions.WindowsEnabledAndConfigured.Value
}
if didUpdateWindowsRequireAllSoftware && spec.MDM.MacOSSetup.RequireAllSoftwareWindows {
if !windowsEnabledAndConfigured {
return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("setup_experience.require_all_software_windows",
`Couldn't update setup_experience.require_all_software_windows. `+fleet.ErrWindowsMDMNotConfigured.Error()))
}
}
team.Config.MDM.MacOSSetup.RequireAllSoftwareWindows = spec.MDM.MacOSSetup.RequireAllSoftwareWindows
// Whether Windows/Android MDM is configured is gated at the global level
// in ModifyAppConfig; createTeamFromSpec doesn't gate custom_settings on
// the configured flag, and gating it here can spuriously reject team
// edits when the cached AppConfig lags behind a recent enable.
if spec.MDM.WindowsSettings.CustomSettings.Set {
team.Config.MDM.WindowsSettings.CustomSettings = spec.MDM.WindowsSettings.CustomSettings
}
var didUpdateWindowsManagedLocalAccount bool
if spec.MDM.WindowsSettings.ManagedLocalAccountSettings.Enabled.Valid {
newWindowsManagedLocalAccount := spec.MDM.WindowsSettings.ManagedLocalAccountSettings.Enabled
didUpdateWindowsManagedLocalAccount = team.Config.MDM.WindowsSettings.ManagedLocalAccountSettings.Enabled.Value != newWindowsManagedLocalAccount.Value
if didUpdateWindowsManagedLocalAccount && newWindowsManagedLocalAccount.Value && !windowsEnabledAndConfigured {
return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("windows_settings.managed_local_account_settings.enabled",
"Couldn't enable windows_settings.managed_local_account_settings. "+fleet.ErrWindowsMDMNotConfigured.Error()))
}
team.Config.MDM.WindowsSettings.ManagedLocalAccountSettings.Enabled = newWindowsManagedLocalAccount
}
if spec.MDM.AndroidSettings.CustomSettings.Set {
team.Config.MDM.AndroidSettings.CustomSettings = spec.MDM.AndroidSettings.CustomSettings
}
if spec.Scripts.Set {
team.Config.Scripts = spec.Scripts
}
if spec.Software != nil {
if team.Config.Software == nil {
team.Config.Software = &fleet.SoftwareSpec{}
}
if spec.Software.Packages.Set {
team.Config.Software.Packages = spec.Software.Packages
}
if spec.Software.AppStoreApps.Set {
team.Config.Software.AppStoreApps = spec.Software.AppStoreApps
}
}
if secrets != nil {
team.Secrets = secrets
}
// if host_expiry_settings are not provided, do not change them
if spec.HostExpirySettings != nil {
if spec.HostExpirySettings.HostExpiryEnabled && spec.HostExpirySettings.HostExpiryWindow <= 0 {
invalid.Append(
"host_expiry_settings.host_expiry_window", "When enabling host expiry, host expiry window must be a positive number.",
)
}
team.Config.HostExpirySettings = *spec.HostExpirySettings
}
fleet.ValidateMDMProfileSpecs(invalid, "apple", team.Config.MDM.MacOSSettings.CustomSettings)
fleet.ValidateMDMProfileSpecs(invalid, "windows", team.Config.MDM.WindowsSettings.CustomSettings.Value)
fleet.ValidateMDMProfileSpecs(invalid, "android", team.Config.MDM.AndroidSettings.CustomSettings.Value)
// If host status webhook is not provided, do not change it
if spec.WebhookSettings.HostStatusWebhook != nil {
fleet.ValidateEnabledHostStatusIntegrations(*spec.WebhookSettings.HostStatusWebhook, invalid)
team.Config.WebhookSettings.HostStatusWebhook = spec.WebhookSettings.HostStatusWebhook
}
if spec.WebhookSettings.FailingPoliciesWebhook != nil {
fleet.ValidateEnabledFailingPoliciesTeamIntegrations(*spec.WebhookSettings.FailingPoliciesWebhook, fleet.TeamIntegrations{}, invalid)
team.Config.WebhookSettings.FailingPoliciesWebhook = *spec.WebhookSettings.FailingPoliciesWebhook
}
if spec.Integrations.GoogleCalendar != nil {
err = svc.validateTeamCalendarIntegrations(spec.Integrations.GoogleCalendar, appCfg, opts.DryRun, invalid)
if err != nil {
return ctxerr.Wrap(ctx, err, "validate team calendar integrations")
}
team.Config.Integrations.GoogleCalendar = spec.Integrations.GoogleCalendar
}
oldConditionalAccessEnabled := team.Config.Integrations.ConditionalAccessEnabled.Value
if spec.Integrations.ConditionalAccessEnabled != nil {
if err := fleet.ValidateConditionalAccessIntegration(ctx,
svc,
appCfg.ConditionalAccess,
team.Config.Integrations.ConditionalAccessEnabled.Value,
*spec.Integrations.ConditionalAccessEnabled,
); err != nil {
return ctxerr.Wrap(ctx, err)
}
team.Config.Integrations.ConditionalAccessEnabled = optjson.SetBool(*spec.Integrations.ConditionalAccessEnabled)
}
if opts.DryRun {
for _, secret := range secrets {
available, err := svc.ds.IsEnrollSecretAvailable(ctx, secret.Secret, false, &team.ID)
if err != nil {
return err
}
if !available {
invalid.Append("secrets", fmt.Sprintf("a provided enroll secret for team '%s' is already being used", spec.Name))
break
}
}
}
if invalid.HasErrors() {
return ctxerr.Wrap(ctx, invalid)
}
if opts.DryRun {
return nil
}
if _, err := svc.ds.SaveTeam(ctx, team); err != nil {
return err
}
// If no secrets are provided and user did not explicitly specify an empty list, do not replace secrets. (#6774)
if secrets != nil {
if err := svc.ds.ApplyEnrollSecrets(ctx, ptr.Uint(team.ID), secrets); err != nil {
return err
}
}
// Emit activities and enqueue scrub jobs for any historical_data sub-key
// whose value flipped on this team via GitOps batch apply. SaveTeam
// (above) has already committed.
//
// Log-and-continue on failure: the joined error covers both activity
// emit and scrub enqueue, both non-fatal individually. See design
// decision 8a of chart-disabling-collection-scrub.
if err := fleet.OnHistoricalDataChanged(
ctx,
svc,
svc.ds,
authz.UserFromContext(ctx),
oldHistoricalData,
team.Config.Features.HistoricalData,
&team.ID, &team.Name,
); err != nil {
err = ctxerr.Wrap(ctx, err, "OnHistoricalDataChanged")
ctxerr.Handle(ctx, err)
svc.logger.ErrorContext(ctx, "OnHistoricalDataChanged", "err", err, "team_id", team.ID)
}
if appCfg.MDM.EnabledAndConfigured && didUpdateDiskEncryption {
// TODO: Are we missing an activity or anything else for BitLocker here?
var act fleet.ActivityDetails
if team.Config.MDM.EnableDiskEncryption {
act = fleet.ActivityTypeEnabledMacosDiskEncryption{TeamID: &team.ID, TeamName: &team.Name}
if err := svc.MDMAppleEnableFileVaultAndEscrow(ctx, &team.ID); err != nil {
return ctxerr.Wrap(ctx, err, "enable team filevault and escrow")
}
} else {
act = fleet.ActivityTypeDisabledMacosDiskEncryption{TeamID: &team.ID, TeamName: &team.Name}
if err := svc.MDMAppleDisableFileVaultAndEscrow(ctx, &team.ID); err != nil {
return ctxerr.Wrap(ctx, err, "disable team filevault and escrow")
}
}
if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), act); err != nil {
return ctxerr.Wrap(ctx, err, "create activity for team macos disk encryption")
}
}
if appCfg.MDM.EnabledAndConfigured && didUpdateRecoveryLockPassword {
var act fleet.ActivityDetails
if team.Config.MDM.EnableRecoveryLockPassword {
act = fleet.ActivityTypeEnabledRecoveryLockPasswords{TeamID: &team.ID, TeamName: &team.Name}
} else {
act = fleet.ActivityTypeDisabledRecoveryLockPasswords{TeamID: &team.ID, TeamName: &team.Name}
}
if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), act); err != nil {
return ctxerr.Wrap(ctx, err, "create activity for team recovery lock password")
}
}
if didUpdateHostNameTemplate {
if err := svc.applyHostNameTemplateChange(ctx, team, team.Config.MDM.HostNameTemplate); err != nil {
return err
}
}
// if the macos setup assistant was cleared, remove it for that team
if spec.MDM.MacOSSetup.MacOSSetupAssistant.Set &&
spec.MDM.MacOSSetup.MacOSSetupAssistant.Value == "" &&
oldMacOSSetup.MacOSSetupAssistant.Value != "" {
if err := svc.DeleteMDMAppleSetupAssistant(ctx, &team.ID); err != nil {
return ctxerr.Wrapf(ctx, err, "clear macos setup assistant for team %d", team.ID)
}
}
// if the bootstrap package was cleared, remove it for that team
if spec.MDM.MacOSSetup.BootstrapPackage.Set &&
spec.MDM.MacOSSetup.BootstrapPackage.Value == "" &&
oldMacOSSetup.BootstrapPackage.Value != "" {
if err := svc.DeleteMDMAppleBootstrapPackage(ctx, &team.ID, opts.DryRun); err != nil {
// The package may have already been deleted via the GUI while the
// team config JSON still had the stale URL; ignore not-found.
if !fleet.IsNotFound(err) {
return ctxerr.Wrapf(ctx, err, "clear bootstrap package for team %d", team.ID)
}
}
}
// if the setup experience script was cleared, remove it for that team
if spec.MDM.MacOSSetup.Script.Set &&
spec.MDM.MacOSSetup.Script.Value == "" &&
oldMacOSSetup.Script.Value != "" {
if err := svc.DeleteSetupExperienceScript(ctx, &team.ID); err != nil {
return ctxerr.Wrapf(ctx, err, "clear setup experience script for team %d", team.ID)
}
}
if didUpdateMacOSEndUserAuth {
if err := svc.updateMacOSSetupEnableEndUserAuth(
ctx, spec.MDM.MacOSSetup.EnableEndUserAuthentication, &team.ID, &team.Name,
); err != nil {
return err
}
}
if didUpdateEnableManagedLocalAccount {
if err := svc.logEnableManagedLocalAccountActivity(
ctx, team.Config.MDM.MacOSSetup.EnableManagedLocalAccount.Value, "darwin", &team.ID, &team.Name,
); err != nil {
return err
}
}
if didUpdateWindowsManagedLocalAccount {
if err := svc.logEnableManagedLocalAccountActivity(
ctx, team.Config.MDM.WindowsSettings.ManagedLocalAccountSettings.Enabled.Value, "windows", &team.ID, &team.Name,
); err != nil {
return err
}
}
// Update OS update settings if they were updated.
if mdmMacOSUpdatesEdited {
if err := svc.mdmAppleEditedAppleOSUpdates(ctx, &team.ID, fleet.MacOS, team.Config.MDM.MacOSUpdates); err != nil {
return err
}
}
if mdmIOSUpdatesEdited {
if err := svc.mdmAppleEditedAppleOSUpdates(ctx, &team.ID, fleet.IOS, team.Config.MDM.IOSUpdates); err != nil {
return err
}
}
if mdmIPadOSUpdatesEdited {
if err := svc.mdmAppleEditedAppleOSUpdates(ctx, &team.ID, fleet.IPadOS, team.Config.MDM.IPadOSUpdates); err != nil {
return err
}
}
if mdmWindowsUpdatesEdited {
if team.Config.MDM.WindowsUpdates.DeadlineDays.Valid {
if err := svc.mdmWindowsEnableOSUpdates(ctx, &team.ID, team.Config.MDM.WindowsUpdates); err != nil {
return ctxerr.Wrap(ctx, err, "enable team windows OS updates")
}
} else {
if err := svc.mdmWindowsDisableOSUpdates(ctx, &team.ID); err != nil {
return ctxerr.Wrap(ctx, err, "disable team windows OS updates")
}
}
}
// Create activity if conditional access was enabled or disabled for the team.
if spec.Integrations.ConditionalAccessEnabled != nil {
if *spec.Integrations.ConditionalAccessEnabled {
if !oldConditionalAccessEnabled {
if err := svc.NewActivity(
ctx,
authz.UserFromContext(ctx),
fleet.ActivityTypeEnabledConditionalAccessAutomations{
TeamID: &team.ID,
TeamName: team.Name,
},
); err != nil {
return ctxerr.Wrap(ctx, err, "create activity for enabling conditional access")
}
}
} else {
if oldConditionalAccessEnabled {
if err := svc.NewActivity(
ctx,
authz.UserFromContext(ctx),
fleet.ActivityTypeDisabledConditionalAccessAutomations{
TeamID: &team.ID,
TeamName: team.Name,
},
); err != nil {
return ctxerr.Wrap(ctx, err, "create activity for disabling conditional access")
}
}
}
}
return nil
}
func (svc *Service) validateTeamCalendarIntegrations(
calendarIntegration *fleet.TeamGoogleCalendarIntegration,
appCfg *fleet.AppConfig, dryRun bool, invalid *fleet.InvalidArgumentError,
) error {
if !calendarIntegration.Enable {
return nil
}
// Check that global configs exist. During dry run, the global config may not be available yet.
if len(appCfg.Integrations.GoogleCalendar) == 0 && !dryRun {
invalid.Append("integrations.google_calendar.enable_calendar_events", "global Google Calendar integration is not configured")
}
// Validate URL
if u, err := url.ParseRequestURI(calendarIntegration.WebhookURL); err != nil {
invalid.Append("integrations.google_calendar.webhook_url", err.Error())
} else if u.Scheme != "https" && u.Scheme != "http" {
invalid.Append("integrations.google_calendar.webhook_url", "webhook_url must be https or http")
}
return nil
}
func (svc *Service) applyTeamMacOSSettings(ctx context.Context, spec *fleet.TeamSpec, applyUpon *fleet.MacOSSettings) error {
oldCustomSettings := applyUpon.CustomSettings
setFields, err := applyUpon.FromMap(spec.MDM.MacOSSettings)
if err != nil {
return fleet.NewUserMessageError(err, http.StatusBadRequest)
}
appCfg, err := svc.ds.AppConfig(ctx)
if err != nil {
return ctxerr.Wrap(ctx, err, "apply team macos settings")
}
customSettingsChanged := setFields["custom_settings"] &&
len(applyUpon.CustomSettings) > 0 &&
!fleet.MDMProfileSpecsMatch(applyUpon.CustomSettings, oldCustomSettings)
if customSettingsChanged || (setFields["enable_disk_encryption"] && *applyUpon.DeprecatedEnableDiskEncryption) {
field := "custom_settings"
if !setFields["custom_settings"] {
field = "enable_disk_encryption"
}
if !appCfg.MDM.EnabledAndConfigured {
// TODO: Address potential edge cases when teams that previously utilized MDM features
// are edited later edited when MDM disabled
return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError(fmt.Sprintf("apple_settings.%s", field),
`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.`))
}
}
return nil
}
// unmarshalWithGlobalDefaults unmarshals features from a team spec, and
// assigns default values based on the global defaults for missing fields
func unmarshalWithGlobalDefaults(b *json.RawMessage) (fleet.Features, error) {
// build a default config with default values applied
defaults := &fleet.Features{}
defaults.ApplyDefaultsForNewInstalls()
// unmarshal the features from the spec into the defaults
if b != nil {
if err := json.Unmarshal(*b, defaults); err != nil {
return fleet.Features{}, err
}
}
return *defaults, nil
}
func (svc *Service) updateTeamMDMDiskEncryption(ctx context.Context, tm *fleet.Team, enable *bool, requireBitLockerPIN *bool) error {
var didUpdateEncryption bool
var didUpdateRequirePIN bool
if enable != nil {
if tm.Config.MDM.EnableDiskEncryption != *enable {
if *enable && svc.config.Server.PrivateKey == "" {
return ctxerr.New(ctx, "Missing required private key. Learn how to configure the private key here: https://fleetdm.com/learn-more-about/fleet-server-private-key")
}
tm.Config.MDM.EnableDiskEncryption = *enable
didUpdateEncryption = true
}
}
if requireBitLockerPIN != nil {
if tm.Config.MDM.RequireBitLockerPIN != *requireBitLockerPIN {
tm.Config.MDM.RequireBitLockerPIN = *requireBitLockerPIN
didUpdateRequirePIN = true
}
}
if didUpdateEncryption || didUpdateRequirePIN {
if didUpdateEncryption && !tm.Config.MDM.EnableDiskEncryption && tm.Config.MDM.RequireBitLockerPIN {
return ctxerr.New(ctx, fleet.CantDisableDiskEncryptionIfPINRequiredErrMsg)
}
if !didUpdateEncryption && !tm.Config.MDM.EnableDiskEncryption && tm.Config.MDM.RequireBitLockerPIN {
return ctxerr.New(ctx, fleet.CantEnablePINRequiredIfDiskEncryptionEnabled)
}
if _, err := svc.ds.SaveTeam(ctx, tm); err != nil {
return err
}
if didUpdateEncryption {
appCfg, err := svc.ds.AppConfig(ctx)
if err != nil {
return err
}
if appCfg.MDM.EnabledAndConfigured {
var act fleet.ActivityDetails
if tm.Config.MDM.EnableDiskEncryption {
act = fleet.ActivityTypeEnabledMacosDiskEncryption{TeamID: &tm.ID, TeamName: &tm.Name}
if err := svc.MDMAppleEnableFileVaultAndEscrow(ctx, &tm.ID); err != nil {
return ctxerr.Wrap(ctx, err, "enable team filevault and escrow")
}
} else {
act = fleet.ActivityTypeDisabledMacosDiskEncryption{TeamID: &tm.ID, TeamName: &tm.Name}
if err := svc.MDMAppleDisableFileVaultAndEscrow(ctx, &tm.ID); err != nil {
return ctxerr.Wrap(ctx, err, "disable team filevault and escrow")
}
}
if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), act); err != nil {
return ctxerr.Wrap(ctx, err, "create activity for team macos disk encryption")
}
}
}
}
return nil
}
func (svc *Service) updateTeamMDMHostNameTemplate(ctx context.Context, tm *fleet.Team, nameTemplate string) error {
if tm.Config.MDM.HostNameTemplate == nameTemplate {
return nil
}
tm.Config.MDM.HostNameTemplate = nameTemplate
if _, err := svc.ds.SaveTeam(ctx, tm); err != nil {
return err
}
return svc.applyHostNameTemplateChange(ctx, tm, nameTemplate)
}
// applyHostNameTemplateChange reconciles host-name enforcement rows and emits
// the edited_host_name_template activity for a template change.
func (svc *Service) applyHostNameTemplateChange(ctx context.Context, team *fleet.Team, nameTemplate string) error {
var fleetID *uint
var fleetName *string
if team != nil {
fleetID, fleetName = &team.ID, &team.Name
}
if nameTemplate == "" {
if err := svc.ds.DeleteHostDeviceNameEnforcementForTeam(ctx, fleetID); err != nil {
return ctxerr.Wrap(ctx, err, "delete host name enforcement for team")
}
} else if err := svc.ds.BulkUpsertHostDeviceNameEnforcement(ctx, fleetID); err != nil {
return ctxerr.Wrap(ctx, err, "queue host name enforcement for team")
}
var tmpl *string
if nameTemplate != "" {
tmpl = &nameTemplate
}
if err := svc.NewActivity(
ctx,
authz.UserFromContext(ctx),
fleet.ActivityTypeEditedHostNameTemplate{
FleetID: fleetID,
FleetName: fleetName,
HostNameTemplate: tmpl,
},
); err != nil {
return ctxerr.Wrap(ctx, err, "create activity for team host name template")
}
return nil
}
func (svc *Service) updateTeamMDMAppleSetup(ctx context.Context, tm *fleet.Team, payload fleet.MDMAppleSetupPayload) error {
appCfg, err := svc.ds.AppConfig(ctx)
if err != nil {
return ctxerr.Wrap(ctx, err, "fetch app config")
}
var didUpdate, didUpdateMacOSEndUserAuth, didUpdateMacOSManagedLocalAccount bool
if payload.EnableEndUserAuthentication != nil {
if tm.Config.MDM.MacOSSetup.EnableEndUserAuthentication != *payload.EnableEndUserAuthentication {
tm.Config.MDM.MacOSSetup.EnableEndUserAuthentication = *payload.EnableEndUserAuthentication
didUpdate = true
didUpdateMacOSEndUserAuth = true
}
}
if payload.LockEndUserInfo != nil {
if tm.Config.MDM.MacOSSetup.LockEndUserInfo.Value != *payload.LockEndUserInfo {
tm.Config.MDM.MacOSSetup.LockEndUserInfo = optjson.SetBool(*payload.LockEndUserInfo)
didUpdate = true
}
}
// 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 below stays satisfied.
if didUpdateMacOSEndUserAuth && payload.LockEndUserInfo == nil &&
(appCfg.MDM.EnabledAndConfigured || !tm.Config.MDM.MacOSSetup.EnableEndUserAuthentication) {
tm.Config.MDM.MacOSSetup.LockEndUserInfo = optjson.SetBool(tm.Config.MDM.MacOSSetup.EnableEndUserAuthentication)
}
if !tm.Config.MDM.MacOSSetup.EnableEndUserAuthentication && tm.Config.MDM.MacOSSetup.LockEndUserInfo.Value {
return fleet.NewUserMessageError(errors.New(`Couldn't edit. "enable_end_user_authentication" must be set to "true" in order to enable "lock_end_user_info".`), http.StatusUnprocessableEntity)
}
if payload.EnableReleaseDeviceManually != nil {
if tm.Config.MDM.MacOSSetup.EnableReleaseDeviceManually.Value != *payload.EnableReleaseDeviceManually {
tm.Config.MDM.MacOSSetup.EnableReleaseDeviceManually = optjson.SetBool(*payload.EnableReleaseDeviceManually)
didUpdate = true
}
}
if payload.RequireAllSoftware != nil && tm.Config.MDM.MacOSSetup.RequireAllSoftware != *payload.RequireAllSoftware {
tm.Config.MDM.MacOSSetup.RequireAllSoftware = *payload.RequireAllSoftware
didUpdate = true
}
if payload.RequireAllSoftwareWindows != nil && tm.Config.MDM.MacOSSetup.RequireAllSoftwareWindows != *payload.RequireAllSoftwareWindows {
tm.Config.MDM.MacOSSetup.RequireAllSoftwareWindows = *payload.RequireAllSoftwareWindows
didUpdate = true
}
if payload.ManualAgentInstall != nil {
if tm.Config.MDM.MacOSSetup.ManualAgentInstall.Value != *payload.ManualAgentInstall {
// Try to load the bootstrap package to verify it exists.
_, err := svc.GetMDMAppleBootstrapPackageMetadata(ctx, tm.ID, false)
// If we got an error other than not found, return it.
if err != nil && !fleet.IsNotFound(err) {
return ctxerr.Wrap(ctx, err, "checking bootstrap package")
}
// Otherwise if we got a not found error, we can't enable manual agent install.
if *payload.ManualAgentInstall && err != nil {
return fleet.NewUserMessageError(errors.New("Couldnt enable macos_manual_agent_install. To use this option, first specify a macos_bootstrap_package."), http.StatusUnprocessableEntity)
}
sec, err := svc.ds.GetSetupExperienceCount(ctx, string(fleet.MacOSPlatform), &tm.ID)
if err != nil {
return ctxerr.Wrap(ctx, err, "getting setup experience information")
}
if sec.Installers != 0 || sec.VPP != 0 {
return fleet.NewUserMessageError(errors.New("Couldnt enable macos_manual_agent_install. To use this option, first disable setup experience software."), http.StatusUnprocessableEntity)
}
if sec.Scripts != 0 {
return fleet.NewUserMessageError(errors.New("Couldnt enable macos_manual_agent_install. To use this option, first remove your setup experience script."), http.StatusUnprocessableEntity)
}
tm.Config.MDM.MacOSSetup.ManualAgentInstall = optjson.SetBool(*payload.ManualAgentInstall)
didUpdate = true
}
}
if payload.EnableManagedLocalAccount != nil {
if !tm.Config.MDM.MacOSSetup.EnableManagedLocalAccount.Valid || tm.Config.MDM.MacOSSetup.EnableManagedLocalAccount.Value != *payload.EnableManagedLocalAccount {
tm.Config.MDM.MacOSSetup.EnableManagedLocalAccount = optjson.SetBool(*payload.EnableManagedLocalAccount)
didUpdateMacOSManagedLocalAccount = true
didUpdate = true
}
}
if didUpdateFromValidation, err := payload.Validate(&tm.Config.MDM.MacOSSetup); err != nil {
return err
} else if didUpdateFromValidation {
didUpdate = true
}
if didUpdate {
if _, err := svc.ds.SaveTeam(ctx, tm); err != nil {
return err
}
if didUpdateMacOSEndUserAuth {
if err := svc.updateMacOSSetupEnableEndUserAuth(ctx, tm.Config.MDM.MacOSSetup.EnableEndUserAuthentication, &tm.ID, &tm.Name); err != nil {
return err
}
}
if didUpdateMacOSManagedLocalAccount {
if err := svc.logEnableManagedLocalAccountActivity(ctx, tm.Config.MDM.MacOSSetup.EnableManagedLocalAccount.Value, "darwin", &tm.ID, &tm.Name); err != nil {
return err
}
}
}
return nil
}
func (svc *Service) validateEndUserAuthenticationAndSetupAssistant(ctx context.Context, tmID *uint) error {
hasCustomConfigurationWebURL, err := svc.HasCustomSetupAssistantConfigurationWebURL(ctx, tmID)
if err != nil {
return ctxerr.Wrap(ctx, err, "checking setup assistant configuration web url")
}
if hasCustomConfigurationWebURL {
return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("setup_experience.enable_end_user_authentication", fleet.EndUserAuthDEPWebURLConfiguredErrMsg))
}
return nil
}
// validateTeamWebhookSettings validates webhook settings for teams and default team config
func validateTeamWebhookSettings(ctx context.Context, webhookSettings *fleet.TeamWebhookSettings) error {
if webhookSettings == nil {
return nil
}
if webhookSettings.FailingPoliciesWebhook.Enable {
if webhookSettings.FailingPoliciesWebhook.DestinationURL == "" {
return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("webhook_settings.failing_policies_webhook.destination_url", "destination URL is required when webhook is enabled"))
}
if _, err := url.Parse(webhookSettings.FailingPoliciesWebhook.DestinationURL); err != nil {
return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("webhook_settings.failing_policies_webhook.destination_url", err.Error()))
}
}
return nil
}
func (svc *Service) modifyDefaultTeamConfig(ctx context.Context, payload fleet.TeamPayload) (*fleet.Team, error) {
// Use same authorization as AppConfig modifications
if err := svc.authz.Authorize(ctx, &fleet.AppConfig{}, fleet.ActionWrite); err != nil {
return nil, err
}
// Get current config
config, err := svc.ds.DefaultTeamConfig(ctx)
if err != nil {
return nil, err
}
// Apply and validate webhook settings if provided
if payload.WebhookSettings != nil {
if err := validateTeamWebhookSettings(ctx, payload.WebhookSettings); err != nil {
return nil, err
}
config.WebhookSettings = *payload.WebhookSettings
}
// Apply integrations if provided
if payload.Integrations != nil {
// Note: GoogleCalendar and ConditionalAccessEnabled are currently not supported for "No team"
// Reject unsupported fields for "No team"
if payload.Integrations.GoogleCalendar != nil ||
payload.Integrations.ConditionalAccessEnabled.Set {
return nil, fleet.NewInvalidArgumentError("integrations",
"google_calendar and conditional_access_enabled are not supported for \"No team\"")
}
// Get app config for integration validation (needed even if clearing integrations)
appCfg, err := svc.ds.AppConfig(ctx)
if err != nil {
return nil, err
}
if payload.Integrations.Jira != nil || payload.Integrations.Zendesk != nil {
// the team integrations must reference an existing global config integration.
if _, err := payload.Integrations.MatchWithIntegrations(appCfg.Integrations); err != nil {
return nil, fleet.NewInvalidArgumentError("integrations", err.Error())
}
// integrations must be unique
if err := payload.Integrations.Validate(); err != nil {
return nil, fleet.NewInvalidArgumentError("integrations", err.Error())
}
}
// Always update integrations when provided (even if empty arrays to clear them)
config.Integrations.Jira = payload.Integrations.Jira
config.Integrations.Zendesk = payload.Integrations.Zendesk
}
// Validate mutual exclusivity of automations if either webhooks or integrations were updated
if payload.WebhookSettings != nil || payload.Integrations != nil {
// must validate that at most only one automation is enabled for each
// supported feature - by now the updated payload has been applied to config.
invalid := &fleet.InvalidArgumentError{}
fleet.ValidateEnabledFailingPoliciesTeamIntegrations(
config.WebhookSettings.FailingPoliciesWebhook,
config.Integrations,
invalid,
)
if invalid.HasErrors() {
return nil, ctxerr.Wrap(ctx, invalid)
}
}
// Save the configuration
if err := svc.ds.SaveDefaultTeamConfig(ctx, config); err != nil {
return nil, err
}
// Return as a Team for API compatibility
team := &fleet.Team{
ID: 0,
Name: fleet.ReservedNameNoTeam,
Config: *config,
}
return team, nil
}