enhance: inherit team access for all units (#38938)

Admin and write team authorize now grant that mode on every unit,
including units added later, instead of only rows present in
`team_unit`. Granular teams keep `authorize=none` and explicit unit
rows.

Closes the `TEAM-UNIT-PERMISSION` design gap from
https://github.com/go-gitea/gitea/pull/34128.

Maybe also fix #15962 (actually maybe it had been fixed before, the root
cause is out-of-sync "access" table)


## Screenshots

only writing selected:
<img width="1399" height="1007" alt="image"
src="https://github.com/user-attachments/assets/1d1b4c49-a59a-47b6-998f-0464a067395b"
/>


_Created with the help of AI_

---------

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
bircni
2026-08-17 22:30:24 +02:00
committed by GitHub
parent 1cf904f101
commit ed4a23e893
43 changed files with 305 additions and 375 deletions
+17 -9
View File
@@ -11,6 +11,7 @@ import (
"gitea.dev/models/db"
"gitea.dev/models/organization"
"gitea.dev/models/perm"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unit"
user_model "gitea.dev/models/user"
@@ -287,8 +288,8 @@ func applyConditions(sess db.Session, opts *IssuesOptions) {
}
}
// teamUnitsRepoCond returns query condition for those repo id in the special org team with special units access
func teamUnitsRepoCond(id string, userID, orgID, teamID int64, units ...unit.Type) builder.Cond {
// teamUnitsRepoReaderCond returns query condition for those repo id in the special org team with special units access
func teamUnitsRepoReaderCond(id string, userID, orgID, teamID int64, units ...unit.Type) builder.Cond {
return builder.In(id,
builder.Select("repo_id").From("team_repo").Where(
builder.Eq{
@@ -316,12 +317,19 @@ func teamUnitsRepoCond(id string, userID, orgID, teamID int64, units ...unit.Typ
}),
),
)).And(
builder.In(
"team_id", builder.Select("team_id").From("team_unit").Where(
builder.Eq{
"`team_unit`.org_id": orgID,
}.And(
builder.In("`team_unit`.type", units),
builder.Or(
builder.In(
"team_id", builder.Select("id").From("team").Where(
builder.Eq{"id": teamID}.And(builder.Gt{"authorize": perm.AccessModeNone}),
),
),
builder.In(
"team_id", builder.Select("team_id").From("team_unit").Where(
builder.Eq{
"`team_unit`.org_id": orgID,
}.And(
builder.In("`team_unit`.type", units),
),
),
),
),
@@ -338,7 +346,7 @@ func issuePullAccessibleRepoCond(repoIDstr string, userID int64, owner *user_mod
}
if owner != nil && owner.IsOrganization() {
if team != nil {
cond = cond.And(teamUnitsRepoCond(repoIDstr, userID, owner.ID, team.ID, unitType)) // special team member repos
cond = cond.And(teamUnitsRepoReaderCond(repoIDstr, userID, owner.ID, team.ID, unitType)) // special team member repos
} else {
cond = cond.And(
builder.Or(
+1 -10
View File
@@ -626,16 +626,7 @@ func ResolveIssueMentionsByVisibility(ctx context.Context, issue *Issue, doer *u
unittype = unit.TypePullRequests
}
for _, team := range teams {
if team.HasAdminAccess() {
checked = append(checked, team.ID)
resolved[issue.Repo.Owner.LowerName+"/"+team.LowerName] = true
continue
}
has, err := db.Exist[organization.TeamUnit](ctx, builder.Eq{"org_id": issue.Repo.Owner.ID, "team_id": team.ID, "`type`": unittype})
if err != nil {
return nil, fmt.Errorf("get team units (%d): %w", team.ID, err)
}
if has {
if team.UnitEnabled(ctx, unittype) {
checked = append(checked, team.ID)
resolved[issue.Repo.Owner.LowerName+"/"+team.LowerName] = true
}
+3 -3
View File
@@ -284,8 +284,8 @@ func (org *Organization) CustomAvatarRelativePath() string {
return org.Avatar
}
// UnitPermission returns unit permission
func (org *Organization) UnitPermission(ctx context.Context, doer *user_model.User, unitType unit.Type) perm.AccessMode {
func (org *Organization) AnyRepoUnitPermission(ctx context.Context, doer *user_model.User, unitType unit.Type) perm.AccessMode {
// FIXME: ORG-TEAM-UNIT-MAX-PERMISSION: this function is not right, team can access repo1's code doesn't mean it can access repo2's code
if doer != nil {
teams, err := GetUserOrgTeams(ctx, org.ID, doer.ID)
if err != nil {
@@ -299,7 +299,7 @@ func (org *Organization) UnitPermission(ctx context.Context, doer *user_model.Us
}
if len(teams) > 0 {
return teams.UnitMaxAccess(unitType)
return teams.AnyRepoUnitMaxAccess(ctx, unitType)
}
}
+14 -24
View File
@@ -149,29 +149,14 @@ func (t *Team) LoadUnits(ctx context.Context) (err error) {
return err
}
// GetUnitNames returns the team units names
func (t *Team) GetUnitNames() (res []string) {
if t.HasAdminAccess() {
return unit.AllUnitKeyNames()
}
for _, u := range t.Units {
res = append(res, unit.Units[u.Type].NameKey)
}
return res
}
// GetUnitsMap returns the team units permissions
func (t *Team) GetUnitsMap() map[string]string {
if len(t.Units) == 0 {
return nil
}
m := make(map[string]string)
if t.HasAdminAccess() {
for _, u := range unit.Units {
m[u.NameKey] = t.AccessMode.ToString()
}
} else {
for _, u := range t.Units {
m[u.Unit().NameKey] = u.AccessMode.ToString()
}
for _, u := range t.Units {
m[u.Unit().NameKey] = u.AccessMode.ToString()
}
return m
}
@@ -214,16 +199,21 @@ func (t *Team) UnitAccessMode(ctx context.Context, tp unit.Type) perm.AccessMode
return accessMode
}
func (t *Team) UnitAccessModeEx(ctx context.Context, tp unit.Type) (accessMode perm.AccessMode, exist bool) {
func (t *Team) UnitAccessModeEx(ctx context.Context, tp unit.Type) (mode perm.AccessMode, exist bool) {
if err := t.LoadUnits(ctx); err != nil {
log.Warn("Error loading team (ID: %d) units: %s", t.ID, err.Error())
log.Error("Error loading team (ID: %d) units: %v", t.ID, err)
}
for _, u := range t.Units {
if u.Type == tp {
return u.AccessMode, true
mode, exist = u.AccessMode, true
break
}
}
return perm.AccessModeNone, false
mode = max(mode, t.AccessMode)
if unitDef, ok := unit.Units[tp]; ok {
mode = min(mode, unitDef.MaxPerm())
}
return mode, exist || t.AccessMode > perm.AccessModeNone
}
// IsUsableTeamName tests if a name could be as team name
+3 -9
View File
@@ -27,20 +27,14 @@ func (t TeamList) LoadUnits(ctx context.Context) error {
return nil
}
func (t TeamList) UnitMaxAccess(tp unit.Type) perm.AccessMode {
func (t TeamList) AnyRepoUnitMaxAccess(ctx context.Context, tp unit.Type) perm.AccessMode {
// FIXME: ORG-TEAM-UNIT-MAX-PERMISSION: this function is not right, team can access repo1's code doesn't mean it can access repo2's code
maxAccess := perm.AccessModeNone
for _, team := range t {
if team.IsOwnerTeam() {
return perm.AccessModeOwner
}
for _, teamUnit := range team.Units {
if teamUnit.Type != tp {
continue
}
if teamUnit.AccessMode > maxAccess {
maxAccess = teamUnit.AccessMode
}
}
maxAccess = max(maxAccess, team.UnitAccessMode(ctx, tp))
}
return maxAccess
}
+20
View File
@@ -7,6 +7,8 @@ import (
"testing"
org_model "gitea.dev/models/organization"
"gitea.dev/models/perm"
"gitea.dev/models/unit"
"gitea.dev/models/unittest"
"github.com/stretchr/testify/assert"
@@ -22,3 +24,21 @@ func Test_GetTeamsByIDs(t *testing.T) {
assert.Equal(t, "Owners", teams[1].Name)
assert.Equal(t, "team1", teams[2].Name)
}
func TestTeamList_UnitMaxAccess(t *testing.T) {
ctx := t.Context()
adminTeam := &org_model.Team{AccessMode: perm.AccessModeAdmin, Units: nil}
writeTeam := &org_model.Team{AccessMode: perm.AccessModeWrite, Units: nil}
granularTeam := &org_model.Team{
AccessMode: perm.AccessModeNone,
Units: []*org_model.TeamUnit{
{Type: unit.TypeCode, AccessMode: perm.AccessModeWrite},
},
}
assert.Equal(t, perm.AccessModeAdmin, org_model.TeamList{adminTeam}.AnyRepoUnitMaxAccess(ctx, unit.TypeActions))
assert.Equal(t, perm.AccessModeWrite, org_model.TeamList{writeTeam}.AnyRepoUnitMaxAccess(ctx, unit.TypeActions))
assert.Equal(t, perm.AccessModeWrite, org_model.TeamList{granularTeam}.AnyRepoUnitMaxAccess(ctx, unit.TypeCode))
assert.Equal(t, perm.AccessModeNone, org_model.TeamList{granularTeam}.AnyRepoUnitMaxAccess(ctx, unit.TypeActions))
assert.Equal(t, perm.AccessModeAdmin, org_model.TeamList{granularTeam, adminTeam}.AnyRepoUnitMaxAccess(ctx, unit.TypeActions))
}
-1
View File
@@ -52,7 +52,6 @@ func RemoveTeamRepo(ctx context.Context, teamID, repoID int64) error {
// GetTeamsWithAccessToAnyRepoUnit returns all teams in an organization that have given access level to the repository special unit.
// This function is only used for finding some teams that can be used as branch protection allowlist or reviewers, it isn't really used for access control.
// FIXME: TEAM-UNIT-PERMISSION this logic is not complete, search the fixme keyword to see more details
func GetTeamsWithAccessToAnyRepoUnit(ctx context.Context, orgID, repoID int64, mode perm.AccessMode, unitType unit.Type, unitTypesMore ...unit.Type) (teams []*Team, err error) {
teamIDs, err := getTeamIDsWithAccessToAnyRepoUnit(ctx, orgID, repoID, mode, unitType, unitTypesMore...)
if err != nil {
+27
View File
@@ -8,7 +8,9 @@ import (
"gitea.dev/models/db"
"gitea.dev/models/organization"
"gitea.dev/models/perm"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unit"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/structs"
@@ -291,3 +293,28 @@ func TestIsUsableTeamName(t *testing.T) {
assert.NoError(t, organization.IsUsableTeamName("usable"))
assert.True(t, db.IsErrNameReserved(organization.IsUsableTeamName("new")))
}
func TestTeam_UnitAccessModeEx(t *testing.T) {
team := &organization.Team{
AccessMode: perm.AccessModeWrite, Units: []*organization.TeamUnit{
{Type: unit.TypeIssues, AccessMode: perm.AccessModeRead}, // team mode wins
{Type: unit.TypeWiki, AccessMode: perm.AccessModeAdmin}, // unit mode wins
},
}
mode, exist := team.UnitAccessModeEx(t.Context(), unit.TypeActions)
assert.True(t, exist)
assert.Equal(t, perm.AccessModeWrite, mode)
assert.Equal(t, perm.AccessModeWrite, team.UnitAccessMode(t.Context(), unit.TypeIssues))
assert.Equal(t, perm.AccessModeAdmin, team.UnitAccessMode(t.Context(), unit.TypeWiki))
assert.Equal(t, perm.AccessModeRead, team.UnitAccessMode(t.Context(), unit.TypeExternalWiki)) // limited by unit definition
team = &organization.Team{AccessMode: perm.AccessModeOwner, Units: []*organization.TeamUnit{}}
mode, exist = team.UnitAccessModeEx(t.Context(), unit.TypePackages)
assert.True(t, exist)
assert.Equal(t, perm.AccessModeAdmin, mode)
team = &organization.Team{AccessMode: perm.AccessModeNone, Units: []*organization.TeamUnit{}}
mode, exist = team.UnitAccessModeEx(t.Context(), unit.TypeActions)
assert.False(t, exist)
assert.Equal(t, perm.AccessModeNone, mode)
}
+11 -6
View File
@@ -111,16 +111,21 @@ func IsCollaborator(ctx context.Context, repoID, userID int64) (bool, error) {
return db.Exist[Collaboration](ctx, builder.Eq{"repo_id": repoID, "user_id": userID})
}
// IsOwnerMemberCollaborator checks if a provided user is the owner, a collaborator or a member of a team in a repository
func IsOwnerMemberCollaborator(ctx context.Context, repo *Repository, userID int64) (bool, error) {
func HasAccessToRepoCodeUnit(ctx context.Context, repo *Repository, userID int64) (bool, error) {
if repo.OwnerID == userID {
return true, nil
}
teamMember, err := db.GetEngine(ctx).Join("INNER", "team_repo", "team_repo.team_id = team_user.team_id").
Join("INNER", "team_unit", "team_unit.team_id = team_user.team_id").
teamMember, err := db.GetEngine(ctx).Table("team_user").
Join("INNER", "team_repo", "team_repo.team_id = team_user.team_id").
Join("INNER", "team", "team.id = team_user.team_id").
Join("LEFT", "team_unit", "team_unit.team_id = team_user.team_id AND team_unit.`type` = ?", unit.TypeCode).
Where("team_repo.repo_id = ?", repo.ID).
And("team_unit.`type` = ?", unit.TypeCode).
And("team_user.uid = ?", userID).Table("team_user").Exist()
And("team_user.uid = ?", userID).
And(builder.Or(
builder.Gt{"team.authorize": perm.AccessModeNone},
builder.Gt{"team_unit.access_mode": perm.AccessModeNone},
)).
Exist()
if err != nil {
return false, err
}
+5 -5
View File
@@ -73,31 +73,31 @@ func TestRepository_IsOwnerMemberCollaborator(t *testing.T) {
repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3})
// Organisation owner.
actual, err := repo_model.IsOwnerMemberCollaborator(t.Context(), repo1, 2)
actual, err := repo_model.HasAccessToRepoCodeUnit(t.Context(), repo1, 2)
assert.NoError(t, err)
assert.True(t, actual)
// Team member.
actual, err = repo_model.IsOwnerMemberCollaborator(t.Context(), repo1, 4)
actual, err = repo_model.HasAccessToRepoCodeUnit(t.Context(), repo1, 4)
assert.NoError(t, err)
assert.True(t, actual)
// Normal user.
actual, err = repo_model.IsOwnerMemberCollaborator(t.Context(), repo1, 1)
actual, err = repo_model.HasAccessToRepoCodeUnit(t.Context(), repo1, 1)
assert.NoError(t, err)
assert.False(t, actual)
repo2 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
// Collaborator.
actual, err = repo_model.IsOwnerMemberCollaborator(t.Context(), repo2, 4)
actual, err = repo_model.HasAccessToRepoCodeUnit(t.Context(), repo2, 4)
assert.NoError(t, err)
assert.True(t, actual)
repo3 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 15})
// Repository owner.
actual, err = repo_model.IsOwnerMemberCollaborator(t.Context(), repo3, 2)
actual, err = repo_model.HasAccessToRepoCodeUnit(t.Context(), repo3, 2)
assert.NoError(t, err)
assert.True(t, actual)
}
+1 -4
View File
@@ -310,15 +310,12 @@ func userOrgTeamRepoBuilder(userID int64) *builder.Builder {
}
// userOrgTeamUnitRepoBuilder returns repo ids where user's teams can access the special unit.
// A team grants the unit either through an explicit team_unit row (access_mode > none) or by being an
// admin/owner team (team.authorize >= admin), which grants every unit regardless of team_unit rows —
// mirroring the HasAdminAccess() short-circuit in access.GetIndividualUserRepoPermission.
func userOrgTeamUnitRepoBuilder(userID int64, unitType unit.Type) *builder.Builder {
return userOrgTeamRepoBuilder(userID).
Join("INNER", "team", "`team`.id = `team_repo`.team_id").
Join("LEFT", "team_unit", builder.Expr("`team_unit`.team_id = `team_repo`.team_id AND `team_unit`.`type` = ?", unitType)).
Where(builder.Or(
builder.Gte{"`team`.authorize": int(perm.AccessModeAdmin)},
builder.Gt{"`team`.authorize": int(perm.AccessModeNone)},
builder.Gt{"`team_unit`.`access_mode`": int(perm.AccessModeNone)},
))
}
+12 -16
View File
@@ -486,10 +486,7 @@ func TestFindUserActionsAccessibleOwnerRepoIDs(t *testing.T) {
assert.Contains(t, publicOnly, int64(32), "a public repo under a public owner stays listed")
}
// TestUserOrgUnitRepoCondTeamAuthorize pins the team.authorize behavior of userOrgTeamUnitRepoBuilder
// (exercised through UserOrgUnitRepoCond): an admin/owner team grants every unit even without an explicit
// team_unit row, while a non-admin team only grants a unit it has an explicit row for. This guards both
// directions — hiding repos from admin-team members, and over-broadening a plain team's access.
// TestUserOrgUnitRepoCondTeamAuthorize pins team.authorize vs team_unit.access_mode
func TestUserOrgUnitRepoCondTeamAuthorize(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
@@ -500,17 +497,16 @@ func TestUserOrgUnitRepoCondTeamAuthorize(t *testing.T) {
return ids
}
// Case A: user18 is only on org17's owner team (team5, authorize=owner), linked to the private repo24
// but with no Actions team_unit row. The owner authorize must still grant it, mirroring the runtime
// HasAdminAccess() short-circuit in access.GetIndividualUserRepoPermission.
assert.Contains(t, accessibleRepoIDs(18, 17, unit.TypeActions), int64(24),
"an owner team grants a unit it has no explicit team_unit row for")
// Owner team5 has no Actions team_unit row but still grants via authorize=owner.
assert.Contains(t, accessibleRepoIDs(18, 17, unit.TypeActions), int64(24))
// Cases B and C share one subject so the team_unit row is the only difference: user4 is only on org3's
// write team (team2, authorize=write, non-admin), linked to the private repo3. team2 has an explicit
// Projects row but none for Actions.
assert.Contains(t, accessibleRepoIDs(4, 3, unit.TypeProjects), int64(3),
"a non-admin team grants a unit it has an explicit team_unit row for")
assert.NotContains(t, accessibleRepoIDs(4, 3, unit.TypeActions), int64(3),
"a non-admin team must NOT grant a unit it has no team_unit row for")
// team2 is "authorize=write" with Projects team_unit but no Actions row.
assert.Contains(t, accessibleRepoIDs(4, 3, unit.TypeProjects), int64(3))
assert.Contains(t, accessibleRepoIDs(4, 3, unit.TypeActions), int64(3))
// now team2 is "authorize=none", no Actions row.
_, err := db.GetEngine(t.Context()).Exec("UPDATE team SET authorize=0 WHERE id=2")
assert.NoError(t, err)
assert.Contains(t, accessibleRepoIDs(4, 3, unit.TypeProjects), int64(3))
assert.NotContains(t, accessibleRepoIDs(4, 3, unit.TypeActions), int64(3))
}
-3
View File
@@ -33,9 +33,6 @@ const (
TypeProjects // 8 Projects
TypePackages // 9 Packages
TypeActions // 10 Actions
// FIXME: TEAM-UNIT-PERMISSION: the team unit "admin" permission's design is not right, when a new unit is added in the future,
// admin team won't inherit the correct admin permission for the new unit, need to have a complete fix before adding any new unit.
)
// Value returns integer value for unit type (used by template)
+8 -1
View File
@@ -27,6 +27,7 @@ type TestingT interface {
require.TestingT
assert.TestingT
Context() context.Context
Helper()
}
type testCond struct {
@@ -77,6 +78,7 @@ func GetBean[T any](t TestingT, bean T, conditions ...any) (ret T) {
// AssertExistsAndLoadBean assert that a bean exists and load it from the test database
func AssertExistsAndLoadBean[T any](t TestingT, bean T, conditions ...any) T {
t.Helper()
exists, err := getBeanIfExists(t, bean, conditions...)
require.NoError(t, err)
require.True(t, exists,
@@ -87,6 +89,7 @@ func AssertExistsAndLoadBean[T any](t TestingT, bean T, conditions ...any) T {
// AssertExistsAndLoadMap assert that a row exists and load it from the test database
func AssertExistsAndLoadMap(t TestingT, table string, conditions ...any) map[string]string {
t.Helper()
e := db.GetEngine(t.Context()).Table(table)
res, err := whereOrderConditions(e, conditions).Query()
assert.NoError(t, err)
@@ -123,6 +126,7 @@ func GetCount(t TestingT, bean any, conditions ...any) int {
// AssertNotExistsBean assert that a bean does not exist in the test database
func AssertNotExistsBean(t TestingT, bean any, conditions ...any) {
t.Helper()
exists, err := getBeanIfExists(t, bean, conditions...)
assert.NoError(t, err)
assert.False(t, exists)
@@ -130,17 +134,20 @@ func AssertNotExistsBean(t TestingT, bean any, conditions ...any) {
// AssertCount assert the count of a bean
func AssertCount(t TestingT, bean, expected any) bool {
t.Helper()
return assert.EqualValues(t, expected, GetCount(t, bean))
}
// AssertInt64InRange assert value is in range [low, high]
func AssertInt64InRange(t assert.TestingT, low, high, value int64) {
func AssertInt64InRange(t TestingT, low, high, value int64) {
t.Helper()
assert.True(t, value >= low && value <= high,
"Expected value in range [%d, %d], found %d", low, high, value)
}
// GetCountByCond get the count of database entries matching bean
func GetCountByCond(t TestingT, tableName string, cond builder.Cond) int64 {
t.Helper()
e := db.GetEngine(t.Context())
count, err := e.Table(tableName).Where(cond).Count()
assert.NoError(t, err)