fix(org): enforce unique team names in the database (#40228)

Concurrent requests could create teams with the same name in one
organization, because only the service checked the name. Editing such a
team, or renaming a team to a taken name, then failed with a 500.

- Add a unique index on `team (org_id, lower_name)`. Migration 368
renames every duplicate except the oldest to a free `<name>-<n>` and
logs each rename. IDs, members, repositories and permissions are kept,
but lookups by the old name (CODEOWNERS, group-to-team sync, mentions,
URLs) now reach only the kept team.
- Team writes report a name rejected by the index as
`ErrTeamAlreadyExist`, so duplicates, concurrent ones included, return
409 from `POST /orgs/{org}/teams` (previously 422) and `PATCH
/teams/{id}`, and a form error in the web UI.

Fixes https://github.com/go-gitea/gitea/issues/39632

---------

Co-authored-by: silverwind <me@silverwind.io>
This commit is contained in:
Trung Hehe
2026-11-07 11:16:11 +00:00
committed by GitHub
parent baccc2d7cd
commit 6bc92f464a
7 changed files with 120 additions and 33 deletions

View File

@@ -444,6 +444,7 @@ func prepareMigrationTasks() []*migration {
newMigration(365, "Normalize legacy repository unit and login source JSON", v29.NormalizeLegacyConfigJSON),
newMigration(366, "Drop user_open_id table", v29.DropUserOpenIDTable),
newMigration(367, "Add latest_release_id to repository table", v29.AddLatestReleaseIDToRepository),
newMigration(368, "Add unique index for team name", v29.AddUniqueTeamNameIndex),
}
return preparedMigrations
}

View File

@@ -0,0 +1,50 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package v29
import (
"context"
"fmt"
"strings"
"gitea.dev/modelmigration/base"
"gitea.dev/modules/log"
"gitea.dev/modules/util"
"xorm.io/xorm"
)
func AddUniqueTeamNameIndex(_ctx context.Context, x base.EngineMigration) error {
type Team struct {
ID int64 `xorm:"pk autoincr"`
OrgID int64 `xorm:"INDEX UNIQUE(s)"`
LowerName string `xorm:"UNIQUE(s)"`
Name string
}
var teams []Team
if err := x.Where("id NOT IN (SELECT MIN(id) FROM team GROUP BY org_id, lower_name)").Asc("id").Find(&teams); err != nil {
return err
}
for _, team := range teams {
for suffix := team.ID; ; suffix++ {
ending := fmt.Sprintf("-%d", suffix)
name := util.TruncateStringBytes(team.Name, 255-len(ending)) + ending
exists, err := x.Where("org_id = ? AND lower_name = ?", team.OrgID, strings.ToLower(name)).Exist(new(Team))
if err != nil {
return err
}
if !exists {
if _, err := x.ID(team.ID).Cols("name", "lower_name").Update(&Team{Name: name, LowerName: strings.ToLower(name)}); err != nil {
return err
}
log.Warn("renamed duplicate team id=%d in org id=%d from %q to %q", team.ID, team.OrgID, team.Name, name)
break
}
}
}
_, err := x.SyncWithOptions(xorm.SyncOptions{IgnoreDropIndices: true}, new(Team))
return err
}

View File

@@ -0,0 +1,52 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package v29
import (
"strings"
"testing"
"gitea.dev/modelmigration/migrationtest"
"github.com/stretchr/testify/require"
)
func TestAddUniqueTeamNameIndex(t *testing.T) {
type Team struct {
ID int64 `xorm:"pk autoincr"`
OrgID int64 `xorm:"INDEX"`
LowerName, Name string
}
x, cleanup := migrationtest.PrepareTestEnv(t, 0, new(Team))
defer cleanup()
if x == nil || t.Failed() {
return
}
_, err := x.Insert(
&Team{OrgID: 1, Name: "Team", LowerName: "team"},
&Team{OrgID: 1, Name: "Team", LowerName: "team"},
&Team{OrgID: 1, Name: "Team-2", LowerName: "team-2"},
&Team{OrgID: 2, Name: "Team", LowerName: "team"},
&Team{OrgID: 1, Name: strings.Repeat("A", 255), LowerName: strings.Repeat("a", 255)},
&Team{OrgID: 1, Name: strings.Repeat("A", 255), LowerName: strings.Repeat("a", 255)},
)
require.NoError(t, err)
for range 2 {
require.NoError(t, AddUniqueTeamNameIndex(t.Context(), x))
var teams []Team
require.NoError(t, x.Asc("id").Find(&teams))
require.Equal(t, []Team{
{ID: 1, OrgID: 1, Name: "Team", LowerName: "team"},
{ID: 2, OrgID: 1, Name: "Team-3", LowerName: "team-3"},
{ID: 3, OrgID: 1, Name: "Team-2", LowerName: "team-2"},
{ID: 4, OrgID: 2, Name: "Team", LowerName: "team"},
{ID: 5, OrgID: 1, Name: strings.Repeat("A", 255), LowerName: strings.Repeat("a", 255)},
{ID: 6, OrgID: 1, Name: strings.Repeat("A", 253) + "-6", LowerName: strings.Repeat("a", 253) + "-6"},
}, teams)
}
_, err = x.Insert(&Team{OrgID: 1, Name: "TEAM", LowerName: "team"})
require.Error(t, err)
}