mirror of
https://github.com/go-gitea/gitea.git
synced 2026-08-13 19:01:20 +00:00
feat(org): add team visibility so org members can discover teams (#37680)
Closes #37670. Today, org members in Gitea only see teams they're a member of. In larger orgs that hurts onboarding and discoverability — there's no way to look up which team owns what without asking around. GitHub solves this with a per-team visibility setting; this PR brings the same model to Gitea. ## What changes - Every team gets a `visibility` setting: - `private` *(default)* — only team members and org owners can see the team. Same as today's behavior. - `limited` — listable by any member of the organization. Members and the repos the team has access to are visible too. Non-org-members still see nothing. - `public` — listable by any signed-in user. - The Owners team visibility is fixed and cannot be changed via settings. - Existing teams default to `private`, so this is a no-op for anyone who doesn't change anything. ## API - `Team`, `CreateTeamOption`, `EditTeamOption` all gain a `visibility` field (string enum: `private` | `limited` | `public`). - `GET /orgs/{org}/teams` and `/orgs/{org}/teams/search` now apply the same visibility rules as the web UI: - site admins and org owners still see every team - other org members see their own teams plus any `limited` or `public` team - `private` teams are no longer leaked through these endpoints - Swagger/OpenAPI specs regenerated. ## UI View from admin2 (not an owner): <img width="1669" height="726" src="https://github.com/user-attachments/assets/daf4bccb-644b-4426-b178-71963aeaf73b" /> View from admin (owner): <img width="2559" height="863" src="https://github.com/user-attachments/assets/4f22cebc-e9df-4fd2-8ed4-724d31fadb7a" /> --------- Signed-off-by: bircni <bircni@icloud.com> Co-authored-by: TheFox0x7 <thefox0x7@gmail.com> Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
+87
-32
@@ -505,41 +505,79 @@ func reqOrgOwnership() func(ctx *context.APIContext) {
|
||||
}
|
||||
}
|
||||
|
||||
// reqTeamMembership user should be an team member, or a site admin
|
||||
func reqTeamMembership() func(ctx *context.APIContext) {
|
||||
func teamAccessPrivileged(ctx *context.APIContext) (orgID int64, privileged, ok bool) {
|
||||
if ctx.IsUserSiteAdmin() {
|
||||
return 0, true, true
|
||||
}
|
||||
if ctx.Org.Team == nil {
|
||||
setting.PanicInDevOrTesting("teamAccess: unprepared context")
|
||||
ctx.APIErrorInternal(errors.New("teamAccess: unprepared context"))
|
||||
return 0, false, false
|
||||
}
|
||||
|
||||
orgID = ctx.Org.Team.OrgID
|
||||
isOwner, err := organization.IsOrganizationOwner(ctx, orgID, ctx.Doer.ID)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return 0, false, false
|
||||
} else if isOwner {
|
||||
return orgID, true, true
|
||||
}
|
||||
|
||||
isTeamMember, err := organization.IsTeamMember(ctx, orgID, ctx.Org.Team.ID, ctx.Doer.ID)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return 0, false, false
|
||||
}
|
||||
return orgID, isTeamMember, true
|
||||
}
|
||||
|
||||
func denyNonTeamMember(ctx *context.APIContext, orgID int64) {
|
||||
isOrgMember, err := organization.IsOrganizationMember(ctx, orgID, ctx.Doer.ID)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
} else if isOrgMember {
|
||||
ctx.APIError(http.StatusForbidden, "Must be a team member")
|
||||
} else {
|
||||
ctx.APIErrorNotFound()
|
||||
}
|
||||
}
|
||||
|
||||
// reqTeamReadAccess allows callers who can list the team to read its metadata.
|
||||
// Non-members are admitted by the team's visibility tier and parent org visibility.
|
||||
// Not sufficient for mutations — use reqOrgOwnership() or reqTeamMembership() for those.
|
||||
func reqTeamReadAccess() func(ctx *context.APIContext) {
|
||||
return func(ctx *context.APIContext) {
|
||||
if ctx.IsUserSiteAdmin() {
|
||||
orgID, privileged, ok := teamAccessPrivileged(ctx)
|
||||
if !ok || privileged {
|
||||
return
|
||||
}
|
||||
if ctx.Org.Team == nil {
|
||||
setting.PanicInDevOrTesting("reqTeamMembership: unprepared context")
|
||||
ctx.APIErrorInternal(errors.New("reqTeamMembership: unprepared context"))
|
||||
if ctx.Org.Organization == nil {
|
||||
setting.PanicInDevOrTesting("reqTeamReadAccess: organization not loaded")
|
||||
ctx.APIErrorInternal(errors.New("reqTeamReadAccess: organization not loaded"))
|
||||
return
|
||||
}
|
||||
|
||||
orgID := ctx.Org.Team.OrgID
|
||||
isOwner, err := organization.IsOrganizationOwner(ctx, orgID, ctx.Doer.ID)
|
||||
visible, err := ctx.Org.Team.CanNonMemberReadMeta(ctx, ctx.Org.Organization.AsUser(), ctx.Doer)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
} else if isOwner {
|
||||
return
|
||||
}
|
||||
if !visible {
|
||||
// Not admitted by visibility: 403 for org members, 404 otherwise.
|
||||
denyNonTeamMember(ctx, orgID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if isTeamMember, err := organization.IsTeamMember(ctx, orgID, ctx.Org.Team.ID, ctx.Doer.ID); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
} else if !isTeamMember {
|
||||
isOrgMember, err := organization.IsOrganizationMember(ctx, orgID, ctx.Doer.ID)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
} else if isOrgMember {
|
||||
ctx.APIError(http.StatusForbidden, "Must be a team member")
|
||||
} else {
|
||||
ctx.APIErrorNotFound()
|
||||
}
|
||||
// reqTeamMembership user should be a team member, or a site admin
|
||||
func reqTeamMembership() func(ctx *context.APIContext) {
|
||||
return func(ctx *context.APIContext) {
|
||||
orgID, privileged, ok := teamAccessPrivileged(ctx)
|
||||
if !ok || privileged {
|
||||
return
|
||||
}
|
||||
denyNonTeamMember(ctx, orgID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -649,6 +687,17 @@ func orgAssignment(args ...bool) func(ctx *context.APIContext) {
|
||||
}
|
||||
return
|
||||
}
|
||||
if ctx.Org.Organization == nil {
|
||||
ctx.Org.Organization, err = organization.GetOrgByID(ctx, ctx.Org.Team.OrgID)
|
||||
if err != nil {
|
||||
if organization.IsErrOrgNotExist(err) {
|
||||
ctx.APIErrorNotFound()
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1703,25 +1752,31 @@ func Routes() *web.Router {
|
||||
}, reqToken(), reqOrgOwnership())
|
||||
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), orgAssignment(true), checkTokenPublicOnly())
|
||||
m.Group("/teams/{teamid}", func() {
|
||||
m.Combo("").Get(reqToken(), org.GetTeam).
|
||||
Patch(reqToken(), reqOrgOwnership(), bind(api.EditTeamOption{}), org.EditTeam).
|
||||
m.Combo("").Patch(reqToken(), reqOrgOwnership(), bind(api.EditTeamOption{}), org.EditTeam).
|
||||
Delete(reqToken(), reqOrgOwnership(), org.DeleteTeam)
|
||||
m.Group("", func() {
|
||||
m.Get("", org.GetTeam)
|
||||
m.Group("/members", func() {
|
||||
m.Get("", reqOrgMembership(), org.GetTeamMembers)
|
||||
m.Combo("/{username}").Get(reqOrgMembership(), org.GetTeamMember)
|
||||
})
|
||||
m.Group("/repos", func() {
|
||||
m.Get("", org.GetTeamRepos)
|
||||
m.Combo("/{org}/{reponame}").Get(org.GetTeamRepo)
|
||||
})
|
||||
m.Get("/activities/feeds", org.ListTeamActivityFeeds)
|
||||
}, reqTeamReadAccess())
|
||||
m.Group("/members", func() {
|
||||
m.Get("", reqToken(), org.GetTeamMembers)
|
||||
m.Combo("/{username}").
|
||||
Get(reqToken(), org.GetTeamMember).
|
||||
Put(reqToken(), reqOrgOwnership(), org.AddTeamMember).
|
||||
Delete(reqToken(), reqOrgOwnership(), org.RemoveTeamMember)
|
||||
})
|
||||
m.Group("/repos", func() {
|
||||
m.Get("", reqToken(), org.GetTeamRepos)
|
||||
m.Combo("/{org}/{reponame}").
|
||||
Put(reqToken(), org.AddTeamRepository).
|
||||
Delete(reqToken(), org.RemoveTeamRepository).
|
||||
Get(reqToken(), org.GetTeamRepo)
|
||||
Put(reqToken(), reqTeamMembership(), org.AddTeamRepository).
|
||||
Delete(reqToken(), reqTeamMembership(), org.RemoveTeamRepository)
|
||||
})
|
||||
m.Get("/activities/feeds", org.ListTeamActivityFeeds)
|
||||
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), orgAssignment(false, true), reqToken(), reqTeamMembership(), checkTokenPublicOnly())
|
||||
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), orgAssignment(false, true), reqToken(), checkTokenPublicOnly())
|
||||
|
||||
m.Group("/admin", func() {
|
||||
m.Group("/cron", func() {
|
||||
|
||||
+30
-17
@@ -55,10 +55,15 @@ func ListTeams(ctx *context.APIContext) {
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
listOptions := utils.GetListOptions(ctx)
|
||||
teams, count, err := organization.SearchTeam(ctx, &organization.SearchTeamOptions{
|
||||
opts := &organization.SearchTeamOptions{
|
||||
ListOptions: listOptions,
|
||||
OrgID: ctx.Org.Organization.ID,
|
||||
})
|
||||
}
|
||||
if err := organization.ApplyTeamListFilter(ctx, ctx.Org.Organization.ID, ctx.Doer, ctx.IsSigned, opts); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
teams, count, err := organization.SearchTeam(ctx, opts)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
@@ -218,6 +223,7 @@ func CreateTeam(ctx *context.APIContext) {
|
||||
IncludesAllRepositories: form.IncludesAllRepositories,
|
||||
CanCreateOrgRepo: form.CanCreateOrgRepo,
|
||||
AccessMode: teamPermission,
|
||||
Visibility: organization.NormalizeTeamVisibility(string(form.Visibility)),
|
||||
}
|
||||
|
||||
if team.AccessMode < perm.AccessModeAdmin {
|
||||
@@ -295,6 +301,10 @@ func EditTeam(ctx *context.APIContext) {
|
||||
team.Description = *form.Description
|
||||
}
|
||||
|
||||
if form.Visibility != nil && !team.IsOwnerTeam() {
|
||||
team.Visibility = organization.NormalizeTeamVisibility(string(*form.Visibility))
|
||||
}
|
||||
|
||||
isAuthChanged := false
|
||||
isIncludeAllChanged := false
|
||||
if !team.IsOwnerTeam() && len(form.Permission) != 0 {
|
||||
@@ -387,15 +397,6 @@ func GetTeamMembers(ctx *context.APIContext) {
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
isMember, err := organization.IsOrganizationMember(ctx, ctx.Org.Team.OrgID, ctx.Doer.ID)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
} else if !isMember && !ctx.Doer.IsAdmin {
|
||||
ctx.APIErrorNotFound()
|
||||
return
|
||||
}
|
||||
|
||||
listOptions := utils.GetListOptions(ctx)
|
||||
teamMembers, err := organization.GetTeamMembers(ctx, &organization.SearchMembersOptions{
|
||||
ListOptions: listOptions,
|
||||
@@ -574,14 +575,20 @@ func GetTeamRepos(ctx *context.APIContext) {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
repos := make([]*api.Repository, len(teamRepos))
|
||||
for i, repo := range teamRepos {
|
||||
repos := make([]*api.Repository, 0, len(teamRepos))
|
||||
for _, repo := range teamRepos {
|
||||
permission, err := access_model.GetDoerRepoPermission(ctx, repo, ctx.Doer)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
repos[i] = convert.ToRepo(ctx, repo, permission)
|
||||
// A team's repo list is reachable by non-team-members through the team's
|
||||
// visibility tier, so never expose repos (incl. their names) the doer
|
||||
// cannot access.
|
||||
if !permission.HasAnyUnitAccessOrPublicAccess() {
|
||||
continue
|
||||
}
|
||||
repos = append(repos, convert.ToRepo(ctx, repo, permission))
|
||||
}
|
||||
ctx.SetLinkHeader(int64(team.NumRepos), listOptions.PageSize)
|
||||
ctx.SetTotalCountHeader(int64(team.NumRepos))
|
||||
@@ -633,6 +640,12 @@ func GetTeamRepo(ctx *context.APIContext) {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
// The team may be reachable by a non-team-member via its visibility tier;
|
||||
// don't confirm the existence of a repo the doer cannot access.
|
||||
if !permission.HasAnyUnitAccessOrPublicAccess() {
|
||||
ctx.APIErrorNotFound()
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, convert.ToRepo(ctx, repo, permission))
|
||||
}
|
||||
@@ -806,9 +819,9 @@ func SearchTeam(ctx *context.APIContext) {
|
||||
ListOptions: listOptions,
|
||||
}
|
||||
|
||||
// Only admin is allowed to search for all teams
|
||||
if !ctx.Doer.IsAdmin {
|
||||
opts.UserID = ctx.Doer.ID
|
||||
if err := organization.ApplyTeamListFilter(ctx, ctx.Org.Organization.ID, ctx.Doer, ctx.IsSigned, opts); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
teams, maxResults, err := organization.SearchTeam(ctx, opts)
|
||||
|
||||
+20
-1
@@ -101,7 +101,26 @@ func home(ctx *context.Context, viewRepositories bool) {
|
||||
|
||||
const orgOverviewTeamsLimit = 5
|
||||
ctx.Data["OrgOverviewMembers"] = members
|
||||
ctx.Data["OrgOverviewTeams"] = ctx.Org.Teams[:min(len(ctx.Org.Teams), orgOverviewTeamsLimit)]
|
||||
// The overview widget shows only teams the viewer belongs to. ctx.Org.Teams
|
||||
// may include visible-but-not-joined teams (via IncludeVisibilities for
|
||||
// signed-in non-members), so re-query the viewer's own membership; owners
|
||||
// keep the full list they are entitled to manage.
|
||||
overviewTeams := ctx.Org.Teams
|
||||
if !ctx.Org.IsOwner {
|
||||
overviewTeams = nil
|
||||
if ctx.Org.IsMember {
|
||||
overviewTeams, _, err = organization.SearchTeam(ctx, &organization.SearchTeamOptions{
|
||||
OrgID: org.ID,
|
||||
UserID: ctx.Doer.ID,
|
||||
ListOptions: db.ListOptions{Page: 1, PageSize: orgOverviewTeamsLimit},
|
||||
})
|
||||
if err != nil {
|
||||
ctx.ServerError("SearchTeam", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.Data["OrgOverviewTeams"] = overviewTeams[:min(len(overviewTeams), orgOverviewTeamsLimit)]
|
||||
ctx.Data["DisableNewPullMirrors"] = setting.Mirror.DisableNewPull
|
||||
ctx.Data["ShowMemberAndTeamTab"] = ctx.Org.IsMember || len(members) > 0
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/structs"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/modules/web"
|
||||
@@ -80,6 +81,8 @@ func Teams(ctx *context.Context) {
|
||||
UserID: util.Iif(shouldSeeAllOrgTeams, 0, ctx.Doer.ID),
|
||||
Keyword: keyword,
|
||||
IncludeDesc: true,
|
||||
IncludeVisibilities: util.Iif(shouldSeeAllOrgTeams, nil,
|
||||
org_model.VisibleTeamVisibilitiesFor(ctx.Org.IsMember, ctx.IsSigned)),
|
||||
ListOptions: db.ListOptions{Page: page, PageSize: pagingNum},
|
||||
}
|
||||
return org_model.SearchTeam(ctx, opts)
|
||||
@@ -377,6 +380,7 @@ func NewTeamPost(ctx *context.Context) {
|
||||
AccessMode: teamPermission,
|
||||
IncludesAllRepositories: includesAllRepositories,
|
||||
CanCreateOrgRepo: form.CanCreateOrgRepo,
|
||||
Visibility: org_model.NormalizeTeamVisibility(form.Visibility),
|
||||
}
|
||||
|
||||
units := make([]*org_model.TeamUnit, 0, len(unitPerms))
|
||||
@@ -477,13 +481,22 @@ func SearchTeam(ctx *context.Context) {
|
||||
PageSize: convert.ToCorrectPageSize(ctx.FormInt("limit")),
|
||||
}
|
||||
|
||||
shouldSeeAll, err := context.UserShouldSeeAllOrgTeams(ctx)
|
||||
if err != nil {
|
||||
ctx.ServerError("UserShouldSeeAllOrgTeams", err)
|
||||
return
|
||||
}
|
||||
|
||||
opts := &org_model.SearchTeamOptions{
|
||||
// UserID is not set because the router already requires the doer to be an org admin. Thus, we don't need to restrict to teams that the user belongs in
|
||||
Keyword: ctx.FormTrim("q"),
|
||||
OrgID: ctx.Org.Organization.ID,
|
||||
IncludeDesc: ctx.FormString("include_desc") == "" || ctx.FormBool("include_desc"),
|
||||
ListOptions: listOptions,
|
||||
}
|
||||
if !shouldSeeAll {
|
||||
opts.UserID = ctx.Doer.ID
|
||||
opts.IncludeVisibilities = org_model.VisibleTeamVisibilitiesFor(ctx.Org.IsMember, ctx.IsSigned)
|
||||
}
|
||||
|
||||
teams, maxResults, err := org_model.SearchTeam(ctx, opts)
|
||||
if err != nil {
|
||||
@@ -556,8 +569,11 @@ func EditTeamPost(ctx *context.Context) {
|
||||
t.IncludesAllRepositories = includesAllRepositories
|
||||
}
|
||||
t.CanCreateOrgRepo = form.CanCreateOrgRepo
|
||||
t.Visibility = org_model.NormalizeTeamVisibility(form.Visibility)
|
||||
} else {
|
||||
t.CanCreateOrgRepo = true
|
||||
// The owner team must remain listable to all org members.
|
||||
t.Visibility = structs.VisibleTypeLimited
|
||||
}
|
||||
|
||||
t.Description = form.Description
|
||||
|
||||
Reference in New Issue
Block a user