Demo: https://www.youtube.com/watch?v=cWxZlu9WuwA Guide updates: https://github.com/fleetdm/fleet/pull/49603/changes IT admins can configure the fleet that hosts enrolling through user-driven Windows MDM enrollment (Windows Autopilot, Entra join) are automatically assigned to, via the Windows MDM settings page, the mdm.windows_enrollment.default_fleet config setting, or GitOps. - New windows_enrollment_config row stores the default team; the config API surfaces it by fleet name and hydrates reads from the row so team renames and deletions never serve a stale name. Deleting the fleet clears the setting. - New edited_windows_enrollment_default_fleet activity, emitted only when the value changes. - The OMA-DM session persists the device-reported SMBIOS serial on still-unlinked enrollments, and orbit enrollment reverse-links by that serial and assigns the default fleet before orbit's one-shot setup-experience init, so the default fleet's software, scripts, and profiles apply during the Autopilot ESP. The DevDetail and osquery link paths keep the same assignment as fallbacks, and the EUA-token link path now shares the same post-link bookkeeping. - Hosts are only assigned when new to Fleet in this enrollment cycle: existing hosts, including ones parked in Unassigned, keep their fleet on re-enrollment, matching macOS ABM behavior. - GitOps defers applying the setting until teams declared in the same run are created, and fleetctl generate-gitops exports it. - Windows MDM settings page redesign per Figma: programmatic enrollment toggle, User driven enrollment section with the Entra-gated Default fleet dropdown, and a Migration section. <!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** Resolves #41787 # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files) for more information. - [x] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements), JS inline code is prevented especially for url redirects, and untrusted data interpolated into shell scripts/commands is validated against shell metacharacters. - [x] Timeouts are implemented and retries are limited to avoid infinite loops ## Testing - [x] Added/updated automated tests - [x] Where appropriate, [automated tests simulate multiple hosts and test for host isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing) (updates to one hosts's records do not affect another) - [x] QA'd all new/changed functionality manually ## Database migrations - [x] Checked schema for all modified table for columns that will auto-update timestamps during migration. - [x] Ensured the correct collation is explicitly set for character columns (`COLLATE utf8mb4_unicode_ci`). ## New Fleet configuration settings - [x] Verified that the setting is exported via `fleetctl generate-gitops` - [x] Verified the setting is documented in a separate PR to [the GitOps documentation](https://github.com/fleetdm/fleet/blob/main/docs/Configuration/yaml-files.md#L485) - [x] Verified that the setting is cleared on the server if it is not supplied in a YAML file (or that it is documented as being optional) - [x] Verified that any relevant UI is disabled when GitOps mode is enabled <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added support for assigning a default Fleet Premium fleet to new Windows MDM enrollments, including Autopilot and Entra join. * Default-fleet settings can be configured, cleared, and managed through Windows MDM settings and GitOps. * Assigned fleet software, scripts, and profiles can apply during out-of-box setup. * Added activity-feed visibility for default-fleet changes. * Improved Windows enrollment matching using hardware serial numbers. * **Documentation** * Documented default-fleet assignment for Windows enrollment. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1461 lines
52 KiB
Go
1461 lines
52 KiB
Go
package fleetctl
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
|
|
"github.com/fleetdm/fleet/v4/pkg/spec"
|
|
"github.com/fleetdm/fleet/v4/server/fleet"
|
|
"github.com/fleetdm/fleet/v4/server/platform/logging"
|
|
"github.com/fleetdm/fleet/v4/server/ptr"
|
|
"github.com/fleetdm/fleet/v4/server/service"
|
|
"github.com/urfave/cli/v2"
|
|
"golang.org/x/text/unicode/norm"
|
|
)
|
|
|
|
const (
|
|
filenameMaxLength = 255
|
|
ReapplyingTeamForVPPAppsMsg = "[!] re-applying configs for team %s to set VPP apps\n"
|
|
)
|
|
|
|
type LabelUsage struct {
|
|
Name string
|
|
Type string
|
|
}
|
|
|
|
func gitopsCommand() *cli.Command {
|
|
var (
|
|
flFilenames cli.StringSlice
|
|
flDryRun bool
|
|
flDeleteOtherTeams bool
|
|
flAllowUnknownKeys bool
|
|
flConcurrentIconUploads int
|
|
flConcurrentIconUpdates int
|
|
)
|
|
return &cli.Command{
|
|
Name: "gitops",
|
|
Usage: "This command is used by Fleet's best practice GitOps workflow.",
|
|
UsageText: `fleetctl gitops [options]`,
|
|
Flags: []cli.Flag{
|
|
&cli.StringSliceFlag{
|
|
Name: "f",
|
|
Required: true,
|
|
EnvVars: []string{"FILENAME"},
|
|
Destination: &flFilenames,
|
|
Usage: "The file(s) with the GitOps configuration.",
|
|
},
|
|
&cli.BoolFlag{
|
|
Name: "delete-other-fleets",
|
|
Aliases: []string{"delete-other-teams"},
|
|
EnvVars: []string{"DELETE_OTHER_FLEETS", "DELETE_OTHER_TEAMS"},
|
|
Destination: &flDeleteOtherTeams,
|
|
Usage: "Delete other fleets not present in the GitOps configuration",
|
|
},
|
|
&cli.BoolFlag{
|
|
Name: "dry-run",
|
|
EnvVars: []string{"DRY_RUN"},
|
|
Destination: &flDryRun,
|
|
Usage: "Do not apply the file(s), just validate",
|
|
},
|
|
&cli.BoolFlag{
|
|
Name: "allow-unknown-keys",
|
|
EnvVars: []string{"ALLOW_UNKNOWN_KEYS"},
|
|
Destination: &flAllowUnknownKeys,
|
|
Usage: "Log unknown keys as warnings instead of failing with errors",
|
|
},
|
|
&cli.IntFlag{
|
|
Name: "icons-concurrent-uploads",
|
|
EnvVars: []string{"ICONS_CONCURRENT_UPLOADS"},
|
|
Destination: &flConcurrentIconUploads,
|
|
Usage: "Number of custom software icons to upload simultaneously",
|
|
Value: 4,
|
|
Hidden: true,
|
|
},
|
|
&cli.IntFlag{
|
|
Name: "icons-concurrent-updates",
|
|
EnvVars: []string{"ICONS_CONCURRENT_UPDATES"},
|
|
Destination: &flConcurrentIconUpdates,
|
|
Usage: "Number of simultaneous requests to make for updating custom software icons when the icon files themselves have already been uploaded",
|
|
Value: 10,
|
|
Hidden: true,
|
|
},
|
|
configFlag(),
|
|
contextFlag(),
|
|
debugFlag(),
|
|
enableLogTopicsFlag(),
|
|
disableLogTopicsFlag(),
|
|
},
|
|
Action: func(c *cli.Context) error {
|
|
// Apply log topic overrides from CLI flags.
|
|
applyLogTopicFlags(c)
|
|
|
|
logDeprecatedFlagName(c, "delete-other-teams", "delete-other-fleets")
|
|
logDeprecatedEnvVar(c, "DELETE_OTHER_TEAMS", "DELETE_OTHER_FLEETS")
|
|
|
|
gitOpsOpts := spec.GitOpsOptions{AllowUnknownKeys: flAllowUnknownKeys}
|
|
|
|
logf := func(format string, a ...interface{}) {
|
|
_, _ = fmt.Fprintf(c.App.Writer, format, a...)
|
|
}
|
|
|
|
if len(c.Args().Slice()) != 0 {
|
|
return errors.New("No positional arguments are allowed. To load multiple config files, use one -f flag per file.")
|
|
}
|
|
|
|
totalFilenames := len(flFilenames.Value())
|
|
if totalFilenames == 0 {
|
|
return errors.New("-f must be specified")
|
|
}
|
|
// TODO - remove No Team in Fleet 5
|
|
noTeamFilesEncountered := 0
|
|
for _, flFilename := range flFilenames.Value() {
|
|
if strings.TrimSpace(flFilename) == "" {
|
|
return errors.New("file name cannot be empty")
|
|
}
|
|
if len(filepath.Base(flFilename)) > filenameMaxLength {
|
|
return fmt.Errorf("file name must be less than %d characters: %s", filenameMaxLength, filepath.Base(flFilename))
|
|
}
|
|
if filepath.Base(flFilename) == "no-team.yml" || filepath.Base(flFilename) == "unassigned.yml" {
|
|
noTeamFilesEncountered++
|
|
if noTeamFilesEncountered > 1 {
|
|
return errors.New("Only one of `no-team.yml` or `unassigned.yml` can be provided. Use `unassigned.yml`; `no-team.yml` is deprecated.")
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check license
|
|
fleetClient, err := clientFromCLI(c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
appConfig, err := fleetClient.GetAppConfig()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if appConfig.License == nil {
|
|
return errors.New("no license struct found in app config")
|
|
}
|
|
|
|
var originalABMConfig []any
|
|
var originalVPPConfig []any
|
|
var teamNames []string
|
|
var teamDryRunAssumptions *fleet.TeamSpecsDryRunAssumptions
|
|
var abmTeams, vppTeams, missingVPPTeams []string
|
|
var hasMissingABMTeam, usesLegacyABMConfig bool
|
|
var windowsEnrollmentDefaultFleet string
|
|
var windowsEnrollmentFleetMissing bool
|
|
type missingVPPTeamWithApps struct {
|
|
config *spec.GitOps
|
|
vppApps []*fleet.TeamSpecAppStoreApp
|
|
filename string
|
|
}
|
|
var missingVPPTeamsWithApps []missingVPPTeamWithApps
|
|
|
|
// we keep track of team software installers and scripts for correct policy application
|
|
teamsSoftwareInstallers := make(map[string][]fleet.SoftwarePackageResponse)
|
|
teamsVPPApps := make(map[string][]fleet.VPPAppResponse)
|
|
teamsScripts := make(map[string][]fleet.ScriptResponse)
|
|
|
|
// we keep track of uploaded icon hashes so we don't upload icons unnecessarily
|
|
iconSettings := fleet.IconGitOpsSettings{ConcurrentUpdates: flConcurrentIconUpdates, ConcurrentUploads: flConcurrentIconUploads}
|
|
|
|
// We keep track of the secrets to check if duplicates exist during dry run
|
|
secrets := make(map[string]struct{})
|
|
// We keep track of the environment FLEET_SECRET_* variables
|
|
allFleetSecrets := make(map[string]string)
|
|
|
|
// We need the list of built-in labels for showing contextual errors in case the user
|
|
// decides to reference a built-in label.
|
|
builtInLabelNames := make(map[string]any)
|
|
globalLabels, err := fleetClient.GetLabels(0)
|
|
if err != nil {
|
|
return fmt.Errorf("getting global labels: %w", err)
|
|
}
|
|
for _, l := range globalLabels {
|
|
if l.LabelType == fleet.LabelTypeBuiltIn {
|
|
builtInLabelNames[l.Name] = nil
|
|
}
|
|
}
|
|
|
|
// We don't have access to the TeamID at this point in the time, and we need it down the pipeline to get
|
|
// the labels from existing teams
|
|
teamIDLookup := make(map[string]*uint)
|
|
teamIDLookup[spec.LabelAPIGlobalTeamName] = ptr.Uint(0)
|
|
if appConfig.License.IsPremium() {
|
|
teams, err := fleetClient.ListTeams("")
|
|
if err != nil {
|
|
return fmt.Errorf("getting teams: %w", err)
|
|
}
|
|
for _, tm := range teams {
|
|
teamIDLookup[tm.Name] = &tm.ID
|
|
}
|
|
}
|
|
|
|
// Check if a no-team/unassigned file is present (by filename, before parsing).
|
|
prefetchNoTeamSoftware := false
|
|
for _, flFilename := range flFilenames.Value() {
|
|
fn := filepath.Base(flFilename)
|
|
if fn == "no-team.yml" || fn == "unassigned.yml" {
|
|
prefetchNoTeamSoftware = true
|
|
break
|
|
}
|
|
}
|
|
|
|
// When software is excepted from GitOps, pre-fetch server-side software
|
|
// for all existing teams (including "No team") so the parser can validate
|
|
// policy references, and DoGitOps can resolve policy title IDs. This must
|
|
// happen before extractControlsForNoTeam, which parses the no-team file
|
|
// and would otherwise fail validating policy software references.
|
|
if appConfig.GitOpsConfig.Exceptions.Software {
|
|
syntheticSoftwareByTeam := make(map[string]json.RawMessage)
|
|
// Pre-fetch for "No team" (unassigned hosts, teamID=0) if present.
|
|
if prefetchNoTeamSoftware {
|
|
softwareMap, installers, vppApps, err := generateSoftwareForValidation(fleetClient, appConfig, 0)
|
|
if err != nil {
|
|
return fmt.Errorf("getting software for unassigned hosts: %w", err)
|
|
}
|
|
if softwareMap != nil {
|
|
raw, err := json.Marshal(softwareMap)
|
|
if err != nil {
|
|
return fmt.Errorf("marshaling software for unassigned hosts: %w", err)
|
|
}
|
|
syntheticSoftwareByTeam[fleet.TeamNameNoTeam] = raw
|
|
teamsSoftwareInstallers[fleet.TeamNameNoTeam] = installers
|
|
teamsVPPApps[fleet.TeamNameNoTeam] = vppApps
|
|
}
|
|
}
|
|
for teamName, teamID := range teamIDLookup {
|
|
if teamID == nil || *teamID == 0 {
|
|
continue // skip global and no-team/unassigned (handled above).
|
|
}
|
|
softwareMap, installers, vppApps, err := generateSoftwareForValidation(fleetClient, appConfig, *teamID)
|
|
if err != nil {
|
|
return fmt.Errorf("getting software for team %q: %w", teamName, err)
|
|
}
|
|
if softwareMap == nil {
|
|
continue
|
|
}
|
|
raw, err := json.Marshal(softwareMap)
|
|
if err != nil {
|
|
return fmt.Errorf("marshaling software for team %q: %w", teamName, err)
|
|
}
|
|
syntheticSoftwareByTeam[teamName] = raw
|
|
teamsSoftwareInstallers[teamName] = installers
|
|
teamsVPPApps[teamName] = vppApps
|
|
}
|
|
gitOpsOpts.SyntheticSoftwareByTeam = syntheticSoftwareByTeam
|
|
}
|
|
|
|
// We need the controls from no-team.yml to apply them when applying the global app config.
|
|
noTeamControls, noTeamPresent, noTeamFilename, err := extractControlsForNoTeam(flFilenames, appConfig, gitOpsOpts)
|
|
if err != nil {
|
|
return fmt.Errorf("extracting controls from %s: %w", noTeamFilename, err)
|
|
}
|
|
// Log a deprecation warning if the user is still using no-team.yml
|
|
if noTeamPresent && noTeamFilename == "no-team.yml" {
|
|
if logging.TopicEnabled(logging.DeprecatedFieldTopic) {
|
|
logf("[!] no-team.yml is deprecated; please ensure the fleet name has been updated to 'Unassigned' and rename the file to 'unassigned.yml'.\n")
|
|
}
|
|
}
|
|
|
|
// Used for keeping track of all label changes in this run.
|
|
labelChanges := make(map[string][]spec.LabelChange) // team name -> label changes
|
|
|
|
// Load all configs in before processing them
|
|
configs := make([]ConfigFile, 0, len(flFilenames.Value()))
|
|
|
|
// We only want to have one global config loaded
|
|
globalConfigLoaded := false
|
|
|
|
// List of things we want to do at the end of this run
|
|
var allPostOps []func() error
|
|
|
|
for _, flFilename := range flFilenames.Value() {
|
|
baseDir := filepath.Dir(flFilename)
|
|
config, err := spec.GitOpsFromFile(flFilename, baseDir, appConfig, logf, gitOpsOpts)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
isGlobalConfig := config.TeamName == nil
|
|
if isGlobalConfig {
|
|
if globalConfigLoaded {
|
|
return errors.New("only one global config file may be provided to fleetctl gitops")
|
|
}
|
|
globalConfigLoaded = true
|
|
}
|
|
configFile := ConfigFile{Config: config, Filename: flFilename, IsGlobalConfig: isGlobalConfig}
|
|
|
|
if !isGlobalConfig && !appConfig.License.IsPremium() {
|
|
logf("[!] skipping team config %s since teams are only supported for premium Fleet users\n", flFilename)
|
|
continue
|
|
}
|
|
if isGlobalConfig {
|
|
// If it's a global file, put it at the beginning
|
|
// of the array so it gets processed first
|
|
configs = append([]ConfigFile{configFile}, configs...)
|
|
} else {
|
|
configs = append(configs, configFile)
|
|
}
|
|
|
|
// We want to compute label changes early ... this will allow us to detect any monkey business around
|
|
// labels like trying to add the same label on different teams; labels can also move from one
|
|
// team to another, so we need the complete list of changes to plan the label movements.
|
|
teamName := config.CoercedTeamName()
|
|
teamID := teamIDLookup[teamName]
|
|
|
|
if _, ok := labelChanges[teamName]; ok {
|
|
continue
|
|
}
|
|
|
|
var existingLabels []*fleet.LabelSpec
|
|
if teamID != nil {
|
|
if *teamID == 0 {
|
|
existingLabels = globalLabels
|
|
} else {
|
|
if existingLabels, err = fleetClient.GetLabels(*teamID); err != nil {
|
|
return fmt.Errorf("getting team '%s' labels: %w", teamName, err)
|
|
}
|
|
}
|
|
}
|
|
// When labels are excepted and the key is omitted, preserve
|
|
// existing labels (no-op). Otherwise delete/update as normal.
|
|
labelChanges[teamName] = computeLabelChanges(
|
|
flFilename,
|
|
teamName,
|
|
existingLabels,
|
|
config.Labels,
|
|
appConfig.GitOpsConfig.Exceptions.Labels,
|
|
)
|
|
}
|
|
|
|
// fail if scripts are supplied on no-team and global config is missing
|
|
if noTeamPresent && !globalConfigLoaded {
|
|
return fmt.Errorf("global config must be provided alongside %s", noTeamFilename)
|
|
}
|
|
|
|
// Fail fast if two YAML files in this run resolve to the same
|
|
// team name under MySQL's utf8mb4_unicode_ci collation.
|
|
seenTeamNames := make(map[string]string, len(configs)) // key -> filename
|
|
for _, cf := range configs {
|
|
if cf.IsGlobalConfig || cf.Config.TeamName == nil {
|
|
continue
|
|
}
|
|
name := strings.TrimSpace(*cf.Config.TeamName)
|
|
key := norm.NFC.String(strings.ToLower(name))
|
|
if key == "" {
|
|
continue
|
|
}
|
|
if prev, ok := seenTeamNames[key]; ok {
|
|
return fmt.Errorf(
|
|
"duplicate fleet names in GitOps files: %q and %q both resolve to the same fleet name. Fleet names must differ by more than letter case.",
|
|
prev, cf.Filename,
|
|
)
|
|
}
|
|
seenTeamNames[key] = cf.Filename
|
|
}
|
|
|
|
// Cross-file invariant: if any file in this run enables MDM
|
|
// end-user authentication, the IdP must be configured either in
|
|
// this run's global file or in the server's current state.
|
|
// Without this check, a partial gitops run can enable EUA on a
|
|
// team while leaving the IdP unconfigured — locking ADE
|
|
// enrollment for that team's hosts. See issue #43371.
|
|
if err := validateGitOpsGroupEUA(configs, appConfig, fleetClient.ListTeams, flDeleteOtherTeams); err != nil {
|
|
return err
|
|
}
|
|
|
|
labelMoves, err := computeLabelMoves(labelChanges)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, configFile := range configs {
|
|
config := configFile.Config
|
|
flFilename := configFile.Filename
|
|
isGlobalConfig := configFile.IsGlobalConfig
|
|
|
|
if isGlobalConfig {
|
|
if noTeamControls.Set() && config.Controls.Set() {
|
|
return fmt.Errorf("'controls' cannot be set on both global config and on %s", noTeamFilename)
|
|
}
|
|
if !noTeamControls.Defined && !config.Controls.Defined {
|
|
if appConfig.License.IsPremium() {
|
|
suggestion := ", no-team.yml or unassigned.yml"
|
|
if noTeamFilename != "" {
|
|
suggestion = fmt.Sprintf(" or %s", noTeamFilename)
|
|
}
|
|
return fmt.Errorf("'controls' must be set on global config%s", suggestion)
|
|
}
|
|
return errors.New("'controls' must be set on global config")
|
|
}
|
|
if !config.Controls.Set() {
|
|
// noTeamControls had its file paths resolved to absolute against the
|
|
// no-team file's own dir in extractControlsForNoTeam, so they survive
|
|
// being applied here under the global file's baseDir.
|
|
config.Controls = noTeamControls
|
|
}
|
|
}
|
|
|
|
if !appConfig.License.IsPremium() {
|
|
// Targeting queries against labels is a Premium feature only
|
|
for _, query := range config.Queries {
|
|
if len(query.LabelsIncludeAny) > 0 {
|
|
return fmt.Errorf("report %q uses 'labels_include_any', which is only available in Fleet Premium", query.Name)
|
|
}
|
|
if len(query.LabelsIncludeAll) > 0 {
|
|
return fmt.Errorf("report %q uses 'labels_include_all', which is only available in Fleet Premium", query.Name)
|
|
}
|
|
}
|
|
for _, policy := range config.Policies {
|
|
if len(policy.LabelsIncludeAny) > 0 {
|
|
return fmt.Errorf("policy %q uses 'labels_include_any', which is only available in Fleet Premium", policy.Name)
|
|
}
|
|
if len(policy.LabelsIncludeAll) > 0 {
|
|
return fmt.Errorf("policy %q uses 'labels_include_all', which is only available in Fleet Premium", policy.Name)
|
|
}
|
|
if len(policy.LabelsExcludeAny) > 0 {
|
|
return fmt.Errorf("policy %q uses 'labels_exclude_any', which is only available in Fleet Premium", policy.Name)
|
|
}
|
|
if len(policy.LabelsExcludeAll) > 0 {
|
|
return fmt.Errorf("policy %q uses 'labels_exclude_all', which is only available in Fleet Premium", policy.Name)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Gather stats on where labels are used in this gitops config,
|
|
// so we can bail if any of the referenced labels don't exist
|
|
// after this run (either because they'd be deleted, never existed
|
|
// in the first place).
|
|
labelsUsed, err := getLabelUsage(config)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// The validity of a label is based on their existence (either the label is going to be added or
|
|
// the label stayed the same). We look at both global label changes and team-specific label changes
|
|
// because of scoping rules (a team resource can reference a global label).
|
|
validLabelNames := make(map[string]struct{})
|
|
if globalLabelChanges, ok := labelChanges[spec.LabelAPIGlobalTeamName]; ok {
|
|
for _, label := range globalLabelChanges {
|
|
if label.Op == "+" || label.Op == "=" || label.Op == "~" {
|
|
validLabelNames[label.Name] = struct{}{}
|
|
}
|
|
}
|
|
} else {
|
|
// We are applying a stand-alone team config file, so no changes for the global labels were
|
|
// computed.
|
|
for _, l := range globalLabels {
|
|
if l.LabelType != fleet.LabelTypeBuiltIn {
|
|
validLabelNames[l.Name] = struct{}{}
|
|
}
|
|
}
|
|
}
|
|
if config.CoercedTeamName() != spec.LabelAPIGlobalTeamName {
|
|
for _, label := range labelChanges[config.CoercedTeamName()] {
|
|
if label.Op == "+" || label.Op == "=" || label.Op == "~" {
|
|
validLabelNames[label.Name] = struct{}{}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check if any used labels are not in the proposed labels list.
|
|
// If there are, we'll bail out with helpful error messages.
|
|
unknownLabelsUsed := false
|
|
builtInLabelsUsed := false
|
|
|
|
for labelUsed := range labelsUsed {
|
|
if _, ok := validLabelNames[labelUsed]; ok {
|
|
continue
|
|
}
|
|
if _, ok := builtInLabelNames[labelUsed]; ok {
|
|
logf(
|
|
"[!] '%s' label is built-in. Only custom labels are supported. If you want to target a specific platform please use 'platform' instead. If not, please create a custom label and try again. \n",
|
|
labelUsed,
|
|
)
|
|
builtInLabelsUsed = true
|
|
continue
|
|
}
|
|
for _, labelUsage := range labelsUsed[labelUsed] {
|
|
logf("[!] Unknown label '%s' is referenced by %s '%s'\n", labelUsed, labelUsage.Type, labelUsage.Name)
|
|
}
|
|
unknownLabelsUsed = true
|
|
}
|
|
if unknownLabelsUsed {
|
|
return errors.New("Please create the missing labels, or update your settings to not refer to these labels.")
|
|
}
|
|
if builtInLabelsUsed {
|
|
return errors.New("Please update your settings to not refer to built-in labels.")
|
|
}
|
|
|
|
teamName := config.CoercedTeamName()
|
|
labelChangesSummary := spec.NewLabelChangesSummary(labelChanges[teamName], labelMoves[teamName])
|
|
config.LabelChangesSummary = labelChangesSummary
|
|
|
|
// Delete labels at the end of the run to avoid issues with resource contention.
|
|
if !flDryRun {
|
|
for _, name := range labelChangesSummary.LabelsToRemove {
|
|
l := name // rebind for closure
|
|
allPostOps = append(allPostOps, func() error {
|
|
if err := fleetClient.DeleteLabel(l); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
}
|
|
|
|
// Special handling for tokens is required because they link to teams (by
|
|
// name.) Because teams can be created/deleted during the same gitops run, we
|
|
// grab some information to help us determine allowed/restricted actions and
|
|
// when to perform the associations.
|
|
if isGlobalConfig && totalFilenames > 1 && !(totalFilenames == 2 && noTeamPresent) && appConfig.License.IsPremium() {
|
|
abmTeams, hasMissingABMTeam, usesLegacyABMConfig, err = checkABMTeamAssignments(config, fleetClient)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
vppTeams, missingVPPTeams, err = checkVPPTeamAssignments(config, fleetClient)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// if one of the teams assigned to an ABM token doesn't exist yet, we need to
|
|
// submit the configs without the ABM default team set. We'll set those
|
|
// separately later when the teams are already created.
|
|
if hasMissingABMTeam {
|
|
if mdm, ok := config.OrgSettings["mdm"]; ok {
|
|
if mdmMap, ok := mdm.(map[string]any); ok {
|
|
if appleBM, ok := mdmMap["apple_business"]; ok {
|
|
if bmSettings, ok := appleBM.([]any); ok {
|
|
originalABMConfig = bmSettings
|
|
}
|
|
}
|
|
|
|
// If team is not found, we need to remove the AppleBMDefaultTeam from
|
|
// the global config, and then apply it after teams are processed
|
|
mdmMap["apple_business"] = nil
|
|
mdmMap["apple_bm_default_team"] = ""
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(missingVPPTeams) > 0 {
|
|
if mdm, ok := config.OrgSettings["mdm"]; ok {
|
|
if mdmMap, ok := mdm.(map[string]any); ok {
|
|
if vpp, ok := mdmMap["volume_purchasing_program"]; ok {
|
|
if vppSettings, ok := vpp.([]any); ok {
|
|
originalVPPConfig = vppSettings
|
|
}
|
|
}
|
|
|
|
// If a team is not found, we need to remove the VPP config from
|
|
// the global config and then apply it after teams are processed
|
|
mdmMap["volume_purchasing_program"] = nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Runs outside the multi-file gate above: the resolved default fleet name is also needed by the --delete-other-fleets guard
|
|
// below, even on single-file runs.
|
|
if isGlobalConfig && appConfig.License.IsPremium() {
|
|
windowsEnrollmentDefaultFleet, windowsEnrollmentFleetMissing, err = checkWindowsEnrollmentAssignment(config, fleetClient)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if windowsEnrollmentFleetMissing {
|
|
if mdm, ok := config.OrgSettings["mdm"]; ok {
|
|
if mdmMap, ok := mdm.(map[string]any); ok {
|
|
// The referenced fleet may be created later in this run. Deleting the key makes the first apply a no-op for this
|
|
// setting (an omitted key keeps the stored value); it is applied separately after teams are processed.
|
|
delete(mdmMap, "windows_enrollment")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Teams need a VPP token before VPP apps can be applied. When some VPP
|
|
// teams don't exist yet, the VPP config is temporarily removed from the
|
|
// global config, which clears all VPP token assignments. To avoid
|
|
// "No available VPP Token" errors, we defer app_store_apps for every
|
|
// team in the VPP config and re-apply them after tokens are reassigned.
|
|
if !isGlobalConfig && len(missingVPPTeams) > 0 && len(config.Software.AppStoreApps) > 0 {
|
|
if slices.Contains(vppTeams, *config.TeamName) {
|
|
missingVPPTeamsWithApps = append(missingVPPTeamsWithApps, missingVPPTeamWithApps{
|
|
config: config,
|
|
vppApps: config.Software.AppStoreApps,
|
|
filename: flFilename,
|
|
})
|
|
config.Software.AppStoreApps = nil
|
|
}
|
|
}
|
|
|
|
if flDryRun {
|
|
incomingSecrets := fleetClient.GetGitOpsSecrets(config)
|
|
for _, secret := range incomingSecrets {
|
|
if _, ok := secrets[secret]; ok {
|
|
return fmt.Errorf("duplicate enroll secret found in %s", flFilename)
|
|
}
|
|
secrets[secret] = struct{}{}
|
|
}
|
|
}
|
|
|
|
err = fleetClient.SaveEnvSecrets(allFleetSecrets, config.FleetSecrets, flDryRun)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Capture CAs before DoGitOps (which deletes the key from OrgSettings).
|
|
// DoGitOps processes CA creates/updates inline (with skipDeletes=true).
|
|
// CA deletions are always deferred to a post-op so that team configs can
|
|
// clean up certificate templates (which have FK references to CAs) first.
|
|
var deferredCAs any
|
|
if isGlobalConfig && !flDryRun {
|
|
deferredCAs = config.OrgSettings["certificate_authorities"]
|
|
}
|
|
|
|
assumptions, err := fleetClient.DoGitOps(
|
|
c.Context,
|
|
config,
|
|
flFilename,
|
|
logf,
|
|
flDryRun,
|
|
teamDryRunAssumptions,
|
|
appConfig,
|
|
teamsSoftwareInstallers,
|
|
teamsVPPApps,
|
|
teamsScripts,
|
|
&iconSettings,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Schedule CA deletions as a post-op after all team configs have been processed.
|
|
if isGlobalConfig && !flDryRun {
|
|
allPostOps = append(allPostOps, func() error {
|
|
groupedCAs, caErr := fleet.ValidateCertificateAuthoritiesSpec(deferredCAs)
|
|
if caErr != nil {
|
|
return fmt.Errorf("invalid certificate_authorities: %w", caErr)
|
|
}
|
|
if caErr = fleetClient.ApplyCertificateAuthoritiesSpec(*groupedCAs, fleet.ApplySpecOptions{}, fleet.BatchApplyCertificateAuthoritiesOpts{}); caErr != nil {
|
|
return fmt.Errorf("applying certificate authorities: %w", caErr)
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
if config.TeamName != nil {
|
|
teamNames = append(teamNames, *config.TeamName)
|
|
} else {
|
|
teamDryRunAssumptions = assumptions
|
|
}
|
|
}
|
|
|
|
// if there were assignments to tokens, and some of the teams were missing at that time, submit a separate patch request to set them now.
|
|
if len(abmTeams) > 0 && hasMissingABMTeam {
|
|
if err = applyABMTokenAssignmentIfNeeded(c, teamNames, abmTeams, originalABMConfig, usesLegacyABMConfig, flDryRun,
|
|
fleetClient); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if len(missingVPPTeams) > 0 {
|
|
if err = applyVPPTokenAssignmentIfNeeded(c, teamNames, vppTeams, originalVPPConfig, flDryRun, fleetClient); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if windowsEnrollmentDefaultFleet != "" && windowsEnrollmentFleetMissing {
|
|
if err = applyWindowsEnrollmentAssignmentIfNeeded(c, teamNames, windowsEnrollmentDefaultFleet, flDryRun, fleetClient); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
// Now that VPP tokens have been assigned, we can apply VPP apps to the new team.
|
|
// For simplicity, we simply re-apply the entire config. This only happens once when the team is created.
|
|
for _, teamWithApps := range missingVPPTeamsWithApps {
|
|
_, _ = fmt.Fprintf(c.App.Writer, ReapplyingTeamForVPPAppsMsg, *teamWithApps.config.TeamName)
|
|
teamWithApps.config.Software.AppStoreApps = teamWithApps.vppApps
|
|
_, err := fleetClient.DoGitOps(
|
|
c.Context,
|
|
teamWithApps.config,
|
|
teamWithApps.filename,
|
|
logf,
|
|
flDryRun,
|
|
teamDryRunAssumptions,
|
|
appConfig,
|
|
teamsSoftwareInstallers,
|
|
teamsVPPApps,
|
|
teamsScripts,
|
|
&iconSettings,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if flDeleteOtherTeams && appConfig.License.IsPremium() { // skip team deletion for non-premium users
|
|
teams, err := fleetClient.ListTeams("")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, team := range teams {
|
|
if !slices.Contains(teamNames, team.Name) {
|
|
if slices.Contains(abmTeams, team.Name) {
|
|
if usesLegacyABMConfig {
|
|
return fmt.Errorf("apple_bm_default_team %s cannot be deleted", team.Name)
|
|
}
|
|
return fmt.Errorf("apple_business team %s cannot be deleted", team.Name)
|
|
}
|
|
if slices.Contains(vppTeams, team.Name) {
|
|
return fmt.Errorf("volume_purchasing_program team %s cannot be deleted", team.Name)
|
|
}
|
|
if windowsEnrollmentDefaultFleet != "" && norm.NFC.String(team.Name) == windowsEnrollmentDefaultFleet {
|
|
return fmt.Errorf("windows_enrollment default_fleet %s cannot be deleted", team.Name)
|
|
}
|
|
if flDryRun {
|
|
_, _ = fmt.Fprintf(c.App.Writer, "[!] would've deleted team %s\n", team.Name)
|
|
} else {
|
|
_, _ = fmt.Fprintf(c.App.Writer, "[-] deleting team %s\n", team.Name)
|
|
if err := fleetClient.DeleteTeam(team.ID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// we only want to reset the no-team config if the global config was loaded.
|
|
// NOTE: noTeamPresent is referring to the "No Team" team. It does not
|
|
// mean that other teams are not present.
|
|
if globalConfigLoaded && !noTeamPresent {
|
|
defaultNoTeamConfig := new(spec.GitOps)
|
|
defaultNoTeamConfig.TeamName = ptr.String(fleet.TeamNameNoTeam)
|
|
_, err := fleetClient.DoGitOps(
|
|
c.Context,
|
|
defaultNoTeamConfig,
|
|
noTeamFilename,
|
|
logf,
|
|
flDryRun,
|
|
nil,
|
|
appConfig,
|
|
map[string][]fleet.SoftwarePackageResponse{},
|
|
map[string][]fleet.VPPAppResponse{},
|
|
map[string][]fleet.ScriptResponse{},
|
|
&iconSettings,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if !flDryRun {
|
|
for _, postOp := range allPostOps {
|
|
if err := postOp(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
if flDryRun {
|
|
_, _ = fmt.Fprintf(c.App.Writer, "[!] gitops dry run succeeded\n")
|
|
} else {
|
|
_, _ = fmt.Fprintf(c.App.Writer, "[!] gitops succeeded\n")
|
|
}
|
|
|
|
return nil
|
|
},
|
|
}
|
|
}
|
|
|
|
// ConfigFile pairs a parsed gitops config with its source filename, used
|
|
// while orchestrating a multi-file gitops run.
|
|
type ConfigFile struct {
|
|
Config *spec.GitOps
|
|
Filename string
|
|
IsGlobalConfig bool
|
|
}
|
|
|
|
// Verify that if any fleet has EUA enabled after this GitOps run, then the IdP for EUA will be fully configured
|
|
// after this run. This checks all fleets whether they're being modified in this run or not.
|
|
func validateGitOpsGroupEUA(configs []ConfigFile, appCfg *fleet.EnrichedAppConfig, listTeams func(query string) ([]fleet.Team, error), deleteOtherTeams bool) error {
|
|
// Compute the effective post-apply IdP state. Start from stored state; if a
|
|
// global file is in this run, its incoming org_settings override stored
|
|
// state (overwrite mode), so an empty/incomplete/missing block leaves the
|
|
// IdP incomplete.
|
|
idpName := appCfg.MDM.EndUserAuthentication.IDPName
|
|
entityID := appCfg.MDM.EndUserAuthentication.EntityID
|
|
metadata := appCfg.MDM.EndUserAuthentication.Metadata
|
|
metadataURL := appCfg.MDM.EndUserAuthentication.MetadataURL
|
|
globalInRun := false
|
|
for _, cf := range configs {
|
|
if !cf.IsGlobalConfig {
|
|
continue
|
|
}
|
|
globalInRun = true
|
|
mdm, _ := cf.Config.OrgSettings["mdm"].(map[string]any)
|
|
eua, _ := mdm["end_user_authentication"].(map[string]any)
|
|
idpName, _ = eua["idp_name"].(string)
|
|
entityID, _ = eua["entity_id"].(string)
|
|
metadata, _ = eua["metadata"].(string)
|
|
metadataURL, _ = eua["metadata_url"].(string)
|
|
break
|
|
}
|
|
|
|
// An IdP is complete only when idp_name, entity_id, and one of
|
|
// metadata/metadata_url are all set (mirrors the server-side complete-IdP
|
|
// predicate). If the effective IdP is complete, EUA may be enabled anywhere.
|
|
idpComplete := idpName != "" && entityID != "" && (metadata != "" || metadataURL != "")
|
|
if idpComplete {
|
|
return nil
|
|
}
|
|
|
|
const idpHint = "Set org_settings.mdm.end_user_authentication idp_name, entity_id, and metadata or metadata_url in your global config, or configure a complete IdP on the server first"
|
|
|
|
// The effective IdP is incomplete: EUA must not be enabled at any effective
|
|
// post-apply scope. First, any file in this run that enables it. Track the
|
|
// teams present in this run so their stored state is not double-counted
|
|
// below — the in-run file's value wins (including a file that disables EUA).
|
|
teamsInRun := make(map[string]struct{})
|
|
for _, cf := range configs {
|
|
if cf.Config.TeamName != nil {
|
|
key := norm.NFC.String(strings.ToLower(strings.TrimSpace(*cf.Config.TeamName)))
|
|
if key != "" {
|
|
teamsInRun[key] = struct{}{}
|
|
}
|
|
}
|
|
if cf.Config.Controls.MacOSSetup == nil ||
|
|
!cf.Config.Controls.MacOSSetup.EnableEndUserAuthentication {
|
|
continue
|
|
}
|
|
return fmt.Errorf(
|
|
"%s: controls.setup_experience.enable_end_user_authentication is true but the IdP is not fully configured. %s.",
|
|
cf.Filename, idpHint,
|
|
)
|
|
}
|
|
|
|
// Then any stored team NOT present in this run that still has EUA enabled —
|
|
// the case where a global-only run degrades the IdP while a team keeps EUA
|
|
// on (issue #43371). Teams are premium-only, so skip the lookup otherwise.
|
|
//
|
|
// With --delete-other-fleets, teams omitted from the run are deleted rather
|
|
// than preserved, so they can't lock anyone out post-apply; consulting their
|
|
// stored EUA state would be a false positive.
|
|
//
|
|
// Note there is an edge case here where a fleet has EUA enabled but not
|
|
// supplied in a --delete-other-fleets GitOps run, but the fleet CAN'T
|
|
// be deleted due to its being in ABM or VPP. This could lead to EUA being
|
|
// degraded (since that API call happens first) while EUA is still enabled
|
|
// on that fleet. This would be better handled by detecting un-deletable
|
|
// fleets early when --delete-other-fleets is used.
|
|
if appCfg.License.IsPremium() && !deleteOtherTeams {
|
|
teams, err := listTeams("")
|
|
if err != nil {
|
|
return fmt.Errorf("listing fleets to validate end user authentication: %w", err)
|
|
}
|
|
for _, tm := range teams {
|
|
key := norm.NFC.String(strings.ToLower(strings.TrimSpace(tm.Name)))
|
|
if _, ok := teamsInRun[key]; ok {
|
|
continue
|
|
}
|
|
if tm.Config.MDM.MacOSSetup.EnableEndUserAuthentication {
|
|
return fmt.Errorf(
|
|
"fleet %q has end user authentication enabled but the IdP is not fully configured. %s, or disable end user authentication for that fleet.",
|
|
tm.Name, idpHint,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Finally, the stored no-team/global EUA flag survives only when no global
|
|
// file is in this run (a global file in the run authoritatively redefines
|
|
// no-team state in overwrite mode, and is covered by the file loop above).
|
|
if !globalInRun && appCfg.MDM.MacOSSetup.EnableEndUserAuthentication {
|
|
return fmt.Errorf(
|
|
"end user authentication is enabled in Unassigned but the IdP is not fully configured. %s.",
|
|
idpHint,
|
|
)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Computes label moves and validates that there is no funny business around label changes,
|
|
// like trying to add the same label on multiple teams or deleting the same label multiple times.
|
|
// A label is moved when it is deleted from one team and added to another, the moves are stored in
|
|
// a team name -> label names map.
|
|
func computeLabelMoves(allChanges map[string][]spec.LabelChange) (map[string][]spec.LabelMovement, error) {
|
|
deleteOps := make(map[string]spec.LabelChange) // label name -> file name
|
|
addOps := make(map[string]spec.LabelChange) // label name -> file name
|
|
|
|
for _, teamChanges := range allChanges {
|
|
for _, change := range teamChanges {
|
|
switch change.Op {
|
|
case "-":
|
|
if prevCh, ok := deleteOps[change.Name]; ok {
|
|
errMsg := "can't delete label %q from %q, as it is already being deleted in %q"
|
|
return nil, fmt.Errorf(errMsg, change.Name, change.FileName, prevCh.FileName)
|
|
}
|
|
deleteOps[change.Name] = change
|
|
case "+":
|
|
if prevCh, ok := addOps[change.Name]; ok {
|
|
errMsg := "can't add label %q to %q, as it is already being added in %q"
|
|
return nil, fmt.Errorf(errMsg, change.Name, change.FileName, prevCh.FileName)
|
|
}
|
|
addOps[change.Name] = change
|
|
}
|
|
}
|
|
}
|
|
|
|
// A label is moved if it is added ('+') to a team AND deleted ('-') from any other team
|
|
moves := make(map[string][]spec.LabelMovement)
|
|
for teamName, teamChanges := range allChanges {
|
|
for _, ch := range teamChanges {
|
|
if ch.Op == "+" {
|
|
if prevCh, isDeletedElsewhere := deleteOps[ch.Name]; isDeletedElsewhere {
|
|
moves[teamName] = append(moves[teamName], spec.LabelMovement{
|
|
Name: ch.Name,
|
|
FromTeamName: prevCh.TeamName,
|
|
ToTeamName: ch.TeamName,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return moves, nil
|
|
}
|
|
|
|
// Returns a list of label changes to be applied for either a global config file or a team config file.
|
|
func computeLabelChanges(
|
|
filename string,
|
|
teamName string,
|
|
existingLabels []*fleet.LabelSpec,
|
|
specifiedLabels []*fleet.LabelSpec,
|
|
labelsExcepted bool,
|
|
) []spec.LabelChange {
|
|
var regularLabels []*fleet.LabelSpec
|
|
var labelOperations []spec.LabelChange
|
|
|
|
for _, l := range existingLabels {
|
|
if l.LabelType == fleet.LabelTypeRegular {
|
|
regularLabels = append(regularLabels, l)
|
|
}
|
|
}
|
|
|
|
// If no labels are specified: either no-op if labels are excepted from GitOps,
|
|
// or else delete them all.
|
|
if len(specifiedLabels) == 0 {
|
|
op := "-"
|
|
if labelsExcepted {
|
|
op = "~" // preserved (excepted from GitOps, no action needed)
|
|
}
|
|
for _, l := range regularLabels {
|
|
change := spec.LabelChange{Name: l.Name, Op: op, TeamName: teamName, FileName: filename}
|
|
labelOperations = append(labelOperations, change)
|
|
}
|
|
return labelOperations
|
|
}
|
|
|
|
specifiedMap := make(map[string]struct{}, len(specifiedLabels))
|
|
for _, l := range specifiedLabels {
|
|
specifiedMap[l.Name] = struct{}{}
|
|
}
|
|
|
|
// Determine which existing labels to remove.
|
|
for _, l := range regularLabels {
|
|
op := "-"
|
|
if _, ok := specifiedMap[l.Name]; ok {
|
|
op = "="
|
|
}
|
|
// Remove from the map to track which specified labels are to be added.
|
|
delete(specifiedMap, l.Name)
|
|
change := spec.LabelChange{Name: l.Name, Op: op, TeamName: teamName, FileName: filename}
|
|
labelOperations = append(labelOperations, change)
|
|
}
|
|
|
|
// Any names remaining in the map are new labels.
|
|
for lblName := range specifiedMap {
|
|
change := spec.LabelChange{Name: lblName, Op: "+", TeamName: teamName, FileName: filename}
|
|
labelOperations = append(labelOperations, change)
|
|
}
|
|
|
|
return labelOperations
|
|
}
|
|
|
|
// Given a set of referenced labels and info about who is using them, update a provided usage map.
|
|
func updateLabelUsage(labels []string, ident string, usageType string, currentUsage map[string][]LabelUsage) {
|
|
for _, label := range labels {
|
|
var usage []LabelUsage
|
|
if _, ok := currentUsage[label]; !ok {
|
|
currentUsage[label] = make([]LabelUsage, 0)
|
|
}
|
|
usage = currentUsage[label]
|
|
usage = append(usage, LabelUsage{
|
|
Name: ident,
|
|
Type: usageType,
|
|
})
|
|
currentUsage[label] = usage
|
|
}
|
|
}
|
|
|
|
// Create a map of label name -> who is using that label.
|
|
// This will be used to determine if any non-existent labels are being referenced.
|
|
func getLabelUsage(config *spec.GitOps) (map[string][]LabelUsage, error) {
|
|
result := make(map[string][]LabelUsage)
|
|
|
|
// Get profile label usage
|
|
for _, osSettingName := range []interface{}{config.Controls.MacOSSettings, config.Controls.WindowsSettings} {
|
|
if osSettings, ok := getCustomSettings(osSettingName); ok {
|
|
for _, setting := range osSettings {
|
|
var labels []string
|
|
if len(setting.LabelsIncludeAny) > 0 {
|
|
labels = setting.LabelsIncludeAny
|
|
}
|
|
if len(setting.LabelsIncludeAll) > 0 && len(setting.LabelsIncludeAny) > 0 {
|
|
return nil, fmt.Errorf("Couldn't edit configuration profiles. For profile '%s', only one of \"labels_include_all\" or \"labels_include_any\" can be included.", filepath.Base(setting.Path))
|
|
}
|
|
if len(setting.LabelsIncludeAll) > 0 {
|
|
labels = setting.LabelsIncludeAll
|
|
}
|
|
if overlap := fleet.LabelOverlap(labels, setting.LabelsExcludeAny); overlap != "" {
|
|
return nil, fmt.Errorf("configuration profile '%s': label %q cannot appear in both include and exclude lists.", filepath.Base(setting.Path), overlap)
|
|
}
|
|
labels = append(labels, setting.LabelsExcludeAny...)
|
|
updateLabelUsage(labels, filepath.Base(setting.Path), "configuration profile", result)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Get software package installer label usage
|
|
for _, softwarePackage := range config.Software.Packages {
|
|
var labels []string
|
|
if len(softwarePackage.LabelsIncludeAny) > 0 {
|
|
labels = softwarePackage.LabelsIncludeAny
|
|
}
|
|
if len(softwarePackage.LabelsExcludeAny) > 0 {
|
|
if len(labels) > 0 {
|
|
return nil, fmt.Errorf("Software package '%s' has multiple label keys; please choose one of `labels_include_all`, `labels_include_any`, `labels_exclude_any`.", softwarePackage.URL)
|
|
}
|
|
labels = softwarePackage.LabelsExcludeAny
|
|
}
|
|
if len(softwarePackage.LabelsIncludeAll) > 0 {
|
|
if len(labels) > 0 {
|
|
return nil, fmt.Errorf("Software package '%s' has multiple label keys; please choose one of `labels_include_all`, `labels_include_any`, `labels_exclude_any`.", softwarePackage.URL)
|
|
}
|
|
labels = softwarePackage.LabelsIncludeAll
|
|
}
|
|
updateLabelUsage(labels, softwarePackage.URL, "Software Package", result)
|
|
}
|
|
|
|
// Get app store app installer label usage
|
|
for _, vppApp := range config.Software.AppStoreApps {
|
|
var labels []string
|
|
if len(vppApp.LabelsIncludeAny) > 0 {
|
|
labels = vppApp.LabelsIncludeAny
|
|
}
|
|
if len(vppApp.LabelsExcludeAny) > 0 {
|
|
if len(labels) > 0 {
|
|
return nil, fmt.Errorf("App Store App '%s' has multiple label keys; please choose one of `labels_include_all`, `labels_include_any`, `labels_exclude_any`.", vppApp.AppStoreID)
|
|
}
|
|
labels = vppApp.LabelsExcludeAny
|
|
}
|
|
if len(vppApp.LabelsIncludeAll) > 0 {
|
|
if len(labels) > 0 {
|
|
return nil, fmt.Errorf("App Store App '%s' has multiple label keys; please choose one of `labels_include_all`, `labels_include_any`, `labels_exclude_any`.", vppApp.AppStoreID)
|
|
}
|
|
labels = vppApp.LabelsIncludeAll
|
|
}
|
|
updateLabelUsage(labels, vppApp.AppStoreID, "App Store App", result)
|
|
}
|
|
|
|
for _, maintainedApp := range config.Software.FleetMaintainedApps {
|
|
var labels []string
|
|
if len(maintainedApp.LabelsIncludeAny) > 0 {
|
|
labels = maintainedApp.LabelsIncludeAny
|
|
}
|
|
if len(maintainedApp.LabelsExcludeAny) > 0 {
|
|
if len(labels) > 0 {
|
|
return nil, fmt.Errorf("Fleet Maintained App '%s' has multiple label keys; please choose one of `labels_include_all`, `labels_include_any`, `labels_exclude_any`.", maintainedApp.Slug)
|
|
}
|
|
labels = maintainedApp.LabelsExcludeAny
|
|
}
|
|
if len(maintainedApp.LabelsIncludeAll) > 0 {
|
|
if len(labels) > 0 {
|
|
return nil, fmt.Errorf("Fleet Maintained App '%s' has multiple label keys; please choose one of `labels_include_all`, `labels_include_any`, `labels_exclude_any`.", maintainedApp.Slug)
|
|
}
|
|
labels = maintainedApp.LabelsIncludeAll
|
|
}
|
|
updateLabelUsage(labels, maintainedApp.Slug, "Fleet Maintained App", result)
|
|
}
|
|
|
|
// Get query label usage.
|
|
for _, query := range config.Queries {
|
|
if len(query.LabelsIncludeAny) > 0 && len(query.LabelsIncludeAll) > 0 {
|
|
return nil, fmt.Errorf("Query '%s' has multiple label keys; please choose one of `labels_include_any` or `labels_include_all`.", query.Name)
|
|
}
|
|
labels := slices.Concat(query.LabelsIncludeAny, query.LabelsIncludeAll)
|
|
updateLabelUsage(labels, query.Name, "Query", result)
|
|
}
|
|
|
|
// Get policy label usage. A policy may combine one include scope (any/all)
|
|
// with one exclude scope (any/all); VerifyLabelScopes rejects more than one
|
|
// of either, or a label appearing in both an include and an exclude list.
|
|
for _, policy := range config.Policies {
|
|
if err := policy.VerifyLabelScopes(); err != nil {
|
|
return nil, fmt.Errorf("Policy '%s': %w", policy.Name, err)
|
|
}
|
|
labels := slices.Concat(policy.LabelsIncludeAny, policy.LabelsIncludeAll, policy.LabelsExcludeAny, policy.LabelsExcludeAll)
|
|
updateLabelUsage(labels, policy.Name, "Policy", result)
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func getCustomSettings(osSettings interface{}) ([]fleet.MDMProfileSpec, bool) {
|
|
if settingsMap, ok := osSettings.(fleet.WithMDMProfileSpecs); ok {
|
|
return settingsMap.GetMDMProfileSpecs(), true
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
func extractControlsForNoTeam(flFilenames cli.StringSlice, appConfig *fleet.EnrichedAppConfig, gitOpsOpts spec.GitOpsOptions) (spec.GitOpsControls, bool, string, error) {
|
|
for _, flFilename := range flFilenames.Value() {
|
|
fileName := filepath.Base(flFilename)
|
|
if fileName == "no-team.yml" || fileName == "unassigned.yml" {
|
|
if !appConfig.License.IsPremium() {
|
|
// Message is printed in the next flFilenames loop to avoid printing it multiple times
|
|
break
|
|
}
|
|
baseDir := filepath.Dir(flFilename)
|
|
config, err := spec.GitOpsFromFile(flFilename, baseDir, appConfig, func(format string, a ...any) {}, gitOpsOpts)
|
|
if err != nil {
|
|
return spec.GitOpsControls{}, false, fileName, err
|
|
}
|
|
// These controls are applied onto the global config and applied under the
|
|
// global file's baseDir, so resolve their file paths to absolute against this
|
|
// file's own dir to keep relative paths (e.g. ../lib/...) working.
|
|
config.Controls.ResolveFilePathsAbs(baseDir)
|
|
return config.Controls, true, fileName, nil
|
|
}
|
|
}
|
|
return spec.GitOpsControls{}, false, "", nil
|
|
}
|
|
|
|
// checkABMTeamAssignments validates the spec, and finds if:
|
|
//
|
|
// 1. The user is using the legacy apple_bm_default_team config.
|
|
// 2. All teams assigned to ABM tokens already exist.
|
|
// 3. Performs validations according to the spec for both the new and the
|
|
// deprecated key used for this setting.
|
|
func checkABMTeamAssignments(config *spec.GitOps, fleetClient *service.Client) (
|
|
abmTeams []string, missingTeam bool, usesLegacyConfig bool, err error,
|
|
) {
|
|
if mdm, ok := config.OrgSettings["mdm"]; ok {
|
|
if mdmMap, ok := mdm.(map[string]any); ok {
|
|
appleBMDT, hasLegacyConfig := mdmMap["apple_bm_default_team"]
|
|
// After ApplyDeprecatedKeyMappings runs, any legacy
|
|
// "apple_business_manager" key has already been migrated to
|
|
// "apple_business", so we only look up the new name here.
|
|
appleBM, hasNewConfig := mdmMap["apple_business"]
|
|
|
|
if hasLegacyConfig && hasNewConfig {
|
|
return nil, false, false, errors.New(fleet.AppleABMDefaultTeamDeprecatedMessage)
|
|
}
|
|
|
|
abmToks, err := fleetClient.CountABMTokens()
|
|
if err != nil {
|
|
return nil, false, false, err
|
|
}
|
|
|
|
if hasLegacyConfig && abmToks > 1 {
|
|
return nil, false, false, errors.New(fleet.AppleABMDefaultTeamDeprecatedMessage)
|
|
}
|
|
|
|
if !hasLegacyConfig && !hasNewConfig {
|
|
return nil, false, false, nil
|
|
}
|
|
|
|
teams, err := fleetClient.ListTeams("")
|
|
if err != nil {
|
|
return nil, false, false, err
|
|
}
|
|
teamNames := map[string]struct{}{}
|
|
for _, tm := range teams {
|
|
teamNames[tm.Name] = struct{}{}
|
|
}
|
|
|
|
if hasLegacyConfig {
|
|
if appleBMDefaultTeam, ok := appleBMDT.(string); ok {
|
|
// normalize for Unicode support
|
|
appleBMDefaultTeam = norm.NFC.String(appleBMDefaultTeam)
|
|
abmTeams = append(abmTeams, appleBMDefaultTeam)
|
|
usesLegacyConfig = true
|
|
if _, ok = teamNames[appleBMDefaultTeam]; !ok {
|
|
missingTeam = true
|
|
}
|
|
}
|
|
}
|
|
|
|
if hasNewConfig {
|
|
if settingMap, ok := appleBM.([]any); ok {
|
|
for _, item := range settingMap {
|
|
if cfg, ok := item.(map[string]any); ok {
|
|
for _, teamConfigKey := range []string{"macos_fleet", "ios_fleet", "ipados_fleet"} {
|
|
if team, ok := cfg[teamConfigKey].(string); ok && team != "" {
|
|
// normalize for Unicode support
|
|
team = norm.NFC.String(team)
|
|
abmTeams = append(abmTeams, team)
|
|
if _, ok := teamNames[team]; !ok {
|
|
missingTeam = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return abmTeams, missingTeam, usesLegacyConfig, nil
|
|
}
|
|
|
|
// knownTeamNamesForTokenAssignment returns the set of team names that count as
|
|
// "existing" when validating deferred ABM/VPP token assignments. A team is known
|
|
// if it already exists in Fleet (returned by ListTeams, which by this point
|
|
// includes any teams created earlier in this gitops run) or if it was processed
|
|
// during this run (teamNames).
|
|
func knownTeamNamesForTokenAssignment(teamNames []string, fleetClient *service.Client) (map[string]struct{}, error) {
|
|
teams, err := fleetClient.ListTeams("")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
known := make(map[string]struct{}, len(teams)+len(teamNames))
|
|
for _, tm := range teams {
|
|
known[norm.NFC.String(tm.Name)] = struct{}{}
|
|
}
|
|
for _, name := range teamNames {
|
|
known[norm.NFC.String(name)] = struct{}{}
|
|
}
|
|
return known, nil
|
|
}
|
|
|
|
func applyABMTokenAssignmentIfNeeded(
|
|
ctx *cli.Context,
|
|
teamNames []string,
|
|
abmTeamNames []string,
|
|
originalMDMConfig []any,
|
|
usesLegacyConfig bool,
|
|
flDryRun bool,
|
|
fleetClient *service.Client,
|
|
) error {
|
|
if usesLegacyConfig && len(abmTeamNames) > 1 {
|
|
return errors.New(fleet.AppleABMDefaultTeamDeprecatedMessage)
|
|
}
|
|
|
|
if usesLegacyConfig && len(abmTeamNames) == 0 {
|
|
return errors.New("using legacy config without any ABM teams defined")
|
|
}
|
|
|
|
knownTeams, err := knownTeamNamesForTokenAssignment(teamNames, fleetClient)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var appConfigUpdate map[string]map[string]any
|
|
if usesLegacyConfig {
|
|
appleBMDefaultTeam := abmTeamNames[0]
|
|
if !fleet.IsReservedTeamName(appleBMDefaultTeam) {
|
|
if _, ok := knownTeams[norm.NFC.String(appleBMDefaultTeam)]; !ok {
|
|
return fmt.Errorf("apple_bm_default_team team %q not found in team configs", appleBMDefaultTeam)
|
|
}
|
|
}
|
|
appConfigUpdate = map[string]map[string]any{
|
|
"mdm": {
|
|
"apple_bm_default_team": appleBMDefaultTeam,
|
|
},
|
|
}
|
|
} else {
|
|
for _, abmTeam := range abmTeamNames {
|
|
if fleet.IsReservedTeamName(abmTeam) {
|
|
continue
|
|
}
|
|
if _, ok := knownTeams[norm.NFC.String(abmTeam)]; !ok {
|
|
return fmt.Errorf("apple_business team %q not found in team configs", abmTeam)
|
|
}
|
|
}
|
|
|
|
appConfigUpdate = map[string]map[string]any{
|
|
"mdm": {
|
|
"apple_business": originalMDMConfig,
|
|
},
|
|
}
|
|
}
|
|
|
|
if flDryRun {
|
|
_, _ = fmt.Fprint(ctx.App.Writer, "[!] would apply ABM teams\n")
|
|
return nil
|
|
}
|
|
_, _ = fmt.Fprintf(ctx.App.Writer, "[+] applying ABM teams\n")
|
|
if err := fleetClient.ApplyAppConfig(appConfigUpdate, fleet.ApplySpecOptions{}); err != nil {
|
|
return fmt.Errorf("applying fleet config: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// checkWindowsEnrollmentAssignment reads org_settings.mdm.windows_enrollment.default_fleet and reports whether the referenced
|
|
// fleet doesn't exist in Fleet yet (it may be created later in the same gitops run). Returns an empty name when the section or
|
|
// the value is absent.
|
|
func checkWindowsEnrollmentAssignment(config *spec.GitOps, fleetClient *service.Client) (defaultFleet string, missingTeam bool, err error) {
|
|
mdm, ok := config.OrgSettings["mdm"]
|
|
if !ok {
|
|
return "", false, nil
|
|
}
|
|
mdmMap, ok := mdm.(map[string]any)
|
|
if !ok {
|
|
return "", false, nil
|
|
}
|
|
we, ok := mdmMap["windows_enrollment"]
|
|
if !ok {
|
|
return "", false, nil
|
|
}
|
|
// A wrong shape is passed through untouched so the server-side validation reports it.
|
|
weMap, ok := we.(map[string]any)
|
|
if !ok {
|
|
return "", false, nil
|
|
}
|
|
name, _ := weMap["default_fleet"].(string)
|
|
if name == "" {
|
|
return "", false, nil
|
|
}
|
|
// normalize for Unicode support
|
|
name = norm.NFC.String(name)
|
|
teams, err := fleetClient.ListTeams("")
|
|
if err != nil {
|
|
return "", false, err
|
|
}
|
|
for _, tm := range teams {
|
|
if norm.NFC.String(tm.Name) == name {
|
|
return name, false, nil
|
|
}
|
|
}
|
|
return name, true, nil
|
|
}
|
|
|
|
// applyWindowsEnrollmentAssignmentIfNeeded applies the deferred org_settings.mdm.windows_enrollment.default_fleet once teams have
|
|
// been processed, failing if the referenced fleet still doesn't exist.
|
|
func applyWindowsEnrollmentAssignmentIfNeeded(
|
|
ctx *cli.Context,
|
|
teamNames []string,
|
|
defaultFleet string,
|
|
flDryRun bool,
|
|
fleetClient *service.Client,
|
|
) error {
|
|
knownTeams, err := knownTeamNamesForTokenAssignment(teamNames, fleetClient)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, ok := knownTeams[norm.NFC.String(defaultFleet)]; !ok {
|
|
return fmt.Errorf("windows_enrollment default_fleet %q not found in team configs", defaultFleet)
|
|
}
|
|
if flDryRun {
|
|
_, _ = fmt.Fprint(ctx.App.Writer, "[!] would apply Windows enrollment default fleet\n")
|
|
return nil
|
|
}
|
|
_, _ = fmt.Fprintf(ctx.App.Writer, "[+] applying Windows enrollment default fleet\n")
|
|
appConfigUpdate := map[string]map[string]any{
|
|
"mdm": {
|
|
"windows_enrollment": map[string]any{"default_fleet": defaultFleet},
|
|
},
|
|
}
|
|
if err := fleetClient.ApplyAppConfig(appConfigUpdate, fleet.ApplySpecOptions{}); err != nil {
|
|
return fmt.Errorf("applying fleet config: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func checkVPPTeamAssignments(config *spec.GitOps, fleetClient *service.Client) (
|
|
vppTeams []string, missingTeams []string, err error,
|
|
) {
|
|
if mdm, ok := config.OrgSettings["mdm"]; ok {
|
|
if mdmMap, ok := mdm.(map[string]any); ok {
|
|
teams, err := fleetClient.ListTeams("")
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
teamNames := map[string]struct{}{}
|
|
for _, tm := range teams {
|
|
teamNames[tm.Name] = struct{}{}
|
|
}
|
|
|
|
if vpp, ok := mdmMap["volume_purchasing_program"]; ok {
|
|
if vppInterfaces, ok := vpp.([]any); ok {
|
|
for _, item := range vppInterfaces {
|
|
if itemMap, ok := item.(map[string]any); ok {
|
|
if teams, ok := itemMap["fleets"].([]any); ok {
|
|
for _, team := range teams {
|
|
if teamStr, ok := team.(string); ok {
|
|
// normalize for Unicode support
|
|
normalizedTeam := norm.NFC.String(teamStr)
|
|
// Accept display name "All fleets" as equivalent to the internal "All teams"
|
|
if normalizedTeam == fleet.DisplayNameAllTeams {
|
|
normalizedTeam = fleet.TeamNameAllTeams
|
|
}
|
|
vppTeams = append(vppTeams, normalizedTeam)
|
|
// ListTeams doesn't return "No team" or "All teams", so account for those special cases
|
|
if _, ok := teamNames[normalizedTeam]; !ok && normalizedTeam != fleet.TeamNameNoTeam && normalizedTeam != fleet.TeamNameAllTeams {
|
|
missingTeams = append(missingTeams, normalizedTeam)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return vppTeams, missingTeams, nil
|
|
}
|
|
|
|
func applyVPPTokenAssignmentIfNeeded(
|
|
ctx *cli.Context,
|
|
teamNames []string,
|
|
vppTeamNames []string,
|
|
originalVPPConfig []any,
|
|
flDryRun bool,
|
|
fleetClient *service.Client,
|
|
) error {
|
|
knownTeams, err := knownTeamNamesForTokenAssignment(teamNames, fleetClient)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, vppTeam := range vppTeamNames {
|
|
if fleet.IsReservedTeamName(vppTeam) {
|
|
continue
|
|
}
|
|
if _, ok := knownTeams[norm.NFC.String(vppTeam)]; !ok {
|
|
return fmt.Errorf("volume_purchasing_program team %s not found in team configs", vppTeam)
|
|
}
|
|
}
|
|
|
|
appConfigUpdate := map[string]map[string]any{
|
|
"mdm": {
|
|
"volume_purchasing_program": originalVPPConfig,
|
|
},
|
|
}
|
|
|
|
if flDryRun {
|
|
_, _ = fmt.Fprint(ctx.App.Writer, "[!] would apply volume_purchasing_program teams\n")
|
|
return nil
|
|
}
|
|
_, _ = fmt.Fprintf(ctx.App.Writer, "[+] applying volume_purchasing_program teams\n")
|
|
if err := fleetClient.ApplyAppConfig(appConfigUpdate, fleet.ApplySpecOptions{}); err != nil {
|
|
return fmt.Errorf("applying fleet config for volume_purchasing_program teams: %w", err)
|
|
}
|
|
return nil
|
|
}
|