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:
bircni
2026-06-14 21:07:25 +02:00
committed by GitHub
parent 80ca22a9ef
commit 55250407dd
31 changed files with 850 additions and 144 deletions

View File

@@ -10,12 +10,14 @@ import (
"testing"
auth_model "gitea.dev/models/auth"
"gitea.dev/models/db"
"gitea.dev/models/organization"
"gitea.dev/models/perm"
"gitea.dev/models/repo"
"gitea.dev/models/unit"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/structs"
api "gitea.dev/modules/structs"
"gitea.dev/services/convert"
"gitea.dev/tests"
@@ -303,3 +305,61 @@ func TestAPIGetTeamRepo(t *testing.T) {
AddTokenAuth(token5)
MakeRequest(t, req, http.StatusNotFound)
}
func TestAPITeamVisibilityAccess(t *testing.T) {
defer tests.PrepareTestEnv(t)()
insertTestTeam := func(t *testing.T, orgID int64, name string, visibility structs.VisibleType) *organization.Team {
t.Helper()
team := &organization.Team{
OrgID: orgID,
LowerName: name,
Name: name,
AccessMode: perm.AccessModeRead,
Visibility: visibility,
}
assert.NoError(t, db.Insert(t.Context(), team))
return team
}
limitedTeam := insertTestTeam(t, 3, "limited-team", structs.VisibleTypeLimited)
// Org member who can read a limited team must not mutate its repos without membership.
user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
token := getUserToken(t, user4.Name, auth_model.AccessTokenScopeWriteOrganization)
req := NewRequestf(t, "PUT", "/api/v1/teams/%d/repos/org3/repo3", limitedTeam.ID).
AddTokenAuth(token)
MakeRequest(t, req, http.StatusForbidden)
publicTeam := insertTestTeam(t, 23, "public-team", structs.VisibleTypePublic)
// Public team in a private org must not be readable by outsiders.
outsider := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
token = getUserToken(t, outsider.Name, auth_model.AccessTokenScopeReadOrganization)
req = NewRequestf(t, "GET", "/api/v1/teams/%d", publicTeam.ID).
AddTokenAuth(token)
MakeRequest(t, req, http.StatusNotFound)
// Member lookup must require org membership even for public teams.
req = NewRequestf(t, "GET", "/api/v1/teams/%d/members/%s", publicTeam.ID, outsider.Name).
AddTokenAuth(token)
MakeRequest(t, req, http.StatusNotFound)
// A limited team's repo list must not leak repos the viewer cannot access.
// repo3 is private; user28 is an org3 member (team12, no repo access) who can
// read the limited team but has no access to repo3.
assert.NoError(t, db.Insert(t.Context(), &organization.TeamRepo{OrgID: 3, TeamID: limitedTeam.ID, RepoID: 3}))
user28 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 28})
token28 := getUserToken(t, user28.Name, auth_model.AccessTokenScopeReadOrganization)
req = NewRequestf(t, "GET", "/api/v1/teams/%d/repos", limitedTeam.ID).AddTokenAuth(token28)
resp := MakeRequest(t, req, http.StatusOK)
var repos []*api.Repository
DecodeJSON(t, resp, &repos)
for _, r := range repos {
assert.NotEqual(t, int64(3), r.ID, "must not leak inaccessible private repo3")
}
// The single-repo lookup must not confirm an inaccessible repo's existence.
req = NewRequestf(t, "GET", "/api/v1/teams/%d/repos/org3/repo3", limitedTeam.ID).AddTokenAuth(token28)
MakeRequest(t, req, http.StatusNotFound)
}