mirror of
https://github.com/empayre/fleet.git
synced 2024-11-06 00:45:19 +00:00
Adding full unicode/emoji support for team/policy names. (#17163)
#17027 Added Unicode and emoji support for policy and team names. I have the manual test steps in the issue: https://github.com/fleetdm/fleet/issues/17027 # Checklist for submitter - [x] Changes file added for user-visible changes in `changes/` or `orbit/changes/`. See [Changes files](https://fleetdm.com/docs/contributing/committing-changes#changes-files) for more information. - [x] Added/updated tests - [x] Manual QA for all new/changed functionality
This commit is contained in:
parent
2541574b93
commit
02de6b5695
1
changes/17027-unicode-emojis
Normal file
1
changes/17027-unicode-emojis
Normal file
@ -0,0 +1 @@
|
||||
Added Unicode and emoji support for policy and team names.
|
@ -7,6 +7,7 @@ import (
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
"github.com/ghodss/yaml"
|
||||
"github.com/hashicorp/go-multierror"
|
||||
"golang.org/x/text/unicode/norm"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
@ -105,9 +106,9 @@ func parseName(raw json.RawMessage, result *GitOps, multiError *multierror.Error
|
||||
if result.TeamName == nil || *result.TeamName == "" {
|
||||
return multierror.Append(multiError, errors.New("team 'name' is required"))
|
||||
}
|
||||
if !isASCII(*result.TeamName) {
|
||||
multiError = multierror.Append(multiError, fmt.Errorf("team name must be in ASCII: %s", *result.TeamName))
|
||||
}
|
||||
// Normalize team name for full Unicode support, so that we can assume team names are unique going forward
|
||||
normalized := norm.NFC.String(*result.TeamName)
|
||||
result.TeamName = &normalized
|
||||
return multiError
|
||||
}
|
||||
|
||||
@ -364,6 +365,8 @@ func parsePolicies(top map[string]json.RawMessage, result *GitOps, baseDir strin
|
||||
for _, item := range result.Policies {
|
||||
if item.Name == "" {
|
||||
multiError = multierror.Append(multiError, errors.New("policy name is required for each policy"))
|
||||
} else {
|
||||
item.Name = norm.NFC.String(item.Name)
|
||||
}
|
||||
if item.Query == "" {
|
||||
multiError = multierror.Append(multiError, errors.New("policy query is required for each policy"))
|
||||
|
@ -215,7 +215,7 @@ func TestUnicodeTeamName(t *testing.T) {
|
||||
config := getTeamConfig([]string{"name"})
|
||||
config += `name: 😊 TeamName`
|
||||
_, err := GitOpsFromBytes([]byte(config), "")
|
||||
assert.ErrorContains(t, err, "team name must be in ASCII")
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestMixingGlobalAndTeamConfig(t *testing.T) {
|
||||
|
@ -0,0 +1,35 @@
|
||||
package tables
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func init() {
|
||||
MigrationClient.AddMigration(Up_20240226082255, Down_20240226082255)
|
||||
}
|
||||
|
||||
func Up_20240226082255(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`ALTER TABLE teams DROP INDEX idx_name`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to drop teams idx_name: %w", err)
|
||||
}
|
||||
|
||||
// Add a new virtual name column with binary collation
|
||||
_, err = tx.Exec(`ALTER TABLE teams ADD COLUMN name_bin VARCHAR(255) COLLATE utf8mb4_bin GENERATED ALWAYS AS (name) VIRTUAL`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to add virtual column to teams: %w", err)
|
||||
}
|
||||
|
||||
// Put index on the new virtual column -- this is needed to support emojis.
|
||||
_, err = tx.Exec(`CREATE UNIQUE INDEX idx_name_bin ON teams (name_bin)`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to add idx_name to teams: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func Down_20240226082255(_ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
@ -0,0 +1,37 @@
|
||||
package tables
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"github.com/VividCortex/mysqlerr"
|
||||
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
|
||||
"github.com/go-sql-driver/mysql"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUp_20240226082255(t *testing.T) {
|
||||
db := applyUpToPrev(t)
|
||||
applyNext(t, db)
|
||||
|
||||
isDuplicate := func(err error) bool {
|
||||
err = ctxerr.Cause(err)
|
||||
var driverErr *mysql.MySQLError
|
||||
if errors.As(err, &driverErr) && driverErr.Number == mysqlerr.ER_DUP_ENTRY {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Insert 2 teams with emoji names
|
||||
_ = execNoErrLastID(t, db, "INSERT INTO teams (name) VALUES (?)", "🖥️")
|
||||
_ = execNoErrLastID(t, db, "INSERT INTO teams (name) VALUES (?)", "💿")
|
||||
// Try to insert a duplicate team name -- should error
|
||||
_, err := db.Exec("INSERT INTO teams (name) VALUES (?)", "🖥️")
|
||||
assert.True(t, isDuplicate(err))
|
||||
var count []uint
|
||||
err = db.SelectContext(context.Background(), &count, `SELECT COUNT(*) FROM teams`)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, uint(2), count[0])
|
||||
|
||||
}
|
@ -918,7 +918,7 @@ func (ds *Datastore) whereFilterGlobalOrTeamIDByTeams(filter fleet.TeamFilter, f
|
||||
// whereFilterTeams returns the appropriate condition to use in the WHERE
|
||||
// clause to render only the appropriate teams.
|
||||
//
|
||||
// filter provides the filtering parameters that should be used. hostKey is the
|
||||
// filter provides the filtering parameters that should be used. teamKey is the
|
||||
// name/alias of the teams table to use in generating the SQL.
|
||||
func (ds *Datastore) whereFilterTeams(filter fleet.TeamFilter, teamKey string) string {
|
||||
if filter.User == nil {
|
||||
|
@ -5,6 +5,7 @@ import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"golang.org/x/text/unicode/norm"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@ -33,18 +34,20 @@ func (ds *Datastore) NewGlobalPolicy(ctx context.Context, authorID *uint, args f
|
||||
args.Query = q.Query
|
||||
args.Description = q.Description
|
||||
}
|
||||
// We must normalize the name for full Unicode support (Unicode equivalence).
|
||||
nameUnicode := norm.NFC.String(args.Name)
|
||||
res, err := ds.writer(ctx).ExecContext(ctx,
|
||||
fmt.Sprintf(
|
||||
`INSERT INTO policies (name, query, description, resolution, author_id, platforms, critical, checksum) VALUES (?, ?, ?, ?, ?, ?, ?, %s)`,
|
||||
policiesChecksumComputedColumn(),
|
||||
),
|
||||
args.Name, args.Query, args.Description, args.Resolution, authorID, args.Platform, args.Critical,
|
||||
nameUnicode, args.Query, args.Description, args.Resolution, authorID, args.Platform, args.Critical,
|
||||
)
|
||||
switch {
|
||||
case err == nil:
|
||||
// OK
|
||||
case isDuplicate(err):
|
||||
return nil, ctxerr.Wrap(ctx, alreadyExists("Policy", args.Name))
|
||||
return nil, ctxerr.Wrap(ctx, alreadyExists("Policy", nameUnicode))
|
||||
default:
|
||||
return nil, ctxerr.Wrap(ctx, err, "inserting new policy")
|
||||
}
|
||||
@ -71,30 +74,6 @@ 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,
|
||||
ps.updated_at as host_count_updated_at,
|
||||
COALESCE(ps.passing_host_count, 0) as passing_host_count,
|
||||
COALESCE(ps.failing_host_count, 0) as failing_host_count
|
||||
FROM policies p
|
||||
LEFT JOIN users u ON p.author_id = u.id
|
||||
LEFT JOIN policy_stats ps ON p.id = ps.policy_id
|
||||
AND ((p.team_id IS NULL AND ps.inherited_team_id = 0)
|
||||
OR (p.team_id IS NOT NULL AND ps.inherited_team_id = p.team_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}
|
||||
@ -132,6 +111,8 @@ func policyDB(ctx context.Context, q sqlx.QueryerContext, id uint, teamID *uint)
|
||||
//
|
||||
// Currently SavePolicy does not allow updating the team of an existing policy.
|
||||
func (ds *Datastore) SavePolicy(ctx context.Context, p *fleet.Policy, shouldRemoveAllPolicyMemberships bool) error {
|
||||
// We must normalize the name for full Unicode support (Unicode equivalence).
|
||||
p.Name = norm.NFC.String(p.Name)
|
||||
sql := `
|
||||
UPDATE policies
|
||||
SET name = ?, query = ?, description = ?, resolution = ?, platforms = ?, critical = ?, checksum = ` + policiesChecksumComputedColumn() + `
|
||||
@ -344,7 +325,9 @@ func listPoliciesDB(ctx context.Context, q sqlx.QueryerContext, teamID *uint, op
|
||||
query += " WHERE team_id IS NULL"
|
||||
}
|
||||
|
||||
query, args = searchLike(query, args, opts.MatchQuery, policySearchColumns...)
|
||||
// We must normalize the name for full Unicode support (Unicode equivalence).
|
||||
match := norm.NFC.String(opts.MatchQuery)
|
||||
query, args = searchLike(query, args, match, policySearchColumns...)
|
||||
query, args = appendListOptionsWithCursorToSQL(query, args, &opts)
|
||||
|
||||
var policies []*fleet.Policy
|
||||
@ -377,7 +360,9 @@ func getInheritedPoliciesForTeam(ctx context.Context, q sqlx.QueryerContext, Tea
|
||||
|
||||
args = append(args, TeamID)
|
||||
|
||||
query, args = searchLike(query, args, opts.MatchQuery, policySearchColumns...)
|
||||
// We must normalize the name for full Unicode support (Unicode equivalence).
|
||||
match := norm.NFC.String(opts.MatchQuery)
|
||||
query, args = searchLike(query, args, match, policySearchColumns...)
|
||||
query, _ = appendListOptionsToSQL(query, &opts)
|
||||
|
||||
var policies []*fleet.Policy
|
||||
@ -405,7 +390,9 @@ func (ds *Datastore) CountPolicies(ctx context.Context, teamID *uint, matchQuery
|
||||
args = append(args, *teamID)
|
||||
}
|
||||
|
||||
query, args = searchLike(query, args, matchQuery, policySearchColumns...)
|
||||
// We must normalize the name for full Unicode support (Unicode equivalence).
|
||||
match := norm.NFC.String(matchQuery)
|
||||
query, args = searchLike(query, args, match, policySearchColumns...)
|
||||
|
||||
err := sqlx.GetContext(ctx, ds.reader(ctx), &count, query, args...)
|
||||
if err != nil {
|
||||
@ -534,17 +521,20 @@ func (ds *Datastore) NewTeamPolicy(ctx context.Context, teamID uint, authorID *u
|
||||
args.Query = q.Query
|
||||
args.Description = q.Description
|
||||
}
|
||||
// We must normalize the name for full Unicode support (Unicode equivalence).
|
||||
nameUnicode := norm.NFC.String(args.Name)
|
||||
res, err := ds.writer(ctx).ExecContext(ctx,
|
||||
fmt.Sprintf(
|
||||
`INSERT INTO policies (name, query, description, team_id, resolution, author_id, platforms, critical, checksum) VALUES (?, ?, ?, ?, ?, ?, ?, ?, %s)`,
|
||||
policiesChecksumComputedColumn(),
|
||||
),
|
||||
args.Name, args.Query, args.Description, teamID, args.Resolution, authorID, args.Platform, args.Critical)
|
||||
nameUnicode, args.Query, args.Description, teamID, args.Resolution, authorID, args.Platform, args.Critical,
|
||||
)
|
||||
switch {
|
||||
case err == nil:
|
||||
// OK
|
||||
case isDuplicate(err):
|
||||
return nil, ctxerr.Wrap(ctx, alreadyExists("Policy", args.Name))
|
||||
return nil, ctxerr.Wrap(ctx, alreadyExists("Policy", nameUnicode))
|
||||
default:
|
||||
return nil, ctxerr.Wrap(ctx, err, "inserting new policy")
|
||||
}
|
||||
@ -609,6 +599,8 @@ func (ds *Datastore) ApplyPolicySpecs(ctx context.Context, authorID uint, specs
|
||||
)
|
||||
for _, spec := range specs {
|
||||
|
||||
// We must normalize the name for full Unicode support (Unicode equivalence).
|
||||
spec.Name = norm.NFC.String(spec.Name)
|
||||
res, err := tx.ExecContext(ctx,
|
||||
query, spec.Name, spec.Query, spec.Description, authorID, spec.Resolution, spec.Team, spec.Platform, spec.Critical,
|
||||
)
|
||||
|
@ -3,7 +3,6 @@ package mysql
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5" //nolint:gosec // (only used for tests)
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
@ -51,13 +50,15 @@ func TestPolicies(t *testing.T) {
|
||||
{"IncreasePolicyAutomationIteration", testIncreasePolicyAutomationIteration},
|
||||
{"OutdatedAutomationBatch", testOutdatedAutomationBatch},
|
||||
{"TestUpdatePolicyFailureCountsForHosts", testUpdatePolicyFailureCountsForHosts},
|
||||
{"TestPolicyIDsByName", testPolicyByName},
|
||||
{"TestListGlobalPoliciesCanPaginate", testListGlobalPoliciesCanPaginate},
|
||||
{"TestListTeamPoliciesCanPaginate", testListTeamPoliciesCanPaginate},
|
||||
{"TestCountPolicies", testCountPolicies},
|
||||
{"TestUpdatePolicyHostCounts", testUpdatePolicyHostCounts},
|
||||
{"TestCachedPolicyCountDeletesOnPolicyChange", testCachedPolicyCountDeletesOnPolicyChange},
|
||||
{"TestPoliciesListOptions", testPoliciesListOptions},
|
||||
{"TestPoliciesNameUnicode", testPoliciesNameUnicode},
|
||||
{"TestPoliciesNameEmoji", testPoliciesNameEmoji},
|
||||
{"TestPoliciesNameSort", testPoliciesNameSort},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
@ -1230,9 +1231,12 @@ func testApplyPolicySpec(t *testing.T, ds *Datastore) {
|
||||
team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1"})
|
||||
require.NoError(t, err)
|
||||
|
||||
unicode, _ := strconv.Unquote(`"\uAC00"`) // 가
|
||||
unicodeEq, _ := strconv.Unquote(`"\u1100\u1161"`) // ᄀ + ᅡ
|
||||
|
||||
require.NoError(t, ds.ApplyPolicySpecs(ctx, user1.ID, []*fleet.PolicySpec{
|
||||
{
|
||||
Name: "query1",
|
||||
Name: "query1" + unicodeEq,
|
||||
Query: "select 1;",
|
||||
Description: "query1 desc",
|
||||
Resolution: "some resolution",
|
||||
@ -1260,7 +1264,7 @@ func testApplyPolicySpec(t *testing.T, ds *Datastore) {
|
||||
policies, err := ds.ListGlobalPolicies(ctx, fleet.ListOptions{})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, policies, 1)
|
||||
assert.Equal(t, "query1", policies[0].Name)
|
||||
assert.Equal(t, "query1"+unicode, policies[0].Name)
|
||||
assert.Equal(t, "select 1;", policies[0].Query)
|
||||
assert.Equal(t, "query1 desc", policies[0].Description)
|
||||
require.NotNil(t, policies[0].AuthorID)
|
||||
@ -1293,7 +1297,7 @@ func testApplyPolicySpec(t *testing.T, ds *Datastore) {
|
||||
// Make sure apply is idempotent
|
||||
require.NoError(t, ds.ApplyPolicySpecs(ctx, user1.ID, []*fleet.PolicySpec{
|
||||
{
|
||||
Name: "query1",
|
||||
Name: "query1" + unicode,
|
||||
Query: "select 1;",
|
||||
Description: "query1 desc",
|
||||
Resolution: "some resolution",
|
||||
@ -1328,7 +1332,7 @@ func testApplyPolicySpec(t *testing.T, ds *Datastore) {
|
||||
// Test policy updating.
|
||||
require.NoError(t, ds.ApplyPolicySpecs(ctx, user1.ID, []*fleet.PolicySpec{
|
||||
{
|
||||
Name: "query1",
|
||||
Name: "query1" + unicodeEq,
|
||||
Query: "select 1 from updated;",
|
||||
Description: "query1 desc updated",
|
||||
Resolution: "some resolution updated",
|
||||
@ -1348,7 +1352,7 @@ func testApplyPolicySpec(t *testing.T, ds *Datastore) {
|
||||
require.NoError(t, err)
|
||||
require.Len(t, policies, 1)
|
||||
|
||||
assert.Equal(t, "query1", policies[0].Name)
|
||||
assert.Equal(t, "query1"+unicode, policies[0].Name)
|
||||
assert.Equal(t, "select 1 from updated;", policies[0].Query)
|
||||
assert.Equal(t, "query1 desc updated", policies[0].Description)
|
||||
require.NotNil(t, policies[0].AuthorID)
|
||||
@ -1376,7 +1380,7 @@ func testApplyPolicySpec(t *testing.T, ds *Datastore) {
|
||||
t, ds.ApplyPolicySpecs(
|
||||
ctx, user1.ID, []*fleet.PolicySpec{
|
||||
{
|
||||
Name: "query1",
|
||||
Name: "query1" + unicode,
|
||||
Query: "select 1 from updated again;",
|
||||
Description: "query1 desc updated again",
|
||||
Resolution: "some resolution updated again",
|
||||
@ -1634,27 +1638,6 @@ func testPoliciesDelUser(t *testing.T, ds *Datastore) {
|
||||
assert.Empty(t, gp.AuthorEmail)
|
||||
}
|
||||
|
||||
func testPolicyByName(t *testing.T, ds *Datastore) {
|
||||
user1 := test.NewUser(t, ds, "User1", "user1@example.com", true)
|
||||
ctx := context.Background()
|
||||
|
||||
gp, err := ds.NewGlobalPolicy(ctx, &user1.ID, fleet.PolicyPayload{
|
||||
Name: "global query",
|
||||
Query: "select 1;",
|
||||
Description: "global query desc",
|
||||
Resolution: "global query resolution",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
policy, err := ds.PolicyByName(ctx, "global query")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, gp.ID, policy.ID)
|
||||
|
||||
policy, err = ds.PolicyByName(ctx, "non-existent")
|
||||
require.Error(t, sql.ErrNoRows, err)
|
||||
assert.Nil(t, policy)
|
||||
}
|
||||
|
||||
func testFlippingPoliciesForHost(t *testing.T, ds *Datastore) {
|
||||
user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true)
|
||||
ctx := context.Background()
|
||||
@ -2705,3 +2688,110 @@ func testUpdatePolicyHostCounts(t *testing.T, ds *Datastore) {
|
||||
t, policy.HostCountUpdatedAt.Compare(later) < 0, fmt.Sprintf("later:%v HostCountUpdatedAt:%v", later, *policy.HostCountUpdatedAt),
|
||||
)
|
||||
}
|
||||
|
||||
func testPoliciesNameUnicode(t *testing.T, ds *Datastore) {
|
||||
var equivalentNames []string
|
||||
item, _ := strconv.Unquote(`"\uAC00"`) // 가
|
||||
equivalentNames = append(equivalentNames, item)
|
||||
item, _ = strconv.Unquote(`"\u1100\u1161"`) // ᄀ + ᅡ
|
||||
equivalentNames = append(equivalentNames, item)
|
||||
|
||||
// Save policy
|
||||
policy, err := ds.NewGlobalPolicy(context.Background(), nil, fleet.PolicyPayload{Name: equivalentNames[0]})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, equivalentNames[0], policy.Name)
|
||||
|
||||
// Try to create policy with equivalent name
|
||||
_, err = ds.NewGlobalPolicy(context.Background(), nil, fleet.PolicyPayload{Name: equivalentNames[1]})
|
||||
var existsErr *existsError
|
||||
assert.ErrorAs(t, err, &existsErr)
|
||||
|
||||
// Try to update a different policy with equivalent name -- not allowed
|
||||
policyEmoji, err := ds.NewGlobalPolicy(context.Background(), nil, fleet.PolicyPayload{Name: "💻"})
|
||||
require.NoError(t, err)
|
||||
err = ds.SavePolicy(
|
||||
context.Background(), &fleet.Policy{PolicyData: fleet.PolicyData{ID: policyEmoji.ID, Name: equivalentNames[1]}}, false,
|
||||
)
|
||||
assert.True(t, isDuplicate(err), err)
|
||||
|
||||
// Try to find policy with equivalent name
|
||||
policies, err := ds.ListGlobalPolicies(context.Background(), fleet.ListOptions{MatchQuery: equivalentNames[1]})
|
||||
assert.NoError(t, err)
|
||||
require.Len(t, policies, 1)
|
||||
assert.Equal(t, equivalentNames[0], policies[0].Name)
|
||||
|
||||
// Test team methods
|
||||
team, err := ds.NewTeam(context.Background(), &fleet.Team{Name: "team1"})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create team policy
|
||||
teamPolicy, err := ds.NewTeamPolicy(context.Background(), team.ID, nil, fleet.PolicyPayload{Name: equivalentNames[0]})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, equivalentNames[0], teamPolicy.Name)
|
||||
|
||||
// Try to create another team policy with equivalent name -- not allowed
|
||||
_, err = ds.NewTeamPolicy(context.Background(), team.ID, nil, fleet.PolicyPayload{Name: equivalentNames[1]})
|
||||
assert.ErrorAs(t, err, &existsErr)
|
||||
|
||||
// ListTeamPolicies, including inherited policy
|
||||
teamPolicies, inheritedPolicies, err := ds.ListTeamPolicies(
|
||||
context.Background(), team.ID, fleet.ListOptions{MatchQuery: equivalentNames[1]}, fleet.ListOptions{MatchQuery: equivalentNames[1]},
|
||||
)
|
||||
assert.NoError(t, err)
|
||||
require.Len(t, teamPolicies, 1)
|
||||
assert.Equal(t, equivalentNames[0], teamPolicies[0].Name)
|
||||
require.Len(t, inheritedPolicies, 1)
|
||||
assert.Equal(t, equivalentNames[0], inheritedPolicies[0].Name)
|
||||
|
||||
// CountPolicies
|
||||
count, err := ds.CountPolicies(context.Background(), &team.ID, equivalentNames[1])
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 1, count)
|
||||
count, err = ds.CountPolicies(context.Background(), nil, equivalentNames[1])
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 1, count)
|
||||
}
|
||||
|
||||
func testPoliciesNameEmoji(t *testing.T, ds *Datastore) {
|
||||
// Try to save policies with emojis
|
||||
emoji0 := "🔥"
|
||||
_, err := ds.NewGlobalPolicy(context.Background(), nil, fleet.PolicyPayload{Name: emoji0})
|
||||
require.NoError(t, err)
|
||||
emoji1 := "💻"
|
||||
policyEmoji, err := ds.NewGlobalPolicy(context.Background(), nil, fleet.PolicyPayload{Name: emoji1})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, emoji1, policyEmoji.Name)
|
||||
|
||||
// Try to find policy with emoji0
|
||||
policies, err := ds.ListGlobalPolicies(context.Background(), fleet.ListOptions{MatchQuery: emoji0})
|
||||
assert.NoError(t, err)
|
||||
require.Len(t, policies, 1)
|
||||
assert.Equal(t, emoji0, policies[0].Name)
|
||||
|
||||
// Try to find policy with emoji1
|
||||
policies, err = ds.ListGlobalPolicies(context.Background(), fleet.ListOptions{MatchQuery: emoji1})
|
||||
assert.NoError(t, err)
|
||||
require.Len(t, policies, 1)
|
||||
assert.Equal(t, emoji1, policies[0].Name)
|
||||
|
||||
}
|
||||
|
||||
// Ensure case-insensitive sort order for policy names
|
||||
func testPoliciesNameSort(t *testing.T, ds *Datastore) {
|
||||
var policies [3]*fleet.Policy
|
||||
var err error
|
||||
// Save policy
|
||||
policies[1], err = ds.NewGlobalPolicy(context.Background(), nil, fleet.PolicyPayload{Name: "В"})
|
||||
require.NoError(t, err)
|
||||
policies[2], err = ds.NewGlobalPolicy(context.Background(), nil, fleet.PolicyPayload{Name: "о"})
|
||||
require.NoError(t, err)
|
||||
policies[0], err = ds.NewGlobalPolicy(context.Background(), nil, fleet.PolicyPayload{Name: "а"})
|
||||
require.NoError(t, err)
|
||||
|
||||
policiesResult, err := ds.ListGlobalPolicies(context.Background(), fleet.ListOptions{OrderKey: "name"})
|
||||
assert.NoError(t, err)
|
||||
require.Len(t, policies, 3)
|
||||
for i, policy := range policies {
|
||||
assert.Equal(t, policy.Name, policiesResult[i].Name)
|
||||
}
|
||||
}
|
||||
|
File diff suppressed because one or more lines are too long
@ -5,6 +5,7 @@ import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"golang.org/x/text/unicode/norm"
|
||||
"strings"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
|
||||
@ -15,7 +16,11 @@ import (
|
||||
|
||||
var teamSearchColumns = []string{"name"}
|
||||
|
||||
const teamColumns = `id, created_at, name, description, config`
|
||||
|
||||
func (ds *Datastore) NewTeam(ctx context.Context, team *fleet.Team) (*fleet.Team, error) {
|
||||
// We must normalize the name for full Unicode support (Unicode equivalence).
|
||||
team.Name = norm.NFC.String(team.Name)
|
||||
err := ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
|
||||
query := `
|
||||
INSERT INTO teams (
|
||||
@ -52,7 +57,7 @@ func (ds *Datastore) Team(ctx context.Context, tid uint) (*fleet.Team, error) {
|
||||
|
||||
func teamDB(ctx context.Context, q sqlx.QueryerContext, tid uint) (*fleet.Team, error) {
|
||||
stmt := `
|
||||
SELECT * FROM teams
|
||||
SELECT ` + teamColumns + ` FROM teams
|
||||
WHERE id = ?
|
||||
`
|
||||
team := &fleet.Team{}
|
||||
@ -116,15 +121,17 @@ func (ds *Datastore) DeleteTeam(ctx context.Context, tid uint) error {
|
||||
}
|
||||
|
||||
func (ds *Datastore) TeamByName(ctx context.Context, name string) (*fleet.Team, error) {
|
||||
// We must normalize the name for full Unicode support (Unicode equivalence).
|
||||
nameUnicode := norm.NFC.String(name)
|
||||
stmt := `
|
||||
SELECT * FROM teams
|
||||
SELECT ` + teamColumns + ` FROM teams
|
||||
WHERE name = ?
|
||||
`
|
||||
team := &fleet.Team{}
|
||||
|
||||
if err := sqlx.GetContext(ctx, ds.reader(ctx), team, stmt, name); err != nil {
|
||||
if err := sqlx.GetContext(ctx, ds.reader(ctx), team, stmt, nameUnicode); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, ctxerr.Wrap(ctx, notFound("Team").WithName(name))
|
||||
return nil, ctxerr.Wrap(ctx, notFound("Team").WithName(nameUnicode))
|
||||
}
|
||||
return nil, ctxerr.Wrap(ctx, err, "select team")
|
||||
}
|
||||
@ -214,6 +221,8 @@ func saveUsersForTeamDB(ctx context.Context, exec sqlx.ExecerContext, team *flee
|
||||
}
|
||||
|
||||
func (ds *Datastore) SaveTeam(ctx context.Context, team *fleet.Team) (*fleet.Team, error) {
|
||||
// We must normalize the name for full Unicode support (Unicode equivalence).
|
||||
team.Name = norm.NFC.String(team.Name)
|
||||
err := ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
|
||||
query := `
|
||||
UPDATE teams
|
||||
@ -244,7 +253,7 @@ WHERE
|
||||
// fleet.ListOptions
|
||||
func (ds *Datastore) ListTeams(ctx context.Context, filter fleet.TeamFilter, opt fleet.ListOptions) ([]*fleet.Team, error) {
|
||||
query := fmt.Sprintf(`
|
||||
SELECT *,
|
||||
SELECT `+teamColumns+`,
|
||||
(SELECT count(*) FROM user_teams WHERE team_id = t.id) AS user_count,
|
||||
(SELECT count(*) FROM hosts WHERE team_id = t.id) AS host_count
|
||||
FROM teams t
|
||||
@ -252,7 +261,9 @@ func (ds *Datastore) ListTeams(ctx context.Context, filter fleet.TeamFilter, opt
|
||||
`,
|
||||
ds.whereFilterTeams(filter, "t"),
|
||||
)
|
||||
query, params := searchLike(query, nil, opt.MatchQuery, teamSearchColumns...)
|
||||
// We must normalize the name for full Unicode support (Unicode equivalence).
|
||||
matchQuery := norm.NFC.String(opt.MatchQuery)
|
||||
query, params := searchLike(query, nil, matchQuery, teamSearchColumns...)
|
||||
query, params = appendListOptionsWithCursorToSQL(query, params, &opt)
|
||||
teams := []*fleet.Team{}
|
||||
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &teams, query, params...); err != nil {
|
||||
@ -294,15 +305,18 @@ func (ds *Datastore) TeamExists(ctx context.Context, teamID uint) (bool, error)
|
||||
|
||||
func (ds *Datastore) SearchTeams(ctx context.Context, filter fleet.TeamFilter, matchQuery string, omit ...uint) ([]*fleet.Team, error) {
|
||||
sql := fmt.Sprintf(`
|
||||
SELECT *,
|
||||
SELECT %s,
|
||||
(SELECT count(*) FROM user_teams WHERE team_id = t.id) AS user_count,
|
||||
(SELECT count(*) FROM hosts WHERE team_id = t.id) AS host_count
|
||||
FROM teams t
|
||||
WHERE %s AND %s
|
||||
`,
|
||||
teamColumns,
|
||||
ds.whereOmitIDs("t.id", omit),
|
||||
ds.whereFilterTeams(filter, "t"),
|
||||
)
|
||||
// We must normalize the name for full Unicode support (Unicode equivalence).
|
||||
matchQuery = norm.NFC.String(matchQuery)
|
||||
sql, params := searchLike(sql, nil, matchQuery, teamSearchColumns...)
|
||||
teams := []*fleet.Team{}
|
||||
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &teams, sql, params...); err != nil {
|
||||
|
@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@ -34,6 +35,9 @@ func TestTeams(t *testing.T) {
|
||||
{"DeleteIntegrationsFromTeams", testTeamsDeleteIntegrationsFromTeams},
|
||||
{"TeamsFeatures", testTeamsFeatures},
|
||||
{"TeamsMDMConfig", testTeamsMDMConfig},
|
||||
{"TestTeamsNameUnicode", testTeamsNameUnicode},
|
||||
{"TestTeamsNameEmoji", testTeamsNameEmoji},
|
||||
{"TestTeamsNameSort", testTeamsNameSort},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
@ -618,3 +622,89 @@ func testTeamsMDMConfig(t *testing.T, ds *Datastore) {
|
||||
}, mdm)
|
||||
})
|
||||
}
|
||||
|
||||
func testTeamsNameUnicode(t *testing.T, ds *Datastore) {
|
||||
var equivalentNames []string
|
||||
item, _ := strconv.Unquote(`"\uAC00"`) // 가
|
||||
equivalentNames = append(equivalentNames, item)
|
||||
item, _ = strconv.Unquote(`"\u1100\u1161"`) // ᄀ + ᅡ
|
||||
equivalentNames = append(equivalentNames, item)
|
||||
|
||||
// Save team
|
||||
team, err := ds.NewTeam(context.Background(), &fleet.Team{Name: equivalentNames[0]})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, equivalentNames[0], team.Name)
|
||||
|
||||
// Try to create team with equivalent name
|
||||
_, err = ds.NewTeam(context.Background(), &fleet.Team{Name: equivalentNames[1]})
|
||||
assert.True(t, isDuplicate(err), err)
|
||||
|
||||
// Try to update a different team with equivalent name -- not allowed
|
||||
teamEmoji, err := ds.NewTeam(context.Background(), &fleet.Team{Name: "💻"})
|
||||
require.NoError(t, err)
|
||||
_, err = ds.SaveTeam(context.Background(), &fleet.Team{ID: teamEmoji.ID, Name: equivalentNames[1]})
|
||||
assert.True(t, isDuplicate(err), err)
|
||||
|
||||
// Try to find team with equivalent name
|
||||
teamFilter := fleet.TeamFilter{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}}
|
||||
results, err := ds.ListTeams(context.Background(), teamFilter, fleet.ListOptions{MatchQuery: equivalentNames[1]})
|
||||
assert.NoError(t, err)
|
||||
require.Len(t, results, 1)
|
||||
assert.Equal(t, equivalentNames[0], results[0].Name)
|
||||
|
||||
results, err = ds.SearchTeams(context.Background(), teamFilter, equivalentNames[1])
|
||||
assert.NoError(t, err)
|
||||
require.Len(t, results, 1)
|
||||
assert.Equal(t, equivalentNames[0], results[0].Name)
|
||||
|
||||
result, err := ds.TeamByName(context.Background(), equivalentNames[1])
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, equivalentNames[0], result.Name)
|
||||
}
|
||||
|
||||
func testTeamsNameEmoji(t *testing.T, ds *Datastore) {
|
||||
// Try to save teams with emojis
|
||||
emoji0 := "🔥"
|
||||
_, err := ds.NewTeam(context.Background(), &fleet.Team{Name: emoji0})
|
||||
require.NoError(t, err)
|
||||
emoji1 := "💻"
|
||||
teamEmoji, err := ds.NewTeam(context.Background(), &fleet.Team{Name: emoji1})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, emoji1, teamEmoji.Name)
|
||||
|
||||
// Try to find team with emoji0
|
||||
teamFilter := fleet.TeamFilter{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}}
|
||||
results, err := ds.ListTeams(context.Background(), teamFilter, fleet.ListOptions{MatchQuery: emoji0})
|
||||
assert.NoError(t, err)
|
||||
require.Len(t, results, 1)
|
||||
assert.Equal(t, emoji0, results[0].Name)
|
||||
|
||||
// Try to find team with emoji1
|
||||
results, err = ds.SearchTeams(context.Background(), teamFilter, emoji1)
|
||||
assert.NoError(t, err)
|
||||
require.Len(t, results, 1)
|
||||
assert.Equal(t, emoji1, results[0].Name)
|
||||
|
||||
}
|
||||
|
||||
// Ensure case-insensitive sort order for ames
|
||||
func testTeamsNameSort(t *testing.T, ds *Datastore) {
|
||||
var teams [3]*fleet.Team
|
||||
var err error
|
||||
// Save teams
|
||||
teams[1], err = ds.NewTeam(context.Background(), &fleet.Team{Name: "В"})
|
||||
require.NoError(t, err)
|
||||
teams[2], err = ds.NewTeam(context.Background(), &fleet.Team{Name: "о"})
|
||||
require.NoError(t, err)
|
||||
teams[0], err = ds.NewTeam(context.Background(), &fleet.Team{Name: "а"})
|
||||
require.NoError(t, err)
|
||||
|
||||
teamFilter := fleet.TeamFilter{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}}
|
||||
results, err := ds.ListTeams(context.Background(), teamFilter, fleet.ListOptions{OrderKey: "name"})
|
||||
assert.NoError(t, err)
|
||||
require.Len(t, teams, 3)
|
||||
for i, item := range teams {
|
||||
assert.Equal(t, item.Name, results[i].Name)
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -572,7 +572,6 @@ 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.
|
||||
//
|
||||
|
@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"golang.org/x/text/unicode/norm"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
@ -754,6 +755,7 @@ func extractTmSpecsMDMCustomSettings(tmSpecs []json.RawMessage) map[string][]fle
|
||||
// ignore, this will fail in the call to apply team specs
|
||||
continue
|
||||
}
|
||||
spec.Name = norm.NFC.String(spec.Name)
|
||||
if spec.Name != "" {
|
||||
var macOSSettings []fleet.MDMProfileSpec
|
||||
var windowsSettings []fleet.MDMProfileSpec
|
||||
@ -810,6 +812,7 @@ func extractTmSpecsScripts(tmSpecs []json.RawMessage) map[string][]string {
|
||||
// ignore, this will fail in the call to apply team specs
|
||||
continue
|
||||
}
|
||||
spec.Name = norm.NFC.String(spec.Name)
|
||||
if spec.Name != "" && len(spec.Scripts) > 0 {
|
||||
if m == nil {
|
||||
m = make(map[string][]string)
|
||||
@ -844,6 +847,7 @@ func extractTmSpecsMacOSSetup(tmSpecs []json.RawMessage) map[string]*fleet.MacOS
|
||||
// ignore, this will fail in the call to apply team specs
|
||||
continue
|
||||
}
|
||||
spec.Name = norm.NFC.String(spec.Name)
|
||||
if spec.Name != "" {
|
||||
if m == nil {
|
||||
m = make(map[string]*fleet.MacOSSetup)
|
||||
|
@ -82,10 +82,12 @@ func (s *integrationEnterpriseTestSuite) TestTeamSpecs() {
|
||||
|
||||
// create a team through the service so it initializes the agent ops
|
||||
teamName := t.Name() + "team1"
|
||||
teamNameDecomposed := teamName + "ᄀ" + "ᅡ" // Add a decomposed Unicode character
|
||||
team := &fleet.Team{
|
||||
Name: teamName,
|
||||
Name: teamNameDecomposed,
|
||||
Description: "desc team1",
|
||||
}
|
||||
teamName = teamName + "가"
|
||||
|
||||
s.Do("POST", "/api/latest/fleet/teams", team, http.StatusOK)
|
||||
|
||||
@ -159,7 +161,7 @@ func (s *integrationEnterpriseTestSuite) TestTeamSpecs() {
|
||||
teamSpecs = map[string]any{
|
||||
"specs": []any{
|
||||
map[string]any{
|
||||
"name": teamName,
|
||||
"name": teamNameDecomposed,
|
||||
"mdm": map[string]any{
|
||||
"windows_updates": map[string]any{
|
||||
"deadline_days": -1,
|
||||
@ -177,7 +179,7 @@ func (s *integrationEnterpriseTestSuite) TestTeamSpecs() {
|
||||
teamSpecs = map[string]any{
|
||||
"specs": []any{
|
||||
map[string]any{
|
||||
"name": teamName,
|
||||
"name": teamNameDecomposed,
|
||||
"mdm": map[string]any{
|
||||
"windows_updates": map[string]any{
|
||||
"deadline_days": 1,
|
||||
@ -259,7 +261,7 @@ func (s *integrationEnterpriseTestSuite) TestTeamSpecs() {
|
||||
teamSpecs = map[string]any{
|
||||
"specs": []any{
|
||||
map[string]any{
|
||||
"name": teamName,
|
||||
"name": teamNameDecomposed,
|
||||
"agent_options": agentOpts,
|
||||
},
|
||||
},
|
||||
@ -288,7 +290,7 @@ func (s *integrationEnterpriseTestSuite) TestTeamSpecs() {
|
||||
teamSpecs = map[string]any{
|
||||
"specs": []any{
|
||||
map[string]any{
|
||||
"name": teamName,
|
||||
"name": teamNameDecomposed,
|
||||
"agent_options": agentOpts,
|
||||
"mdm": map[string]any{
|
||||
"macos_settings": map[string]any{
|
||||
|
@ -5,6 +5,7 @@ import (
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"golang.org/x/text/unicode/norm"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@ -229,6 +230,8 @@ func applyTeamSpecsEndpoint(ctx context.Context, request interface{}, svc fleet.
|
||||
actualSpecs := make([]*fleet.TeamSpec, 0, len(req.Specs))
|
||||
for _, spec := range req.Specs {
|
||||
if spec != nil {
|
||||
// Normalize the team name for full Unicode support to prevent potential issue further in the spec flow
|
||||
spec.Name = norm.NFC.String(spec.Name)
|
||||
actualSpecs = append(actualSpecs, spec)
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user