Repository: fleetdm/fleet (Go)

Issue:
Adding policies with duplicate names (but different teams) via CLI fails, but says it worked
Assigned to Tim.


**Fleet version**: 4.33.1

**Operating system**: macOS 13.4.1

<hr/>

### 🧑‍💻  Expected behavior
<!-- What did you do?  What did you expect to see? -->
When applying the following policies config...
```yaml
---
apiVersion: v1
kind: policy
spec:
  name: TESTING ADDING POLICIES VIA CLI
  platform: darwin,windows
  description: Test!!!!
  resolution: ¯\_(ツ)_/¯
  query: SELECT 1 FROM osquery_info WHERE 1=1;
  contributors: rachaelshaw
  team: Test team 1
---
apiVersion: v1
kind: policy
spec:
  name: TESTING ADDING POLICIES VIA CLI
  platform: darwin,windows
  description: Test!!!!
  resolution: ¯\_(ツ)_/¯
  query: SELECT 1 FROM osquery_info WHERE 1=1;
  contributors: rachaelshaw
  team: Test team 2
```

- `fleetctl` should report an error when attempting to change the group of a policy
- `fleetctl` should report an error when duplicate policy names are in the yml

~~So when I view the policies for the "Test team 1" and "Test team 2" teams, I expect to see a policy called "TESTING ADDING POLICIES VIA CLI" in both places.~~

### 💥  Actual behavior
<!-- What did you see instead? -->

I get the following output from the CLI:
![Screenshot 2023-06-27 at 7 22 03 PM](https://github.com/fleetdm/fleet/assets/3065949/5b511af9-80f6-4718-8915-f359cda5a52f)

A policy is added to whichever team is on the 1st policy with that name, but not the 2nd, even though it says both were applied.

### 👣  Reproduction steps
<!-- What step-by-step actions did you take? -->
This config is pretty handy for illustrating the problem on dogfood:
```
---
apiVersion: v1
kind: policy
spec:
  name: TESTING ADDING POLICIES VIA CLI
  platform: darwin,windows
  description: Test!!!!
  resolution: ¯\_(ツ)_/¯
  query: SELECT 1 FROM osquery_info WHERE 1=1;
  contributors: rachaelshaw
  team: Test team 1
---
apiVersion: v1
kind: policy
spec:
  name: TESTING ADDING POLICIES VIA CLI
  platform: darwin,windows
  description: Test!!!!
  resolution: ¯\_(ツ)_/¯
  query: SELECT 1 FROM osquery_info WHERE 1=1;
  contributors: rachaelshaw
  team: Test team 2
---
apiVersion: v1
kind: policy
spec:
  name: TESTING ADDING POLICIES VIA CLI 2
  platform: darwin,windows
  description: Test!!!!
  resolution: ¯\_(ツ)_/¯
  query: SELECT 1 FROM osquery_info WHERE 1=1;
  contributors: rachaelshaw
  team: Test team 2
---
apiVersion: v1
kind: policy
spec:
  name: TESTING ADDING POLICIES VIA CLI 2
  platform: darwin,windows
  description: Test!!!!
  resolution: ¯\_(ツ)_/¯
  query: SELECT 1 FROM osquery_info WHERE 1=1;
  contributors: rachaelshaw
  team: Test team 1
```
If you clear out the policies on those test teams and apply the config, the CLI will say that 4 policies were applied, but you'll only see 2 in the UI.

### Acceptance Criteria
- When a spec is applied to an existing policy that attempts to change the team, `fleetctl` will fail at that point and report an error.  
- When duplicate policy names are detected in the yml, `fleetctl` will fail immediately, not run any specs and report an error
Copy:
```
$ fleetctl apply -f /Users/rachael/code/test-fleet-config/policies.yml
Error: applying policies: policy names must be globally unique. Please check for duplicates and try again.
```

### Test Plans

##### Duplicate Names
1.  Create team (Test team 1) in Fleet UI  
2. Apply config1 (under expected behavior)
3. Assert fleetctl error
4. Assert no policies created

##### Cannot change existing policy team
1. Create team 1
2. Create team 2
3. create policy
4. attempt to change team via fleetctl
5. Assert fleetctl error

##### API reports error
1. Create Global Policy in UI
2. attempt to change team of policy via `/api/_version_/fleet/spec/policies`


Discussion: The UI does allow policies with the same name for different teams (Since the name is also the ID)
Let's match this behaviour in the ctl and send an error.

I was taking a quick look at this issue to leave pointers to @mostlikelee , but looking into this, a policy must have a unique name - you cannot have two policies with the same name, even if they target different teams:

![Screenshot from 2023-08-07 09-24-55](https://github.com/fleetdm/fleet/assets/692384/ea1a9ebc-e1f0-4228-881d-603fab5a6595)

This is enforced by a unique key constraint on `policies.name`. Given that, it appears that the cli works as expected: it creates a new policy with that name if none exists, and if a policy with that name already exists, it updates it. This should be verifiable by setting e.g. a different description to each policy (while leaving them with the same name), and applying the spec. The resulting policy in the DB should have the description of the last one applied.

The output of `fleetctl apply` where it says `applied 2 policies` is correct/consistent with other types of specs, it counts the number of specs applied which may be inserts or updates. /cc @sharon-fdm  
One thing I noticed that is arguably a bug, though, is that the team id is not updated once the policy exists by name (it is left out of the ON DUPLICATE KEY UPDATE part of the insert: https://github.com/fleetdm/fleet/blob/main/server/datastore/mysql/policies.go#L477-L495).

This could be surprising, if the team in the yaml is a different one, to end up with the other fields being updated but not the team id. 
@rachaelshaw TLDR of this proposed change will change the policy team from Team 1 to Team 2 (in the original example).  Does that work from a UX perspective?
~~@mostlikelee I think fixing that behavior makes sense if that's how it's meant to work, but would still consider the CLI output a bug: it implies there have been x number of items changed, when in reality, there have been x number of changes to an unknown number of items. 
Maybe we could update the output from `fleetctl apply` to say "applied &lt;number&gt; changes to &lt;thing&gt;" instead of just "applied &lt;number&gt; &lt;thing&gt;"?~~

~~@zhumo WDYT? Worth bringing this by tomorrow's design review?~~

(edit: just remembered we don't let you transfer a policy to a new team in the UI, makes sense to have config files behave the same)
**Decision after discussing this @ CX standup:** applying a config file with non-unique policy names will throw an error to match the behavior in the UI. If a policy's `team` changes in the YAML, it will throw an error since it is treated as a new policy (you can't change a policy's team in the UI).

When applying the config example in the description, users will instead see:
```
$ fleetctl apply -f /Users/rachael/code/test-fleet-config/policies.yml
Error: applying policies: policy names must be globally unique. Please check for duplicates and try again.
```

(cc @mostlikelee)

@xpkoala can you review for completeness?  I added a couple test plans in the description.
Putting this as three "rules":

1. If the yml has two policies with the same name --> Throw an error (As described above)
2. If the yml holds a policy with the **same name** and **same team** as an existing policy in the DB --> Update the DB with what's in the YML.
3. If the yml holds a policy with the **same name** but **DIFFERENT teams** as an existing policy in the DB --> Throw an error. ( @rachaelshaw, need an error msg for that)







Fix (patch):
diff --git a/changes/12550-add-validation-for-policy-specs b/changes/12550-add-validation-for-policy-specs
new file mode 100644
index 000000000000..aad83bcc7ce4
--- /dev/null
+++ b/changes/12550-add-validation-for-policy-specs
@@ -0,0 +1,2 @@
+- added validation to `fleetctl apply` for duplicate policy names in the yml file and attempting to change the team of an existing policy
+- added validation in `/api/latest/fleet/spec/policies` to error when attempting to change the team of an existing policy
\ No newline at end of file
diff --git a/server/datastore/mysql/policies.go b/server/datastore/mysql/policies.go
index 5ba1ac094e36..83edb7c8d34b 100644
--- a/server/datastore/mysql/policies.go
+++ b/server/datastore/mysql/policies.go
@@ -54,6 +54,26 @@ func (ds *Datastore) Policy(ctx context.Context, id uint) (*fleet.Policy, error)
 	return policyDB(ctx, ds.reader(ctx), id, nil)
 }
 
+func (ds *Datastore) PolicyByName(ctx context.Context, name string) (*fleet.Policy, error) {
+	var policy fleet.Policy
+	err := sqlx.GetContext(ctx, ds.reader(ctx), &policy,
+		fmt.Sprint(`SELECT `+policyCols+`,
+		COALESCE(u.name, '<deleted>') AS author_name,
+		COALESCE(u.email, '') AS author_email,
+		(select count(*) from policy_membership where policy_id=p.id and passes=true) as passing_host_count,
+		(select count(*) from policy_membership where policy_id=p.id and passes=false) as failing_host_count
+		FROM policies p
+		LEFT JOIN users u ON p.author_id = u.id
+		WHERE p.name=?`), name)
+	if err != nil {
+		if err == sql.ErrNoRows {
+			return nil, ctxerr.Wrap(ctx, notFound("Policy").WithName(name))
+		}
+		return nil, ctxerr.Wrap(ctx, err, "getting policy")
+	}
+	return &policy, nil
+}
+
 func policyDB(ctx context.Context, q sqlx.QueryerContext, id uint, teamID *uint) (*fleet.Policy, error) {
 	teamWhere := "TRUE"
 	args := []interface{}{id}
@@ -474,7 +494,7 @@ func (ds *Datastore) TeamPolicy(ctx context.Context, teamID uint, policyID uint)
 // Currently ApplyPolicySpecs does not allow updating the team of an existing policy.
 func (ds *Datastore) ApplyPolicySpecs(ctx context.Context, authorID uint, specs []*fleet.PolicySpec) error {
 	return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
-		sql := `
+		query := `
 		INSERT INTO policies (
 			name,
 			query,
@@ -495,8 +515,15 @@ func (ds *Datastore) ApplyPolicySpecs(ctx context.Context, authorID uint, specs
 			critical = VALUES(critical)
 		`
 		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,
-				sql, spec.Name, spec.Query, spec.Description, authorID, spec.Resolution, spec.Team, spec.Platform, spec.Critical,
+				query, spec.Name, spec.Query, spec.Description, authorID, spec.Resolution, spec.Team, spec.Platform, spec.Critical,
 			)
 			if err != nil {
 				return ctxerr.Wrap(ctx, err, "exec ApplyPolicySpecs insert")
@@ -516,6 +543,44 @@ 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`)
diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go
index 01b1276b955b..09a4991ed6e1 100644
--- a/server/fleet/datastore.go
+++ b/server/fleet/datastore.go
@@ -499,6 +499,8 @@ type Datastore interface {
 
 	NewGlobalPolicy(ctx context.Context, authorID *uint, args PolicyPayload) (*Policy, error)
 	Policy(ctx context.Context, id uint) (*Policy, error)
+	PolicyByName(ctx context.Context, name string) (*Policy, error)
+
 	// SavePolicy updates some fields of the given policy on the datastore.
 	//
 	// It is also used to update team policies.
diff --git a/server/fleet/policies.go b/server/fleet/policies.go
index 2ac25dd3899f..d3ba98e32d76 100644
--- a/server/fleet/policies.go
+++ b/server/fleet/policies.go
@@ -226,6 +226,18 @@ func (p PolicySpec) Verify() error {
 	return nil
 }
 
+// return first duplicate name of policies or empty string if no duplicates found
+func FirstDuplicatePolicySpecName(specs []*PolicySpec) string {
+	names := make(map[string]struct{})
+	for _, spec := range specs {
+		if _, ok := names[spec.Name]; ok {
+			return spec.Name
+		}
+		names[spec.Name] = struct{}{}
+	}
+	return ""
+}
+
 // FailingPolicySet holds sets of hosts that failed policy executions.
 type FailingPolicySet interface {
 	// ListSets lists all the policy sets.
diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go
index f1e10d438dee..5cecaab6ae91 100644
--- a/server/mock/datastore_mock.go
+++ b/server/mock/datastore_mock.go
@@ -368,6 +368,8 @@ type NewGlobalPolicyFunc func(ctx context.Context, authorID *uint, args fleet.Po
 
 type PolicyFunc func(ctx context.Context, id uint) (*fleet.Policy, error)
 
+type PolicyByNameFunc func(ctx context.Context, name string) (*fleet.Policy, error)
+
 type SavePolicyFunc func(ctx context.Context, p *fleet.Policy) error
 
 type ListGlobalPoliciesFunc func(ctx context.Context) ([]*fleet.Policy, error)
@@ -1188,6 +1190,9 @@ type DataStore struct {
 	PolicyFunc        PolicyFunc
 	PolicyFuncInvoked bool
 
+	PolicyByNameFunc        PolicyByNameFunc
+	PolicyByNameFuncInvoked bool
+
 	SavePolicyFunc        SavePolicyFunc
 	SavePolicyFuncInvoked bool
 
@@ -2857,6 +2862,13 @@ func (s *DataStore) Policy(ctx context.Context, id uint) (*fleet.Policy, error)
 	return s.PolicyFunc(ctx, id)
 }
 
+func (s *DataStore) PolicyByName(ctx context.Context, name string) (*fleet.Policy, error) {
+	s.mu.Lock()
+	s.PolicyByNameFuncInvoked = true
+	s.mu.Unlock()
+	return s.PolicyByNameFunc(ctx, name)
+}
+
 func (s *DataStore) SavePolicy(ctx context.Context, p *fleet.Policy) error {
 	s.mu.Lock()
 	s.SavePolicyFuncInvoked = true
diff --git a/server/service/client.go b/server/service/client.go
index 86c51a0b6460..ccccf317460b 100644
--- a/server/service/client.go
+++ b/server/service/client.go
@@ -309,6 +309,11 @@ func (c *Client) ApplyGroup(
 		if opts.DryRun {
 			logfn("[!] ignoring policies, dry run mode only supported for 'config' and 'team' specs\n")
 		} 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)
+			}
+
 			// If set, override the team in all the policies.
 			if opts.TeamForPolicies != "" {
 				for _, policySpec := range specs.Policies {
diff --git a/server/service/global_policies.go b/server/service/global_policies.go
index e2bfbc0a09ba..78dc170f2e7b 100644
--- a/server/service/global_policies.go
+++ b/server/service/global_policies.go
@@ -477,6 +477,13 @@ func (svc *Service) ApplyPolicySpecs(ctx context.Context, policies []*fleet.Poli
 		}
 	}
 
+	// An empty string indicates there are no duplicate names.
+	if name := fleet.FirstDuplicatePolicySpecName(policies); name != "" {
+		return ctxerr.Wrap(ctx, &fleet.BadRequestError{
+			Message: "duplicate policy names not allowed",
+		})
+	}
+
 	vc, ok := viewer.FromContext(ctx)
 	if !ok {
 		return errors.New("user must be authenticated to apply policies")