mirror of
https://github.com/go-gitea/gitea.git
synced 2026-11-09 17:37:20 -06:00
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:
@@ -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
|
||||
}
|
||||
|
||||
50
modelmigration/v29/v368.go
Normal file
50
modelmigration/v29/v368.go
Normal 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
|
||||
}
|
||||
52
modelmigration/v29/v368_test.go
Normal file
52
modelmigration/v29/v368_test.go
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user