<!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** Resolves #46584 # Checklist for submitter - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files) for more information. (Already added in main.) ## Testing - [x] Added/updated automated tests - [x] QA'd all new/changed functionality manually `generate-gitops`: https://github.com/user-attachments/assets/d32e89c3-2ce7-4c57-9492-66deb0a3dfe8 `gitops`: https://github.com/user-attachments/assets/ac0e3935-bc8d-4541-b3e5-f992109630d9 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added `labels_exclude_all` field support for refining policy label scopes (available with Fleet Premium license). * **Bug Fixes** * Enhanced validation of policy label scope configurations to prevent invalid field combinations and enforce license requirements. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -1674,6 +1674,9 @@ func (cmd *GenerateGitopsCommand) generatePolicies(teamId *uint, filePath string
|
||||
if policy.LabelsExcludeAny != nil {
|
||||
policySpec["labels_exclude_any"] = fleet.LabelIdentsToNames(policy.LabelsExcludeAny)
|
||||
}
|
||||
if policy.LabelsExcludeAll != nil && cmd.AppConfig.License.IsPremium() {
|
||||
policySpec["labels_exclude_all"] = fleet.LabelIdentsToNames(policy.LabelsExcludeAll)
|
||||
}
|
||||
result[i] = policySpec
|
||||
}
|
||||
return result, nil
|
||||
|
||||
@@ -381,6 +381,9 @@ func (MockClient) GetPolicies(teamID *uint) ([]*fleet.Policy, error) {
|
||||
}, {
|
||||
LabelName: "Label D",
|
||||
}},
|
||||
LabelsExcludeAll: []fleet.LabelIdent{{
|
||||
LabelName: "Label E",
|
||||
}},
|
||||
Type: fleet.PolicyTypeDynamic,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -395,8 +395,8 @@ func gitopsCommand() *cli.Command {
|
||||
}
|
||||
}
|
||||
|
||||
// Targeting queries against labels is a Premium feature only
|
||||
if !appConfig.License.IsPremium() {
|
||||
// Targeting queries against labels is a Premium feature only
|
||||
for _, query := range config.Queries {
|
||||
if len(query.LabelsIncludeAny) > 0 {
|
||||
return fmt.Errorf("report %q uses 'labels_include_any', which is only available in Fleet Premium", query.Name)
|
||||
@@ -405,6 +405,15 @@ func gitopsCommand() *cli.Command {
|
||||
return fmt.Errorf("report %q uses 'labels_include_all', which is only available in Fleet Premium", query.Name)
|
||||
}
|
||||
}
|
||||
// TODO(nulmete): might need to revisit if just this scopes are premium-only or all of them (include_any and exclude_any)
|
||||
for _, policy := range config.Policies {
|
||||
if len(policy.LabelsIncludeAll) > 0 {
|
||||
return fmt.Errorf("policy %q uses 'labels_include_all', which is only available in Fleet Premium", policy.Name)
|
||||
}
|
||||
if len(policy.LabelsExcludeAll) > 0 {
|
||||
return fmt.Errorf("policy %q uses 'labels_exclude_all', which is only available in Fleet Premium", policy.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Gather stats on where labels are used in this gitops config,
|
||||
@@ -1054,22 +1063,14 @@ func getLabelUsage(config *spec.GitOps) (map[string][]LabelUsage, error) {
|
||||
updateLabelUsage(labels, query.Name, "Query", result)
|
||||
}
|
||||
|
||||
// Get policy label usage.
|
||||
// Get policy label usage. A policy may combine one include scope (any/all)
|
||||
// with one exclude scope (any/all); VerifyLabelScopes rejects more than one
|
||||
// of either, or a label appearing in both an include and an exclude list.
|
||||
for _, policy := range config.Policies {
|
||||
nonEmptyScopes := 0
|
||||
if len(policy.LabelsIncludeAny) > 0 {
|
||||
nonEmptyScopes++
|
||||
if err := policy.VerifyLabelScopes(); err != nil {
|
||||
return nil, fmt.Errorf("Policy '%s': %w", policy.Name, err)
|
||||
}
|
||||
if len(policy.LabelsIncludeAll) > 0 {
|
||||
nonEmptyScopes++
|
||||
}
|
||||
if len(policy.LabelsExcludeAny) > 0 {
|
||||
nonEmptyScopes++
|
||||
}
|
||||
if nonEmptyScopes > 1 {
|
||||
return nil, fmt.Errorf("Policy '%s' has multiple label keys; please choose one of `labels_include_any`, `labels_include_all`, or `labels_exclude_any`.", policy.Name)
|
||||
}
|
||||
labels := slices.Concat(policy.LabelsIncludeAny, policy.LabelsIncludeAll, policy.LabelsExcludeAny)
|
||||
labels := slices.Concat(policy.LabelsIncludeAny, policy.LabelsIncludeAll, policy.LabelsExcludeAny, policy.LabelsExcludeAll)
|
||||
updateLabelUsage(labels, policy.Name, "Policy", result)
|
||||
}
|
||||
|
||||
|
||||
@@ -7163,18 +7163,64 @@ policies:
|
||||
query: SELECT 1
|
||||
resolution: ""
|
||||
platform: linux
|
||||
labels_include_all:
|
||||
labels_include_any:
|
||||
- lbl-a
|
||||
labels_exclude_any:
|
||||
labels_include_all:
|
||||
- lbl-b
|
||||
`)
|
||||
require.NoError(t, err)
|
||||
|
||||
// A policy may combine one include scope with one exclude scope, but not two
|
||||
// include scopes; this should be rejected before any API call is made.
|
||||
_, err = runAppNoChecks([]string{"gitops", "-f", tmpFile.Name()})
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, "bad-policy")
|
||||
require.ErrorContains(t, err, "labels_include_all")
|
||||
require.ErrorContains(t, err, "labels_exclude_any")
|
||||
require.ErrorContains(t, err, "at most one of labels_include_any or labels_include_all")
|
||||
}
|
||||
|
||||
func TestGitOpsPolicyLabelsExcludeAllRequiresPremium(t *testing.T) {
|
||||
license := &fleet.LicenseInfo{Tier: fleet.TierFree}
|
||||
_, ds := testing_utils.RunServerWithMockedDS(t, &service.TestServerOpts{License: license})
|
||||
setupEmptyGitOpsMocks(ds)
|
||||
|
||||
tmpFile, err := os.CreateTemp(t.TempDir(), "*.yml")
|
||||
require.NoError(t, err)
|
||||
_, err = tmpFile.WriteString(`
|
||||
controls:
|
||||
queries:
|
||||
agent_options:
|
||||
labels:
|
||||
- name: lbl-a
|
||||
description: A
|
||||
label_membership_type: dynamic
|
||||
query: SELECT 1
|
||||
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: GitOps Test
|
||||
secrets:
|
||||
policies:
|
||||
- name: premium-policy
|
||||
description: uses premium scope
|
||||
query: SELECT 1
|
||||
resolution: ""
|
||||
platform: linux
|
||||
labels_exclude_all:
|
||||
- lbl-a
|
||||
`)
|
||||
require.NoError(t, err)
|
||||
|
||||
// labels_exclude_all is a Premium-only scope for policies; a free-tier apply
|
||||
// is rejected with a friendly pre-flight error before any policy is created.
|
||||
_, err = runAppNoChecks([]string{"gitops", "-f", tmpFile.Name()})
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, "premium-policy")
|
||||
require.ErrorContains(t, err, "labels_exclude_all")
|
||||
require.ErrorContains(t, err, "Fleet Premium")
|
||||
}
|
||||
|
||||
func TestGitOpsScriptsLogging(t *testing.T) {
|
||||
@@ -7927,3 +7973,56 @@ func TestValidateGitOpsGroupEUA(t *testing.T) {
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetLabelUsagePolicyScopes(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
policy fleet.PolicySpec
|
||||
wantErrContains string
|
||||
wantLabels []string // labels that must be tracked when no error is expected
|
||||
}{
|
||||
{
|
||||
name: "include_any + exclude_any combined",
|
||||
policy: fleet.PolicySpec{Name: "p", LabelsIncludeAny: []string{"a"}, LabelsExcludeAny: []string{"b"}},
|
||||
wantLabels: []string{"a", "b"},
|
||||
},
|
||||
{
|
||||
name: "include_all + exclude_all combined",
|
||||
policy: fleet.PolicySpec{Name: "p", LabelsIncludeAll: []string{"a"}, LabelsExcludeAll: []string{"b"}},
|
||||
wantLabels: []string{"a", "b"},
|
||||
},
|
||||
{
|
||||
name: "two include scopes rejected",
|
||||
policy: fleet.PolicySpec{Name: "p", LabelsIncludeAny: []string{"a"}, LabelsIncludeAll: []string{"b"}},
|
||||
wantErrContains: "at most one of labels_include_any or labels_include_all",
|
||||
},
|
||||
{
|
||||
name: "two exclude scopes rejected",
|
||||
policy: fleet.PolicySpec{Name: "p", LabelsExcludeAny: []string{"a"}, LabelsExcludeAll: []string{"b"}},
|
||||
wantErrContains: "at most one of labels_exclude_any or labels_exclude_all",
|
||||
},
|
||||
{
|
||||
name: "overlap between include and exclude rejected",
|
||||
policy: fleet.PolicySpec{Name: "p", LabelsIncludeAny: []string{"a"}, LabelsExcludeAll: []string{"a"}},
|
||||
wantErrContains: `label "a" cannot appear in both an include and an exclude list`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
usage, err := getLabelUsage(&spec.GitOps{
|
||||
Policies: []*spec.GitOpsPolicySpec{{PolicySpec: tc.policy}},
|
||||
})
|
||||
if tc.wantErrContains != "" {
|
||||
require.ErrorContains(t, err, tc.wantErrContains)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
// Both include and exclude labels must be tracked as in-use so a
|
||||
// referenced label can't be silently deleted.
|
||||
for _, l := range tc.wantLabels {
|
||||
require.Contains(t, usage, l)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
labels_include_all:
|
||||
- Label C
|
||||
- Label D
|
||||
labels_exclude_all:
|
||||
- Label E
|
||||
name: Global Policy Include All
|
||||
platform: darwin
|
||||
query: SELECT * FROM global_policy WHERE id = 2
|
||||
|
||||
@@ -200,6 +200,8 @@ policies:
|
||||
continuous_automations_enabled: false
|
||||
critical: false
|
||||
description: This is a global policy with include_all scope
|
||||
labels_exclude_all:
|
||||
- Label E
|
||||
labels_include_all:
|
||||
- Label C
|
||||
- Label D
|
||||
|
||||
@@ -5006,6 +5006,142 @@ org_settings:
|
||||
require.Len(t, labels, 0)
|
||||
}
|
||||
|
||||
// TestGitOpsPolicyLabelScopes verifies that policies applied via GitOps can combine
|
||||
// one include scope (any/all) with one exclude scope (any/all) — including the premium
|
||||
// labels_exclude_all scope — and that conflicting include/exclude scopes are rejected.
|
||||
func (s *enterpriseIntegrationGitopsTestSuite) TestGitOpsPolicyLabelScopes() {
|
||||
t := s.T()
|
||||
ctx := t.Context()
|
||||
|
||||
user := s.createGitOpsUser(t)
|
||||
fleetctlConfig := s.createFleetctlConfig(t, user)
|
||||
t.Setenv("FLEET_URL", s.Server.URL)
|
||||
|
||||
tempDir := t.TempDir()
|
||||
fleetName := "Label Scopes " + uuid.NewString()
|
||||
|
||||
// The global file defines the labels referenced by both the global and team
|
||||
// policies, plus a global policy that combines an include scope (any) with the
|
||||
// premium exclude scope (all).
|
||||
globalConfig := `
|
||||
org_settings:
|
||||
server_settings:
|
||||
server_url: $FLEET_URL
|
||||
org_info:
|
||||
org_name: Fleet
|
||||
secrets:
|
||||
- secret: label_scopes_secret
|
||||
agent_options:
|
||||
controls:
|
||||
reports:
|
||||
labels:
|
||||
- name: Label A
|
||||
label_membership_type: dynamic
|
||||
query: SELECT 1
|
||||
- name: Label B
|
||||
label_membership_type: dynamic
|
||||
query: SELECT 1
|
||||
- name: Label C
|
||||
label_membership_type: dynamic
|
||||
query: SELECT 1
|
||||
policies:
|
||||
- name: Global Combined Scope
|
||||
query: SELECT 1;
|
||||
labels_include_any:
|
||||
- Label A
|
||||
labels_exclude_all:
|
||||
- Label B
|
||||
- Label C
|
||||
`
|
||||
globalFile := filepath.Join(tempDir, "global.yml")
|
||||
require.NoError(t, os.WriteFile(globalFile, []byte(globalConfig), 0o644)) //nolint:gosec
|
||||
|
||||
// The team file defines a team policy that combines an include scope (all) with
|
||||
// an exclude scope (any), referencing the globally-defined labels.
|
||||
teamConfig := fmt.Sprintf(`
|
||||
name: %s
|
||||
controls:
|
||||
software:
|
||||
reports:
|
||||
agent_options:
|
||||
settings:
|
||||
secrets:
|
||||
- secret: label_scopes_team_secret
|
||||
policies:
|
||||
- name: Team Combined Scope
|
||||
query: SELECT 1;
|
||||
labels_include_all:
|
||||
- Label A
|
||||
- Label B
|
||||
labels_exclude_any:
|
||||
- Label C
|
||||
`, fleetName)
|
||||
teamFile := filepath.Join(tempDir, "team.yml")
|
||||
require.NoError(t, os.WriteFile(teamFile, []byte(teamConfig), 0o644)) //nolint:gosec
|
||||
|
||||
s.assertRealRunOutput(t, fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile, "-f", teamFile}))
|
||||
|
||||
// The global policy persisted both its include_any and exclude_all scopes.
|
||||
globalPolicies, err := s.DS.ListGlobalPolicies(ctx, fleet.ListOptions{})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, globalPolicies, 1)
|
||||
gp := globalPolicies[0]
|
||||
require.Equal(t, "Global Combined Scope", gp.Name)
|
||||
require.Len(t, gp.LabelsIncludeAny, 1)
|
||||
require.Equal(t, "Label A", gp.LabelsIncludeAny[0].LabelName)
|
||||
require.Empty(t, gp.LabelsIncludeAll)
|
||||
require.Empty(t, gp.LabelsExcludeAny)
|
||||
require.Len(t, gp.LabelsExcludeAll, 2)
|
||||
require.ElementsMatch(t, []string{"Label B", "Label C"}, []string{gp.LabelsExcludeAll[0].LabelName, gp.LabelsExcludeAll[1].LabelName})
|
||||
|
||||
// The team policy persisted both its include_all and exclude_any scopes.
|
||||
tm, err := s.DS.TeamByName(ctx, fleetName)
|
||||
require.NoError(t, err)
|
||||
teamPolicies, _, err := s.DS.ListTeamPolicies(ctx, tm.ID, fleet.ListOptions{}, fleet.ListOptions{}, "")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, teamPolicies, 1)
|
||||
tp := teamPolicies[0]
|
||||
require.Equal(t, "Team Combined Scope", tp.Name)
|
||||
require.Empty(t, tp.LabelsIncludeAny)
|
||||
require.Len(t, tp.LabelsIncludeAll, 2)
|
||||
require.ElementsMatch(t, []string{"Label A", "Label B"}, []string{tp.LabelsIncludeAll[0].LabelName, tp.LabelsIncludeAll[1].LabelName})
|
||||
require.Len(t, tp.LabelsExcludeAny, 1)
|
||||
require.Equal(t, "Label C", tp.LabelsExcludeAny[0].LabelName)
|
||||
require.Empty(t, tp.LabelsExcludeAll)
|
||||
|
||||
// A policy that specifies two include scopes is rejected.
|
||||
conflictConfig := `
|
||||
org_settings:
|
||||
server_settings:
|
||||
server_url: $FLEET_URL
|
||||
org_info:
|
||||
org_name: Fleet
|
||||
secrets:
|
||||
- secret: label_scopes_secret
|
||||
agent_options:
|
||||
controls:
|
||||
reports:
|
||||
labels:
|
||||
- name: Label A
|
||||
label_membership_type: dynamic
|
||||
query: SELECT 1
|
||||
- name: Label B
|
||||
label_membership_type: dynamic
|
||||
query: SELECT 1
|
||||
policies:
|
||||
- name: Conflicting Scope
|
||||
query: SELECT 1;
|
||||
labels_include_any:
|
||||
- Label A
|
||||
labels_include_all:
|
||||
- Label B
|
||||
`
|
||||
conflictFile := filepath.Join(tempDir, "conflict.yml")
|
||||
require.NoError(t, os.WriteFile(conflictFile, []byte(conflictConfig), 0o644)) //nolint:gosec
|
||||
_, err = fleetctltest.RunAppNoChecks([]string{"gitops", "--config", fleetctlConfig.Name(), "-f", conflictFile, "--dry-run"})
|
||||
require.ErrorContains(t, err, "at most one of labels_include_any or labels_include_all")
|
||||
}
|
||||
|
||||
// TestOmittedTopLevelKeysFleet verifies that omitting top-level keys from a fleet
|
||||
// gitops file clears the corresponding settings (e.g. policies, agent_options, settings).
|
||||
func (s *enterpriseIntegrationGitopsTestSuite) TestOmittedTopLevelKeysFleet() {
|
||||
|
||||
@@ -165,7 +165,7 @@ func updatePolicyLabelsTx(ctx context.Context, tx sqlx.ExtContext, policy *fleet
|
||||
WHERE name IN (?)
|
||||
`
|
||||
|
||||
if err := policy.Verify(); err != nil {
|
||||
if err := policy.VerifyLabelScopes(); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "validating policy label scopes")
|
||||
}
|
||||
|
||||
|
||||
@@ -430,12 +430,10 @@ type PolicyData struct {
|
||||
UpdateCreateTimestamps
|
||||
}
|
||||
|
||||
// Verify checks that the policy's label scopes are valid: at most one include
|
||||
// scope (any/all) combined with at most one exclude scope (any/all), with no
|
||||
// label appearing in both an include and an exclude list. It validates only the
|
||||
// label scopes — name/query/platform are validated on the payload types at
|
||||
// create/modify time.
|
||||
func (p PolicyData) Verify() error {
|
||||
// VerifyLabelScopes checks that the policy's label scopes are valid: at most one
|
||||
// include scope (any/all) combined with at most one exclude scope (any/all),
|
||||
// with no label appearing in both an include and an exclude list.
|
||||
func (p PolicyData) VerifyLabelScopes() error {
|
||||
return verifyPolicyLabelScopes(
|
||||
LabelIdentsToNames(p.LabelsIncludeAny),
|
||||
LabelIdentsToNames(p.LabelsIncludeAll),
|
||||
|
||||
Reference in New Issue
Block a user