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
+1 -1
View File
@@ -816,7 +816,7 @@ func reqProjectsUnitAccess(accessMode perm.AccessMode) func(ctx *context.APICont
}
// individual visibility is handled by individualPermsChecker
if ctx.ContextUser.IsOrganization() &&
organization.OrgFromUser(ctx.ContextUser).UnitPermission(ctx, ctx.Doer, unit.TypeProjects) < accessMode {
organization.OrgFromUser(ctx.ContextUser).AnyRepoUnitPermission(ctx, ctx.Doer, unit.TypeProjects) < accessMode {
ctx.APIErrorNotFound()
}
}
+43 -67
View File
@@ -6,6 +6,7 @@ package org
import (
"errors"
"maps"
"net/http"
activities_model "gitea.dev/models/activities"
@@ -17,6 +18,7 @@ import (
user_model "gitea.dev/models/user"
"gitea.dev/modules/log"
api "gitea.dev/modules/structs"
"gitea.dev/modules/util"
"gitea.dev/modules/web"
"gitea.dev/routers/api/v1/user"
"gitea.dev/routers/api/v1/utils"
@@ -150,42 +152,36 @@ func GetTeam(ctx *context.APIContext) {
ctx.JSON(http.StatusOK, apiTeam)
}
func attachTeamUnits(team *organization.Team, defaultAccessMode perm.AccessMode, units []string) {
unitTypes, _ := unit_model.FindUnitTypes(units...)
team.Units = make([]*organization.TeamUnit, 0, len(units))
for _, tp := range unitTypes {
team.Units = append(team.Units, &organization.TeamUnit{
OrgID: team.OrgID,
Type: tp,
AccessMode: defaultAccessMode,
})
// assignTeamPermissionUnits sets authorize + team_unit rows.
func assignTeamPermissionUnits(team *organization.Team, permission string, units []string, unitsMap map[string]string) (changed bool, _ error) {
if len(units) > 0 && len(unitsMap) > 0 {
return false, util.NewInvalidArgumentErrorf("only one of units or units_map can be set")
}
}
func attachTeamUnitsMap(team *organization.Team, unitsMap map[string]string) {
team.Units = make([]*organization.TeamUnit, 0, len(unitsMap))
for unitKey, p := range unitsMap {
team.Units = append(team.Units, &organization.TeamUnit{
OrgID: team.OrgID,
Type: unit_model.TypeFromKey(unitKey),
AccessMode: perm.ParseAccessMode(p),
})
}
}
func attachAdminTeamUnits(team *organization.Team) {
team.Units = make([]*organization.TeamUnit, 0, len(unit_model.AllRepoUnitTypes))
for _, ut := range unit_model.AllRepoUnitTypes {
up := perm.AccessModeAdmin
if ut == unit_model.TypeExternalTracker || ut == unit_model.TypeExternalWiki {
up = perm.AccessModeRead
if len(units) > 0 {
unitsMap = map[string]string{}
for _, unit := range units {
unitsMap[unit] = permission
}
team.Units = append(team.Units, &organization.TeamUnit{
OrgID: team.OrgID,
Type: ut,
AccessMode: up,
})
}
oldAccessMode := team.AccessMode
oldUnitPerms := team.GetUnitsMap()
if len(unitsMap) > 0 {
team.Units = make([]*organization.TeamUnit, 0, len(unitsMap))
for unitKey, p := range unitsMap {
unitType, unitPerm := unit_model.TypeFromKey(unitKey), perm.ParseAccessMode(p)
team.Units = append(team.Units, &organization.TeamUnit{OrgID: team.OrgID, Type: unitType, AccessMode: unitPerm})
}
} else {
requested := perm.ParseAccessMode(permission, perm.AccessModeNone, perm.AccessModeRead, perm.AccessModeWrite, perm.AccessModeAdmin)
if requested == perm.AccessModeNone {
return false, util.NewInvalidArgumentErrorf("no permission specified")
}
team.AccessMode, team.Units = requested, nil
}
changed = oldAccessMode != team.AccessMode || !maps.Equal(oldUnitPerms, team.GetUnitsMap())
return changed, nil
}
// CreateTeam api for create a team
@@ -215,29 +211,18 @@ func CreateTeam(ctx *context.APIContext) {
// "422":
// "$ref": "#/responses/validationError"
form := web.GetForm[*api.CreateTeamOption](ctx)
teamPermission := perm.ParseAccessMode(string(form.Permission), perm.AccessModeNone, perm.AccessModeAdmin)
team := &organization.Team{
OrgID: ctx.Org.Organization.ID,
Name: form.Name,
Description: form.Description,
IncludesAllRepositories: form.IncludesAllRepositories,
CanCreateOrgRepo: form.CanCreateOrgRepo,
AccessMode: teamPermission,
Visibility: organization.NormalizeTeamVisibility(form.Visibility),
}
if team.AccessMode < perm.AccessModeAdmin {
if len(form.UnitsMap) > 0 {
attachTeamUnitsMap(team, form.UnitsMap)
} else if len(form.Units) > 0 {
unitPerm := perm.ParseAccessMode(string(form.Permission), perm.AccessModeRead, perm.AccessModeWrite)
attachTeamUnits(team, unitPerm, form.Units)
} else {
ctx.APIErrorInternal(errors.New("units permission should not be empty"))
return
}
} else {
attachAdminTeamUnits(team)
_, err := assignTeamPermissionUnits(team, string(form.Permission), form.Units, form.UnitsMap)
if err != nil {
ctx.APIErrorAuto(err)
return
}
if err := org_service.NewTeam(ctx, team); err != nil {
@@ -307,28 +292,19 @@ func EditTeam(ctx *context.APIContext) {
isAuthChanged := false
isIncludeAllChanged := false
if !team.IsOwnerTeam() && len(form.Permission) != 0 {
teamPermission := perm.ParseAccessMode(string(form.Permission), perm.AccessModeNone, perm.AccessModeAdmin)
if team.AccessMode != teamPermission {
isAuthChanged = true
team.AccessMode = teamPermission
}
if form.IncludesAllRepositories != nil {
isIncludeAllChanged = true
team.IncludesAllRepositories = *form.IncludesAllRepositories
hasPermFields := form.Permission != "" || len(form.Units) > 0 || len(form.UnitsMap) > 0
if !team.IsOwnerTeam() && hasPermFields {
var err error
isAuthChanged, err = assignTeamPermissionUnits(team, string(form.Permission), form.Units, form.UnitsMap)
if err != nil {
ctx.APIErrorAuto(err)
return
}
}
if team.AccessMode < perm.AccessModeAdmin {
if len(form.UnitsMap) > 0 {
attachTeamUnitsMap(team, form.UnitsMap)
} else if len(form.Units) > 0 {
unitPerm := perm.ParseAccessMode(string(form.Permission), perm.AccessModeRead, perm.AccessModeWrite)
attachTeamUnits(team, unitPerm, form.Units)
}
} else {
attachAdminTeamUnits(team)
if !team.IsOwnerTeam() && form.IncludesAllRepositories != nil {
isIncludeAllChanged = true
team.IncludesAllRepositories = *form.IncludesAllRepositories
}
if err := org_service.UpdateTeam(ctx, team, isAuthChanged, isIncludeAllChanged); err != nil {
+1 -1
View File
@@ -117,7 +117,7 @@ func Projects(ctx *context.Context) {
func canWriteProjects(ctx *context.Context) bool {
if ctx.ContextUser.IsOrganization() {
return ctx.Org.CanWriteUnit(ctx, unit.TypeProjects)
return ctx.Org.CanWriteAnyRepoUnit(ctx, unit.TypeProjects)
}
return ctx.Doer != nil && ctx.ContextUser.ID == ctx.Doer.ID
}
+30 -73
View File
@@ -6,7 +6,7 @@ package org
import (
"errors"
"fmt"
"maps"
"net/http"
"net/url"
"path"
@@ -327,51 +327,25 @@ func NewTeam(ctx *context.Context) {
ctx.HTML(http.StatusOK, tplTeamNew)
}
// FIXME: TEAM-UNIT-PERMISSION: this design is not right, when a new unit is added in the future,
// The existing teams won't inherit the correct admin permission for the new unit.
// The full history is like this:
// 1. There was only "team", no "team unit", so "team.authorize" was used to determine the team permission.
// 2. Later, "team unit" was introduced, then the usage of "team.authorize" became inconsistent, and causes various bugs.
// - Sometimes, "team.authorize" is used to determine the team permission, e.g. admin, owner
// - Sometimes, "team unit" is used not really used and "team unit" is used.
// - Some functions like `GetTeamsWithAccessToAnyRepoUnit` use both.
//
// 3. After introducing "team unit" and more unclear changes, it becomes difficult to maintain team permissions.
// - Org owner need to click the permission for each unit, but can't just set a common "write" permission for all units.
//
// Ideally, "team.authorize=write" should mean the team has write access to all units including newly (future) added ones.
func getUnitPerms(forms url.Values, teamPermission perm.AccessMode) map[unit_model.Type]perm.AccessMode {
unitPerms := make(map[unit_model.Type]perm.AccessMode)
func paresFormTeamUnits(orgID int64, forms url.Values) (units []*org_model.TeamUnit) {
for _, ut := range unit_model.AllRepoUnitTypes {
// Default access mode is none
unitPerms[ut] = perm.AccessModeNone
v, ok := forms[fmt.Sprintf("unit_%d", ut)]
if ok {
vv, _ := strconv.Atoi(v[0])
if teamPermission >= perm.AccessModeAdmin {
unitPerms[ut] = teamPermission
// Don't allow `TypeExternal{Tracker,Wiki}` to influence this as they can only be set to READ perms.
if ut == unit_model.TypeExternalTracker || ut == unit_model.TypeExternalWiki {
unitPerms[ut] = perm.AccessModeRead
}
} else {
unitPerms[ut] = perm.AccessMode(vv)
if unitPerms[ut] >= perm.AccessModeAdmin {
unitPerms[ut] = perm.AccessModeWrite
}
}
v, ok := forms["unit_"+strconv.Itoa(ut.Value())]
if !ok {
continue
}
vv, _ := strconv.Atoi(v[0])
mode := perm.AccessMode(vv)
mode = min(mode, perm.AccessModeWrite, unit_model.Units[ut].MaxPerm())
units = append(units, &org_model.TeamUnit{OrgID: orgID, Type: ut, AccessMode: mode})
}
return unitPerms
return units
}
// NewTeamPost response for create new team
func NewTeamPost(ctx *context.Context) {
form := web.GetForm[*forms.CreateTeamForm](ctx)
includesAllRepositories := form.RepoAccess == "all"
teamPermission := perm.ParseAccessMode(form.Permission, perm.AccessModeNone, perm.AccessModeAdmin)
unitPerms := getUnitPerms(ctx.Req.Form, teamPermission)
teamPermission := perm.ParseAccessMode(form.Permission, perm.AccessModeNone, perm.AccessModeWrite, perm.AccessModeAdmin)
t := &org_model.Team{
OrgID: ctx.Org.Organization.ID,
@@ -383,16 +357,6 @@ func NewTeamPost(ctx *context.Context) {
Visibility: org_model.NormalizeTeamVisibility(form.Visibility),
}
units := make([]*org_model.TeamUnit, 0, len(unitPerms))
for tp, perm := range unitPerms {
units = append(units, &org_model.TeamUnit{
OrgID: ctx.Org.Organization.ID,
Type: tp,
AccessMode: perm,
})
}
t.Units = units
ctx.Data["Title"] = ctx.Org.Organization.FullName
ctx.Data["PageIsOrgTeams"] = true
ctx.Data["PageIsOrgTeamsNew"] = true
@@ -404,9 +368,12 @@ func NewTeamPost(ctx *context.Context) {
return
}
if t.AccessMode < perm.AccessModeAdmin && len(unitPerms) == 0 {
ctx.RenderWithErrDeprecated(ctx.Tr("form.team_no_units_error"), tplTeamNew, &form)
return
if t.AccessMode == perm.AccessModeNone {
t.Units = paresFormTeamUnits(ctx.Org.Organization.ID, ctx.Req.Form)
if len(t.Units) == 0 {
ctx.RenderWithErrDeprecated(ctx.Tr("form.team_no_units_error"), tplTeamNew, &form)
return
}
}
if err := org_service.NewTeam(ctx, t); err != nil {
@@ -545,10 +512,9 @@ func EditTeam(ctx *context.Context) {
// EditTeamPost response for modify team information
func EditTeamPost(ctx *context.Context) {
form := web.GetForm[*forms.CreateTeamForm](ctx)
t := ctx.Org.Team
teamPermission := perm.ParseAccessMode(form.Permission, perm.AccessModeNone, perm.AccessModeAdmin)
unitPerms := getUnitPerms(ctx.Req.Form, teamPermission)
isAuthChanged := false
teamPermission := perm.ParseAccessMode(form.Permission, perm.AccessModeNone, perm.AccessModeWrite, perm.AccessModeAdmin)
isIncludeAllChanged := false
includesAllRepositories := form.RepoAccess == "all"
@@ -557,13 +523,10 @@ func EditTeamPost(ctx *context.Context) {
ctx.Data["Team"] = t
ctx.Data["Units"] = unit_model.Units
oldTeamAccessMode := t.AccessMode
if !t.IsOwnerTeam() {
t.Name = form.TeamName
if t.AccessMode != teamPermission {
isAuthChanged = true
t.AccessMode = teamPermission
}
t.AccessMode = teamPermission
if t.IncludesAllRepositories != includesAllRepositories {
isIncludeAllChanged = true
t.IncludesAllRepositories = includesAllRepositories
@@ -576,28 +539,22 @@ func EditTeamPost(ctx *context.Context) {
t.Visibility = structs.VisibleTypeLimited
}
t.Description = form.Description
units := make([]*org_model.TeamUnit, 0, len(unitPerms))
for tp, perm := range unitPerms {
units = append(units, &org_model.TeamUnit{
OrgID: t.OrgID,
TeamID: t.ID,
Type: tp,
AccessMode: perm,
})
oldTeamUnitsMap := t.GetUnitsMap()
if t.AccessMode == perm.AccessModeNone {
t.Units = paresFormTeamUnits(ctx.Org.Organization.ID, ctx.Req.Form)
if len(t.Units) == 0 {
ctx.RenderWithErrDeprecated(ctx.Tr("form.team_no_units_error"), tplTeamNew, &form)
return
}
}
t.Units = units
if ctx.HasError() {
ctx.HTML(http.StatusOK, tplTeamNew)
return
}
if t.AccessMode < perm.AccessModeAdmin && len(unitPerms) == 0 {
ctx.RenderWithErrDeprecated(ctx.Tr("form.team_no_units_error"), tplTeamNew, &form)
return
}
isAuthChanged := oldTeamAccessMode != t.AccessMode || !maps.Equal(oldTeamUnitsMap, t.GetUnitsMap())
t.Description = form.Description
if err := org_service.UpdateTeam(ctx, t, isAuthChanged, isIncludeAllChanged); err != nil {
ctx.Data["Err_TeamName"] = true
switch {
+1 -1
View File
@@ -403,7 +403,7 @@ func Diff(ctx *context.Context) {
ctx.Data["DiffNotAvailable"] = diffShortStat.NumFiles == 0
if err := asymkey_model.CalculateTrustStatus(verification, ctx.Repo.Repository.GetTrustModel(), func(user *user_model.User) (bool, error) {
return repo_model.IsOwnerMemberCollaborator(ctx, ctx.Repo.Repository, user.ID)
return repo_model.HasAccessToRepoCodeUnit(ctx, ctx.Repo.Repository, user.ID)
}, nil); err != nil {
ctx.ServerError("CalculateTrustStatus", err)
return
+1 -1
View File
@@ -128,7 +128,7 @@ func loadLatestCommitData(ctx *context.Context, latestCommit *git.Commit) bool {
verification := asymkey_service.ParseCommitWithSignature(ctx, latestCommit)
if err := asymkey_model.CalculateTrustStatus(verification, ctx.Repo.Repository.GetTrustModel(), func(user *user_model.User) (bool, error) {
return repo_model.IsOwnerMemberCollaborator(ctx, ctx.Repo.Repository, user.ID)
return repo_model.HasAccessToRepoCodeUnit(ctx, ctx.Repo.Repository, user.ID)
}, nil); err != nil {
ctx.ServerError("CalculateTrustStatus", err)
return false
+1 -1
View File
@@ -453,7 +453,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
}
if ctx.ContextUser.IsOrganization() {
if ctx.Org.Organization.UnitPermission(ctx, ctx.Doer, unitType) < accessMode {
if ctx.Org.Organization.AnyRepoUnitPermission(ctx, ctx.Doer, unitType) < accessMode {
ctx.NotFound(nil)
return
}