Add backend changes for continuous automations on policies (#45999)
Resolves #45149 and #45150. - [X] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. - [X] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements), JS inline code is prevented especially for url redirects, and untrusted data interpolated into shell scripts/commands is validated against shell metacharacters. ## Testing - [X] Added/updated automated tests - [x] QA'd all new/changed functionality manually ## Database migrations - [X] Checked schema for all modified table for columns that will auto-update timestamps during migration. - [X] Confirmed that updating the timestamps is acceptable, and will not cause unwanted side effects. - [X] Ensured the correct collation is explicitly set for character columns (`COLLATE utf8mb4_unicode_ci`). - [x] Verified that the setting is exported via `fleetctl generate-gitops` - [X] Verified the setting is documented in a separate PR to [the GitOps documentation](https://github.com/fleetdm/fleet/blob/main/docs/Configuration/yaml-files.md#L485) - [x] Verified that the setting is cleared on the server if it is not supplied in a YAML file (or that it is documented as being optional) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added team policy setting continuous_automations_enabled (default: false) to re-run software/script automations on every failing evaluation; exposed in APIs and GitOps YAML. Disallowed for "All fleets" and requires a premium license. * **Tests** * Added integration tests for CRUD, GitOps, and re-queuing behavior validating continuous automations. <!-- review_stack_entry_start --> [](https://app.coderabbit.ai/change-stack/fleetdm/fleet/pull/45999?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack) <!-- review_stack_entry_end --> <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
- Added `continuous_automations_enabled` to team policies. When enabled, software and script automations run on every failing policy result instead of only on the host's first failure or a pass→fail transition.
|
||||
- Surfaced `continuous_automations_enabled` in GitOps YAML (read and generated by `fleetctl generate-gitops`).
|
||||
@@ -74,6 +74,7 @@ func TestRunApiCommand(t *testing.T) {
|
||||
"calendar_events_enabled": false,
|
||||
"conditional_access_enabled": false,
|
||||
"type": "dynamic",
|
||||
"continuous_automations_enabled": false,
|
||||
"created_at": "0001-01-01T00:00:00Z",
|
||||
"updated_at": "0001-01-01T00:00:00Z",
|
||||
"passing_host_count": 0,
|
||||
|
||||
@@ -1591,13 +1591,14 @@ func (cmd *GenerateGitopsCommand) generatePolicies(teamId *uint, filePath string
|
||||
result := make([]map[string]interface{}, len(policies))
|
||||
for i, policy := range policies {
|
||||
policySpec := map[string]interface{}{
|
||||
jsonFieldName(t, "Name"): policy.Name,
|
||||
jsonFieldName(t, "Description"): policy.Description,
|
||||
jsonFieldName(t, "Resolution"): policy.Resolution,
|
||||
jsonFieldName(t, "Platform"): policy.Platform,
|
||||
jsonFieldName(t, "Critical"): policy.Critical,
|
||||
jsonFieldName(t, "CalendarEventsEnabled"): policy.CalendarEventsEnabled,
|
||||
jsonFieldName(t, "ConditionalAccessEnabled"): policy.ConditionalAccessEnabled,
|
||||
jsonFieldName(t, "Name"): policy.Name,
|
||||
jsonFieldName(t, "Description"): policy.Description,
|
||||
jsonFieldName(t, "Resolution"): policy.Resolution,
|
||||
jsonFieldName(t, "Platform"): policy.Platform,
|
||||
jsonFieldName(t, "Critical"): policy.Critical,
|
||||
jsonFieldName(t, "CalendarEventsEnabled"): policy.CalendarEventsEnabled,
|
||||
jsonFieldName(t, "ConditionalAccessEnabled"): policy.ConditionalAccessEnabled,
|
||||
jsonFieldName(t, "ContinuousAutomationsEnabled"): policy.ContinuousAutomationsEnabled,
|
||||
}
|
||||
|
||||
if policy.Type == fleet.PolicyTypeDynamic {
|
||||
|
||||
@@ -68,6 +68,7 @@
|
||||
"author_name": "Alice",
|
||||
"calendar_events_enabled": true,
|
||||
"conditional_access_enabled": false,
|
||||
"continuous_automations_enabled": false,
|
||||
"created_at": "0001-01-01T00:00:00Z",
|
||||
"critical": false,
|
||||
"description": "Some description",
|
||||
@@ -88,6 +89,7 @@
|
||||
"author_name": "Alice",
|
||||
"calendar_events_enabled": false,
|
||||
"conditional_access_enabled": false,
|
||||
"continuous_automations_enabled": false,
|
||||
"created_at": "0001-01-01T00:00:00Z",
|
||||
"critical": false,
|
||||
"description": "",
|
||||
|
||||
@@ -66,6 +66,7 @@ spec:
|
||||
author_name: Alice
|
||||
calendar_events_enabled: true
|
||||
conditional_access_enabled: false
|
||||
continuous_automations_enabled: false
|
||||
created_at: "0001-01-01T00:00:00Z"
|
||||
critical: false
|
||||
description: Some description
|
||||
@@ -84,6 +85,7 @@ spec:
|
||||
author_name: Alice
|
||||
calendar_events_enabled: false
|
||||
conditional_access_enabled: false
|
||||
continuous_automations_enabled: false
|
||||
created_at: "0001-01-01T00:00:00Z"
|
||||
critical: false
|
||||
description: ""
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
- calendar_events_enabled: false
|
||||
conditional_access_enabled: true
|
||||
continuous_automations_enabled: false
|
||||
critical: false
|
||||
description: This is a global policy
|
||||
install_software:
|
||||
@@ -15,6 +16,7 @@
|
||||
webhooks_and_tickets_enabled: false
|
||||
- calendar_events_enabled: false
|
||||
conditional_access_enabled: false
|
||||
continuous_automations_enabled: false
|
||||
critical: false
|
||||
description: This is a global policy with include_all scope
|
||||
labels_include_all:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
- calendar_events_enabled: false
|
||||
continuous_automations_enabled: false
|
||||
critical: false
|
||||
description: This is a team policy
|
||||
name: Team Policy
|
||||
@@ -12,6 +13,7 @@
|
||||
conditional_access_bypass_enabled: true
|
||||
webhooks_and_tickets_enabled: false
|
||||
- calendar_events_enabled: false
|
||||
continuous_automations_enabled: false
|
||||
critical: false
|
||||
description: This is a team patch policy
|
||||
name: Team patch policy
|
||||
@@ -25,6 +27,7 @@
|
||||
webhooks_and_tickets_enabled: false
|
||||
- calendar_events_enabled: false
|
||||
conditional_access_enabled: false
|
||||
continuous_automations_enabled: false
|
||||
critical: false
|
||||
description: This is a team policy with VPP app automation
|
||||
install_software:
|
||||
|
||||
@@ -185,6 +185,7 @@ org_settings:
|
||||
policies:
|
||||
- calendar_events_enabled: false
|
||||
conditional_access_enabled: true
|
||||
continuous_automations_enabled: false
|
||||
critical: false
|
||||
description: This is a global policy
|
||||
install_software:
|
||||
@@ -200,6 +201,7 @@ policies:
|
||||
webhooks_and_tickets_enabled: false
|
||||
- calendar_events_enabled: false
|
||||
conditional_access_enabled: false
|
||||
continuous_automations_enabled: false
|
||||
critical: false
|
||||
description: This is a global policy with include_all scope
|
||||
name: Global Policy Include All
|
||||
|
||||
@@ -181,6 +181,7 @@ org_settings:
|
||||
policies:
|
||||
- calendar_events_enabled: false
|
||||
conditional_access_enabled: true
|
||||
continuous_automations_enabled: false
|
||||
critical: false
|
||||
description: This is a global policy
|
||||
install_software:
|
||||
@@ -196,6 +197,7 @@ policies:
|
||||
webhooks_and_tickets_enabled: false
|
||||
- calendar_events_enabled: false
|
||||
conditional_access_enabled: false
|
||||
continuous_automations_enabled: false
|
||||
critical: false
|
||||
description: This is a global policy with include_all scope
|
||||
labels_include_all:
|
||||
|
||||
+5
@@ -53,6 +53,7 @@ name: "Team A 👍"
|
||||
policies:
|
||||
- calendar_events_enabled: false
|
||||
conditional_access_enabled: true
|
||||
continuous_automations_enabled: false
|
||||
critical: false
|
||||
description: This is a team policy
|
||||
name: Team Policy
|
||||
@@ -63,6 +64,7 @@ policies:
|
||||
webhooks_and_tickets_enabled: true
|
||||
- calendar_events_enabled: false
|
||||
conditional_access_enabled: true
|
||||
continuous_automations_enabled: false
|
||||
critical: false
|
||||
description: This is a team patch policy
|
||||
fleet_maintained_app_slug: foo/darwin
|
||||
@@ -73,6 +75,7 @@ policies:
|
||||
webhooks_and_tickets_enabled: true
|
||||
- calendar_events_enabled: false
|
||||
conditional_access_enabled: false
|
||||
continuous_automations_enabled: false
|
||||
critical: false
|
||||
description: This is a team policy with VPP app automation
|
||||
install_software:
|
||||
@@ -85,6 +88,7 @@ policies:
|
||||
webhooks_and_tickets_enabled: true
|
||||
- calendar_events_enabled: false
|
||||
conditional_access_enabled: false
|
||||
continuous_automations_enabled: false
|
||||
critical: false
|
||||
description: This is a team policy with FMA install automation
|
||||
install_software:
|
||||
@@ -97,6 +101,7 @@ policies:
|
||||
webhooks_and_tickets_enabled: false
|
||||
- calendar_events_enabled: false
|
||||
conditional_access_enabled: false
|
||||
continuous_automations_enabled: false
|
||||
critical: false
|
||||
description: This is a team policy with custom package install automation
|
||||
install_software:
|
||||
|
||||
+3
@@ -36,6 +36,7 @@ name: Unassigned
|
||||
policies:
|
||||
- calendar_events_enabled: false
|
||||
conditional_access_enabled: true
|
||||
continuous_automations_enabled: false
|
||||
critical: false
|
||||
description: This is a team policy
|
||||
name: Team Policy
|
||||
@@ -46,6 +47,7 @@ policies:
|
||||
webhooks_and_tickets_enabled: true
|
||||
- calendar_events_enabled: false
|
||||
conditional_access_enabled: true
|
||||
continuous_automations_enabled: false
|
||||
critical: false
|
||||
description: This is a team patch policy
|
||||
fleet_maintained_app_slug: foo/darwin
|
||||
@@ -56,6 +58,7 @@ policies:
|
||||
webhooks_and_tickets_enabled: true
|
||||
- calendar_events_enabled: false
|
||||
conditional_access_enabled: false
|
||||
continuous_automations_enabled: false
|
||||
critical: false
|
||||
description: This is a team policy with VPP app automation
|
||||
install_software:
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package tables
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func init() {
|
||||
MigrationClient.AddMigration(Up_20260522195237, Down_20260522195237)
|
||||
}
|
||||
|
||||
func Up_20260522195237(tx *sql.Tx) error {
|
||||
if columnExists(tx, "policies", "continuous_automations_enabled") {
|
||||
return nil
|
||||
}
|
||||
if _, err := tx.Exec(`
|
||||
ALTER TABLE policies
|
||||
ADD COLUMN continuous_automations_enabled TINYINT(1) NOT NULL DEFAULT 0,
|
||||
ALGORITHM=INSTANT
|
||||
`); err != nil {
|
||||
return fmt.Errorf("add continuous_automations_enabled to policies: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func Down_20260522195237(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package tables
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestUp_20260522195237(t *testing.T) {
|
||||
db := applyUpToPrev(t)
|
||||
|
||||
policy1 := execNoErrLastID(
|
||||
t, db, "INSERT INTO policies (name, query, description, checksum) VALUES (?,?,?,?)",
|
||||
"policy1", "", "", "checksum1",
|
||||
)
|
||||
|
||||
applyNext(t, db)
|
||||
|
||||
var policyCheck []struct {
|
||||
ID int64 `db:"id"`
|
||||
ContinuousAutomationsEnabled bool `db:"continuous_automations_enabled"`
|
||||
}
|
||||
err := db.SelectContext(context.Background(), &policyCheck, `SELECT id, continuous_automations_enabled FROM policies WHERE id = ?`, policy1)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, policyCheck, 1)
|
||||
assert.Equal(t, policy1, policyCheck[0].ID)
|
||||
assert.False(t, policyCheck[0].ContinuousAutomationsEnabled)
|
||||
|
||||
policy2 := execNoErrLastID(
|
||||
t, db, "INSERT INTO policies (name, query, description, checksum, continuous_automations_enabled) VALUES (?,?,?,?,?)",
|
||||
"policy2", "", "", "checksum2", 1,
|
||||
)
|
||||
|
||||
policyCheck = nil
|
||||
err = db.SelectContext(context.Background(), &policyCheck, `SELECT id, continuous_automations_enabled FROM policies WHERE id = ?`, policy2)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, policyCheck, 1)
|
||||
assert.Equal(t, policy2, policyCheck[0].ID)
|
||||
assert.True(t, policyCheck[0].ContinuousAutomationsEnabled)
|
||||
}
|
||||
@@ -39,7 +39,7 @@ const policyCols = `
|
||||
p.author_id, p.platforms, p.created_at, p.updated_at, p.critical,
|
||||
p.calendar_events_enabled, p.software_installer_id, p.script_id,
|
||||
p.vpp_apps_teams_id, p.conditional_access_enabled, p.type,
|
||||
p.patch_software_title_id
|
||||
p.patch_software_title_id, p.continuous_automations_enabled
|
||||
`
|
||||
|
||||
const (
|
||||
@@ -393,11 +393,12 @@ func savePolicy(ctx context.Context, db sqlx.ExtContext, logger *slog.Logger, p
|
||||
SET name = ?, query = ?, description = ?, resolution = ?,
|
||||
platforms = ?, critical = ?, calendar_events_enabled = ?,
|
||||
software_installer_id = ?, script_id = ?, vpp_apps_teams_id = ?,
|
||||
conditional_access_enabled = ?, checksum = ` + policiesChecksumComputedColumn() + `
|
||||
conditional_access_enabled = ?, continuous_automations_enabled = ?,
|
||||
checksum = ` + policiesChecksumComputedColumn() + `
|
||||
WHERE id = ?
|
||||
`
|
||||
result, err := db.ExecContext(
|
||||
ctx, updateStmt, p.Name, p.Query, p.Description, p.Resolution, p.Platform, p.Critical, p.CalendarEventsEnabled, p.SoftwareInstallerID, p.ScriptID, p.VPPAppsTeamsID, p.ConditionalAccessEnabled, p.ID,
|
||||
ctx, updateStmt, p.Name, p.Query, p.Description, p.Resolution, p.Platform, p.Critical, p.CalendarEventsEnabled, p.SoftwareInstallerID, p.ScriptID, p.VPPAppsTeamsID, p.ConditionalAccessEnabled, p.ContinuousAutomationsEnabled, p.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "updating policy")
|
||||
@@ -424,6 +425,33 @@ func savePolicy(ctx context.Context, db sqlx.ExtContext, logger *slog.Logger, p
|
||||
)
|
||||
}
|
||||
|
||||
// ResetPolicyAutomationRetryAttemptsForHost marks all prior script and software
|
||||
// install attempts on this host (across the given policies) as "old sequence" by
|
||||
// setting attempt_number=0. The retry gate counts attempt_number > 0 OR NULL, so
|
||||
// after this reset the next attempt restarts the sequence at 1.
|
||||
func (ds *Datastore) ResetPolicyAutomationRetryAttemptsForHost(ctx context.Context, hostID uint, policyIDs []uint) error {
|
||||
if len(policyIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
|
||||
q, args, err := sqlx.In(resetScriptAttemptsStmt, hostID, policyIDs)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "building reset host script attempts query")
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, q, args...); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "reset host script attempts")
|
||||
}
|
||||
q, args, err = sqlx.In(resetInstallAttemptsStmt, hostID, policyIDs)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "building reset host install attempts query")
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, q, args...); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "reset host install attempts")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// resetPolicyAutomationAttempts resets all attempt numbers for script and software install executions
|
||||
// associated with the given policy.
|
||||
func resetPolicyAutomationAttempts(ctx context.Context, db sqlx.ExecerContext, policyID uint) error {
|
||||
@@ -1167,13 +1195,13 @@ func newTeamPolicy(ctx context.Context, db sqlx.ExtContext, teamID uint, authorI
|
||||
name, query, description, team_id, resolution, author_id,
|
||||
platforms, critical, calendar_events_enabled, software_installer_id,
|
||||
script_id, vpp_apps_teams_id, conditional_access_enabled, checksum,
|
||||
type, patch_software_title_id
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, %s, ?, ?)`,
|
||||
type, patch_software_title_id, continuous_automations_enabled
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, %s, ?, ?, ?)`,
|
||||
policiesChecksumComputedColumn(),
|
||||
),
|
||||
nameUnicode, args.Query, args.Description, teamID, args.Resolution, authorID, args.Platform, args.Critical,
|
||||
args.CalendarEventsEnabled, args.SoftwareInstallerID, args.ScriptID, args.VPPAppsTeamsID,
|
||||
args.ConditionalAccessEnabled, args.Type, args.PatchSoftwareTitleID,
|
||||
args.ConditionalAccessEnabled, args.Type, args.PatchSoftwareTitleID, args.ContinuousAutomationsEnabled,
|
||||
)
|
||||
switch {
|
||||
case err == nil:
|
||||
@@ -1465,8 +1493,9 @@ func (ds *Datastore) ApplyPolicySpecs(ctx context.Context, authorID uint, specs
|
||||
conditional_access_enabled,
|
||||
checksum,
|
||||
type,
|
||||
patch_software_title_id
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, %s, ?, ?)
|
||||
patch_software_title_id,
|
||||
continuous_automations_enabled
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, %s, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
query = VALUES(query),
|
||||
description = VALUES(description),
|
||||
@@ -1480,7 +1509,8 @@ func (ds *Datastore) ApplyPolicySpecs(ctx context.Context, authorID uint, specs
|
||||
script_id = VALUES(script_id),
|
||||
conditional_access_enabled = VALUES(conditional_access_enabled),
|
||||
type = VALUES(type),
|
||||
patch_software_title_id = VALUES(patch_software_title_id)
|
||||
patch_software_title_id = VALUES(patch_software_title_id),
|
||||
continuous_automations_enabled = VALUES(continuous_automations_enabled)
|
||||
`, policiesChecksumComputedColumn(),
|
||||
)
|
||||
for teamID, teamPolicySpecs := range teamIDToPolicies {
|
||||
@@ -1546,7 +1576,7 @@ func (ds *Datastore) ApplyPolicySpecs(ctx context.Context, authorID uint, specs
|
||||
query,
|
||||
spec.Name, spec.Query, spec.Description, authorID, spec.Resolution, teamID, spec.Platform, spec.Critical,
|
||||
spec.CalendarEventsEnabled, softwareInstallerID, vppAppsTeamsID, scriptID, spec.ConditionalAccessEnabled,
|
||||
spec.Type, patchSoftwareTitleIDArg,
|
||||
spec.Type, patchSoftwareTitleIDArg, spec.ContinuousAutomationsEnabled,
|
||||
)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "exec ApplyPolicySpecs insert")
|
||||
@@ -2521,7 +2551,7 @@ func (ds *Datastore) GetPoliciesWithAssociatedInstaller(ctx context.Context, tea
|
||||
if len(policyIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
query := `SELECT id, software_installer_id FROM policies WHERE team_id = ? AND software_installer_id IS NOT NULL AND id IN (?);`
|
||||
query := `SELECT id, software_installer_id, continuous_automations_enabled FROM policies WHERE team_id = ? AND software_installer_id IS NOT NULL AND id IN (?);`
|
||||
query, args, err := sqlx.In(query, teamID, policyIDs)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrapf(ctx, err, "build sqlx.In for get policies with associated installer")
|
||||
@@ -2537,7 +2567,7 @@ func (ds *Datastore) GetPoliciesWithAssociatedVPP(ctx context.Context, teamID ui
|
||||
if len(policyIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
query := `SELECT p.id, vat.adam_id, vat.platform FROM policies p JOIN vpp_apps_teams vat ON vat.id = p.vpp_apps_teams_id WHERE p.team_id = ? AND p.id IN (?);`
|
||||
query := `SELECT p.id, vat.adam_id, vat.platform, p.continuous_automations_enabled FROM policies p JOIN vpp_apps_teams vat ON vat.id = p.vpp_apps_teams_id WHERE p.team_id = ? AND p.id IN (?);`
|
||||
query, args, err := sqlx.In(query, teamID, policyIDs)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrapf(ctx, err, "build sqlx.In for get policies with associated installer")
|
||||
@@ -2553,7 +2583,7 @@ func (ds *Datastore) GetPoliciesWithAssociatedScript(ctx context.Context, teamID
|
||||
if len(policyIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
query := `SELECT id, script_id FROM policies WHERE team_id = ? AND script_id IS NOT NULL AND id IN (?);`
|
||||
query := `SELECT id, script_id, continuous_automations_enabled FROM policies WHERE team_id = ? AND script_id IS NOT NULL AND id IN (?);`
|
||||
query, args, err := sqlx.In(query, teamID, policyIDs)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrapf(ctx, err, "build sqlx.In for get policies with associated script")
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -150,23 +150,24 @@ func (r AutofillPoliciesResponse) Error() error { return r.Err }
|
||||
/////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
type TeamPolicyRequest struct {
|
||||
TeamID uint `url:"fleet_id"`
|
||||
QueryID *uint `json:"query_id" renameto:"report_id"`
|
||||
Query string `json:"query"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Resolution string `json:"resolution"`
|
||||
Platform string `json:"platform"`
|
||||
Critical bool `json:"critical" premium:"true"`
|
||||
CalendarEventsEnabled bool `json:"calendar_events_enabled"`
|
||||
SoftwareTitleID *uint `json:"software_title_id"`
|
||||
ScriptID *uint `json:"script_id"`
|
||||
LabelsIncludeAny []string `json:"labels_include_any"`
|
||||
LabelsIncludeAll []string `json:"labels_include_all" premium:"true"`
|
||||
LabelsExcludeAny []string `json:"labels_exclude_any"`
|
||||
ConditionalAccessEnabled bool `json:"conditional_access_enabled"`
|
||||
Type *string `json:"type"`
|
||||
PatchSoftwareTitleID *uint `json:"patch_software_title_id"`
|
||||
TeamID uint `url:"fleet_id"`
|
||||
QueryID *uint `json:"query_id" renameto:"report_id"`
|
||||
Query string `json:"query"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Resolution string `json:"resolution"`
|
||||
Platform string `json:"platform"`
|
||||
Critical bool `json:"critical" premium:"true"`
|
||||
CalendarEventsEnabled bool `json:"calendar_events_enabled"`
|
||||
SoftwareTitleID *uint `json:"software_title_id"`
|
||||
ScriptID *uint `json:"script_id"`
|
||||
LabelsIncludeAny []string `json:"labels_include_any"`
|
||||
LabelsIncludeAll []string `json:"labels_include_all" premium:"true"`
|
||||
LabelsExcludeAny []string `json:"labels_exclude_any"`
|
||||
ConditionalAccessEnabled bool `json:"conditional_access_enabled"`
|
||||
ContinuousAutomationsEnabled bool `json:"continuous_automations_enabled" premium:"true"`
|
||||
Type *string `json:"type"`
|
||||
PatchSoftwareTitleID *uint `json:"patch_software_title_id"`
|
||||
}
|
||||
|
||||
type TeamPolicyResponse struct {
|
||||
|
||||
@@ -933,6 +933,13 @@ type Datastore interface {
|
||||
// GetPoliciesWithAssociatedVPP returns team policies that have an associated VPP app
|
||||
GetPoliciesWithAssociatedVPP(ctx context.Context, teamID uint, policyIDs []uint) ([]PolicyVPPData, error)
|
||||
GetPoliciesWithAssociatedScript(ctx context.Context, teamID uint, policyIDs []uint) ([]PolicyScriptData, error)
|
||||
// ResetPolicyAutomationRetryAttemptsForHost marks all prior policy automation
|
||||
// script/install attempts on this host as "old sequence" (attempt_number=0)
|
||||
// for the given policies. Used when continuous_automations_enabled triggers
|
||||
// a new automation run while the policy is still failing, so that the new
|
||||
// attempt restarts the retry sequence at 1 instead of inheriting the cap
|
||||
// from the previous sequence.
|
||||
ResetPolicyAutomationRetryAttemptsForHost(ctx context.Context, hostID uint, policyIDs []uint) error
|
||||
GetCalendarPolicies(ctx context.Context, teamID uint) ([]PolicyCalendarData, error)
|
||||
// GetPoliciesForConditionalAccess returns the team policies that are configured for "Conditional access".
|
||||
GetPoliciesForConditionalAccess(ctx context.Context, teamID uint, platform string) ([]uint, error)
|
||||
|
||||
@@ -65,6 +65,12 @@ type PolicyPayload struct {
|
||||
//
|
||||
// Only applies to team policies with the patch type.
|
||||
PatchSoftwareTitleID *uint
|
||||
|
||||
// ContinuousAutomationsEnabled indicates whether software/script automations
|
||||
// should run on every failing policy result, not just on pass→fail transitions.
|
||||
//
|
||||
// Only applies to team policies.
|
||||
ContinuousAutomationsEnabled bool
|
||||
}
|
||||
|
||||
// NewTeamPolicyPayload holds data for team policy creation.
|
||||
@@ -110,6 +116,9 @@ type NewTeamPolicyPayload struct {
|
||||
Type *string
|
||||
// PatchSoftwareTitleID is the title id of the Fleet maintained app checked by a patch policy.
|
||||
PatchSoftwareTitleID *uint
|
||||
// ContinuousAutomationsEnabled indicates whether software/script automations
|
||||
// should run on every failing policy result, not just on pass→fail transitions.
|
||||
ContinuousAutomationsEnabled bool
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -282,6 +291,11 @@ type ModifyPolicyPayload struct {
|
||||
//
|
||||
// Only applies to team policies.
|
||||
ConditionalAccessEnabled *bool `json:"conditional_access_enabled" premium:"true"`
|
||||
// ContinuousAutomationsEnabled indicates whether software/script automations
|
||||
// should run on every failing policy result, not just on pass→fail transitions.
|
||||
//
|
||||
// Only applies to team policies.
|
||||
ContinuousAutomationsEnabled *bool `json:"continuous_automations_enabled" premium:"true"`
|
||||
|
||||
// Type is the policy type. It is 'dynamic' by default and 'patch' for patch policies.
|
||||
Type string `json:"-"`
|
||||
@@ -379,6 +393,12 @@ type PolicyData struct {
|
||||
// Only applies to team policies with the patch type.
|
||||
PatchSoftwareTitleID *uint `json:"-" db:"patch_software_title_id"`
|
||||
|
||||
// ContinuousAutomationsEnabled indicates whether software/script automations
|
||||
// should run on every failing policy result, not just on pass→fail transitions.
|
||||
//
|
||||
// Only applies to team policies.
|
||||
ContinuousAutomationsEnabled bool `json:"continuous_automations_enabled" db:"continuous_automations_enabled"`
|
||||
|
||||
UpdateCreateTimestamps
|
||||
}
|
||||
|
||||
@@ -422,19 +442,22 @@ type PolicyCalendarData struct {
|
||||
}
|
||||
|
||||
type PolicySoftwareInstallerData struct {
|
||||
ID uint `db:"id"`
|
||||
InstallerID uint `db:"software_installer_id"`
|
||||
ID uint `db:"id"`
|
||||
InstallerID uint `db:"software_installer_id"`
|
||||
ContinuousAutomationsEnabled bool `db:"continuous_automations_enabled"`
|
||||
}
|
||||
|
||||
type PolicyVPPData struct {
|
||||
ID uint `db:"id"`
|
||||
AdamID string `db:"adam_id"`
|
||||
Platform InstallableDevicePlatform `db:"platform"`
|
||||
ID uint `db:"id"`
|
||||
AdamID string `db:"adam_id"`
|
||||
Platform InstallableDevicePlatform `db:"platform"`
|
||||
ContinuousAutomationsEnabled bool `db:"continuous_automations_enabled"`
|
||||
}
|
||||
|
||||
type PolicyScriptData struct {
|
||||
ID uint `db:"id"`
|
||||
ScriptID uint `db:"script_id"`
|
||||
ID uint `db:"id"`
|
||||
ScriptID uint `db:"script_id"`
|
||||
ContinuousAutomationsEnabled bool `db:"continuous_automations_enabled"`
|
||||
}
|
||||
|
||||
// PolicyLite is a stripped down version of the policy.
|
||||
@@ -504,6 +527,11 @@ type PolicySpec struct {
|
||||
//
|
||||
// Only applies to team policies.
|
||||
ConditionalAccessEnabled bool `json:"conditional_access_enabled"`
|
||||
// ContinuousAutomationsEnabled indicates whether software/script automations
|
||||
// should run on every failing policy result, not just on pass→fail transitions.
|
||||
//
|
||||
// Only applies to team policies.
|
||||
ContinuousAutomationsEnabled bool `json:"continuous_automations_enabled"`
|
||||
|
||||
Type string `json:"type"`
|
||||
FleetMaintainedAppSlug string `json:"fleet_maintained_app_slug"`
|
||||
|
||||
@@ -677,6 +677,8 @@ type GetPoliciesWithAssociatedVPPFunc func(ctx context.Context, teamID uint, pol
|
||||
|
||||
type GetPoliciesWithAssociatedScriptFunc func(ctx context.Context, teamID uint, policyIDs []uint) ([]fleet.PolicyScriptData, error)
|
||||
|
||||
type ResetPolicyAutomationRetryAttemptsForHostFunc func(ctx context.Context, hostID uint, policyIDs []uint) error
|
||||
|
||||
type GetCalendarPoliciesFunc func(ctx context.Context, teamID uint) ([]fleet.PolicyCalendarData, error)
|
||||
|
||||
type GetPoliciesForConditionalAccessFunc func(ctx context.Context, teamID uint, platform string) ([]uint, error)
|
||||
@@ -2985,6 +2987,9 @@ type DataStore struct {
|
||||
GetPoliciesWithAssociatedScriptFunc GetPoliciesWithAssociatedScriptFunc
|
||||
GetPoliciesWithAssociatedScriptFuncInvoked bool
|
||||
|
||||
ResetPolicyAutomationRetryAttemptsForHostFunc ResetPolicyAutomationRetryAttemptsForHostFunc
|
||||
ResetPolicyAutomationRetryAttemptsForHostFuncInvoked bool
|
||||
|
||||
GetCalendarPoliciesFunc GetCalendarPoliciesFunc
|
||||
GetCalendarPoliciesFuncInvoked bool
|
||||
|
||||
@@ -7266,6 +7271,13 @@ func (s *DataStore) GetPoliciesWithAssociatedScript(ctx context.Context, teamID
|
||||
return s.GetPoliciesWithAssociatedScriptFunc(ctx, teamID, policyIDs)
|
||||
}
|
||||
|
||||
func (s *DataStore) ResetPolicyAutomationRetryAttemptsForHost(ctx context.Context, hostID uint, policyIDs []uint) error {
|
||||
s.mu.Lock()
|
||||
s.ResetPolicyAutomationRetryAttemptsForHostFuncInvoked = true
|
||||
s.mu.Unlock()
|
||||
return s.ResetPolicyAutomationRetryAttemptsForHostFunc(ctx, hostID, policyIDs)
|
||||
}
|
||||
|
||||
func (s *DataStore) GetCalendarPolicies(ctx context.Context, teamID uint) ([]fleet.PolicyCalendarData, error) {
|
||||
s.mu.Lock()
|
||||
s.GetCalendarPoliciesFuncInvoked = true
|
||||
|
||||
@@ -234,7 +234,10 @@ func (svc Service) removeGlobalPoliciesFromWebhookConfig(ctx context.Context, id
|
||||
// Modify
|
||||
/////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
const errPolicyAllFleetsForConditionalAccess = "\"All fleets\" policy cannot have conditional_access_enabled set"
|
||||
const (
|
||||
errPolicyAllFleetsForConditionalAccess = "\"All fleets\" policy cannot have conditional_access_enabled set"
|
||||
errPolicyAllFleetsForContinuousAutomations = "\"All fleets\" policy cannot have continuous_automations_enabled set"
|
||||
)
|
||||
|
||||
func modifyGlobalPolicyEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
||||
req := request.(*fleet.ModifyGlobalPolicyRequest)
|
||||
@@ -456,6 +459,12 @@ func (svc *Service) ApplyPolicySpecs(ctx context.Context, policies []*fleet.Poli
|
||||
})
|
||||
}
|
||||
|
||||
if policy.Team == "" && policy.ContinuousAutomationsEnabled {
|
||||
return ctxerr.Wrap(ctx, &fleet.BadRequestError{
|
||||
Message: fmt.Sprintf("policy spec payload verification: %s", errPolicyAllFleetsForContinuousAutomations),
|
||||
})
|
||||
}
|
||||
|
||||
if err := policy.Verify(); err != nil {
|
||||
return ctxerr.Wrap(ctx, &fleet.BadRequestError{
|
||||
Message: fmt.Sprintf("policy spec payload verification: %s", err),
|
||||
@@ -467,6 +476,11 @@ func (svc *Service) ApplyPolicySpecs(ctx context.Context, policies []*fleet.Poli
|
||||
return fleet.ErrMissingLicense
|
||||
}
|
||||
|
||||
// ContinuousAutomationsEnabled is premium-only.
|
||||
if policy.ContinuousAutomationsEnabled && !license.IsPremium(ctx) {
|
||||
return fleet.ErrMissingLicense
|
||||
}
|
||||
|
||||
// Make sure any applied labels exist.
|
||||
labels := slices.Concat(policy.LabelsIncludeAny, policy.LabelsIncludeAll, policy.LabelsExcludeAny)
|
||||
if len(labels) > 0 {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -19109,6 +19109,409 @@ func (s *integrationMDMTestSuite) TestVPPPolicyAutomationLabelScopingRetrigger()
|
||||
require.Equal(t, uint(1), policy1.FailingHostCount)
|
||||
}
|
||||
|
||||
// TestPolicyAutomationsContinuousVPPApp mirrors
|
||||
// TestPolicyAutomationsContinuousSoftwareInstaller but for a VPP app
|
||||
// automation: continuous_automations_enabled=true must re-trigger an
|
||||
// install on every failing policy result (not only on pass→fail), and
|
||||
// passing results must never trigger an install.
|
||||
func (s *integrationMDMTestSuite) TestPolicyAutomationsContinuousVPPApp() {
|
||||
t := s.T()
|
||||
ctx := context.Background()
|
||||
|
||||
// VPP token setup.
|
||||
orgName := "Fleet Device Management Inc."
|
||||
token := "mycooltoken"
|
||||
expTime := time.Now().Add(200 * time.Hour).UTC().Round(time.Second)
|
||||
expDate := expTime.Format(fleet.VPPTimeFormat)
|
||||
tokenJSON := fmt.Sprintf(`{"expDate":"%s","token":"%s","orgName":"%s"}`, expDate, token, orgName)
|
||||
dev_mode.SetOverride("FLEET_DEV_VPP_URL", s.appleVPPConfigSrv.URL, t)
|
||||
var validToken uploadVPPTokenResponse
|
||||
s.uploadDataViaForm("/api/latest/fleet/vpp_tokens", "token", "token.vpptoken",
|
||||
[]byte(base64.StdEncoding.EncodeToString([]byte(tokenJSON))),
|
||||
http.StatusAccepted, "", &validToken)
|
||||
|
||||
var resp getVPPTokensResponse
|
||||
s.DoJSON("GET", "/api/latest/fleet/vpp_tokens", &getVPPTokensRequest{}, http.StatusOK, &resp)
|
||||
require.NoError(t, resp.Err)
|
||||
|
||||
// Team and MDM-enrolled host.
|
||||
var newTeamResp teamResponse
|
||||
s.DoJSON("POST", "/api/latest/fleet/teams", &createTeamRequest{TeamPayload: fleet.TeamPayload{Name: new(t.Name())}}, http.StatusOK, &newTeamResp)
|
||||
team := newTeamResp.Team
|
||||
|
||||
mdmHost, mdmDevice := createHostThenEnrollMDM(s.ds, s.server.URL, t)
|
||||
setOrbitEnrollment(t, mdmHost, s.ds)
|
||||
s.awaitRunAppleMDMWorkerSchedule()
|
||||
s.runWorker()
|
||||
checkInstallFleetdCommandSent(t, mdmDevice, true)
|
||||
s.appleVPPConfigSrvConfig.SerialNumbers = append(s.appleVPPConfigSrvConfig.SerialNumbers, mdmHost.HardwareSerial)
|
||||
s.Do("POST", "/api/latest/fleet/hosts/transfer",
|
||||
&addHostsToTeamRequest{HostIDs: []uint{mdmHost.ID}, TeamID: &team.ID}, http.StatusOK)
|
||||
|
||||
var resPatchVPP patchVPPTokensTeamsResponse
|
||||
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/vpp_tokens/%d/teams", resp.Tokens[0].ID),
|
||||
patchVPPTokensTeamsRequest{TeamIDs: []uint{team.ID}}, http.StatusOK, &resPatchVPP)
|
||||
|
||||
// Pick two macOS VPP apps and add them to the team. Each policy gets its
|
||||
// own VPP app: a queued VPP install blocks further queueing for the same
|
||||
// adam_id (MapAdamIDsPendingInstall gate), so sharing one app between the
|
||||
// two policies would hide the behavior being tested.
|
||||
var appResp getAppStoreAppsResponse
|
||||
s.DoJSON("GET", "/api/latest/fleet/software/app_store_apps", &getAppStoreAppsRequest{}, http.StatusOK, &appResp, "team_id", fmt.Sprint(team.ID))
|
||||
require.NoError(t, appResp.Err)
|
||||
var macOSApps []*fleet.VPPApp
|
||||
for _, app := range appResp.AppStoreApps {
|
||||
if app.Platform == fleet.MacOSPlatform {
|
||||
macOSApps = append(macOSApps, app)
|
||||
}
|
||||
}
|
||||
require.GreaterOrEqual(t, len(macOSApps), 2, "expected at least two macOS VPP apps in the mock catalog")
|
||||
continuousApp, transitionApp := macOSApps[0], macOSApps[1]
|
||||
|
||||
addTitleID := func(app *fleet.VPPApp) uint {
|
||||
var addAppResp addAppStoreAppResponse
|
||||
s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{
|
||||
TeamID: &team.ID,
|
||||
Platform: app.Platform,
|
||||
AppStoreID: app.AdamID,
|
||||
}, http.StatusOK, &addAppResp)
|
||||
var listSw listSoftwareTitlesResponse
|
||||
s.DoJSON("GET", "/api/latest/fleet/software/titles", nil, http.StatusOK, &listSw,
|
||||
"team_id", fmt.Sprint(team.ID),
|
||||
"available_for_install", "true",
|
||||
"query", app.Name,
|
||||
)
|
||||
require.NotEmpty(t, listSw.SoftwareTitles)
|
||||
require.NotNil(t, listSw.SoftwareTitles[0].AppStoreApp)
|
||||
return listSw.SoftwareTitles[0].ID
|
||||
}
|
||||
continuousTitleID := addTitleID(continuousApp)
|
||||
transitionTitleID := addTitleID(transitionApp)
|
||||
|
||||
// Two policies attached to the same VPP app: one continuous, one default.
|
||||
continuousPolicy, err := s.ds.NewTeamPolicy(ctx, team.ID, nil, fleet.PolicyPayload{
|
||||
Name: "continuous",
|
||||
Query: "SELECT 1 FROM osquery_info WHERE start_time < 0;",
|
||||
Platform: "darwin",
|
||||
ContinuousAutomationsEnabled: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
transitionPolicy, err := s.ds.NewTeamPolicy(ctx, team.ID, nil, fleet.PolicyPayload{
|
||||
Name: "transition-only",
|
||||
Query: "SELECT 1 FROM osquery_info WHERE start_time < 0;",
|
||||
Platform: "darwin",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
attach := func(policyID, titleID uint) {
|
||||
var mtplr fleet.ModifyTeamPolicyResponse
|
||||
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team.ID, policyID), fleet.ModifyTeamPolicyRequest{
|
||||
ModifyPolicyPayload: fleet.ModifyPolicyPayload{
|
||||
SoftwareTitleID: optjson.Any[uint]{Set: true, Valid: true, Value: titleID},
|
||||
},
|
||||
}, http.StatusOK, &mtplr)
|
||||
}
|
||||
attach(continuousPolicy.ID, continuousTitleID)
|
||||
attach(transitionPolicy.ID, transitionTitleID)
|
||||
|
||||
submitPolicyResult := func(policyID uint, passes bool) {
|
||||
var distributedResp submitDistributedQueryResultsResponse
|
||||
s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults(
|
||||
mdmHost,
|
||||
map[uint]*bool{policyID: new(passes)},
|
||||
), http.StatusOK, &distributedResp)
|
||||
}
|
||||
|
||||
// host_vpp_software_installs grows by one row per queued install; a queued
|
||||
// install also blocks further queueing for the same adam_id until the MDM
|
||||
// install command is acknowledged.
|
||||
countInstallsFor := func(policyID uint) int {
|
||||
var count int
|
||||
mysqltest.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
|
||||
return sqlx.GetContext(ctx, q, &count, `
|
||||
SELECT COUNT(*) FROM host_vpp_software_installs
|
||||
WHERE host_id = ? AND policy_id = ?
|
||||
`, mdmHost.ID, policyID)
|
||||
})
|
||||
return count
|
||||
}
|
||||
|
||||
completeVPPInstall := func() {
|
||||
s.awaitRunAppleMDMWorkerSchedule()
|
||||
s.runWorker()
|
||||
// First drain: acknowledge the InstallApplication command.
|
||||
cmd, err := mdmDevice.Idle()
|
||||
require.NoError(t, err)
|
||||
for cmd != nil {
|
||||
switch cmd.Command.RequestType {
|
||||
case "InstallApplication":
|
||||
cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID)
|
||||
require.NoError(t, err)
|
||||
case "InstalledApplicationList":
|
||||
cmd, err = mdmDevice.AcknowledgeInstalledApplicationList(mdmDevice.UUID, cmd.CommandUUID, []fleet.Software{
|
||||
{Name: continuousApp.Name, BundleIdentifier: continuousApp.BundleIdentifier, Version: continuousApp.LatestVersion, Installed: true},
|
||||
{Name: transitionApp.Name, BundleIdentifier: transitionApp.BundleIdentifier, Version: transitionApp.LatestVersion, Installed: true},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
default:
|
||||
require.Fail(t, "unexpected command type", cmd.Command.RequestType)
|
||||
}
|
||||
}
|
||||
// Second drain: the post-install InstalledApplicationList verification
|
||||
// command is queued by a worker after the Acknowledge above, so we have
|
||||
// to runWorker again and re-Idle to pick it up.
|
||||
s.runWorker()
|
||||
cmd, err = mdmDevice.Idle()
|
||||
require.NoError(t, err)
|
||||
for cmd != nil {
|
||||
switch cmd.Command.RequestType {
|
||||
case "InstalledApplicationList":
|
||||
cmd, err = mdmDevice.AcknowledgeInstalledApplicationList(mdmDevice.UUID, cmd.CommandUUID, []fleet.Software{
|
||||
{Name: continuousApp.Name, BundleIdentifier: continuousApp.BundleIdentifier, Version: continuousApp.LatestVersion, Installed: true},
|
||||
{Name: transitionApp.Name, BundleIdentifier: transitionApp.BundleIdentifier, Version: transitionApp.LatestVersion, Installed: true},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
default:
|
||||
require.Fail(t, "unexpected command type", cmd.Command.RequestType)
|
||||
}
|
||||
}
|
||||
s.runWorker()
|
||||
}
|
||||
|
||||
step := func(policyID uint, wantCount int, countFn func() int, msg string) {
|
||||
t.Helper()
|
||||
submitPolicyResult(policyID, false)
|
||||
require.EventuallyWithT(t, func(t *assert.CollectT) {
|
||||
assert.Equal(t, wantCount, countFn(), msg)
|
||||
}, 5*time.Second, 100*time.Millisecond)
|
||||
completeVPPInstall()
|
||||
}
|
||||
|
||||
continuousCount := func() int { return countInstallsFor(continuousPolicy.ID) }
|
||||
transitionCount := func() int { return countInstallsFor(transitionPolicy.ID) }
|
||||
|
||||
// First failing result: pass→fail transition queues an install on both.
|
||||
step(continuousPolicy.ID, 1, continuousCount, "first install for continuous policy")
|
||||
step(transitionPolicy.ID, 1, transitionCount, "first install for transition policy")
|
||||
|
||||
// Second failing result (fail→fail): only the continuous policy re-queues.
|
||||
step(continuousPolicy.ID, 2, continuousCount, "continuous policy must fire on every failing result")
|
||||
submitPolicyResult(transitionPolicy.ID, false)
|
||||
require.Never(t, func() bool {
|
||||
return transitionCount() != 1
|
||||
}, 2*time.Second, 100*time.Millisecond, "default policy must not re-trigger on fail→fail")
|
||||
|
||||
// Third failing result: continuous still re-triggers, default still does not.
|
||||
step(continuousPolicy.ID, 3, continuousCount, "continuous policy must fire on every failing result")
|
||||
submitPolicyResult(transitionPolicy.ID, false)
|
||||
require.Never(t, func() bool {
|
||||
return transitionCount() != 1
|
||||
}, 2*time.Second, 100*time.Millisecond)
|
||||
|
||||
// Final: passing results never trigger an install, regardless of mode.
|
||||
continuousBefore := continuousCount()
|
||||
transitionBefore := transitionCount()
|
||||
submitPolicyResult(continuousPolicy.ID, true)
|
||||
submitPolicyResult(transitionPolicy.ID, true)
|
||||
require.Never(t, func() bool {
|
||||
return continuousCount() != continuousBefore
|
||||
}, 2*time.Second, 100*time.Millisecond, "continuous policy must not trigger install on passing result")
|
||||
require.Never(t, func() bool {
|
||||
return transitionCount() != transitionBefore
|
||||
}, 2*time.Second, 100*time.Millisecond, "transition policy must not trigger install on passing result")
|
||||
}
|
||||
|
||||
// TestPolicyAutomationsContinuousVPPAppRetryReset is the VPP analog of the
|
||||
// script and software-installer retry-reset tests, but the assertion is
|
||||
// different. VPP retry tracking is per-row (host_vpp_software_installs.retry_count
|
||||
// is bumped in-place by RetryVPPInstall, gated against MaxSoftwareInstallAttempts
|
||||
// on that single row), so a continuous re-fire produces a *brand new* row whose
|
||||
// retry_count starts at 0 — no reset of the old row is needed for the new one to
|
||||
// have a fresh retry budget. This test pins that behavior so future refactors
|
||||
// don't quietly break it.
|
||||
func (s *integrationMDMTestSuite) TestPolicyAutomationsContinuousVPPAppRetryReset() {
|
||||
t := s.T()
|
||||
ctx := context.Background()
|
||||
|
||||
// VPP token setup.
|
||||
orgName := "Fleet Device Management Inc."
|
||||
token := "mycooltoken"
|
||||
expTime := time.Now().Add(200 * time.Hour).UTC().Round(time.Second)
|
||||
expDate := expTime.Format(fleet.VPPTimeFormat)
|
||||
tokenJSON := fmt.Sprintf(`{"expDate":"%s","token":"%s","orgName":"%s"}`, expDate, token, orgName)
|
||||
dev_mode.SetOverride("FLEET_DEV_VPP_URL", s.appleVPPConfigSrv.URL, t)
|
||||
var validToken uploadVPPTokenResponse
|
||||
s.uploadDataViaForm("/api/latest/fleet/vpp_tokens", "token", "token.vpptoken",
|
||||
[]byte(base64.StdEncoding.EncodeToString([]byte(tokenJSON))),
|
||||
http.StatusAccepted, "", &validToken)
|
||||
|
||||
var resp getVPPTokensResponse
|
||||
s.DoJSON("GET", "/api/latest/fleet/vpp_tokens", &getVPPTokensRequest{}, http.StatusOK, &resp)
|
||||
require.NoError(t, resp.Err)
|
||||
|
||||
var newTeamResp teamResponse
|
||||
s.DoJSON("POST", "/api/latest/fleet/teams", &createTeamRequest{TeamPayload: fleet.TeamPayload{Name: new(t.Name())}}, http.StatusOK, &newTeamResp)
|
||||
team := newTeamResp.Team
|
||||
|
||||
mdmHost, mdmDevice := createHostThenEnrollMDM(s.ds, s.server.URL, t)
|
||||
setOrbitEnrollment(t, mdmHost, s.ds)
|
||||
s.awaitRunAppleMDMWorkerSchedule()
|
||||
s.runWorker()
|
||||
checkInstallFleetdCommandSent(t, mdmDevice, true)
|
||||
s.appleVPPConfigSrvConfig.SerialNumbers = append(s.appleVPPConfigSrvConfig.SerialNumbers, mdmHost.HardwareSerial)
|
||||
s.Do("POST", "/api/latest/fleet/hosts/transfer",
|
||||
&addHostsToTeamRequest{HostIDs: []uint{mdmHost.ID}, TeamID: &team.ID}, http.StatusOK)
|
||||
|
||||
var resPatchVPP patchVPPTokensTeamsResponse
|
||||
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/vpp_tokens/%d/teams", resp.Tokens[0].ID),
|
||||
patchVPPTokensTeamsRequest{TeamIDs: []uint{team.ID}}, http.StatusOK, &resPatchVPP)
|
||||
|
||||
// Pick a macOS VPP app and add it to the team.
|
||||
var appResp getAppStoreAppsResponse
|
||||
s.DoJSON("GET", "/api/latest/fleet/software/app_store_apps", &getAppStoreAppsRequest{}, http.StatusOK, &appResp, "team_id", fmt.Sprint(team.ID))
|
||||
require.NoError(t, appResp.Err)
|
||||
var addedApp *fleet.VPPApp
|
||||
for _, app := range appResp.AppStoreApps {
|
||||
if app.Platform == fleet.MacOSPlatform {
|
||||
addedApp = app
|
||||
break
|
||||
}
|
||||
}
|
||||
require.NotNil(t, addedApp)
|
||||
|
||||
var addAppResp addAppStoreAppResponse
|
||||
s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{
|
||||
TeamID: &team.ID,
|
||||
Platform: addedApp.Platform,
|
||||
AppStoreID: addedApp.AdamID,
|
||||
}, http.StatusOK, &addAppResp)
|
||||
|
||||
var listSw listSoftwareTitlesResponse
|
||||
s.DoJSON("GET", "/api/latest/fleet/software/titles", nil, http.StatusOK, &listSw,
|
||||
"team_id", fmt.Sprint(team.ID),
|
||||
"available_for_install", "true",
|
||||
"query", addedApp.Name,
|
||||
)
|
||||
require.Len(t, listSw.SoftwareTitles, 1)
|
||||
require.NotNil(t, listSw.SoftwareTitles[0].AppStoreApp)
|
||||
vppTitleID := listSw.SoftwareTitles[0].ID
|
||||
|
||||
policy, err := s.ds.NewTeamPolicy(ctx, team.ID, nil, fleet.PolicyPayload{
|
||||
Name: "continuous-vpp-retry-reset",
|
||||
Query: "SELECT 1 FROM osquery_info WHERE start_time < 0;",
|
||||
Platform: "darwin",
|
||||
ContinuousAutomationsEnabled: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
var mtplr fleet.ModifyTeamPolicyResponse
|
||||
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team.ID, policy.ID), fleet.ModifyTeamPolicyRequest{
|
||||
ModifyPolicyPayload: fleet.ModifyPolicyPayload{
|
||||
SoftwareTitleID: optjson.Any[uint]{Set: true, Valid: true, Value: vppTitleID},
|
||||
},
|
||||
}, http.StatusOK, &mtplr)
|
||||
|
||||
submitPolicyResult := func(passes bool) {
|
||||
var distributedResp submitDistributedQueryResultsResponse
|
||||
s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults(
|
||||
mdmHost,
|
||||
map[uint]*bool{policy.ID: new(passes)},
|
||||
), http.StatusOK, &distributedResp)
|
||||
}
|
||||
|
||||
// errorOnInstallApplicationCommand drains the MDM queue for an
|
||||
// InstallApplication command and responds with an MDM error. apple_mdm.go's
|
||||
// command-result handler reacts to that by calling RetryVPPInstall (in place
|
||||
// on the same host_vpp_software_installs row, incrementing retry_count) up
|
||||
// to MaxSoftwareInstallAttempts.
|
||||
errorOnInstallApplicationCommand := func() {
|
||||
s.awaitRunAppleMDMWorkerSchedule()
|
||||
s.runWorker()
|
||||
cmd, err := mdmDevice.Idle()
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cmd, "expected an InstallApplication command on the MDM queue")
|
||||
for cmd != nil {
|
||||
switch cmd.Command.RequestType {
|
||||
case "InstallApplication":
|
||||
cmd, err = mdmDevice.Err(cmd.CommandUUID, []mdm.ErrorChain{{ErrorCode: 1234}})
|
||||
require.NoError(t, err)
|
||||
default:
|
||||
cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
}
|
||||
s.runWorker()
|
||||
}
|
||||
|
||||
type vppRow struct {
|
||||
ID uint `db:"id"`
|
||||
CommandUUID string `db:"command_uuid"`
|
||||
RetryCount int `db:"retry_count"`
|
||||
}
|
||||
listVPPRows := func() []vppRow {
|
||||
var rows []vppRow
|
||||
mysqltest.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
|
||||
return sqlx.SelectContext(ctx, q, &rows, `
|
||||
SELECT id, command_uuid, retry_count
|
||||
FROM host_vpp_software_installs
|
||||
WHERE host_id = ? AND policy_id = ?
|
||||
ORDER BY id ASC
|
||||
`, mdmHost.ID, policy.ID)
|
||||
})
|
||||
return rows
|
||||
}
|
||||
|
||||
// Phase 1: pass→fail queues a single row. Fail it through every retry until
|
||||
// the per-row cap is reached. Each RetryVPPInstall reuses the same row and
|
||||
// gives it a new command_uuid, so we still have exactly one row at the end.
|
||||
// The gate is retry_count < MaxSoftwareInstallAttempts, so retries fire at
|
||||
// retry_count 0,1,2 (→ ends at MaxSoftwareInstallAttempts); the final
|
||||
// failure at the cap produces no further retry. That's
|
||||
// MaxSoftwareInstallAttempts+1 failures total.
|
||||
submitPolicyResult(false)
|
||||
for i := 0; i <= fleet.MaxSoftwareInstallAttempts; i++ {
|
||||
errorOnInstallApplicationCommand()
|
||||
}
|
||||
rows := listVPPRows()
|
||||
require.Len(t, rows, 1, "single row tracks retry_count in place")
|
||||
require.Equal(t, fleet.MaxSoftwareInstallAttempts, rows[0].RetryCount,
|
||||
"retry_count incremented on each RetryVPPInstall until it reaches MaxSoftwareInstallAttempts")
|
||||
firstRowID := rows[0].ID
|
||||
|
||||
// No more retries should fire once the cap is reached, even though the
|
||||
// install never succeeded.
|
||||
require.Never(t, func() bool {
|
||||
r := listVPPRows()
|
||||
return len(r) != 1 || r[0].ID != firstRowID || r[0].RetryCount != fleet.MaxSoftwareInstallAttempts
|
||||
}, 2*time.Second, 100*time.Millisecond, "no further VPP retries after cap is hit")
|
||||
|
||||
// Phase 2: continuous re-fire. processVPPForNewlyFailingPolicies should
|
||||
// queue a brand-new install, producing a *second* row in
|
||||
// host_vpp_software_installs with retry_count = 0 (fresh budget).
|
||||
submitPolicyResult(false)
|
||||
require.EventuallyWithT(t, func(t *assert.CollectT) {
|
||||
r := listVPPRows()
|
||||
if !assert.Len(t, r, 2, "continuous re-fire should insert a new row") {
|
||||
return
|
||||
}
|
||||
assert.Equal(t, firstRowID, r[0].ID)
|
||||
assert.Equal(t, fleet.MaxSoftwareInstallAttempts, r[0].RetryCount, "old row's retry_count is left untouched")
|
||||
assert.NotEqual(t, firstRowID, r[1].ID, "second row is a brand new install")
|
||||
assert.Equal(t, 0, r[1].RetryCount, "new row starts with retry_count = 0 (fresh budget)")
|
||||
}, 5*time.Second, 100*time.Millisecond)
|
||||
|
||||
// And the new row's retry budget is genuinely fresh — failing its first
|
||||
// InstallApplication should trigger RetryVPPInstall, taking retry_count to 1.
|
||||
errorOnInstallApplicationCommand()
|
||||
require.EventuallyWithT(t, func(t *assert.CollectT) {
|
||||
r := listVPPRows()
|
||||
if !assert.Len(t, r, 2) {
|
||||
return
|
||||
}
|
||||
assert.Equal(t, 1, r[1].RetryCount, "new row's retries are eligible (now at 1)")
|
||||
}, 5*time.Second, 100*time.Millisecond)
|
||||
}
|
||||
|
||||
// registerResetVPPProxyData resets the VPP proxy data after tests in `t` complete.
|
||||
func (s *integrationMDMTestSuite) registerResetVPPProxyData(t *testing.T) {
|
||||
oldApps := s.appleVPPProxySrvData
|
||||
|
||||
@@ -2021,10 +2021,12 @@ func (svc *Service) processSoftwareForNewlyFailingPolicies(
|
||||
return nil
|
||||
}
|
||||
|
||||
// Filter to policies with installers that are newly failing, using the pre-computed set.
|
||||
// Filter to policies with installers that are newly failing, or that have
|
||||
// continuous_automations_enabled set (in which case every failing result
|
||||
// triggers an install, not just pass→fail transitions).
|
||||
var failingPoliciesWithInstaller []fleet.PolicySoftwareInstallerData
|
||||
for _, policyWithInstaller := range policiesWithInstaller {
|
||||
if _, ok := newFailingSet[policyWithInstaller.ID]; ok {
|
||||
if _, ok := newFailingSet[policyWithInstaller.ID]; ok || policyWithInstaller.ContinuousAutomationsEnabled {
|
||||
failingPoliciesWithInstaller = append(failingPoliciesWithInstaller, policyWithInstaller)
|
||||
}
|
||||
}
|
||||
@@ -2075,6 +2077,18 @@ func (svc *Service) processSoftwareForNewlyFailingPolicies(
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
// On a continuous re-fire (policy still failing), reset prior
|
||||
// attempt_number values for this host/policy to 0 so the new attempt
|
||||
// restarts the retry sequence at 1 instead of inheriting the cap from
|
||||
// the previous sequence. A no-op on pass→fail transitions (those rows
|
||||
// are already at 0 from the prior fail→pass reset).
|
||||
if failingPolicyWithInstaller.ContinuousAutomationsEnabled {
|
||||
if err := svc.ds.ResetPolicyAutomationRetryAttemptsForHost(ctx, hostID, []uint{policyID}); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "reset policy automation retry attempts for host")
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE(lucas): The user_id set in this software install will be NULL
|
||||
// so this means that when generating the activity for this action
|
||||
// (in SaveHostSoftwareInstallResult) the author will be set to Fleet.
|
||||
@@ -2135,10 +2149,12 @@ func (svc *Service) processVPPForNewlyFailingPolicies(
|
||||
return nil
|
||||
}
|
||||
|
||||
// Filter to policies with VPP apps that are newly failing, using the pre-computed set.
|
||||
// Filter to policies with VPP apps that are newly failing, or that have
|
||||
// continuous_automations_enabled set (in which case every failing result
|
||||
// triggers an install, not just pass→fail transitions).
|
||||
var failingPoliciesWithVPP []fleet.PolicyVPPData
|
||||
for _, policyWithVPP := range policiesWithVPP {
|
||||
if _, ok := newFailingSet[policyWithVPP.ID]; ok {
|
||||
if _, ok := newFailingSet[policyWithVPP.ID]; ok || policyWithVPP.ContinuousAutomationsEnabled {
|
||||
failingPoliciesWithVPP = append(failingPoliciesWithVPP, policyWithVPP)
|
||||
}
|
||||
}
|
||||
@@ -2270,10 +2286,12 @@ func (svc *Service) processScriptsForNewlyFailingPolicies(
|
||||
return nil
|
||||
}
|
||||
|
||||
// Filter to policies with scripts that are newly failing, using the pre-computed set.
|
||||
// Filter to policies with scripts that are newly failing, or that have
|
||||
// continuous_automations_enabled set (in which case every failing result
|
||||
// triggers a script run, not just pass→fail transitions).
|
||||
var failingPoliciesWithScript []fleet.PolicyScriptData
|
||||
for _, policyWithScript := range policiesWithScript {
|
||||
if _, ok := newFailingSet[policyWithScript.ID]; ok {
|
||||
if _, ok := newFailingSet[policyWithScript.ID]; ok || policyWithScript.ContinuousAutomationsEnabled {
|
||||
failingPoliciesWithScript = append(failingPoliciesWithScript, policyWithScript)
|
||||
}
|
||||
}
|
||||
@@ -2332,6 +2350,17 @@ func (svc *Service) processScriptsForNewlyFailingPolicies(
|
||||
continue
|
||||
}
|
||||
|
||||
// On a continuous re-fire (policy still failing), reset prior
|
||||
// attempt_number values for this host/policy to 0 so the new attempt
|
||||
// restarts the retry sequence at 1 instead of inheriting the cap from
|
||||
// the previous sequence. A no-op on pass→fail transitions (those rows
|
||||
// are already at 0 from the prior fail→pass reset).
|
||||
if failingPolicyWithScript.ContinuousAutomationsEnabled {
|
||||
if err := svc.ds.ResetPolicyAutomationRetryAttemptsForHost(ctx, hostID, []uint{policyID}); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "reset policy automation retry attempts for host")
|
||||
}
|
||||
}
|
||||
|
||||
contents, err := svc.ds.GetScriptContents(ctx, scriptMetadata.ID)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "get script contents")
|
||||
|
||||
@@ -24,22 +24,23 @@ import (
|
||||
func teamPolicyEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
||||
req := request.(*fleet.TeamPolicyRequest)
|
||||
resp, err := svc.NewTeamPolicy(ctx, req.TeamID, fleet.NewTeamPolicyPayload{
|
||||
QueryID: req.QueryID,
|
||||
Name: req.Name,
|
||||
Query: req.Query,
|
||||
Description: req.Description,
|
||||
Resolution: req.Resolution,
|
||||
Platform: req.Platform,
|
||||
Critical: req.Critical,
|
||||
CalendarEventsEnabled: req.CalendarEventsEnabled,
|
||||
SoftwareTitleID: req.SoftwareTitleID,
|
||||
ScriptID: req.ScriptID,
|
||||
LabelsIncludeAny: req.LabelsIncludeAny,
|
||||
LabelsIncludeAll: req.LabelsIncludeAll,
|
||||
LabelsExcludeAny: req.LabelsExcludeAny,
|
||||
ConditionalAccessEnabled: req.ConditionalAccessEnabled,
|
||||
Type: req.Type,
|
||||
PatchSoftwareTitleID: req.PatchSoftwareTitleID,
|
||||
QueryID: req.QueryID,
|
||||
Name: req.Name,
|
||||
Query: req.Query,
|
||||
Description: req.Description,
|
||||
Resolution: req.Resolution,
|
||||
Platform: req.Platform,
|
||||
Critical: req.Critical,
|
||||
CalendarEventsEnabled: req.CalendarEventsEnabled,
|
||||
SoftwareTitleID: req.SoftwareTitleID,
|
||||
ScriptID: req.ScriptID,
|
||||
LabelsIncludeAny: req.LabelsIncludeAny,
|
||||
LabelsIncludeAll: req.LabelsIncludeAll,
|
||||
LabelsExcludeAny: req.LabelsExcludeAny,
|
||||
ConditionalAccessEnabled: req.ConditionalAccessEnabled,
|
||||
ContinuousAutomationsEnabled: req.ContinuousAutomationsEnabled,
|
||||
Type: req.Type,
|
||||
PatchSoftwareTitleID: req.PatchSoftwareTitleID,
|
||||
})
|
||||
if err != nil {
|
||||
return fleet.TeamPolicyResponse{Err: err}, nil
|
||||
@@ -215,23 +216,24 @@ func (svc *Service) newTeamPolicyPayloadToPolicyPayload(ctx context.Context, tea
|
||||
return fleet.PolicyPayload{}, err
|
||||
}
|
||||
return fleet.PolicyPayload{
|
||||
QueryID: p.QueryID,
|
||||
Name: p.Name,
|
||||
Query: p.Query,
|
||||
Critical: p.Critical,
|
||||
Description: p.Description,
|
||||
Resolution: p.Resolution,
|
||||
Platform: p.Platform,
|
||||
CalendarEventsEnabled: p.CalendarEventsEnabled,
|
||||
SoftwareInstallerID: softwareInstallerID,
|
||||
VPPAppsTeamsID: vppAppsTeamsID,
|
||||
ScriptID: p.ScriptID,
|
||||
LabelsIncludeAny: p.LabelsIncludeAny,
|
||||
LabelsIncludeAll: p.LabelsIncludeAll,
|
||||
LabelsExcludeAny: p.LabelsExcludeAny,
|
||||
ConditionalAccessEnabled: p.ConditionalAccessEnabled,
|
||||
Type: policyType,
|
||||
PatchSoftwareTitleID: p.PatchSoftwareTitleID,
|
||||
QueryID: p.QueryID,
|
||||
Name: p.Name,
|
||||
Query: p.Query,
|
||||
Critical: p.Critical,
|
||||
Description: p.Description,
|
||||
Resolution: p.Resolution,
|
||||
Platform: p.Platform,
|
||||
CalendarEventsEnabled: p.CalendarEventsEnabled,
|
||||
SoftwareInstallerID: softwareInstallerID,
|
||||
VPPAppsTeamsID: vppAppsTeamsID,
|
||||
ScriptID: p.ScriptID,
|
||||
LabelsIncludeAny: p.LabelsIncludeAny,
|
||||
LabelsIncludeAll: p.LabelsIncludeAll,
|
||||
LabelsExcludeAny: p.LabelsExcludeAny,
|
||||
ConditionalAccessEnabled: p.ConditionalAccessEnabled,
|
||||
ContinuousAutomationsEnabled: p.ContinuousAutomationsEnabled,
|
||||
Type: policyType,
|
||||
PatchSoftwareTitleID: p.PatchSoftwareTitleID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -528,6 +530,12 @@ func (svc *Service) modifyPolicy(ctx context.Context, teamID *uint, id uint, p f
|
||||
})
|
||||
}
|
||||
|
||||
if p.ContinuousAutomationsEnabled != nil && *p.ContinuousAutomationsEnabled && teamID == nil {
|
||||
return nil, ctxerr.Wrap(ctx, &fleet.BadRequestError{
|
||||
Message: fmt.Sprintf(`policy payload verification: %s`, errPolicyAllFleetsForContinuousAutomations),
|
||||
})
|
||||
}
|
||||
|
||||
p.Type = policy.Type
|
||||
if err := p.Verify(); err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, &fleet.BadRequestError{
|
||||
@@ -576,6 +584,9 @@ func (svc *Service) modifyPolicy(ctx context.Context, teamID *uint, id uint, p f
|
||||
if p.ConditionalAccessEnabled != nil {
|
||||
policy.ConditionalAccessEnabled = *p.ConditionalAccessEnabled
|
||||
}
|
||||
if p.ContinuousAutomationsEnabled != nil {
|
||||
policy.ContinuousAutomationsEnabled = *p.ContinuousAutomationsEnabled
|
||||
}
|
||||
if removeStats {
|
||||
policy.FailingHostCount = 0
|
||||
policy.PassingHostCount = 0
|
||||
|
||||
@@ -129,6 +129,7 @@ func TestTriggerFailingPoliciesWebhookBasic(t *testing.T) {
|
||||
"critical": true,
|
||||
"calendar_events_enabled": false,
|
||||
"conditional_access_enabled": false,
|
||||
"continuous_automations_enabled": false,
|
||||
"type": "dynamic"
|
||||
},
|
||||
"hosts": [
|
||||
@@ -322,6 +323,7 @@ func TestTriggerFailingPoliciesWebhookTeam(t *testing.T) {
|
||||
"critical": false,
|
||||
"calendar_events_enabled": true,
|
||||
"conditional_access_enabled": false,
|
||||
"continuous_automations_enabled": false,
|
||||
"type": "dynamic"
|
||||
},
|
||||
"hosts": [
|
||||
|
||||
Reference in New Issue
Block a user