fleetctl gitops (#16535)

Add `fleetctl gitops` command for #13643 

Code review video:
https://www.loom.com/share/7941c51c709b44ccafd618dd05837d99?sid=27b923d7-1393-4396-bac7-30616b2d6de9

fleet-gitops PR that also needs review:
https://github.com/fleetdm/fleet-gitops/pull/26

Working global/team gitops configs that can be used for testing:
https://github.com/fleetdm/fleet-gitops/tree/victor/fixing-configs

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

<!-- Note that API documentation changes are now addressed by the
product design team. -->

- [x] Changes file added for user-visible changes in `changes/` or
`orbit/changes/`.
- [x] Added/updated tests
- [x] Manual QA for all new/changed functionality
This commit is contained in:
Victor Lyuboslavsky 2024-02-09 13:34:57 -06:00 committed by GitHub
parent 6aedcf97be
commit e4d5e27dd9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
42 changed files with 3009 additions and 139 deletions

View File

@ -0,0 +1,2 @@
Added fleetctl gitops command:
- Synchronize Fleet configuration with provided file. This command is intended to be used in a GitOps workflow.

View File

@ -77,7 +77,7 @@ func applyCommand() *cli.Command {
opts.TeamForPolicies = policiesTeamName
}
baseDir := filepath.Dir(flFilename)
err = fleetClient.ApplyGroup(c.Context, specs, baseDir, logf, opts)
_, err = fleetClient.ApplyGroup(c.Context, specs, baseDir, logf, opts)
if err != nil {
return err
}

View File

@ -265,7 +265,7 @@ spec:
},
}
require.Equal(t, "[+] applied 1 teams\n", runAppForTest(t, []string{"apply", "-f", filename}))
assert.Contains(t, runAppForTest(t, []string{"apply", "-f", filename}), "[+] applied 1 teams\n")
// enroll secret not provided, so left unchanged
assert.Equal(t, []*fleet.EnrollSecret{{Secret: "AAA"}}, enrolledSecretsCalled[uint(42)])
assert.False(t, ds.ApplyEnrollSecretsFuncInvoked)
@ -379,7 +379,7 @@ spec:
},
}
require.Equal(t, "[+] applied 1 teams\n", runAppForTest(t, []string{"apply", "-f", filename}))
assert.Contains(t, runAppForTest(t, []string{"apply", "-f", filename}), "[+] applied 1 teams\n")
// agent options still cleared
assert.Nil(t, teamsByName["team1"].Config.AgentOptions)
// macos settings and updates are now cleared.
@ -869,12 +869,12 @@ func TestApplyPolicies(t *testing.T) {
func TestApplyPoliciesValidation(t *testing.T) {
// Team Policy Spec
filename := writeTmpYml(t, duplicateTeamPolicySpec)
errorMsg := `applying policies: policy names must be globally unique. Please correct policy "Is Gatekeeper enabled on macOS devices?" and try again.`
errorMsg := `applying policies: policy names must be unique. Please correct policy "Is Gatekeeper enabled on macOS devices?" and try again.`
runAppCheckErr(t, []string{"apply", "-f", filename}, errorMsg)
// Global Policy Spec
filename = writeTmpYml(t, duplicateGlobalPolicySpec)
errorMsg = `applying policies: policy names must be globally unique. Please correct policy "Is Gatekeeper enabled on macOS devices?" and try again.`
errorMsg = `applying policies: policy names must be unique. Please correct policy "Is Gatekeeper enabled on macOS devices?" and try again.`
runAppCheckErr(t, []string{"apply", "-f", filename}, errorMsg)
}
@ -1168,10 +1168,10 @@ spec:
`, mobileConfigPath))
// first apply with dry-run
require.Equal(t, "[+] would've applied 1 teams\n", runAppForTest(t, []string{"apply", "-f", name, "--dry-run"}))
assert.Contains(t, runAppForTest(t, []string{"apply", "-f", name, "--dry-run"}), "[+] would've applied 1 teams\n")
// then apply for real
require.Equal(t, "[+] applied 1 teams\n", runAppForTest(t, []string{"apply", "-f", name}))
assert.Contains(t, runAppForTest(t, []string{"apply", "-f", name}), "[+] applied 1 teams\n")
assert.JSONEq(t, string(json.RawMessage(`{"config":{"views":{"foo":"qux"}}}`)), string(*savedTeam.Config.AgentOptions))
assert.Equal(t, fleet.TeamMDM{
EnableDiskEncryption: false,
@ -1204,10 +1204,10 @@ spec:
`, emptySetupAsst))
// first apply with dry-run
require.Equal(t, "[+] would've applied 1 teams\n", runAppForTest(t, []string{"apply", "-f", name, "--dry-run"}))
assert.Contains(t, runAppForTest(t, []string{"apply", "-f", name, "--dry-run"}), "[+] would've applied 1 teams\n")
// then apply for real
require.Equal(t, "[+] applied 1 teams\n", runAppForTest(t, []string{"apply", "-f", name}))
assert.Contains(t, runAppForTest(t, []string{"apply", "-f", name}), "[+] applied 1 teams\n")
require.True(t, ds.GetMDMAppleSetupAssistantFuncInvoked)
require.True(t, ds.SetOrUpdateMDMAppleSetupAssistantFuncInvoked)
require.True(t, ds.NewJobFuncInvoked)
@ -1243,10 +1243,10 @@ spec:
`, bootstrapURL))
// first apply with dry-run
require.Equal(t, "[+] would've applied 1 teams\n", runAppForTest(t, []string{"apply", "-f", name, "--dry-run"}))
assert.Contains(t, runAppForTest(t, []string{"apply", "-f", name, "--dry-run"}), "[+] would've applied 1 teams\n")
// then apply for real
require.Equal(t, "[+] applied 1 teams\n", runAppForTest(t, []string{"apply", "-f", name}))
assert.Contains(t, runAppForTest(t, []string{"apply", "-f", name}), "[+] applied 1 teams\n")
// all left untouched, only bootstrap package added
assert.Equal(t, fleet.TeamMDM{
EnableDiskEncryption: false,

View File

@ -108,6 +108,7 @@ func createApp(
mdmCommand(),
upgradePacksCommand(),
runScriptCommand(),
gitopsCommand(),
}
return app
}

View File

@ -2214,7 +2214,7 @@ func TestGetTeamsYAMLAndApply(t *testing.T) {
actualYaml := runAppForTest(t, []string{"get", "teams", "--yaml"})
yamlFilePath := writeTmpYml(t, actualYaml)
require.Equal(t, "[+] applied 2 teams\n", runAppForTest(t, []string{"apply", "-f", yamlFilePath}))
assert.Contains(t, runAppForTest(t, []string{"apply", "-f", yamlFilePath}), "[+] applied 2 teams\n")
}
func TestGetMDMCommandResults(t *testing.T) {

71
cmd/fleetctl/gitops.go Normal file
View File

@ -0,0 +1,71 @@
package main
import (
"errors"
"fmt"
"github.com/fleetdm/fleet/v4/pkg/spec"
"github.com/urfave/cli/v2"
"os"
"path/filepath"
)
func gitopsCommand() *cli.Command {
var (
flFilename string
flDryRun bool
)
return &cli.Command{
Name: "gitops",
Usage: "Synchronize Fleet configuration with provided file. This command is intended to be used in a GitOps workflow.",
UsageText: `fleetctl gitops [options]`,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "f",
EnvVars: []string{"FILENAME"},
Value: "",
Destination: &flFilename,
Usage: "The file with the GitOps configuration",
},
&cli.BoolFlag{
Name: "dry-run",
EnvVars: []string{"DRY_RUN"},
Destination: &flDryRun,
Usage: "Do not apply the file, just validate it",
},
configFlag(),
contextFlag(),
debugFlag(),
},
Action: func(c *cli.Context) error {
if flFilename == "" {
return errors.New("-f must be specified")
}
b, err := os.ReadFile(flFilename)
if err != nil {
return err
}
fleetClient, err := clientFromCLI(c)
if err != nil {
return err
}
baseDir := filepath.Dir(flFilename)
config, err := spec.GitOpsFromBytes(b, baseDir)
if err != nil {
return err
}
logf := func(format string, a ...interface{}) {
_, _ = fmt.Fprintf(c.App.Writer, format, a...)
}
err = fleetClient.DoGitOps(c.Context, config, baseDir, logf, flDryRun)
if 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
},
}
}

516
cmd/fleetctl/gitops_test.go Normal file
View File

@ -0,0 +1,516 @@
package main
import (
"context"
"github.com/fleetdm/fleet/v4/server/config"
"github.com/fleetdm/fleet/v4/server/fleet"
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
nanomdm_mock "github.com/fleetdm/fleet/v4/server/mock/nanomdm"
"github.com/fleetdm/fleet/v4/server/service"
"github.com/micromdm/nanodep/tokenpki"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"os"
"slices"
"strings"
"testing"
"time"
)
const teamName = "Team Test"
func TestBasicGlobalGitOps(t *testing.T) {
_, ds := runServerWithMockedDS(t)
ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) error { return nil }
ds.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error {
return nil
}
ds.ListGlobalPoliciesFunc = func(ctx context.Context, opts fleet.ListOptions) ([]*fleet.Policy, error) { return nil, nil }
ds.ListQueriesFunc = func(ctx context.Context, opts fleet.ListQueryOptions) ([]*fleet.Query, error) { return nil, nil }
// Mock appConfig
savedAppConfig := &fleet.AppConfig{}
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
return &fleet.AppConfig{}, nil
}
ds.SaveAppConfigFunc = func(ctx context.Context, config *fleet.AppConfig) error {
savedAppConfig = config
return nil
}
var enrolledSecrets []*fleet.EnrollSecret
ds.ApplyEnrollSecretsFunc = func(ctx context.Context, teamID *uint, secrets []*fleet.EnrollSecret) error {
enrolledSecrets = secrets
return nil
}
tmpFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
const (
fleetServerURL = "https://fleet.example.com"
orgName = "GitOps Test"
)
t.Setenv("FLEET_SERVER_URL", fleetServerURL)
_, err = tmpFile.WriteString(
`
controls:
queries:
policies:
agent_options:
org_settings:
server_settings:
server_url: $FLEET_SERVER_URL
org_info:
contact_url: https://example.com/contact
org_logo_url: ""
org_logo_url_light_background: ""
org_name: ${ORG_NAME}
secrets:
`,
)
require.NoError(t, err)
// No file
var errWriter strings.Builder
_, err = runAppNoChecks([]string{"gitops", tmpFile.Name()})
assert.Error(t, err)
assert.Equal(t, err.Error(), "-f must be specified")
// Bad file
errWriter.Reset()
_, err = runAppNoChecks([]string{"gitops", "-f", "fileDoesNotExist.yml"})
assert.Error(t, err)
assert.Contains(t, err.Error(), "no such file or directory")
// Empty file
errWriter.Reset()
badFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = runAppNoChecks([]string{"gitops", "-f", badFile.Name()})
assert.Error(t, err)
assert.Contains(t, err.Error(), "errors occurred")
// DoGitOps error
t.Setenv("ORG_NAME", "")
_, err = runAppNoChecks([]string{"gitops", "-f", tmpFile.Name()})
assert.Error(t, err)
assert.Contains(t, err.Error(), "organization name must be present")
// Dry run
t.Setenv("ORG_NAME", orgName)
_ = runAppForTest(t, []string{"gitops", "-f", tmpFile.Name(), "--dry-run"})
assert.Equal(t, fleet.AppConfig{}, *savedAppConfig, "AppConfig should be empty")
// Real run
_ = runAppForTest(t, []string{"gitops", "-f", tmpFile.Name()})
assert.Equal(t, orgName, savedAppConfig.OrgInfo.OrgName)
assert.Equal(t, fleetServerURL, savedAppConfig.ServerSettings.ServerURL)
assert.Empty(t, enrolledSecrets)
}
func TestBasicTeamGitOps(t *testing.T) {
license := &fleet.LicenseInfo{Tier: fleet.TierPremium, Expiration: time.Now().Add(24 * time.Hour)}
_, ds := runServerWithMockedDS(
t, &service.TestServerOpts{
License: license,
},
)
const secret = "TestSecret"
ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) error { return nil }
ds.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error {
return nil
}
ds.ListTeamPoliciesFunc = func(
ctx context.Context, teamID uint, opts fleet.ListOptions, iopts fleet.ListOptions,
) (teamPolicies []*fleet.Policy, inheritedPolicies []*fleet.Policy, err error) {
return nil, nil, nil
}
ds.ListQueriesFunc = func(ctx context.Context, opts fleet.ListQueryOptions) ([]*fleet.Query, error) { return nil, nil }
team := &fleet.Team{
ID: 1,
CreatedAt: time.Now(),
Name: teamName,
}
ds.TeamByNameFunc = func(ctx context.Context, name string) (*fleet.Team, error) {
if name == teamName {
return team, nil
}
return nil, nil
}
ds.TeamFunc = func(ctx context.Context, tid uint) (*fleet.Team, error) {
if tid == team.ID {
return team, nil
}
return nil, nil
}
var savedTeam *fleet.Team
ds.SaveTeamFunc = func(ctx context.Context, team *fleet.Team) (*fleet.Team, error) {
savedTeam = team
return team, nil
}
var enrolledSecrets []*fleet.EnrollSecret
ds.ApplyEnrollSecretsFunc = func(ctx context.Context, teamID *uint, secrets []*fleet.EnrollSecret) error {
enrolledSecrets = secrets
return nil
}
tmpFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
t.Setenv("TEST_SECRET", secret)
_, err = tmpFile.WriteString(
`
controls:
queries:
policies:
agent_options:
name: ${TEST_TEAM_NAME}
team_settings:
secrets: [{"secret":"${TEST_SECRET}"}]
`,
)
require.NoError(t, err)
// DoGitOps error
t.Setenv("TEST_TEAM_NAME", "")
_, err = runAppNoChecks([]string{"gitops", "-f", tmpFile.Name()})
assert.Error(t, err)
assert.Contains(t, err.Error(), "'name' is required")
// Dry run
t.Setenv("TEST_TEAM_NAME", teamName)
_ = runAppForTest(t, []string{"gitops", "-f", tmpFile.Name(), "--dry-run"})
assert.Nil(t, savedTeam)
// Real run
_ = runAppForTest(t, []string{"gitops", "-f", tmpFile.Name()})
require.NotNil(t, savedTeam)
assert.Equal(t, teamName, savedTeam.Name)
require.Len(t, enrolledSecrets, 1)
assert.Equal(t, secret, enrolledSecrets[0].Secret)
}
func TestFullGlobalGitOps(t *testing.T) {
// mdm test configuration must be set so that activating windows MDM works.
testCert, testKey, err := apple_mdm.NewSCEPCACertKey()
require.NoError(t, err)
testCertPEM := tokenpki.PEMCertificate(testCert.Raw)
testKeyPEM := tokenpki.PEMRSAPrivateKey(testKey)
fleetCfg := config.TestConfig()
config.SetTestMDMConfig(t, &fleetCfg, testCertPEM, testKeyPEM, nil, "../../server/service/testdata")
// License is not needed because we are not using any premium features in our config.
_, ds := runServerWithMockedDS(
t, &service.TestServerOpts{
MDMStorage: new(nanomdm_mock.Storage),
MDMPusher: mockPusher{},
FleetConfig: &fleetCfg,
},
)
var appliedScripts []*fleet.Script
ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) error {
appliedScripts = scripts
return nil
}
ds.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error {
return nil
}
var appliedMacProfiles []*fleet.MDMAppleConfigProfile
var appliedWinProfiles []*fleet.MDMWindowsConfigProfile
ds.BatchSetMDMProfilesFunc = func(
ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile,
) error {
appliedMacProfiles = macProfiles
appliedWinProfiles = winProfiles
return nil
}
ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs, teamIDs []uint, profileUUIDs, hostUUIDs []string) error {
return nil
}
ds.NewJobFunc = func(ctx context.Context, job *fleet.Job) (*fleet.Job, error) {
return job, nil
}
// Policies
policy := fleet.Policy{}
policy.ID = 1
policy.Name = "Policy to delete"
policyDeleted := false
ds.ListGlobalPoliciesFunc = func(ctx context.Context, opts fleet.ListOptions) ([]*fleet.Policy, error) {
return []*fleet.Policy{&policy}, nil
}
ds.PoliciesByIDFunc = func(ctx context.Context, ids []uint) (map[uint]*fleet.Policy, error) {
if slices.Contains(ids, 1) {
return map[uint]*fleet.Policy{1: &policy}, nil
}
return nil, nil
}
ds.DeleteGlobalPoliciesFunc = func(ctx context.Context, ids []uint) ([]uint, error) {
policyDeleted = true
assert.Equal(t, []uint{policy.ID}, ids)
return ids, nil
}
var appliedPolicySpecs []*fleet.PolicySpec
ds.ApplyPolicySpecsFunc = func(ctx context.Context, authorID uint, specs []*fleet.PolicySpec) error {
appliedPolicySpecs = specs
return nil
}
// Queries
query := fleet.Query{}
query.ID = 1
query.Name = "Query to delete"
queryDeleted := false
ds.ListQueriesFunc = func(ctx context.Context, opts fleet.ListQueryOptions) ([]*fleet.Query, error) {
return []*fleet.Query{&query}, nil
}
ds.DeleteQueriesFunc = func(ctx context.Context, ids []uint) (uint, error) {
queryDeleted = true
assert.Equal(t, []uint{query.ID}, ids)
return 1, nil
}
ds.QueryFunc = func(ctx context.Context, id uint) (*fleet.Query, error) {
if id == query.ID {
return &query, nil
}
return nil, nil
}
var appliedQueries []*fleet.Query
ds.QueryByNameFunc = func(ctx context.Context, teamID *uint, name string) (*fleet.Query, error) {
return nil, &notFoundError{}
}
ds.ApplyQueriesFunc = func(
ctx context.Context, authorID uint, queries []*fleet.Query, queriesToDiscardResults map[uint]struct{},
) error {
appliedQueries = queries
return nil
}
// Mock appConfig
savedAppConfig := &fleet.AppConfig{}
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
return &fleet.AppConfig{MDM: fleet.MDM{EnabledAndConfigured: true}}, nil
}
ds.SaveAppConfigFunc = func(ctx context.Context, config *fleet.AppConfig) error {
savedAppConfig = config
return nil
}
var enrolledSecrets []*fleet.EnrollSecret
ds.ApplyEnrollSecretsFunc = func(ctx context.Context, teamID *uint, secrets []*fleet.EnrollSecret) error {
enrolledSecrets = secrets
return nil
}
const (
fleetServerURL = "https://fleet.example.com"
orgName = "GitOps Test"
)
t.Setenv("FLEET_SERVER_URL", fleetServerURL)
t.Setenv("ORG_NAME", orgName)
// Dry run
file := "./testdata/gitops/global_config_no_paths.yml"
_ = runAppForTest(t, []string{"gitops", "-f", file, "--dry-run"})
assert.Equal(t, fleet.AppConfig{}, *savedAppConfig, "AppConfig should be empty")
assert.Len(t, enrolledSecrets, 0)
assert.Len(t, appliedPolicySpecs, 0)
assert.Len(t, appliedQueries, 0)
assert.Len(t, appliedScripts, 0)
assert.Len(t, appliedMacProfiles, 0)
assert.Len(t, appliedWinProfiles, 0)
// Real run
_ = runAppForTest(t, []string{"gitops", "-f", file})
assert.Equal(t, orgName, savedAppConfig.OrgInfo.OrgName)
assert.Equal(t, fleetServerURL, savedAppConfig.ServerSettings.ServerURL)
assert.Contains(t, string(*savedAppConfig.AgentOptions), "distributed_denylist_duration")
assert.Len(t, enrolledSecrets, 2)
assert.True(t, policyDeleted)
assert.Len(t, appliedPolicySpecs, 5)
assert.True(t, queryDeleted)
assert.Len(t, appliedQueries, 3)
assert.Len(t, appliedScripts, 1)
assert.Len(t, appliedMacProfiles, 1)
assert.Len(t, appliedWinProfiles, 1)
}
func TestFullTeamGitOps(t *testing.T) {
license := &fleet.LicenseInfo{Tier: fleet.TierPremium, Expiration: time.Now().Add(24 * time.Hour)}
// mdm test configuration must be set so that activating windows MDM works.
testCert, testKey, err := apple_mdm.NewSCEPCACertKey()
require.NoError(t, err)
testCertPEM := tokenpki.PEMCertificate(testCert.Raw)
testKeyPEM := tokenpki.PEMRSAPrivateKey(testKey)
fleetCfg := config.TestConfig()
config.SetTestMDMConfig(t, &fleetCfg, testCertPEM, testKeyPEM, nil, "../../server/service/testdata")
// License is not needed because we are not using any premium features in our config.
_, ds := runServerWithMockedDS(
t, &service.TestServerOpts{
License: license,
MDMStorage: new(nanomdm_mock.Storage),
MDMPusher: mockPusher{},
FleetConfig: &fleetCfg,
},
)
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
return &fleet.AppConfig{
MDM: fleet.MDM{
EnabledAndConfigured: true,
WindowsEnabledAndConfigured: true,
},
}, nil
}
var appliedScripts []*fleet.Script
ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) error {
appliedScripts = scripts
return nil
}
ds.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error {
return nil
}
var appliedMacProfiles []*fleet.MDMAppleConfigProfile
var appliedWinProfiles []*fleet.MDMWindowsConfigProfile
ds.BatchSetMDMProfilesFunc = func(
ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile,
) error {
appliedMacProfiles = macProfiles
appliedWinProfiles = winProfiles
return nil
}
ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs, teamIDs []uint, profileUUIDs, hostUUIDs []string) error {
return nil
}
ds.NewJobFunc = func(ctx context.Context, job *fleet.Job) (*fleet.Job, error) {
return job, nil
}
// Team
team := &fleet.Team{
ID: 1,
CreatedAt: time.Now(),
Name: teamName,
}
ds.TeamByNameFunc = func(ctx context.Context, name string) (*fleet.Team, error) {
if name == teamName {
return team, nil
}
return nil, nil
}
ds.TeamFunc = func(ctx context.Context, tid uint) (*fleet.Team, error) {
if tid == team.ID {
return team, nil
}
return nil, nil
}
var savedTeam *fleet.Team
ds.SaveTeamFunc = func(ctx context.Context, team *fleet.Team) (*fleet.Team, error) {
savedTeam = team
return team, nil
}
// Policies
policy := fleet.Policy{}
policy.ID = 1
policy.Name = "Policy to delete"
policy.TeamID = &team.ID
policyDeleted := false
ds.ListTeamPoliciesFunc = func(
ctx context.Context, teamID uint, opts fleet.ListOptions, iopts fleet.ListOptions,
) (teamPolicies []*fleet.Policy, inheritedPolicies []*fleet.Policy, err error) {
return []*fleet.Policy{&policy}, nil, nil
}
ds.PoliciesByIDFunc = func(ctx context.Context, ids []uint) (map[uint]*fleet.Policy, error) {
if slices.Contains(ids, 1) {
return map[uint]*fleet.Policy{1: &policy}, nil
}
return nil, nil
}
ds.DeleteTeamPoliciesFunc = func(ctx context.Context, teamID uint, IDs []uint) ([]uint, error) {
policyDeleted = true
assert.Equal(t, []uint{policy.ID}, IDs)
return []uint{policy.ID}, nil
}
var appliedPolicySpecs []*fleet.PolicySpec
ds.ApplyPolicySpecsFunc = func(ctx context.Context, authorID uint, specs []*fleet.PolicySpec) error {
appliedPolicySpecs = specs
return nil
}
// Queries
query := fleet.Query{}
query.ID = 1
query.TeamID = &team.ID
query.Name = "Query to delete"
queryDeleted := false
ds.ListQueriesFunc = func(ctx context.Context, opts fleet.ListQueryOptions) ([]*fleet.Query, error) {
return []*fleet.Query{&query}, nil
}
ds.DeleteQueriesFunc = func(ctx context.Context, ids []uint) (uint, error) {
queryDeleted = true
assert.Equal(t, []uint{query.ID}, ids)
return 1, nil
}
ds.QueryFunc = func(ctx context.Context, id uint) (*fleet.Query, error) {
if id == query.ID {
return &query, nil
}
return nil, nil
}
var appliedQueries []*fleet.Query
ds.QueryByNameFunc = func(ctx context.Context, teamID *uint, name string) (*fleet.Query, error) {
return nil, &notFoundError{}
}
ds.ApplyQueriesFunc = func(
ctx context.Context, authorID uint, queries []*fleet.Query, queriesToDiscardResults map[uint]struct{},
) error {
appliedQueries = queries
return nil
}
var enrolledSecrets []*fleet.EnrollSecret
ds.ApplyEnrollSecretsFunc = func(ctx context.Context, teamID *uint, secrets []*fleet.EnrollSecret) error {
enrolledSecrets = secrets
return nil
}
t.Setenv("TEST_TEAM_NAME", teamName)
// Dry run
file := "./testdata/gitops/team_config_no_paths.yml"
_ = runAppForTest(t, []string{"gitops", "-f", file, "--dry-run"})
assert.Nil(t, savedTeam)
assert.Len(t, enrolledSecrets, 0)
assert.Len(t, appliedPolicySpecs, 0)
assert.Len(t, appliedQueries, 0)
assert.Len(t, appliedScripts, 0)
assert.Len(t, appliedMacProfiles, 0)
assert.Len(t, appliedWinProfiles, 0)
// Real run
_ = runAppForTest(t, []string{"gitops", "-f", file})
require.NotNil(t, savedTeam)
assert.Equal(t, teamName, savedTeam.Name)
assert.Contains(t, string(*savedTeam.Config.AgentOptions), "distributed_denylist_duration")
assert.True(t, savedTeam.Config.Features.EnableHostUsers)
assert.Equal(t, 30, savedTeam.Config.HostExpirySettings.HostExpiryWindow)
assert.True(t, savedTeam.Config.MDM.EnableDiskEncryption)
assert.Len(t, enrolledSecrets, 2)
assert.True(t, policyDeleted)
assert.Len(t, appliedPolicySpecs, 5)
assert.True(t, queryDeleted)
assert.Len(t, appliedQueries, 3)
assert.Len(t, appliedScripts, 1)
assert.Len(t, appliedMacProfiles, 1)
assert.Len(t, appliedWinProfiles, 1)
}

View File

@ -342,7 +342,7 @@ Use the stop and reset subcommands to manage the server and dependencies once st
}
// this only applies standard queries, the base directory is not used,
// so pass in the current working directory.
err = client.ApplyGroup(c.Context, specs, ".", logf, fleet.ApplySpecOptions{})
_, err = client.ApplyGroup(c.Context, specs, ".", logf, fleet.ApplySpecOptions{})
if err != nil {
return err
}

View File

@ -0,0 +1,175 @@
# Test config
controls: # Controls added to "No team"
macos_settings:
custom_settings:
- path: ./lib/macos-password.mobileconfig
windows_settings:
custom_settings:
- path: ./lib/windows-screenlock.xml
scripts:
- path: ./lib/collect-fleetd-logs.sh
enable_disk_encryption: false
macos_migration:
enable: false
mode: ""
webhook_url: ""
macos_setup:
bootstrap_package: null
enable_end_user_authentication: false
macos_setup_assistant: null
macos_updates:
deadline: null
minimum_version: null
windows_enabled_and_configured: true
windows_updates:
deadline_days: null
grace_period_days: null
queries:
- name: Scheduled query stats
description: Collect osquery performance stats directly from osquery
query: SELECT *,
(SELECT value from osquery_flags where name = 'pack_delimiter') AS delimiter
FROM osquery_schedule;
interval: 0
platform: darwin,linux,windows
min_osquery_version: all
observer_can_run: false
automations_enabled: false
logging: snapshot
- name: orbit_info
query: SELECT * from orbit_info;
interval: 0
platform: darwin,linux,windows
min_osquery_version: all
observer_can_run: false
automations_enabled: true
logging: snapshot
- name: osquery_info
query: SELECT * from osquery_info;
interval: 604800 # 1 week
platform: darwin,linux,windows,chrome
min_osquery_version: all
observer_can_run: false
automations_enabled: true
logging: snapshot
policies:
- name: 😊 Failing policy
platform: linux
description: This policy should always fail.
resolution: There is no resolution for this policy.
query: SELECT 1 FROM osquery_info WHERE start_time < 0;
- name: Passing policy
platform: linux,windows,darwin,chrome
description: This policy should always pass.
resolution: There is no resolution for this policy.
query: SELECT 1;
- name: No root logins (macOS, Linux)
platform: linux,darwin
query: SELECT 1 WHERE NOT EXISTS (SELECT * FROM last
WHERE username = "root"
AND time > (( SELECT unix_time FROM time ) - 3600 ))
critical: true
- name: 🔥 Failing policy
platform: linux
description: This policy should always fail.
resolution: There is no resolution for this policy.
query: SELECT 1 FROM osquery_info WHERE start_time < 0;
- name: 😊😊 Failing policy
platform: linux
description: This policy should always fail.
resolution: There is no resolution for this policy.
query: SELECT 1 FROM osquery_info WHERE start_time < 0;
agent_options:
command_line_flags:
distributed_denylist_duration: 0
config:
decorators:
load:
- SELECT uuid AS host_uuid FROM system_info;
- SELECT hostname AS hostname FROM system_info;
options:
disable_distributed: false
distributed_interval: 10
distributed_plugin: tls
distributed_tls_max_attempts: 3
logger_tls_endpoint: /api/v1/osquery/log
pack_delimiter: /
org_settings:
server_settings:
debug_host_ids:
- 10728
deferred_save_host: false
enable_analytics: true
live_query_disabled: false
query_reports_disabled: false
scripts_disabled: false
server_url: $FLEET_SERVER_URL
org_info:
contact_url: https://fleetdm.com/company/contact
org_logo_url: ""
org_logo_url_light_background: ""
org_name: $ORG_NAME
smtp_settings:
authentication_method: authmethod_plain
authentication_type: authtype_username_password
configured: false
domain: ""
enable_smtp: false
enable_ssl_tls: true
enable_start_tls: true
password: ""
port: 587
sender_address: ""
server: ""
user_name: ""
verify_ssl_certs: true
sso_settings:
enable_jit_provisioning: false
enable_jit_role_sync: false
enable_sso: true
enable_sso_idp_login: false
entity_id: https://saml.example.com/entityid
idp_image_url: ""
idp_name: MockSAML
issuer_uri: ""
metadata: ""
metadata_url: https://mocksaml.com/api/saml/metadata
integrations:
jira: []
zendesk: []
mdm:
apple_bm_default_team: ""
end_user_authentication:
entity_id: ""
idp_name: ""
issuer_uri: ""
metadata: ""
metadata_url: ""
webhook_settings:
failing_policies_webhook:
destination_url: https://host.docker.internal:8080/bozo
enable_failing_policies_webhook: false
host_batch_size: 0
policy_ids: []
host_status_webhook:
days_count: 0
destination_url: ""
enable_host_status_webhook: false
host_percentage: 0
interval: 24h0m0s
vulnerabilities_webhook:
destination_url: ""
enable_vulnerabilities_webhook: false
host_batch_size: 0
fleet_desktop: # Applies to Fleet Premium only
transparency_url: https://fleetdm.com/transparency
host_expiry_settings: # Applies to all teams
host_expiry_enabled: false
features: # Features added to all teams
enable_host_users: true
enable_software_inventory: true
vulnerability_settings:
databases_path: ""
secrets: # These secrets are used to enroll hosts to the "All teams" team
- secret: SampleSecret123
- secret: ABC

View File

@ -0,0 +1,7 @@
cp /var/log/orbit/orbit.stderr.log ~/Library/Logs/Fleet/fleet-desktop.log /Users/Shared
echo "Successfully copied fleetd logs to the /Users/Shared folder."
echo "To retrieve logs, ask the end user to open Finder and in the menu bar select Go > Go to Folder."
echo "Then, ask the end user to type in /Users/Shared, press Return, and locate orbit.stderr.log (Orbit logs) and fleet-desktop.log (Fleet Desktop logs) files."

View File

@ -0,0 +1,55 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadContent</key>
<array>
<dict>
<key>PayloadDescription</key>
<string>Configures Passcode settings</string>
<key>PayloadDisplayName</key>
<string>Passcode</string>
<key>PayloadIdentifier</key>
<string>com.github.erikberglund.ProfileCreator.F7CF282E-D91B-44E9-922F-A719634F9C8E.com.apple.mobiledevice.passwordpolicy.231DFC90-D5A7-41B8-9246-564056048AC5</string>
<key>PayloadOrganization</key>
<string></string>
<key>PayloadType</key>
<string>com.apple.mobiledevice.passwordpolicy</string>
<key>PayloadUUID</key>
<string>231DFC90-D5A7-41B8-9246-564056048AC5</string>
<key>PayloadVersion</key>
<integer>1</integer>
<key>allowSimple</key>
<true/>
<key>forcePIN</key>
<true/>
<key>maxFailedAttempts</key>
<integer>11</integer>
<key>maxGracePeriod</key>
<integer>1</integer>
<key>maxInactivity</key>
<integer>15</integer>
<key>minLength</key>
<integer>10</integer>
<key>requireAlphanumeric</key>
<true/>
</dict>
</array>
<key>PayloadDescription</key>
<string>Configures our Macs to require passwords that are 10 character long</string>
<key>PayloadDisplayName</key>
<string>Password policy - require 10 characters</string>
<key>PayloadIdentifier</key>
<string>com.github.erikberglund.ProfileCreator.F7CF282E-D91B-44E9-922F-A719634F9C8E</string>
<key>PayloadOrganization</key>
<string>FleetDM</string>
<key>PayloadScope</key>
<string>System</string>
<key>PayloadType</key>
<string>Configuration</string>
<key>PayloadUUID</key>
<string>F7CF282E-D91B-44E9-922F-A719634F9C8E</string>
<key>PayloadVersion</key>
<integer>1</integer>
</dict>
</plist>

View File

@ -0,0 +1,48 @@
<Replace>
<!-- Enforce screenlock -->
<Item>
<Meta>
<Format xmlns="syncml:metinf">int</Format>
</Meta>
<Target>
<LocURI>./Device/Vendor/MSFT/Policy/Config/DeviceLock/DevicePasswordEnabled</LocURI>
</Target>
<Data>0</Data>
</Item>
</Replace>
<Replace>
<!-- Enforce screenlock after 15 minutes -->
<Item>
<Meta>
<Format xmlns="syncml:metinf">int</Format>
</Meta>
<Target>
<LocURI>./Device/Vendor/MSFT/Policy/Config/DeviceLock/MaxInactivityTimeDeviceLock</LocURI>
</Target>
<Data>15</Data>
</Item>
</Replace>
<Replace>
<!-- Enforce PIN or password length (10 characters) -->
<Item>
<Meta>
<Format xmlns="syncml:metinf">int</Format>
</Meta>
<Target>
<LocURI>./Device/Vendor/MSFT/Policy/Config/DeviceLock/MinDevicePasswordLength</LocURI>
</Target>
<Data>10</Data>
</Item>
</Replace>
<Replace>
<!-- Enforce PIN or password has at least one lowercase letter and at least one number -->
<Item>
<Meta>
<Format xmlns="syncml:metinf">int</Format>
</Meta>
<Target>
<LocURI>./Device/Vendor/MSFT/Policy/Config/DeviceLock/MinDevicePasswordComplexCharacters</LocURI>
</Target>
<Data>2</Data>
</Item>
</Replace>

View File

@ -0,0 +1,111 @@
name: "${TEST_TEAM_NAME}"
team_settings:
secrets:
- secret: "SampleSecret123"
- secret: "ABC"
webhook_settings:
failing_policies_webhook:
enable_failing_policies_webhook: true
destination_url: https://example.tines.com/webhook
policy_ids: [1, 2, 3, 4, 5, 6 ,7, 8, 9]
features:
enable_host_users: true
enable_software_inventory: true
host_expiry_settings:
host_expiry_enabled: true
host_expiry_window: 30
agent_options:
command_line_flags:
distributed_denylist_duration: 0
config:
decorators:
load:
- SELECT uuid AS host_uuid FROM system_info;
- SELECT hostname AS hostname FROM system_info;
options:
disable_distributed: false
distributed_interval: 10
distributed_plugin: tls
distributed_tls_max_attempts: 3
logger_tls_endpoint: /api/v1/osquery/log
pack_delimiter: /
controls:
macos_settings:
custom_settings:
- path: ./lib/macos-password.mobileconfig
windows_settings:
custom_settings:
- path: ./lib/windows-screenlock.xml
scripts:
- path: ./lib/collect-fleetd-logs.sh
enable_disk_encryption: true
macos_migration:
enable: false
mode: ""
webhook_url: ""
macos_setup:
bootstrap_package: null
enable_end_user_authentication: false
macos_setup_assistant: null
macos_updates:
deadline: null
minimum_version: null
windows_enabled_and_configured: true
windows_updates:
deadline_days: null
grace_period_days: null
queries:
- name: Scheduled query stats
description: Collect osquery performance stats directly from osquery
query: SELECT *,
(SELECT value from osquery_flags where name = 'pack_delimiter') AS delimiter
FROM osquery_schedule;
interval: 0
platform: darwin,linux,windows
min_osquery_version: all
observer_can_run: false
automations_enabled: false
logging: snapshot
- name: orbit_info
query: SELECT * from orbit_info;
interval: 0
platform: darwin,linux,windows
min_osquery_version: all
observer_can_run: false
automations_enabled: true
logging: snapshot
- name: osquery_info
query: SELECT * from osquery_info;
interval: 604800 # 1 week
platform: darwin,linux,windows,chrome
min_osquery_version: all
observer_can_run: false
automations_enabled: true
logging: snapshot
policies:
- name: 😊 Failing policy
platform: linux
description: This policy should always fail.
resolution: There is no resolution for this policy.
query: SELECT 1 FROM osquery_info WHERE start_time < 0;
- name: Passing policy
platform: linux,windows,darwin,chrome
description: This policy should always pass.
resolution: There is no resolution for this policy.
query: SELECT 1;
- name: No root logins (macOS, Linux)
platform: linux,darwin
query: SELECT 1 WHERE NOT EXISTS (SELECT * FROM last
WHERE username = "root"
AND time > (( SELECT unix_time FROM time ) - 3600 ))
critical: true
- name: 🔥 Failing policy
platform: linux
description: This policy should always fail.
resolution: There is no resolution for this policy.
query: SELECT 1 FROM osquery_info WHERE start_time < 0;
- name: 😊😊 Failing policy
platform: linux
description: This policy should always fail.
resolution: There is no resolution for this policy.
query: SELECT 1 FROM osquery_info WHERE start_time < 0;

View File

@ -804,16 +804,13 @@ func (svc *Service) ApplyTeamSpecs(ctx context.Context, specs []*fleet.TeamSpec,
})
}
if applyOpts.DryRun {
return nil, nil
}
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.ds.NewActivity(
ctx,
authz.UserFromContext(ctx),
@ -824,6 +821,7 @@ func (svc *Service) ApplyTeamSpecs(ctx context.Context, specs []*fleet.TeamSpec,
return nil, ctxerr.Wrap(ctx, err, "create activity for team spec")
}
}
}
return idsByName, nil
}

492
pkg/spec/gitops.go Normal file
View File

@ -0,0 +1,492 @@
package spec
import (
"encoding/json"
"errors"
"fmt"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/ghodss/yaml"
"github.com/hashicorp/go-multierror"
"os"
"path/filepath"
"slices"
"unicode"
)
type BaseItem struct {
Path *string `json:"path"`
}
type Controls struct {
BaseItem
MacOSUpdates interface{} `json:"macos_updates"`
MacOSSettings interface{} `json:"macos_settings"`
MacOSSetup interface{} `json:"macos_setup"`
MacOSMigration interface{} `json:"macos_migration"`
WindowsUpdates interface{} `json:"windows_updates"`
WindowsSettings interface{} `json:"windows_settings"`
WindowsEnabledAndConfigured interface{} `json:"windows_enabled_and_configured"`
EnableDiskEncryption interface{} `json:"enable_disk_encryption"`
Scripts []BaseItem `json:"scripts"`
}
type Policy struct {
BaseItem
fleet.PolicySpec
}
type Query struct {
BaseItem
fleet.QuerySpec
}
type GitOps struct {
TeamID *uint
TeamName *string
TeamSettings map[string]interface{}
OrgSettings map[string]interface{}
AgentOptions *json.RawMessage
Controls Controls
Policies []*fleet.PolicySpec
Queries []*fleet.QuerySpec
}
// GitOpsFromBytes parses a GitOps yaml file.
func GitOpsFromBytes(b []byte, baseDir string) (*GitOps, error) {
var top map[string]json.RawMessage
b = []byte(os.ExpandEnv(string(b))) // replace $var and ${var} with env values
if err := yaml.Unmarshal(b, &top); err != nil {
return nil, fmt.Errorf("failed to unmarshal file %w: \n", err)
}
var multiError *multierror.Error
result := &GitOps{}
topKeys := []string{"name", "team_settings", "org_settings", "agent_options", "controls", "policies", "queries"}
for k := range top {
if !slices.Contains(topKeys, k) {
multiError = multierror.Append(multiError, fmt.Errorf("unknown top-level field: %s", k))
}
}
// Figure out if this is an org or team settings file
teamRaw, teamOk := top["name"]
teamSettingsRaw, teamSettingsOk := top["team_settings"]
orgSettingsRaw, orgOk := top["org_settings"]
if orgOk {
if teamOk || teamSettingsOk {
multiError = multierror.Append(multiError, errors.New("'org_settings' cannot be used with 'name' or 'team_settings'"))
} else {
multiError = parseOrgSettings(orgSettingsRaw, result, baseDir, multiError)
}
} else if teamOk && teamSettingsOk {
multiError = parseName(teamRaw, result, multiError)
multiError = parseTeamSettings(teamSettingsRaw, result, baseDir, multiError)
} else {
multiError = multierror.Append(multiError, errors.New("either 'org_settings' or 'name' and 'team_settings' is required"))
}
// Validate the required top level options
multiError = parseControls(top, result, baseDir, multiError)
multiError = parseAgentOptions(top, result, baseDir, multiError)
multiError = parsePolicies(top, result, baseDir, multiError)
multiError = parseQueries(top, result, baseDir, multiError)
return result, multiError.ErrorOrNil()
}
func parseName(raw json.RawMessage, result *GitOps, multiError *multierror.Error) *multierror.Error {
if err := json.Unmarshal(raw, &result.TeamName); err != nil {
return multierror.Append(multiError, fmt.Errorf("failed to unmarshal name: %v", err))
}
if result.TeamName == nil || *result.TeamName == "" {
return multierror.Append(multiError, errors.New("team 'name' is required"))
}
if !isASCII(*result.TeamName) {
multiError = multierror.Append(multiError, fmt.Errorf("team name must be in ASCII: %s", *result.TeamName))
}
return multiError
}
func parseOrgSettings(raw json.RawMessage, result *GitOps, baseDir string, multiError *multierror.Error) *multierror.Error {
var orgSettingsTop BaseItem
if err := json.Unmarshal(raw, &orgSettingsTop); err != nil {
return multierror.Append(multiError, fmt.Errorf("failed to unmarshal org_settings: %v", err))
}
noError := true
if orgSettingsTop.Path != nil {
fileBytes, err := os.ReadFile(resolveApplyRelativePath(baseDir, *orgSettingsTop.Path))
if err != nil {
noError = false
multiError = multierror.Append(multiError, fmt.Errorf("failed to read org settings file %s: %v", *orgSettingsTop.Path, err))
} else {
fileBytes = []byte(os.ExpandEnv(string(fileBytes)))
var pathOrgSettings BaseItem
if err := yaml.Unmarshal(fileBytes, &pathOrgSettings); err != nil {
noError = false
multiError = multierror.Append(
multiError, fmt.Errorf("failed to unmarshal org settings file %s: %v", *orgSettingsTop.Path, err),
)
} else {
if pathOrgSettings.Path != nil {
noError = false
multiError = multierror.Append(
multiError,
fmt.Errorf("nested paths are not supported: %s in %s", *pathOrgSettings.Path, *orgSettingsTop.Path),
)
} else {
raw = fileBytes
}
}
}
}
if noError {
if err := yaml.Unmarshal(raw, &result.OrgSettings); err != nil {
// This error is currently unreachable because we know the file is valid YAML when we checked for nested path
multiError = multierror.Append(multiError, fmt.Errorf("failed to unmarshal org settings: %v", err))
} else {
multiError = parseSecrets(result, multiError)
}
// TODO: Validate that integrations.(jira|zendesk)[].api_token is not empty or fleet.MaskedPassword
}
return multiError
}
func parseTeamSettings(raw json.RawMessage, result *GitOps, baseDir string, multiError *multierror.Error) *multierror.Error {
var teamSettingsTop BaseItem
if err := json.Unmarshal(raw, &teamSettingsTop); err != nil {
return multierror.Append(multiError, fmt.Errorf("failed to unmarshal team_settings: %v", err))
}
noError := true
if teamSettingsTop.Path != nil {
fileBytes, err := os.ReadFile(resolveApplyRelativePath(baseDir, *teamSettingsTop.Path))
if err != nil {
noError = false
multiError = multierror.Append(multiError, fmt.Errorf("failed to read team settings file %s: %v", *teamSettingsTop.Path, err))
} else {
fileBytes = []byte(os.ExpandEnv(string(fileBytes)))
var pathTeamSettings BaseItem
if err := yaml.Unmarshal(fileBytes, &pathTeamSettings); err != nil {
noError = false
multiError = multierror.Append(
multiError, fmt.Errorf("failed to unmarshal team settings file %s: %v", *teamSettingsTop.Path, err),
)
} else {
if pathTeamSettings.Path != nil {
noError = false
multiError = multierror.Append(
multiError,
fmt.Errorf("nested paths are not supported: %s in %s", *pathTeamSettings.Path, *teamSettingsTop.Path),
)
} else {
raw = fileBytes
}
}
}
}
if noError {
if err := yaml.Unmarshal(raw, &result.TeamSettings); err != nil {
// This error is currently unreachable because we know the file is valid YAML when we checked for nested path
multiError = multierror.Append(multiError, fmt.Errorf("failed to unmarshal team settings: %v", err))
} else {
multiError = parseSecrets(result, multiError)
}
}
return multiError
}
func parseSecrets(result *GitOps, multiError *multierror.Error) *multierror.Error {
var rawSecrets interface{}
var ok bool
if result.TeamName == nil {
rawSecrets, ok = result.OrgSettings["secrets"]
if !ok {
return multierror.Append(multiError, errors.New("'org_settings.secrets' is required"))
}
} else {
rawSecrets, ok = result.TeamSettings["secrets"]
if !ok {
return multierror.Append(multiError, errors.New("'team_settings.secrets' is required"))
}
}
var enrollSecrets []*fleet.EnrollSecret
if rawSecrets != nil {
secrets, ok := rawSecrets.([]interface{})
if !ok {
return multierror.Append(multiError, errors.New("'secrets' must be a list of secret items"))
}
for _, enrollSecret := range secrets {
var secret string
var secretInterface interface{}
secretMap, ok := enrollSecret.(map[string]interface{})
if ok {
secretInterface, ok = secretMap["secret"]
}
if ok {
secret, ok = secretInterface.(string)
}
if !ok || secret == "" {
multiError = multierror.Append(
multiError, errors.New("each item in 'secrets' must have a 'secret' key containing an ASCII string value"),
)
break
}
enrollSecrets = append(
enrollSecrets, &fleet.EnrollSecret{Secret: secret},
)
}
}
if result.TeamName == nil {
result.OrgSettings["secrets"] = enrollSecrets
} else {
result.TeamSettings["secrets"] = enrollSecrets
}
return multiError
}
func parseAgentOptions(top map[string]json.RawMessage, result *GitOps, baseDir string, multiError *multierror.Error) *multierror.Error {
agentOptionsRaw, ok := top["agent_options"]
if !ok {
return multierror.Append(multiError, errors.New("'agent_options' is required"))
}
var agentOptionsTop BaseItem
if err := json.Unmarshal(agentOptionsRaw, &agentOptionsTop); err != nil {
multiError = multierror.Append(multiError, fmt.Errorf("failed to unmarshal agent_options: %v", err))
} else {
if agentOptionsTop.Path == nil {
result.AgentOptions = &agentOptionsRaw
} else {
fileBytes, err := os.ReadFile(resolveApplyRelativePath(baseDir, *agentOptionsTop.Path))
if err != nil {
return multierror.Append(multiError, fmt.Errorf("failed to read agent options file %s: %v", *agentOptionsTop.Path, err))
}
fileBytes = []byte(os.ExpandEnv(string(fileBytes)))
var pathAgentOptions BaseItem
if err := yaml.Unmarshal(fileBytes, &pathAgentOptions); err != nil {
return multierror.Append(
multiError, fmt.Errorf("failed to unmarshal agent options file %s: %v", *agentOptionsTop.Path, err),
)
}
if pathAgentOptions.Path != nil {
return multierror.Append(
multiError,
fmt.Errorf("nested paths are not supported: %s in %s", *pathAgentOptions.Path, *agentOptionsTop.Path),
)
}
var raw json.RawMessage
if err := yaml.Unmarshal(fileBytes, &raw); err != nil {
// This error is currently unreachable because we know the file is valid YAML when we checked for nested path
return multierror.Append(
multiError, fmt.Errorf("failed to unmarshal agent options file %s: %v", *agentOptionsTop.Path, err),
)
}
result.AgentOptions = &raw
}
}
return multiError
}
func parseControls(top map[string]json.RawMessage, result *GitOps, baseDir string, multiError *multierror.Error) *multierror.Error {
controlsRaw, ok := top["controls"]
if !ok {
return multierror.Append(multiError, errors.New("'controls' is required"))
}
var controlsTop Controls
if err := json.Unmarshal(controlsRaw, &controlsTop); err != nil {
return multierror.Append(multiError, fmt.Errorf("failed to unmarshal controls: %v", err))
}
if controlsTop.Path == nil {
result.Controls = controlsTop
} else {
fileBytes, err := os.ReadFile(resolveApplyRelativePath(baseDir, *controlsTop.Path))
if err != nil {
return multierror.Append(multiError, fmt.Errorf("failed to read controls file %s: %v", *controlsTop.Path, err))
}
fileBytes = []byte(os.ExpandEnv(string(fileBytes)))
var pathControls Controls
if err := yaml.Unmarshal(fileBytes, &pathControls); err != nil {
return multierror.Append(multiError, fmt.Errorf("failed to unmarshal controls file %s: %v", *controlsTop.Path, err))
}
if pathControls.Path != nil {
return multierror.Append(
multiError,
fmt.Errorf("nested paths are not supported: %s in %s", *pathControls.Path, *controlsTop.Path),
)
}
result.Controls = pathControls
}
return multiError
}
func parsePolicies(top map[string]json.RawMessage, result *GitOps, baseDir string, multiError *multierror.Error) *multierror.Error {
policiesRaw, ok := top["policies"]
if !ok {
return multierror.Append(multiError, errors.New("'policies' key is required"))
}
var policies []Policy
if err := json.Unmarshal(policiesRaw, &policies); err != nil {
return multierror.Append(multiError, fmt.Errorf("failed to unmarshal policies: %v", err))
}
for _, item := range policies {
item := item
if item.Path == nil {
result.Policies = append(result.Policies, &item.PolicySpec)
} else {
fileBytes, err := os.ReadFile(resolveApplyRelativePath(baseDir, *item.Path))
if err != nil {
multiError = multierror.Append(multiError, fmt.Errorf("failed to read policies file %s: %v", *item.Path, err))
continue
}
fileBytes = []byte(os.ExpandEnv(string(fileBytes)))
var pathPolicies []*Policy
if err := yaml.Unmarshal(fileBytes, &pathPolicies); err != nil {
multiError = multierror.Append(multiError, fmt.Errorf("failed to unmarshal policies file %s: %v", *item.Path, err))
continue
}
for _, pp := range pathPolicies {
pp := pp
if pp != nil {
if pp.Path != nil {
multiError = multierror.Append(
multiError, fmt.Errorf("nested paths are not supported: %s in %s", *pp.Path, *item.Path),
)
} else {
result.Policies = append(result.Policies, &pp.PolicySpec)
}
}
}
}
}
// Make sure team name is correct, and do additional validation
for _, item := range result.Policies {
if item.Name == "" {
multiError = multierror.Append(multiError, errors.New("policy name is required for each policy"))
}
if item.Query == "" {
multiError = multierror.Append(multiError, errors.New("policy query is required for each policy"))
}
if result.TeamName != nil {
item.Team = *result.TeamName
} else {
item.Team = ""
}
}
duplicates := getDuplicateNames(
result.Policies, func(p *fleet.PolicySpec) string {
return p.Name
},
)
if len(duplicates) > 0 {
multiError = multierror.Append(multiError, fmt.Errorf("duplicate policy names: %v", duplicates))
}
return multiError
}
func parseQueries(top map[string]json.RawMessage, result *GitOps, baseDir string, multiError *multierror.Error) *multierror.Error {
queriesRaw, ok := top["queries"]
if !ok {
return multierror.Append(multiError, errors.New("'queries' key is required"))
}
var queries []Query
if err := json.Unmarshal(queriesRaw, &queries); err != nil {
return multierror.Append(multiError, fmt.Errorf("failed to unmarshal queries: %v", err))
}
for _, item := range queries {
item := item
if item.Path == nil {
result.Queries = append(result.Queries, &item.QuerySpec)
} else {
fileBytes, err := os.ReadFile(resolveApplyRelativePath(baseDir, *item.Path))
if err != nil {
multiError = multierror.Append(multiError, fmt.Errorf("failed to read queries file %s: %v", *item.Path, err))
continue
}
fileBytes = []byte(os.ExpandEnv(string(fileBytes)))
var pathQueries []*Query
if err := yaml.Unmarshal(fileBytes, &pathQueries); err != nil {
multiError = multierror.Append(multiError, fmt.Errorf("failed to unmarshal queries file %s: %v", *item.Path, err))
continue
}
for _, pq := range pathQueries {
pq := pq
if pq != nil {
if pq.Path != nil {
multiError = multierror.Append(
multiError, fmt.Errorf("nested paths are not supported: %s in %s", *pq.Path, *item.Path),
)
} else {
result.Queries = append(result.Queries, &pq.QuerySpec)
}
}
}
}
}
// Make sure team name is correct and do additional validation
for _, q := range result.Queries {
if q.Name == "" {
multiError = multierror.Append(multiError, errors.New("query name is required for each query"))
}
if q.Query == "" {
multiError = multierror.Append(multiError, errors.New("query SQL query is required for each query"))
}
// Don't use non-ASCII
if !isASCII(q.Name) {
multiError = multierror.Append(multiError, fmt.Errorf("query name must be in ASCII: %s", q.Name))
}
if result.TeamName != nil {
q.TeamName = *result.TeamName
} else {
q.TeamName = ""
}
}
duplicates := getDuplicateNames(
result.Queries, func(q *fleet.QuerySpec) string {
return q.Name
},
)
if len(duplicates) > 0 {
multiError = multierror.Append(multiError, fmt.Errorf("duplicate query names: %v", duplicates))
}
return multiError
}
func getDuplicateNames[T any](slice []T, getComparableString func(T) string) []string {
// We are using the allKeys map as a set here. True means the item is a duplicate.
allKeys := make(map[string]bool)
var duplicates []string
for _, item := range slice {
name := getComparableString(item)
if isDuplicate, exists := allKeys[name]; exists {
// If this name hasn't already been marked as a duplicate.
if !isDuplicate {
duplicates = append(duplicates, name)
}
allKeys[name] = true
} else {
allKeys[name] = false
}
}
return duplicates
}
func isASCII(s string) bool {
for _, c := range s {
if c > unicode.MaxASCII {
return false
}
}
return true
}
// resolves the paths to an absolute path relative to the baseDir, which should
// be the path of the YAML file where the relative paths were specified. If the
// path is already absolute, it is left untouched.
func resolveApplyRelativePath(baseDir string, path string) string {
if baseDir == "" || filepath.IsAbs(path) {
return path
}
return filepath.Join(baseDir, path)
}

649
pkg/spec/gitops_test.go Normal file
View File

@ -0,0 +1,649 @@
package spec
import (
"fmt"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"os"
"path/filepath"
"slices"
"testing"
)
var topLevelOptions = map[string]string{
"controls": "controls:",
"queries": "queries:",
"policies": "policies:",
"agent_options": "agent_options:",
"org_settings": `
org_settings:
server_settings:
server_url: https://fleet.example.com
org_info:
contact_url: https://example.com/contact
org_logo_url: ""
org_logo_url_light_background: ""
org_name: Test Org
secrets:
`,
}
var teamLevelOptions = map[string]string{
"controls": "controls:",
"queries": "queries:",
"policies": "policies:",
"agent_options": "agent_options:",
"name": "name: TeamName",
"team_settings": `
team_settings:
secrets:
`,
}
func TestValidGitOpsYaml(t *testing.T) {
t.Parallel()
tests := map[string]struct {
filePath string
isTeam bool
}{
"global_config_no_paths": {
filePath: "testdata/global_config_no_paths.yml",
},
"global_config_with_paths": {
filePath: "testdata/global_config.yml",
},
"team_config_no_paths": {
filePath: "testdata/team_config_no_paths.yml",
isTeam: true,
},
"team_config_with_paths": {
filePath: "testdata/team_config.yml",
isTeam: true,
},
}
for name, test := range tests {
test := test
name := name
t.Run(
name, func(t *testing.T) {
t.Parallel()
dat, err := os.ReadFile(test.filePath)
require.NoError(t, err)
gitops, err := GitOpsFromBytes(dat, "./testdata")
require.NoError(t, err)
if test.isTeam {
// Check team settings
assert.Equal(t, "Team1", *gitops.TeamName)
assert.Contains(t, gitops.TeamSettings, "webhook_settings")
assert.Contains(t, gitops.TeamSettings, "host_expiry_settings")
assert.Contains(t, gitops.TeamSettings, "features")
assert.Contains(t, gitops.TeamSettings, "secrets")
secrets, ok := gitops.TeamSettings["secrets"]
assert.True(t, ok, "secrets not found")
require.Len(t, secrets.([]*fleet.EnrollSecret), 2)
assert.Equal(t, "SampleSecret123", secrets.([]*fleet.EnrollSecret)[0].Secret)
assert.Equal(t, "ABC", secrets.([]*fleet.EnrollSecret)[1].Secret)
} else {
// Check org settings
serverSettings, ok := gitops.OrgSettings["server_settings"]
assert.True(t, ok, "server_settings not found")
assert.Equal(t, "https://fleet.example.com", serverSettings.(map[string]interface{})["server_url"])
assert.Contains(t, gitops.OrgSettings, "org_info")
assert.Contains(t, gitops.OrgSettings, "smtp_settings")
assert.Contains(t, gitops.OrgSettings, "sso_settings")
assert.Contains(t, gitops.OrgSettings, "integrations")
assert.Contains(t, gitops.OrgSettings, "mdm")
assert.Contains(t, gitops.OrgSettings, "webhook_settings")
assert.Contains(t, gitops.OrgSettings, "fleet_desktop")
assert.Contains(t, gitops.OrgSettings, "host_expiry_settings")
assert.Contains(t, gitops.OrgSettings, "features")
assert.Contains(t, gitops.OrgSettings, "vulnerability_settings")
assert.Contains(t, gitops.OrgSettings, "secrets")
secrets, ok := gitops.OrgSettings["secrets"]
assert.True(t, ok, "secrets not found")
require.Len(t, secrets.([]*fleet.EnrollSecret), 2)
assert.Equal(t, "SampleSecret123", secrets.([]*fleet.EnrollSecret)[0].Secret)
assert.Equal(t, "ABC", secrets.([]*fleet.EnrollSecret)[1].Secret)
}
// Check controls
_, ok := gitops.Controls.MacOSSettings.(map[string]interface{})
assert.True(t, ok, "macos_settings not found")
_, ok = gitops.Controls.WindowsSettings.(map[string]interface{})
assert.True(t, ok, "windows_settings not found")
_, ok = gitops.Controls.EnableDiskEncryption.(bool)
assert.True(t, ok, "enable_disk_encryption not found")
_, ok = gitops.Controls.MacOSMigration.(map[string]interface{})
assert.True(t, ok, "macos_migration not found")
_, ok = gitops.Controls.MacOSSetup.(map[string]interface{})
assert.True(t, ok, "macos_setup not found")
_, ok = gitops.Controls.MacOSUpdates.(map[string]interface{})
assert.True(t, ok, "macos_updates not found")
_, ok = gitops.Controls.WindowsEnabledAndConfigured.(bool)
assert.True(t, ok, "windows_enabled_and_configured not found")
_, ok = gitops.Controls.WindowsUpdates.(map[string]interface{})
assert.True(t, ok, "windows_updates not found")
// Check agent options
assert.NotNil(t, gitops.AgentOptions)
assert.Contains(t, string(*gitops.AgentOptions), "distributed_denylist_duration")
// Check queries
require.Len(t, gitops.Queries, 3)
assert.Equal(t, "Scheduled query stats", gitops.Queries[0].Name)
assert.Equal(t, "orbit_info", gitops.Queries[1].Name)
assert.Equal(t, "osquery_info", gitops.Queries[2].Name)
// Check policies
require.Len(t, gitops.Policies, 5)
assert.Equal(t, "😊 Failing policy", gitops.Policies[0].Name)
assert.Equal(t, "Passing policy", gitops.Policies[1].Name)
assert.Equal(t, "No root logins (macOS, Linux)", gitops.Policies[2].Name)
assert.Equal(t, "🔥 Failing policy", gitops.Policies[3].Name)
assert.Equal(t, "😊😊 Failing policy", gitops.Policies[4].Name)
},
)
}
}
func TestDuplicatePolicyNames(t *testing.T) {
t.Parallel()
config := getGlobalConfig([]string{"policies"})
config += `
policies:
- name: My policy
platform: linux
query: SELECT 1 FROM osquery_info WHERE start_time < 0;
- name: My policy
platform: windows
query: SELECT 1;
`
_, err := GitOpsFromBytes([]byte(config), "")
assert.ErrorContains(t, err, "duplicate policy names")
}
func TestDuplicateQueryNames(t *testing.T) {
t.Parallel()
config := getGlobalConfig([]string{"queries"})
config += `
queries:
- name: orbit_info
query: SELECT * from orbit_info;
interval: 0
platform: darwin,linux,windows
min_osquery_version: all
observer_can_run: false
automations_enabled: true
logging: snapshot
- name: orbit_info
query: SELECT 1;
interval: 300
platform: windows
min_osquery_version: all
observer_can_run: false
automations_enabled: true
logging: snapshot
`
_, err := GitOpsFromBytes([]byte(config), "")
assert.ErrorContains(t, err, "duplicate query names")
}
func TestUnicodeQueryNames(t *testing.T) {
t.Parallel()
config := getGlobalConfig([]string{"queries"})
config += `
queries:
- name: 😊 orbit_info
query: SELECT * from orbit_info;
interval: 0
platform: darwin,linux,windows
min_osquery_version: all
observer_can_run: false
automations_enabled: true
logging: snapshot
`
_, err := GitOpsFromBytes([]byte(config), "")
assert.ErrorContains(t, err, "query name must be in ASCII")
}
func TestUnicodeTeamName(t *testing.T) {
t.Parallel()
config := getTeamConfig([]string{"name"})
config += `name: 😊 TeamName`
_, err := GitOpsFromBytes([]byte(config), "")
assert.ErrorContains(t, err, "team name must be in ASCII")
}
func TestMixingGlobalAndTeamConfig(t *testing.T) {
t.Parallel()
// Mixing org_settings and team name
config := getGlobalConfig(nil)
config += "name: TeamName\n"
_, err := GitOpsFromBytes([]byte(config), "")
assert.ErrorContains(t, err, "'org_settings' cannot be used with 'name' or 'team_settings'")
// Mixing org_settings and team_settings
config = getGlobalConfig(nil)
config += "team_settings:\n secrets: []\n"
_, err = GitOpsFromBytes([]byte(config), "")
assert.ErrorContains(t, err, "'org_settings' cannot be used with 'name' or 'team_settings'")
// Mixing org_settings and team name and team_settings
config = getGlobalConfig(nil)
config += "name: TeamName\n"
config += "team_settings:\n secrets: []\n"
_, err = GitOpsFromBytes([]byte(config), "")
assert.ErrorContains(t, err, "'org_settings' cannot be used with 'name' or 'team_settings'")
}
func TestInvalidGitOpsYaml(t *testing.T) {
t.Parallel()
// Bad YAML
_, err := GitOpsFromBytes([]byte("bad:\nbad"), "")
assert.ErrorContains(t, err, "failed to unmarshal")
for _, name := range []string{"global", "team"} {
t.Run(
name, func(t *testing.T) {
isTeam := name == "team"
getConfig := getGlobalConfig
if isTeam {
getConfig = getTeamConfig
}
if isTeam {
// Invalid top level key
config := getConfig(nil)
config += "unknown_key:\n"
_, err = GitOpsFromBytes([]byte(config), "")
assert.ErrorContains(t, err, "unknown top-level field")
// Invalid team name
config = getConfig([]string{"name"})
config += "name: [2]\n"
_, err = GitOpsFromBytes([]byte(config), "")
assert.ErrorContains(t, err, "failed to unmarshal name")
// Missing team name
config = getConfig([]string{"name"})
config += "name:\n"
_, err = GitOpsFromBytes([]byte(config), "")
assert.ErrorContains(t, err, "'name' is required")
// Invalid team_settings
config = getConfig([]string{"team_settings"})
config += "team_settings:\n path: [2]\n"
_, err = GitOpsFromBytes([]byte(config), "")
assert.ErrorContains(t, err, "failed to unmarshal team_settings")
// Invalid team_settings in a separate file
tmpFile, err := os.CreateTemp(t.TempDir(), "*team_settings.yml")
require.NoError(t, err)
_, err = tmpFile.WriteString("[2]")
require.NoError(t, err)
config = getConfig([]string{"team_settings"})
config += fmt.Sprintf("%s:\n path: %s\n", "team_settings", tmpFile.Name())
_, err = GitOpsFromBytes([]byte(config), "")
assert.ErrorContains(t, err, "failed to unmarshal team settings file")
// Invalid secrets 1
config = getConfig([]string{"team_settings"})
config += "team_settings:\n secrets: bad\n"
_, err = GitOpsFromBytes([]byte(config), "")
assert.ErrorContains(t, err, "must be a list of secret items")
// Invalid secrets 2
config = getConfig([]string{"team_settings"})
config += "team_settings:\n secrets: [2]\n"
_, err = GitOpsFromBytes([]byte(config), "")
assert.ErrorContains(t, err, "must have a 'secret' key")
// Missing secrets
config = getConfig([]string{"team_settings"})
config += "team_settings:\n"
_, err = GitOpsFromBytes([]byte(config), "")
assert.ErrorContains(t, err, "'team_settings.secrets' is required")
} else {
// Invalid org_settings
config := getConfig([]string{"org_settings"})
config += "org_settings:\n path: [2]\n"
_, err = GitOpsFromBytes([]byte(config), "")
assert.ErrorContains(t, err, "failed to unmarshal org_settings")
// Invalid org_settings in a separate file
tmpFile, err := os.CreateTemp(t.TempDir(), "*org_settings.yml")
require.NoError(t, err)
_, err = tmpFile.WriteString("[2]")
require.NoError(t, err)
config = getConfig([]string{"org_settings"})
config += fmt.Sprintf("%s:\n path: %s\n", "org_settings", tmpFile.Name())
_, err = GitOpsFromBytes([]byte(config), "")
assert.ErrorContains(t, err, "failed to unmarshal org settings file")
// Invalid secrets 1
config = getConfig([]string{"org_settings"})
config += "org_settings:\n secrets: bad\n"
_, err = GitOpsFromBytes([]byte(config), "")
assert.ErrorContains(t, err, "must be a list of secret items")
// Invalid secrets 2
config = getConfig([]string{"org_settings"})
config += "org_settings:\n secrets: [2]\n"
_, err = GitOpsFromBytes([]byte(config), "")
assert.ErrorContains(t, err, "must have a 'secret' key")
// Missing secrets
config = getConfig([]string{"org_settings"})
config += "org_settings:\n"
_, err = GitOpsFromBytes([]byte(config), "")
assert.ErrorContains(t, err, "'org_settings.secrets' is required")
}
// Invalid agent_options
config := getConfig([]string{"agent_options"})
config += "agent_options:\n path: [2]\n"
_, err = GitOpsFromBytes([]byte(config), "")
assert.ErrorContains(t, err, "failed to unmarshal agent_options")
// Invalid agent_options in a separate file
tmpFile, err := os.CreateTemp(t.TempDir(), "*agent_options.yml")
require.NoError(t, err)
_, err = tmpFile.WriteString("[2]")
require.NoError(t, err)
config = getConfig([]string{"agent_options"})
config += fmt.Sprintf("%s:\n path: %s\n", "agent_options", tmpFile.Name())
_, err = GitOpsFromBytes([]byte(config), "")
assert.ErrorContains(t, err, "failed to unmarshal agent options file")
// Invalid controls
config = getConfig([]string{"controls"})
config += "controls:\n path: [2]\n"
_, err = GitOpsFromBytes([]byte(config), "")
assert.ErrorContains(t, err, "failed to unmarshal controls")
// Invalid controls in a separate file
tmpFile, err = os.CreateTemp(t.TempDir(), "*controls.yml")
require.NoError(t, err)
_, err = tmpFile.WriteString("[2]")
require.NoError(t, err)
config = getConfig([]string{"controls"})
config += fmt.Sprintf("%s:\n path: %s\n", "controls", tmpFile.Name())
_, err = GitOpsFromBytes([]byte(config), "")
assert.ErrorContains(t, err, "failed to unmarshal controls file")
// Invalid policies
config = getConfig([]string{"policies"})
config += "policies:\n path: [2]\n"
_, err = GitOpsFromBytes([]byte(config), "")
assert.ErrorContains(t, err, "failed to unmarshal policies")
// Invalid policies in a separate file
tmpFile, err = os.CreateTemp(t.TempDir(), "*policies.yml")
require.NoError(t, err)
_, err = tmpFile.WriteString("[2]")
require.NoError(t, err)
config = getConfig([]string{"policies"})
config += fmt.Sprintf("%s:\n - path: %s\n", "policies", tmpFile.Name())
_, err = GitOpsFromBytes([]byte(config), "")
assert.ErrorContains(t, err, "failed to unmarshal policies file")
// Policy name missing
config = getConfig([]string{"policies"})
config += "policies:\n - query: SELECT 1;\n"
_, err = GitOpsFromBytes([]byte(config), "")
assert.ErrorContains(t, err, "name is required")
// Policy query missing
config = getConfig([]string{"policies"})
config += "policies:\n - name: Test Policy\n"
_, err = GitOpsFromBytes([]byte(config), "")
assert.ErrorContains(t, err, "query is required")
// Invalid queries
config = getConfig([]string{"queries"})
config += "queries:\n path: [2]\n"
_, err = GitOpsFromBytes([]byte(config), "")
assert.ErrorContains(t, err, "failed to unmarshal queries")
// Invalid policies in a separate file
tmpFile, err = os.CreateTemp(t.TempDir(), "*queries.yml")
require.NoError(t, err)
_, err = tmpFile.WriteString("[2]")
require.NoError(t, err)
config = getConfig([]string{"queries"})
config += fmt.Sprintf("%s:\n - path: %s\n", "queries", tmpFile.Name())
_, err = GitOpsFromBytes([]byte(config), "")
assert.ErrorContains(t, err, "failed to unmarshal queries file")
// Query name missing
config = getConfig([]string{"queries"})
config += "queries:\n - query: SELECT 1;\n"
_, err = GitOpsFromBytes([]byte(config), "")
assert.ErrorContains(t, err, "name is required")
// Query SQL query missing
config = getConfig([]string{"queries"})
config += "queries:\n - name: Test Query\n"
_, err = GitOpsFromBytes([]byte(config), "")
assert.ErrorContains(t, err, "query is required")
},
)
}
}
func TestTopLevelGitOpsValidation(t *testing.T) {
t.Parallel()
tests := map[string]struct {
optsToExclude []string
shouldPass bool
isTeam bool
}{
"all_present_global": {
optsToExclude: []string{},
shouldPass: true,
},
"all_present_team": {
optsToExclude: []string{},
shouldPass: true,
isTeam: true,
},
"missing_all": {
optsToExclude: []string{"controls", "queries", "policies", "agent_options", "org_settings"},
},
"missing_controls": {
optsToExclude: []string{"controls"},
},
"missing_queries": {
optsToExclude: []string{"queries"},
},
"missing_policies": {
optsToExclude: []string{"policies"},
},
"missing_agent_options": {
optsToExclude: []string{"agent_options"},
},
"missing_org_settings": {
optsToExclude: []string{"org_settings"},
},
"missing_name": {
optsToExclude: []string{"name"},
isTeam: true,
},
"missing_team_settings": {
optsToExclude: []string{"team_settings"},
isTeam: true,
},
}
for name, test := range tests {
t.Run(
name, func(t *testing.T) {
var config string
if test.isTeam {
config = getTeamConfig(test.optsToExclude)
} else {
config = getGlobalConfig(test.optsToExclude)
}
_, err := GitOpsFromBytes([]byte(config), "")
if test.shouldPass {
assert.NoError(t, err)
} else {
assert.ErrorContains(t, err, "is required")
}
},
)
}
}
func TestGitOpsNullArrays(t *testing.T) {
t.Parallel()
config := getGlobalConfig([]string{"queries", "policies"})
config += "queries: null\npolicies: ~\n"
gitops, err := GitOpsFromBytes([]byte(config), "")
assert.NoError(t, err)
assert.Nil(t, gitops.Queries)
assert.Nil(t, gitops.Policies)
}
func TestGitOpsPaths(t *testing.T) {
t.Parallel()
tests := map[string]struct {
isArray bool
isTeam bool
goodConfig string
}{
"org_settings": {
isArray: false,
goodConfig: "secrets: []\n",
},
"team_settings": {
isArray: false,
isTeam: true,
goodConfig: "secrets: []\n",
},
"controls": {
isArray: false,
goodConfig: "windows_enabled_and_configured: true\n",
},
"queries": {
isArray: true,
goodConfig: "[]",
},
"policies": {
isArray: true,
goodConfig: "[]",
},
"agent_options": {
isArray: false,
goodConfig: "name: value\n",
},
}
for name, test := range tests {
test := test
name := name
t.Run(
name, func(t *testing.T) {
t.Parallel()
getConfig := getGlobalConfig
if test.isTeam {
getConfig = getTeamConfig
}
// Test an absolute top level path
tmpFile, err := os.CreateTemp(t.TempDir(), "*good.yml")
require.NoError(t, err)
_, err = tmpFile.WriteString(test.goodConfig)
require.NoError(t, err)
config := getConfig([]string{name})
if test.isArray {
config += fmt.Sprintf("%s:\n - path: %s\n", name, tmpFile.Name())
} else {
config += fmt.Sprintf("%s:\n path: %s\n", name, tmpFile.Name())
}
_, err = GitOpsFromBytes([]byte(config), "")
assert.NoError(t, err)
// Test a relative top level path
config = getConfig([]string{name})
dir, file := filepath.Split(tmpFile.Name())
if test.isArray {
config += fmt.Sprintf("%s:\n - path: ./%s\n", name, file)
} else {
config += fmt.Sprintf("%s:\n path: ./%s\n", name, file)
}
_, err = GitOpsFromBytes([]byte(config), dir)
assert.NoError(t, err)
// Test a bad path
config = getConfig([]string{name})
if test.isArray {
config += fmt.Sprintf("%s:\n - path: ./%s\n", name, "doesNotExist.yml")
} else {
config += fmt.Sprintf("%s:\n path: ./%s\n", name, "doesNotExist.yml")
}
_, err = GitOpsFromBytes([]byte(config), dir)
assert.ErrorContains(t, err, "no such file or directory")
// Test a bad file -- cannot be unmarshalled
tmpFileBad, err := os.CreateTemp(t.TempDir(), "*invalid.yml")
require.NoError(t, err)
_, err = tmpFileBad.WriteString("bad:\nbad")
require.NoError(t, err)
config = getConfig([]string{name})
if test.isArray {
config += fmt.Sprintf("%s:\n - path: %s\n", name, tmpFileBad.Name())
} else {
config += fmt.Sprintf("%s:\n path: %s\n", name, tmpFileBad.Name())
}
_, err = GitOpsFromBytes([]byte(config), "")
assert.ErrorContains(t, err, "failed to unmarshal")
// Test a nested path -- bad
tmpFileBad, err = os.CreateTemp(t.TempDir(), "*bad.yml")
require.NoError(t, err)
if test.isArray {
_, err = tmpFileBad.WriteString(fmt.Sprintf("- path: %s\n", tmpFile.Name()))
} else {
_, err = tmpFileBad.WriteString(fmt.Sprintf("path: %s\n", tmpFile.Name()))
}
require.NoError(t, err)
config = getConfig([]string{name})
dir, file = filepath.Split(tmpFileBad.Name())
if test.isArray {
config += fmt.Sprintf("%s:\n - path: ./%s\n", name, file)
} else {
config += fmt.Sprintf("%s:\n path: ./%s\n", name, file)
}
_, err = GitOpsFromBytes([]byte(config), dir)
assert.ErrorContains(t, err, "nested paths are not supported")
},
)
}
}
func getGlobalConfig(optsToExclude []string) string {
return getBaseConfig(topLevelOptions, optsToExclude)
}
func getTeamConfig(optsToExclude []string) string {
return getBaseConfig(teamLevelOptions, optsToExclude)
}
func getBaseConfig(options map[string]string, optsToExclude []string) string {
var config string
for key, value := range options {
if !slices.Contains(optsToExclude, key) {
config += value + "\n"
}
}
return config
}

14
pkg/spec/testdata/agent-options.yml vendored Normal file
View File

@ -0,0 +1,14 @@
command_line_flags:
distributed_denylist_duration: 0
config:
decorators:
load:
- SELECT uuid AS host_uuid FROM system_info;
- SELECT hostname AS hostname FROM system_info;
options:
disable_distributed: false
distributed_interval: 10
distributed_plugin: tls
distributed_tls_max_attempts: 3
logger_tls_endpoint: /api/v1/osquery/log
pack_delimiter: /

24
pkg/spec/testdata/controls.yml vendored Normal file
View File

@ -0,0 +1,24 @@
macos_settings:
custom_settings:
- path: ./lib/macos-password.mobileconfig
windows_settings:
custom_settings:
- path: ./lib/windows-screenlock.xml
scripts:
- path: ./lib/collect-fleetd-logs.sh
enable_disk_encryption: true
macos_migration:
enable: false
mode: ""
webhook_url: ""
macos_setup:
bootstrap_package: null
enable_end_user_authentication: false
macos_setup_assistant: null
macos_updates:
deadline: null
minimum_version: null
windows_enabled_and_configured: true
windows_updates:
deadline_days: null
grace_period_days: null

0
pkg/spec/testdata/empty.yml vendored Normal file
View File

27
pkg/spec/testdata/global_config.yml vendored Normal file
View File

@ -0,0 +1,27 @@
# Test config
controls: # Controls added to "No team"
path: ./controls.yml
queries:
- path: ./top.queries.yml
- path: ./empty.yml
- name: osquery_info
query: SELECT * from osquery_info;
interval: 604800 # 1 week
platform: darwin,linux,windows,chrome
min_osquery_version: all
observer_can_run: false
automations_enabled: true
logging: snapshot
policies:
- path: ./top.policies.yml
- path: ./top.policies2.yml
- path: ./empty.yml
- name: 😊😊 Failing policy
platform: linux
description: This policy should always fail.
resolution: There is no resolution for this policy.
query: SELECT 1 FROM osquery_info WHERE start_time < 0;
agent_options:
path: ./agent-options.yml
org_settings:
path: ./org-settings.yml

View File

@ -0,0 +1,181 @@
# Test config
controls: # Controls added to "No team"
macos_settings:
custom_settings:
- path: ./lib/macos-password.mobileconfig
windows_settings:
custom_settings:
- path: ./lib/windows-screenlock.xml
scripts:
- path: ./lib/collect-fleetd-logs.sh
enable_disk_encryption: true
macos_migration:
enable: false
mode: ""
webhook_url: ""
macos_setup:
bootstrap_package: null
enable_end_user_authentication: false
macos_setup_assistant: null
macos_updates:
deadline: null
minimum_version: null
windows_enabled_and_configured: true
windows_updates:
deadline_days: null
grace_period_days: null
queries:
- name: Scheduled query stats
description: Collect osquery performance stats directly from osquery
query: SELECT *,
(SELECT value from osquery_flags where name = 'pack_delimiter') AS delimiter
FROM osquery_schedule;
interval: 0
platform: darwin,linux,windows
min_osquery_version: all
observer_can_run: false
automations_enabled: false
logging: snapshot
- name: orbit_info
query: SELECT * from orbit_info;
interval: 0
platform: darwin,linux,windows
min_osquery_version: all
observer_can_run: false
automations_enabled: true
logging: snapshot
- name: osquery_info
query: SELECT * from osquery_info;
interval: 604800 # 1 week
platform: darwin,linux,windows,chrome
min_osquery_version: all
observer_can_run: false
automations_enabled: true
logging: snapshot
policies:
- name: 😊 Failing policy
platform: linux
description: This policy should always fail.
resolution: There is no resolution for this policy.
query: SELECT 1 FROM osquery_info WHERE start_time < 0;
- name: Passing policy
platform: linux,windows,darwin,chrome
description: This policy should always pass.
resolution: There is no resolution for this policy.
query: SELECT 1;
- name: No root logins (macOS, Linux)
platform: linux,darwin
query: SELECT 1 WHERE NOT EXISTS (SELECT * FROM last
WHERE username = "root"
AND time > (( SELECT unix_time FROM time ) - 3600 ))
critical: true
- name: 🔥 Failing policy
platform: linux
description: This policy should always fail.
resolution: There is no resolution for this policy.
query: SELECT 1 FROM osquery_info WHERE start_time < 0;
- name: 😊😊 Failing policy
platform: linux
description: This policy should always fail.
resolution: There is no resolution for this policy.
query: SELECT 1 FROM osquery_info WHERE start_time < 0;
agent_options:
command_line_flags:
distributed_denylist_duration: 0
config:
decorators:
load:
- SELECT uuid AS host_uuid FROM system_info;
- SELECT hostname AS hostname FROM system_info;
options:
disable_distributed: false
distributed_interval: 10
distributed_plugin: tls
distributed_tls_max_attempts: 3
logger_tls_endpoint: /api/v1/osquery/log
pack_delimiter: /
org_settings:
server_settings:
debug_host_ids:
- 10728
deferred_save_host: false
enable_analytics: true
live_query_disabled: false
query_reports_disabled: false
scripts_disabled: false
server_url: https://fleet.example.com
org_info:
contact_url: https://fleetdm.com/company/contact
org_logo_url: ""
org_logo_url_light_background: ""
org_name: Fleet Device Management
smtp_settings:
authentication_method: authmethod_plain
authentication_type: authtype_username_password
configured: false
domain: ""
enable_smtp: false
enable_ssl_tls: true
enable_start_tls: true
password: ""
port: 587
sender_address: ""
server: ""
user_name: ""
verify_ssl_certs: true
sso_settings:
enable_jit_provisioning: false
enable_jit_role_sync: false
enable_sso: true
enable_sso_idp_login: false
entity_id: https://saml.example.com/entityid
idp_image_url: ""
idp_name: MockSAML
issuer_uri: ""
metadata: ""
metadata_url: https://mocksaml.com/api/saml/metadata
integrations:
jira:
- api_token: JIRA_TOKEN
enable_failing_policies: true
enable_software_vulnerabilities: false
project_key: JIR
url: https://fleetdm.atlassian.net
username: reed@fleetdm.com
zendesk: []
mdm:
apple_bm_default_team: ""
end_user_authentication:
entity_id: ""
idp_name: ""
issuer_uri: ""
metadata: ""
metadata_url: ""
webhook_settings:
failing_policies_webhook:
destination_url: https://host.docker.internal:8080/bozo
enable_failing_policies_webhook: false
host_batch_size: 0
policy_ids: []
host_status_webhook:
days_count: 0
destination_url: ""
enable_host_status_webhook: false
host_percentage: 0
interval: 24h0m0s
vulnerabilities_webhook:
destination_url: ""
enable_vulnerabilities_webhook: false
host_batch_size: 0
fleet_desktop: # Applies to Fleet Premium only
transparency_url: https://fleetdm.com/transparency
host_expiry_settings: # Applies to all teams
host_expiry_enabled: false
features: # Features added to all teams
enable_host_users: true
enable_software_inventory: true
vulnerability_settings:
databases_path: ""
secrets: # These secrets are used to enroll hosts to the "All teams" team
- secret: SampleSecret123
- secret: ABC

84
pkg/spec/testdata/org-settings.yml vendored Normal file
View File

@ -0,0 +1,84 @@
server_settings:
debug_host_ids:
- 10728
deferred_save_host: false
enable_analytics: true
live_query_disabled: false
query_reports_disabled: false
scripts_disabled: false
server_url: https://fleet.example.com
org_info:
contact_url: https://fleetdm.com/company/contact
org_logo_url: ""
org_logo_url_light_background: ""
org_name: Fleet Device Management
smtp_settings:
authentication_method: authmethod_plain
authentication_type: authtype_username_password
configured: false
domain: ""
enable_smtp: false
enable_ssl_tls: true
enable_start_tls: true
password: ""
port: 587
sender_address: ""
server: ""
user_name: ""
verify_ssl_certs: true
sso_settings:
enable_jit_provisioning: false
enable_jit_role_sync: false
enable_sso: true
enable_sso_idp_login: false
entity_id: https://saml.example.com/entityid
idp_image_url: ""
idp_name: MockSAML
issuer_uri: ""
metadata: ""
metadata_url: https://mocksaml.com/api/saml/metadata
integrations:
jira:
- api_token: JIRA_TOKEN
enable_failing_policies: true
enable_software_vulnerabilities: false
project_key: JIR
url: https://fleetdm.atlassian.net
username: reed@fleetdm.com
zendesk: []
mdm:
apple_bm_default_team: ""
end_user_authentication:
entity_id: ""
idp_name: ""
issuer_uri: ""
metadata: ""
metadata_url: ""
webhook_settings:
failing_policies_webhook:
destination_url: https://host.docker.internal:8080/bozo
enable_failing_policies_webhook: false
host_batch_size: 0
policy_ids: []
host_status_webhook:
days_count: 0
destination_url: ""
enable_host_status_webhook: false
host_percentage: 0
interval: 24h0m0s
vulnerabilities_webhook:
destination_url: ""
enable_vulnerabilities_webhook: false
host_batch_size: 0
fleet_desktop: # Applies to Fleet Premium only
transparency_url: https://fleetdm.com/transparency
host_expiry_settings: # Applies to all teams
host_expiry_enabled: false
features: # Features added to all teams
enable_host_users: true
enable_software_inventory: true
vulnerability_settings:
databases_path: ""
secrets: # These secrets are used to enroll hosts to the "All teams" team
- secret: SampleSecret123
- secret: ABC

14
pkg/spec/testdata/team-settings.yml vendored Normal file
View File

@ -0,0 +1,14 @@
secrets:
- secret: "SampleSecret123"
- secret: "ABC"
webhook_settings:
failing_policies_webhook:
enable_failing_policies_webhook: true
destination_url: https://example.tines.com/webhook
policy_ids: [1, 2, 3, 4, 5, 6 ,7, 8, 9]
features:
enable_host_users: true
enable_software_inventory: true
host_expiry_settings:
host_expiry_enabled: true
host_expiry_window: 30

27
pkg/spec/testdata/team_config.yml vendored Normal file
View File

@ -0,0 +1,27 @@
name: Team1
team_settings:
path: ./team-settings.yml
agent_options:
path: ./agent-options.yml
controls:
path: ./controls.yml
queries:
- path: ./top.queries.yml
- path: ./empty.yml
- name: osquery_info
query: SELECT * from osquery_info;
interval: 604800 # 1 week
platform: darwin,linux,windows,chrome
min_osquery_version: all
observer_can_run: false
automations_enabled: true
logging: snapshot
policies:
- path: ./top.policies.yml
- path: ./top.policies2.yml
- path: ./empty.yml
- name: 😊😊 Failing policy
platform: linux
description: This policy should always fail.
resolution: There is no resolution for this policy.
query: SELECT 1 FROM osquery_info WHERE start_time < 0;

View File

@ -0,0 +1,111 @@
name: Team1
team_settings:
secrets:
- secret: "SampleSecret123"
- secret: "ABC"
webhook_settings:
failing_policies_webhook:
enable_failing_policies_webhook: true
destination_url: https://example.tines.com/webhook
policy_ids: [1, 2, 3, 4, 5, 6 ,7, 8, 9]
features:
enable_host_users: true
enable_software_inventory: true
host_expiry_settings:
host_expiry_enabled: true
host_expiry_window: 30
agent_options:
command_line_flags:
distributed_denylist_duration: 0
config:
decorators:
load:
- SELECT uuid AS host_uuid FROM system_info;
- SELECT hostname AS hostname FROM system_info;
options:
disable_distributed: false
distributed_interval: 10
distributed_plugin: tls
distributed_tls_max_attempts: 3
logger_tls_endpoint: /api/v1/osquery/log
pack_delimiter: /
controls:
macos_settings:
custom_settings:
- path: ./lib/macos-password.mobileconfig
windows_settings:
custom_settings:
- path: ./lib/windows-screenlock.xml
scripts:
- path: ./lib/collect-fleetd-logs.sh
enable_disk_encryption: true
macos_setup:
bootstrap_package: null
enable_end_user_authentication: false
macos_setup_assistant: null
macos_updates:
deadline: null
minimum_version: null
windows_updates:
deadline_days: null
grace_period_days: null
macos_migration:
enable: false
mode: ""
webhook_url: ""
windows_enabled_and_configured: true
queries:
- name: Scheduled query stats
description: Collect osquery performance stats directly from osquery
query: SELECT *,
(SELECT value from osquery_flags where name = 'pack_delimiter') AS delimiter
FROM osquery_schedule;
interval: 0
platform: darwin,linux,windows
min_osquery_version: all
observer_can_run: false
automations_enabled: false
logging: snapshot
- name: orbit_info
query: SELECT * from orbit_info;
interval: 0
platform: darwin,linux,windows
min_osquery_version: all
observer_can_run: false
automations_enabled: true
logging: snapshot
- name: osquery_info
query: SELECT * from osquery_info;
interval: 604800 # 1 week
platform: darwin,linux,windows,chrome
min_osquery_version: all
observer_can_run: false
automations_enabled: true
logging: snapshot
policies:
- name: 😊 Failing policy
platform: linux
description: This policy should always fail.
resolution: There is no resolution for this policy.
query: SELECT 1 FROM osquery_info WHERE start_time < 0;
- name: Passing policy
platform: linux,windows,darwin,chrome
description: This policy should always pass.
resolution: There is no resolution for this policy.
query: SELECT 1;
- name: No root logins (macOS, Linux)
platform: linux,darwin
query: SELECT 1 WHERE NOT EXISTS (SELECT * FROM last
WHERE username = "root"
AND time > (( SELECT unix_time FROM time ) - 3600 ))
critical: true
- name: 🔥 Failing policy
platform: linux
description: This policy should always fail.
resolution: There is no resolution for this policy.
query: SELECT 1 FROM osquery_info WHERE start_time < 0;
- name: 😊😊 Failing policy
platform: linux
description: This policy should always fail.
resolution: There is no resolution for this policy.
query: SELECT 1 FROM osquery_info WHERE start_time < 0;

16
pkg/spec/testdata/top.policies.yml vendored Normal file
View File

@ -0,0 +1,16 @@
- name: 😊 Failing policy
platform: linux
description: This policy should always fail.
resolution: There is no resolution for this policy.
query: SELECT 1 FROM osquery_info WHERE start_time < 0;
- name: Passing policy
platform: linux,windows,darwin,chrome
description: This policy should always pass.
resolution: There is no resolution for this policy.
query: SELECT 1;
- name: No root logins (macOS, Linux)
platform: linux,darwin
query: SELECT 1 WHERE NOT EXISTS (SELECT * FROM last
WHERE username = "root"
AND time > (( SELECT unix_time FROM time ) - 3600 ))
critical: true

5
pkg/spec/testdata/top.policies2.yml vendored Normal file
View File

@ -0,0 +1,5 @@
- name: 🔥 Failing policy
platform: linux
description: This policy should always fail.
resolution: There is no resolution for this policy.
query: SELECT 1 FROM osquery_info WHERE start_time < 0;

19
pkg/spec/testdata/top.queries.yml vendored Normal file
View File

@ -0,0 +1,19 @@
- name: Scheduled query stats
description: Collect osquery performance stats directly from osquery
query: SELECT *,
(SELECT value from osquery_flags where name = 'pack_delimiter') AS delimiter
FROM osquery_schedule;
interval: 0
platform: darwin,linux,windows
min_osquery_version: all
observer_can_run: false
automations_enabled: false
logging: snapshot
- name: orbit_info
query: SELECT * from orbit_info;
interval: 0
platform: darwin,linux,windows
min_osquery_version: all
observer_can_run: false
automations_enabled: true
logging: snapshot

View File

@ -599,7 +599,6 @@ func (ds *Datastore) ApplyPolicySpecs(ctx context.Context, authorID uint, specs
checksum
) VALUES ( ?, ?, ?, ?, ?, (SELECT IFNULL(MIN(id), NULL) FROM teams WHERE name = ?), ?, ?, %s)
ON DUPLICATE KEY UPDATE
name = VALUES(name),
query = VALUES(query),
description = VALUES(description),
author_id = VALUES(author_id),
@ -610,12 +609,6 @@ func (ds *Datastore) ApplyPolicySpecs(ctx context.Context, authorID uint, specs
)
for _, spec := range specs {
// Validate that the team is not being changed
err := validatePolicyTeamChange(ctx, ds, spec)
if err != nil {
return err
}
res, err := tx.ExecContext(ctx,
query, spec.Name, spec.Query, spec.Description, authorID, spec.Resolution, spec.Team, spec.Platform, spec.Critical,
)
@ -637,44 +630,6 @@ func (ds *Datastore) ApplyPolicySpecs(ctx context.Context, authorID uint, specs
})
}
// Changing the team of a policy is not allowed, so check if the policy exists
// and return an error if the team is changing
func validatePolicyTeamChange(ctx context.Context, ds *Datastore, spec *fleet.PolicySpec) error {
policy, err := ds.PolicyByName(ctx, spec.Name)
if err != nil {
if !fleet.IsNotFound(err) {
return ctxerr.Wrap(ctx, err, "Error fetching policy by name")
}
// If no rows found there is no policy to validate against
return nil
}
// If the policy exists
if policy != nil {
// Check if the policy is global
if policy.TeamID == nil {
if spec.Team != "" {
return ctxerr.Wrap(ctx, &fleet.BadRequestError{
Message: fmt.Sprintf("cannot change the team of an existing global policy"),
})
}
} else {
// If it's not global, fetch the team name and compare
team, err := ds.Team(ctx, *policy.TeamID)
if err != nil {
return ctxerr.Wrap(ctx, err, "Error fetching team by ID")
}
if spec.Team != team.Name {
return ctxerr.Wrap(ctx, &fleet.BadRequestError{
Message: fmt.Sprintf("cannot change the team of an existing policy"),
})
}
}
}
return nil
}
func amountPoliciesDB(ctx context.Context, db sqlx.QueryerContext) (int, error) {
var amount int
err := sqlx.GetContext(ctx, db, &amount, `SELECT count(*) FROM policies`)

View File

@ -1369,14 +1369,16 @@ func testApplyPolicySpec(t *testing.T, ds *Datastore) {
assert.Equal(t, "some other resolution updated", *teamPolicies[0].Resolution)
assert.Equal(t, "windows", teamPolicies[0].Platform)
// Test error when modifying team on existing policy
require.Error(t, ds.ApplyPolicySpecs(ctx, user1.ID, []*fleet.PolicySpec{
// Creating the same policy for a different team is allowed.
require.NoError(
t, ds.ApplyPolicySpecs(
ctx, user1.ID, []*fleet.PolicySpec{
{
Name: "query1",
Query: "select 1 from updated again;",
Description: "query1 desc updated again",
Resolution: "some resolution updated again",
Team: "team1", // Modifying teams on existing policies is not allowed
Team: "team1",
Platform: "",
},
}))

View File

@ -228,14 +228,18 @@ func (p PolicySpec) Verify() error {
return nil
}
// return first duplicate name of policies or empty string if no duplicates found
// FirstDuplicatePolicySpecName returns first duplicate name of policies (in a team) or empty string if no duplicates found
func FirstDuplicatePolicySpecName(specs []*PolicySpec) string {
names := make(map[string]struct{})
teams := make(map[string]map[string]struct{})
for _, spec := range specs {
if _, ok := names[spec.Name]; ok {
if team, ok := teams[spec.Team]; ok {
if _, ok = team[spec.Name]; ok {
return spec.Name
}
names[spec.Name] = struct{}{}
team[spec.Name] = struct{}{}
} else {
teams[spec.Team] = map[string]struct{}{spec.Name: {}}
}
}
return ""
}

View File

@ -881,7 +881,10 @@ type Service interface {
// BatchSetMDMProfiles replaces the custom Windows/macOS profiles for a specified
// team or for hosts with no team.
BatchSetMDMProfiles(ctx context.Context, teamID *uint, teamName *string, profiles []MDMProfileBatchPayload, dryRun bool, skipBulkPending bool) error
BatchSetMDMProfiles(
ctx context.Context, teamID *uint, teamName *string, profiles []MDMProfileBatchPayload, dryRun bool, skipBulkPending bool,
assumeEnabled bool,
) error
///////////////////////////////////////////////////////////////////////////////
// Common MDM

View File

@ -322,7 +322,7 @@ func (c *Client) ApplyGroup(
baseDir string,
logf func(format string, args ...interface{}),
opts fleet.ApplySpecOptions,
) error {
) (map[string]uint, error) {
logfn := func(format string, args ...interface{}) {
if logf != nil {
logf(format, args...)
@ -335,7 +335,7 @@ func (c *Client) ApplyGroup(
logfn("[!] ignoring queries, dry run mode only supported for 'config' and 'team' specs\n")
} else {
if err := c.ApplyQueries(specs.Queries); err != nil {
return fmt.Errorf("applying queries: %w", err)
return nil, fmt.Errorf("applying queries: %w", err)
}
logfn("[+] applied %d queries\n", len(specs.Queries))
}
@ -346,7 +346,7 @@ func (c *Client) ApplyGroup(
logfn("[!] ignoring labels, dry run mode only supported for 'config' and 'team' specs\n")
} else {
if err := c.ApplyLabels(specs.Labels); err != nil {
return fmt.Errorf("applying labels: %w", err)
return nil, fmt.Errorf("applying labels: %w", err)
}
logfn("[+] applied %d labels\n", len(specs.Labels))
}
@ -358,7 +358,9 @@ func (c *Client) ApplyGroup(
} else {
// Policy names must be unique, return error if duplicate policy names are found
if policyName := fleet.FirstDuplicatePolicySpecName(specs.Policies); policyName != "" {
return fmt.Errorf("applying policies: policy names must be globally unique. Please correct policy %q and try again.", policyName)
return nil, fmt.Errorf(
"applying policies: policy names must be unique. Please correct policy %q and try again.", policyName,
)
}
// If set, override the team in all the policies.
@ -368,7 +370,7 @@ func (c *Client) ApplyGroup(
}
}
if err := c.ApplyPolicies(specs.Policies); err != nil {
return fmt.Errorf("applying policies: %w", err)
return nil, fmt.Errorf("applying policies: %w", err)
}
logfn("[+] applied %d policies\n", len(specs.Policies))
}
@ -379,7 +381,7 @@ func (c *Client) ApplyGroup(
logfn("[!] ignoring packs, dry run mode only supported for 'config' and 'team' specs\n")
} else {
if err := c.ApplyPacks(specs.Packs); err != nil {
return fmt.Errorf("applying packs: %w", err)
return nil, fmt.Errorf("applying packs: %w", err)
}
logfn("[+] applied %d packs\n", len(specs.Packs))
}
@ -400,33 +402,44 @@ func (c *Client) ApplyGroup(
if (windowsCustomSettings != nil && macosCustomSettings != nil) || len(allCustomSettings) > 0 {
fileContents, err := getProfilesContents(baseDir, allCustomSettings)
if err != nil {
return err
return nil, err
}
if err := c.ApplyNoTeamProfiles(fileContents, opts); err != nil {
return fmt.Errorf("applying custom settings: %w", err)
// Figure out if MDM should be enabled.
assumeEnabled := false
// This cast is safe because we've already checked AppConfig when extracting custom settings
mdmConfigMap, ok := specs.AppConfig.(map[string]interface{})["mdm"].(map[string]interface{})
if ok {
mdmEnabled, ok := mdmConfigMap["windows_enabled_and_configured"]
if ok {
assumeEnabled, ok = mdmEnabled.(bool)
assumeEnabled = ok && assumeEnabled
}
}
if err := c.ApplyNoTeamProfiles(fileContents, opts, assumeEnabled); err != nil {
return nil, fmt.Errorf("applying custom settings: %w", err)
}
}
if macosSetup := extractAppCfgMacOSSetup(specs.AppConfig); macosSetup != nil {
if macosSetup.BootstrapPackage.Value != "" {
pkg, err := c.ValidateBootstrapPackageFromURL(macosSetup.BootstrapPackage.Value)
if err != nil {
return fmt.Errorf("applying fleet config: %w", err)
return nil, fmt.Errorf("applying fleet config: %w", err)
}
if !opts.DryRun {
if err := c.EnsureBootstrapPackage(pkg, uint(0)); err != nil {
return fmt.Errorf("applying fleet config: %w", err)
return nil, fmt.Errorf("applying fleet config: %w", err)
}
}
}
if macosSetup.MacOSSetupAssistant.Value != "" {
content, err := c.validateMacOSSetupAssistant(resolveApplyRelativePath(baseDir, macosSetup.MacOSSetupAssistant.Value))
if err != nil {
return fmt.Errorf("applying fleet config: %w", err)
return nil, fmt.Errorf("applying fleet config: %w", err)
}
if !opts.DryRun {
if err := c.uploadMacOSSetupAssistant(content, nil, macosSetup.MacOSSetupAssistant.Value); err != nil {
return fmt.Errorf("applying fleet config: %w", err)
return nil, fmt.Errorf("applying fleet config: %w", err)
}
}
}
@ -437,7 +450,7 @@ func (c *Client) ApplyGroup(
for i, f := range files {
b, err := os.ReadFile(f)
if err != nil {
return fmt.Errorf("applying fleet config: %w", err)
return nil, fmt.Errorf("applying fleet config: %w", err)
}
scriptPayloads[i] = fleet.ScriptPayload{
ScriptContents: b,
@ -445,11 +458,11 @@ func (c *Client) ApplyGroup(
}
}
if err := c.ApplyNoTeamScripts(scriptPayloads, opts); err != nil {
return fmt.Errorf("applying custom settings: %w", err)
return nil, fmt.Errorf("applying custom settings: %w", err)
}
}
if err := c.ApplyAppConfig(specs.AppConfig, opts); err != nil {
return fmt.Errorf("applying fleet config: %w", err)
return nil, fmt.Errorf("applying fleet config: %w", err)
}
if opts.DryRun {
logfn("[+] would've applied fleet config\n")
@ -460,15 +473,16 @@ func (c *Client) ApplyGroup(
if specs.EnrollSecret != nil {
if opts.DryRun {
logfn("[!] ignoring enroll secrets, dry run mode only supported for 'config' and 'team' specs\n")
logfn("[+] would've applied enroll secrets\n")
} else {
if err := c.ApplyEnrollSecretSpec(specs.EnrollSecret); err != nil {
return fmt.Errorf("applying enroll secrets: %w", err)
return nil, fmt.Errorf("applying enroll secrets: %w", err)
}
logfn("[+] applied enroll secrets\n")
}
}
var teamIDsByName map[string]uint
if len(specs.Teams) > 0 {
// extract the teams' custom settings and resolve the files immediately, so
// that any non-existing file error is found before applying the specs.
@ -478,7 +492,7 @@ func (c *Client) ApplyGroup(
for k, paths := range tmMDMSettings {
fileContents, err := getProfilesContents(baseDir, paths)
if err != nil {
return err
return nil, err
}
tmFileContents[k] = fileContents
}
@ -490,14 +504,14 @@ func (c *Client) ApplyGroup(
if setup.BootstrapPackage.Value != "" {
bp, err := c.ValidateBootstrapPackageFromURL(setup.BootstrapPackage.Value)
if err != nil {
return fmt.Errorf("applying teams: %w", err)
return nil, fmt.Errorf("applying teams: %w", err)
}
tmBootstrapPackages[k] = bp
}
if setup.MacOSSetupAssistant.Value != "" {
b, err := c.validateMacOSSetupAssistant(resolveApplyRelativePath(baseDir, setup.MacOSSetupAssistant.Value))
if err != nil {
return fmt.Errorf("applying teams: %w", err)
return nil, fmt.Errorf("applying teams: %w", err)
}
tmMacSetupAssistants[k] = b
}
@ -511,7 +525,7 @@ func (c *Client) ApplyGroup(
for i, f := range files {
b, err := os.ReadFile(f)
if err != nil {
return fmt.Errorf("applying fleet config: %w", err)
return nil, fmt.Errorf("applying fleet config: %w", err)
}
scriptPayloads[i] = fleet.ScriptPayload{
ScriptContents: b,
@ -523,15 +537,22 @@ func (c *Client) ApplyGroup(
// Next, apply the teams specs before saving the profiles, so that any
// non-existing team gets created.
teamIDsByName, err := c.ApplyTeams(specs.Teams, opts)
var err error
teamIDsByName, err = c.ApplyTeams(specs.Teams, opts)
if err != nil {
return fmt.Errorf("applying teams: %w", err)
return nil, fmt.Errorf("applying teams: %w", err)
}
if len(tmFileContents) > 0 {
for tmName, profs := range tmFileContents {
teamID, ok := teamIDsByName[tmName]
if opts.DryRun && (teamID == 0 || !ok) {
logfn("[+] would've applied MDM profiles for new team %s\n", tmName)
} else {
logfn("[+] applying MDM profiles for team %s\n", tmName)
if err := c.ApplyTeamProfiles(tmName, profs, opts); err != nil {
return fmt.Errorf("applying custom settings for team %q: %w", tmName, err)
return nil, fmt.Errorf("applying custom settings for team %q: %w", tmName, err)
}
}
}
}
@ -539,12 +560,12 @@ func (c *Client) ApplyGroup(
for tmName, tmID := range teamIDsByName {
if bp, ok := tmBootstrapPackages[tmName]; ok {
if err := c.EnsureBootstrapPackage(bp, tmID); err != nil {
return fmt.Errorf("uploading bootstrap package for team %q: %w", tmName, err)
return nil, fmt.Errorf("uploading bootstrap package for team %q: %w", tmName, err)
}
}
if b, ok := tmMacSetupAssistants[tmName]; ok {
if err := c.uploadMacOSSetupAssistant(b, &tmID, tmMacSetup[tmName].MacOSSetupAssistant.Value); err != nil {
return fmt.Errorf("uploading macOS setup assistant for team %q: %w", tmName, err)
return nil, fmt.Errorf("uploading macOS setup assistant for team %q: %w", tmName, err)
}
}
}
@ -552,7 +573,7 @@ func (c *Client) ApplyGroup(
if len(tmScriptsPayloads) > 0 {
for tmName, scripts := range tmScriptsPayloads {
if err := c.ApplyTeamScripts(tmName, scripts, opts); err != nil {
return fmt.Errorf("applying scripts for team %q: %w", tmName, err)
return nil, fmt.Errorf("applying scripts for team %q: %w", tmName, err)
}
}
}
@ -568,12 +589,12 @@ func (c *Client) ApplyGroup(
logfn("[!] ignoring user roles, dry run mode only supported for 'config' and 'team' specs\n")
} else {
if err := c.ApplyUsersRoleSecretSpec(specs.UsersRoles); err != nil {
return fmt.Errorf("applying user roles: %w", err)
return nil, fmt.Errorf("applying user roles: %w", err)
}
logfn("[+] applied user roles\n")
}
}
return nil
return teamIDsByName, nil
}
func extractAppCfgMacOSSetup(appCfg any) *fleet.MacOSSetup {
@ -832,3 +853,179 @@ func extractTmSpecsMacOSSetup(tmSpecs []json.RawMessage) map[string]*fleet.MacOS
}
return m
}
// DoGitOps applies the GitOps config to Fleet.
func (c *Client) DoGitOps(
ctx context.Context,
config *spec.GitOps,
baseDir string,
logf func(format string, args ...interface{}),
dryRun bool,
) error {
var err error
logFn := func(format string, args ...interface{}) {
if logf != nil {
logf(format, args...)
}
}
group := spec.Group{}
scripts := make([]interface{}, len(config.Controls.Scripts))
for i, script := range config.Controls.Scripts {
scripts[i] = *script.Path
}
var mdmAppConfig map[string]interface{}
var team map[string]interface{}
if config.TeamName == nil {
group.AppConfig = config.OrgSettings
group.EnrollSecret = &fleet.EnrollSecretSpec{Secrets: config.OrgSettings["secrets"].([]*fleet.EnrollSecret)}
group.AppConfig.(map[string]interface{})["agent_options"] = config.AgentOptions
delete(config.OrgSettings, "secrets") // secrets are applied separately in Client.ApplyGroup
if _, ok := group.AppConfig.(map[string]interface{})["mdm"]; !ok {
group.AppConfig.(map[string]interface{})["mdm"] = map[string]interface{}{}
}
mdmAppConfig = group.AppConfig.(map[string]interface{})["mdm"].(map[string]interface{})
mdmAppConfig["macos_migration"] = config.Controls.MacOSMigration
mdmAppConfig["windows_enabled_and_configured"] = config.Controls.WindowsEnabledAndConfigured
group.AppConfig.(map[string]interface{})["scripts"] = scripts
} else {
team = make(map[string]interface{})
team["name"] = *config.TeamName
team["agent_options"] = config.AgentOptions
if hostExpirySettings, ok := config.TeamSettings["host_expiry_settings"]; ok {
team["host_expiry_settings"] = hostExpirySettings
}
if features, ok := config.TeamSettings["features"]; ok {
team["features"] = features
}
team["scripts"] = scripts
team["secrets"] = config.TeamSettings["secrets"]
team["mdm"] = map[string]interface{}{}
mdmAppConfig = team["mdm"].(map[string]interface{})
}
// Common controls settings between org and team settings
mdmAppConfig["macos_settings"] = config.Controls.MacOSSettings
mdmAppConfig["macos_updates"] = config.Controls.MacOSUpdates
mdmAppConfig["macos_setup"] = config.Controls.MacOSSetup
mdmAppConfig["windows_updates"] = config.Controls.WindowsUpdates
mdmAppConfig["windows_settings"] = config.Controls.WindowsSettings
mdmAppConfig["enable_disk_encryption"] = config.Controls.EnableDiskEncryption
if config.TeamName != nil {
rawTeam, err := json.Marshal(team)
if err != nil {
return fmt.Errorf("error marshalling team spec: %w", err)
}
group.Teams = []json.RawMessage{rawTeam}
}
// Apply org settings, scripts, enroll secrets, and controls
teamIDsByName, err := c.ApplyGroup(ctx, &group, baseDir, logf, fleet.ApplySpecOptions{DryRun: dryRun})
if err != nil {
return err
}
if config.TeamName != nil {
teamID, ok := teamIDsByName[*config.TeamName]
if !ok || teamID == 0 {
if dryRun {
logFn("[+] would've added any policies/queries to new team %s\n", *config.TeamName)
return nil
}
return fmt.Errorf("team %s not created", *config.TeamName)
}
config.TeamID = &teamID
}
err = c.doGitOpsPolicies(config, logFn, dryRun)
if err != nil {
return err
}
err = c.doGitOpsQueries(config, logFn, dryRun)
if err != nil {
return err
}
return nil
}
func (c *Client) doGitOpsPolicies(config *spec.GitOps, logFn func(format string, args ...interface{}), dryRun bool) error {
// Get the ids and names of current policies to figure out which ones to delete
policies, err := c.GetPolicies(config.TeamID)
if err != nil {
return fmt.Errorf("error getting current policies: %w", err)
}
if len(config.Policies) > 0 {
numPolicies := len(config.Policies)
logFn("[+] syncing %d policies\n", numPolicies)
if !dryRun {
// Note: We are reusing the spec flow here for adding/updating policies, instead of creating a new flow for GitOps.
if err := c.ApplyPolicies(config.Policies); err != nil {
return fmt.Errorf("error applying policies: %w", err)
}
logFn("[+] synced %d policies\n", numPolicies)
}
}
var policiesToDelete []uint
for _, oldItem := range policies {
found := false
for _, newItem := range config.Policies {
if oldItem.Name == newItem.Name {
found = true
break
}
}
if !found {
policiesToDelete = append(policiesToDelete, oldItem.ID)
fmt.Printf("[-] deleting policy %s\n", oldItem.Name)
}
}
if len(policiesToDelete) > 0 {
logFn("[-] deleting %d policies\n", len(policiesToDelete))
if !dryRun {
if err := c.DeletePolicies(config.TeamID, policiesToDelete); err != nil {
return fmt.Errorf("error deleting policies: %w", err)
}
}
}
return nil
}
func (c *Client) doGitOpsQueries(config *spec.GitOps, logFn func(format string, args ...interface{}), dryRun bool) error {
// Get the ids and names of current queries to figure out which ones to delete
queries, err := c.GetQueries(config.TeamID, nil)
if err != nil {
return fmt.Errorf("error getting current queries: %w", err)
}
if len(config.Queries) > 0 {
numQueries := len(config.Queries)
logFn("[+] syncing %d queries\n", numQueries)
if !dryRun {
// Note: We are reusing the spec flow here for adding/updating queries, instead of creating a new flow for GitOps.
if err := c.ApplyQueries(config.Queries); err != nil {
return fmt.Errorf("error applying queries: %w", err)
}
logFn("[+] synced %d queries\n", numQueries)
}
}
var queriesToDelete []uint
for _, oldQuery := range queries {
found := false
for _, newQuery := range config.Queries {
if oldQuery.Name == newQuery.Name {
found = true
break
}
}
if !found {
queriesToDelete = append(queriesToDelete, oldQuery.ID)
fmt.Printf("[-] deleting query %s\n", oldQuery.Name)
}
}
if len(queriesToDelete) > 0 {
logFn("[-] deleting %d queries\n", len(queriesToDelete))
if !dryRun {
if err := c.DeleteQueries(queriesToDelete); err != nil {
return fmt.Errorf("error deleting queries: %w", err)
}
}
}
return nil
}

View File

@ -14,9 +14,16 @@ func (c *Client) ApplyAppConfig(payload interface{}, opts fleet.ApplySpecOptions
// ApplyNoTeamProfiles sends the list of profiles to be applied for the hosts
// in no team.
func (c *Client) ApplyNoTeamProfiles(profiles []fleet.MDMProfileBatchPayload, opts fleet.ApplySpecOptions) error {
func (c *Client) ApplyNoTeamProfiles(profiles []fleet.MDMProfileBatchPayload, opts fleet.ApplySpecOptions, assumeEnabled bool) error {
verb, path := "POST", "/api/latest/fleet/mdm/profiles/batch"
return c.authenticatedRequestWithQuery(map[string]interface{}{"profiles": profiles}, verb, path, nil, opts.RawQuery())
query := opts.RawQuery()
if assumeEnabled {
if query != "" {
query += "&"
}
query += "assume_enabled=true"
}
return c.authenticatedRequestWithQuery(map[string]interface{}{"profiles": profiles}, verb, path, nil, query)
}
// GetAppConfig fetches the application config from the server API

View File

@ -1,5 +1,10 @@
package service
import (
"fmt"
"github.com/fleetdm/fleet/v4/server/fleet"
)
func (c *Client) CreateGlobalPolicy(name, query, description, resolution, platform string) error {
req := globalPolicyRequest{
Name: name,
@ -12,3 +17,44 @@ func (c *Client) CreateGlobalPolicy(name, query, description, resolution, platfo
var responseBody globalPolicyResponse
return c.authenticatedRequest(req, verb, path, &responseBody)
}
// ApplyPolicies sends the list of Policies to be applied to the
// Fleet instance.
func (c *Client) ApplyPolicies(specs []*fleet.PolicySpec) error {
req := applyPolicySpecsRequest{Specs: specs}
verb, path := "POST", "/api/latest/fleet/spec/policies"
var responseBody applyPolicySpecsResponse
return c.authenticatedRequest(req, verb, path, &responseBody)
}
// GetPolicies retrieves the list of Policies. Inherited policies are excluded.
func (c *Client) GetPolicies(teamID *uint) ([]*fleet.Policy, error) {
verb, path := "GET", ""
if teamID != nil {
path = fmt.Sprintf("/api/latest/fleet/teams/%d/policies", *teamID)
} else {
path = "/api/latest/fleet/policies"
}
// The response body also works for listTeamPoliciesResponse because they contain some of the same members.
var responseBody listGlobalPoliciesResponse
err := c.authenticatedRequest(nil, verb, path, &responseBody)
if err != nil {
return nil, err
}
return responseBody.Policies, nil
}
// DeletePolicies deletes several policies.
func (c *Client) DeletePolicies(teamID *uint, IDs []uint) error {
verb, path := "POST", ""
req := deleteTeamPoliciesRequest{IDs: IDs}
if teamID != nil {
path = fmt.Sprintf("/api/latest/fleet/teams/%d/policies/delete", *teamID)
req.TeamID = *teamID
} else {
path = "/api/latest/fleet/policies/delete"
}
// The response body also works for deleteTeamPoliciesResponse because they contain some of the same members.
var responseBody deleteGlobalPoliciesResponse
return c.authenticatedRequest(req, verb, path, &responseBody)
}

View File

@ -52,3 +52,11 @@ func (c *Client) DeleteQuery(name string) error {
var responseBody deleteQueryResponse
return c.authenticatedRequest(nil, verb, path, &responseBody)
}
// DeleteQueries deletes several queries.
func (c *Client) DeleteQueries(IDs []uint) error {
req := deleteQueriesRequest{IDs: IDs}
verb, path := "POST", "/api/latest/fleet/queries/delete"
var responseBody deleteQueriesResponse
return c.authenticatedRequest(req, verb, path, &responseBody)
}

View File

@ -74,15 +74,6 @@ func (c *Client) ApplyTeamProfiles(tmName string, profiles []fleet.MDMProfileBat
return c.authenticatedRequestWithQuery(map[string]interface{}{"profiles": profiles}, verb, path, nil, query.Encode())
}
// ApplyPolicies sends the list of Policies to be applied to the
// Fleet instance.
func (c *Client) ApplyPolicies(specs []*fleet.PolicySpec) error {
req := applyPolicySpecsRequest{Specs: specs}
verb, path := "POST", "/api/latest/fleet/spec/policies"
var responseBody applyPolicySpecsResponse
return c.authenticatedRequest(req, verb, path, &responseBody)
}
// ApplyTeamScripts sends the list of scripts to be applied for the specified
// team.
func (c *Client) ApplyTeamScripts(tmName string, scripts []fleet.ScriptPayload, opts fleet.ApplySpecOptions) error {

View File

@ -317,8 +317,7 @@ func (s *integrationEnterpriseTestSuite) TestTeamSpecs() {
}
applyResp = applyTeamSpecsResponse{}
s.DoJSON("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK, &applyResp, "dry_run", "true")
// dry-run never returns id to name mappings as it may not have them
require.Empty(t, applyResp.TeamIDsByName)
assert.Equal(t, map[string]uint{teamName: team.ID}, applyResp.TeamIDsByName)
// dry-run with macos disk encryption set to true
teamSpecs = map[string]any{

View File

@ -10647,7 +10647,7 @@ func (s *integrationMDMTestSuite) TestApplyTeamsMDMWindowsProfiles() {
s.DoJSON("POST", "/api/latest/fleet/spec/teams", rawTeamSpec(`
{ "windows_settings": { "custom_settings": null } }
`), http.StatusOK, &applyResp, "dry_run", "true")
require.Len(t, applyResp.TeamIDsByName, 0)
assert.Equal(t, map[string]uint{team.Name: team.ID}, applyResp.TeamIDsByName)
teamResp = getTeamResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp)

View File

@ -1396,6 +1396,7 @@ type batchSetMDMProfilesRequest struct {
TeamID *uint `json:"-" query:"team_id,optional"`
TeamName *string `json:"-" query:"team_name,optional"`
DryRun bool `json:"-" query:"dry_run,optional"` // if true, apply validation but do not save changes
AssumeEnabled bool `json:"-" query:"assume_enabled,optional"` // if true, assume MDM is enabled
Profiles backwardsCompatProfilesParam `json:"profiles"`
}
@ -1439,13 +1440,16 @@ func (r batchSetMDMProfilesResponse) Status() int { return http.StatusNoContent
func batchSetMDMProfilesEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
req := request.(*batchSetMDMProfilesRequest)
if err := svc.BatchSetMDMProfiles(ctx, req.TeamID, req.TeamName, req.Profiles, req.DryRun, false); err != nil {
if err := svc.BatchSetMDMProfiles(ctx, req.TeamID, req.TeamName, req.Profiles, req.DryRun, false, req.AssumeEnabled); err != nil {
return batchSetMDMProfilesResponse{Err: err}, nil
}
return batchSetMDMProfilesResponse{}, nil
}
func (svc *Service) BatchSetMDMProfiles(ctx context.Context, tmID *uint, tmName *string, profiles []fleet.MDMProfileBatchPayload, dryRun, skipBulkPending bool) error {
func (svc *Service) BatchSetMDMProfiles(
ctx context.Context, tmID *uint, tmName *string, profiles []fleet.MDMProfileBatchPayload, dryRun, skipBulkPending bool,
assumeEnabled bool,
) error {
var err error
if tmID, tmName, err = svc.authorizeBatchProfiles(ctx, tmID, tmName); err != nil {
return err
@ -1455,6 +1459,9 @@ func (svc *Service) BatchSetMDMProfiles(ctx context.Context, tmID *uint, tmName
if err != nil {
return ctxerr.Wrap(ctx, err, "getting app config")
}
if assumeEnabled {
appCfg.MDM.WindowsEnabledAndConfigured = true
}
if err := validateProfiles(profiles); err != nil {
return ctxerr.Wrap(ctx, err, "validating profiles")

View File

@ -1389,7 +1389,7 @@ func TestMDMBatchSetProfiles(t *testing.T) {
}
ctx = license.NewContext(ctx, &fleet.LicenseInfo{Tier: tier})
err := svc.BatchSetMDMProfiles(ctx, tt.teamID, tt.teamName, tt.profiles, false, false)
err := svc.BatchSetMDMProfiles(ctx, tt.teamID, tt.teamName, tt.profiles, false, false, false)
if tt.wantErr == "" {
require.NoError(t, err)
require.True(t, ds.BatchSetMDMProfilesFuncInvoked)

View File

@ -759,6 +759,10 @@ func (svc *Service) BatchSetScripts(ctx context.Context, maybeTmID *uint, maybeT
if maybeTmID != nil || maybeTmName != nil {
team, err := svc.EnterpriseOverrides.TeamByIDOrName(ctx, maybeTmID, maybeTmName)
if err != nil {
// If this is a dry run, the team may not have been created yet
if dryRun && fleet.IsNotFound(err) {
return nil
}
return err
}
teamID = &team.ID