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:
Victor Lyuboslavsky 2024-02-27 12:55:05 -06:00 committed by GitHub
parent 2541574b93
commit 02de6b5695
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 352 additions and 81 deletions

View File

@ -0,0 +1 @@
Added Unicode and emoji support for policy and team names.

View File

@ -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"))

View File

@ -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) {

View File

@ -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
}

View File

@ -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])
}

View File

@ -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 {

View File

@ -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,
)

View File

@ -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

View File

@ -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 {

View File

@ -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)
}
}

View File

@ -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.
//

View File

@ -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)

View File

@ -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{

View File

@ -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)
}
}