Allow admins and org owners to change org member public status (#28294)

Allows admins and org owners to change org member public status.

Before, this would return `Error 403: Cannot publicize another member`
despite the fact that the same user could make the same change through
the GUI.

Fixes #28372

---------

Co-authored-by: Tomáš Ženčák <zencak@ica.cz>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
Tomeamis 2025-04-13 10:07:29 +02:00 committed by GitHub
parent d0688cb2b3
commit 4dca869ed1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 176 additions and 137 deletions

View File

@ -8,6 +8,7 @@ import (
"net/url" "net/url"
"code.gitea.io/gitea/models/organization" "code.gitea.io/gitea/models/organization"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
api "code.gitea.io/gitea/modules/structs" api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/routers/api/v1/user" "code.gitea.io/gitea/routers/api/v1/user"
@ -210,6 +211,20 @@ func IsPublicMember(ctx *context.APIContext) {
} }
} }
func checkCanChangeOrgUserStatus(ctx *context.APIContext, targetUser *user_model.User) {
// allow user themselves to change their status, and allow admins to change any user
if targetUser.ID == ctx.Doer.ID || ctx.Doer.IsAdmin {
return
}
// allow org owners to change status of members
isOwner, err := ctx.Org.Organization.IsOwnedBy(ctx, ctx.Doer.ID)
if err != nil {
ctx.APIError(http.StatusInternalServerError, err)
} else if !isOwner {
ctx.APIError(http.StatusForbidden, "Cannot change member visibility")
}
}
// PublicizeMember make a member's membership public // PublicizeMember make a member's membership public
func PublicizeMember(ctx *context.APIContext) { func PublicizeMember(ctx *context.APIContext) {
// swagger:operation PUT /orgs/{org}/public_members/{username} organization orgPublicizeMember // swagger:operation PUT /orgs/{org}/public_members/{username} organization orgPublicizeMember
@ -240,8 +255,8 @@ func PublicizeMember(ctx *context.APIContext) {
if ctx.Written() { if ctx.Written() {
return return
} }
if userToPublicize.ID != ctx.Doer.ID { checkCanChangeOrgUserStatus(ctx, userToPublicize)
ctx.APIError(http.StatusForbidden, "Cannot publicize another member") if ctx.Written() {
return return
} }
err := organization.ChangeOrgUserStatus(ctx, ctx.Org.Organization.ID, userToPublicize.ID, true) err := organization.ChangeOrgUserStatus(ctx, ctx.Org.Organization.ID, userToPublicize.ID, true)
@ -282,8 +297,8 @@ func ConcealMember(ctx *context.APIContext) {
if ctx.Written() { if ctx.Written() {
return return
} }
if userToConceal.ID != ctx.Doer.ID { checkCanChangeOrgUserStatus(ctx, userToConceal)
ctx.APIError(http.StatusForbidden, "Cannot conceal another member") if ctx.Written() {
return return
} }
err := organization.ChangeOrgUserStatus(ctx, ctx.Org.Organization.ID, userToConceal.ID, false) err := organization.ChangeOrgUserStatus(ctx, ctx.Org.Organization.ID, userToConceal.ID, false)

View File

@ -22,6 +22,7 @@ import (
"code.gitea.io/gitea/tests" "code.gitea.io/gitea/tests"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
func TestAPIOrgCreateRename(t *testing.T) { func TestAPIOrgCreateRename(t *testing.T) {
@ -110,121 +111,142 @@ func TestAPIOrgCreateRename(t *testing.T) {
}) })
} }
func TestAPIOrgEdit(t *testing.T) { func TestAPIOrgGeneral(t *testing.T) {
defer tests.PrepareTestEnv(t)() defer tests.PrepareTestEnv(t)()
session := loginUser(t, "user1") user1Session := loginUser(t, "user1")
user1Token := getTokenForLoggedInUser(t, user1Session, auth_model.AccessTokenScopeWriteOrganization)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteOrganization) t.Run("OrgGetAll", func(t *testing.T) {
org := api.EditOrgOption{ // accessing with a token will return all orgs
FullName: "Org3 organization new full name", req := NewRequest(t, "GET", "/api/v1/orgs").AddTokenAuth(user1Token)
Description: "A new description", resp := MakeRequest(t, req, http.StatusOK)
Website: "https://try.gitea.io/new", var apiOrgList []*api.Organization
Location: "Beijing",
Visibility: "private",
}
req := NewRequestWithJSON(t, "PATCH", "/api/v1/orgs/org3", &org).
AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
var apiOrg api.Organization DecodeJSON(t, resp, &apiOrgList)
DecodeJSON(t, resp, &apiOrg) assert.Len(t, apiOrgList, 13)
assert.Equal(t, "Limited Org 36", apiOrgList[1].FullName)
assert.Equal(t, "limited", apiOrgList[1].Visibility)
assert.Equal(t, "org3", apiOrg.Name) // accessing without a token will return only public orgs
assert.Equal(t, org.FullName, apiOrg.FullName) req = NewRequest(t, "GET", "/api/v1/orgs")
assert.Equal(t, org.Description, apiOrg.Description) resp = MakeRequest(t, req, http.StatusOK)
assert.Equal(t, org.Website, apiOrg.Website)
assert.Equal(t, org.Location, apiOrg.Location) DecodeJSON(t, resp, &apiOrgList)
assert.Equal(t, org.Visibility, apiOrg.Visibility) assert.Len(t, apiOrgList, 9)
} assert.Equal(t, "org 17", apiOrgList[0].FullName)
assert.Equal(t, "public", apiOrgList[0].Visibility)
func TestAPIOrgEditBadVisibility(t *testing.T) { })
defer tests.PrepareTestEnv(t)()
session := loginUser(t, "user1") t.Run("OrgEdit", func(t *testing.T) {
org := api.EditOrgOption{
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteOrganization) FullName: "Org3 organization new full name",
org := api.EditOrgOption{ Description: "A new description",
FullName: "Org3 organization new full name", Website: "https://try.gitea.io/new",
Description: "A new description", Location: "Beijing",
Website: "https://try.gitea.io/new", Visibility: "private",
Location: "Beijing", }
Visibility: "badvisibility", req := NewRequestWithJSON(t, "PATCH", "/api/v1/orgs/org3", &org).AddTokenAuth(user1Token)
} resp := MakeRequest(t, req, http.StatusOK)
req := NewRequestWithJSON(t, "PATCH", "/api/v1/orgs/org3", &org).
AddTokenAuth(token) var apiOrg api.Organization
MakeRequest(t, req, http.StatusUnprocessableEntity) DecodeJSON(t, resp, &apiOrg)
}
assert.Equal(t, "org3", apiOrg.Name)
func TestAPIOrgDeny(t *testing.T) { assert.Equal(t, org.FullName, apiOrg.FullName)
defer tests.PrepareTestEnv(t)() assert.Equal(t, org.Description, apiOrg.Description)
defer test.MockVariableValue(&setting.Service.RequireSignInViewStrict, true)() assert.Equal(t, org.Website, apiOrg.Website)
assert.Equal(t, org.Location, apiOrg.Location)
orgName := "user1_org" assert.Equal(t, org.Visibility, apiOrg.Visibility)
req := NewRequestf(t, "GET", "/api/v1/orgs/%s", orgName) })
MakeRequest(t, req, http.StatusNotFound)
t.Run("OrgEditBadVisibility", func(t *testing.T) {
req = NewRequestf(t, "GET", "/api/v1/orgs/%s/repos", orgName) org := api.EditOrgOption{
MakeRequest(t, req, http.StatusNotFound) FullName: "Org3 organization new full name",
Description: "A new description",
req = NewRequestf(t, "GET", "/api/v1/orgs/%s/members", orgName) Website: "https://try.gitea.io/new",
MakeRequest(t, req, http.StatusNotFound) Location: "Beijing",
} Visibility: "badvisibility",
}
func TestAPIGetAll(t *testing.T) { req := NewRequestWithJSON(t, "PATCH", "/api/v1/orgs/org3", &org).AddTokenAuth(user1Token)
defer tests.PrepareTestEnv(t)() MakeRequest(t, req, http.StatusUnprocessableEntity)
token := getUserToken(t, "user1", auth_model.AccessTokenScopeReadOrganization) })
// accessing with a token will return all orgs t.Run("OrgDeny", func(t *testing.T) {
req := NewRequest(t, "GET", "/api/v1/orgs"). defer test.MockVariableValue(&setting.Service.RequireSignInViewStrict, true)()
AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK) orgName := "user1_org"
var apiOrgList []*api.Organization req := NewRequestf(t, "GET", "/api/v1/orgs/%s", orgName)
MakeRequest(t, req, http.StatusNotFound)
DecodeJSON(t, resp, &apiOrgList)
assert.Len(t, apiOrgList, 13) req = NewRequestf(t, "GET", "/api/v1/orgs/%s/repos", orgName)
assert.Equal(t, "Limited Org 36", apiOrgList[1].FullName) MakeRequest(t, req, http.StatusNotFound)
assert.Equal(t, "limited", apiOrgList[1].Visibility)
req = NewRequestf(t, "GET", "/api/v1/orgs/%s/members", orgName)
// accessing without a token will return only public orgs MakeRequest(t, req, http.StatusNotFound)
req = NewRequest(t, "GET", "/api/v1/orgs") })
resp = MakeRequest(t, req, http.StatusOK)
t.Run("OrgSearchEmptyTeam", func(t *testing.T) {
DecodeJSON(t, resp, &apiOrgList) orgName := "org_with_empty_team"
assert.Len(t, apiOrgList, 9) // create org
assert.Equal(t, "org 17", apiOrgList[0].FullName) req := NewRequestWithJSON(t, "POST", "/api/v1/orgs", &api.CreateOrgOption{
assert.Equal(t, "public", apiOrgList[0].Visibility) UserName: orgName,
} }).AddTokenAuth(user1Token)
MakeRequest(t, req, http.StatusCreated)
func TestAPIOrgSearchEmptyTeam(t *testing.T) {
defer tests.PrepareTestEnv(t)() // create team with no member
token := getUserToken(t, "user1", auth_model.AccessTokenScopeWriteOrganization) req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/orgs/%s/teams", orgName), &api.CreateTeamOption{
orgName := "org_with_empty_team" Name: "Empty",
IncludesAllRepositories: true,
// create org Permission: "read",
req := NewRequestWithJSON(t, "POST", "/api/v1/orgs", &api.CreateOrgOption{ Units: []string{"repo.code", "repo.issues", "repo.ext_issues", "repo.wiki", "repo.pulls"},
UserName: orgName, }).AddTokenAuth(user1Token)
}).AddTokenAuth(token) MakeRequest(t, req, http.StatusCreated)
MakeRequest(t, req, http.StatusCreated)
// case-insensitive search for teams that have no members
// create team with no member req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/orgs/%s/teams/search?q=%s", orgName, "empty")).
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/orgs/%s/teams", orgName), &api.CreateTeamOption{ AddTokenAuth(user1Token)
Name: "Empty", resp := MakeRequest(t, req, http.StatusOK)
IncludesAllRepositories: true, data := struct {
Permission: "read", Ok bool
Units: []string{"repo.code", "repo.issues", "repo.ext_issues", "repo.wiki", "repo.pulls"}, Data []*api.Team
}).AddTokenAuth(token) }{}
MakeRequest(t, req, http.StatusCreated) DecodeJSON(t, resp, &data)
assert.True(t, data.Ok)
// case-insensitive search for teams that have no members if assert.Len(t, data.Data, 1) {
req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/orgs/%s/teams/search?q=%s", orgName, "empty")). assert.Equal(t, "Empty", data.Data[0].Name)
AddTokenAuth(token) }
resp := MakeRequest(t, req, http.StatusOK) })
data := struct {
Ok bool t.Run("User2ChangeStatus", func(t *testing.T) {
Data []*api.Team user2Session := loginUser(t, "user2")
}{} user2Token := getTokenForLoggedInUser(t, user2Session, auth_model.AccessTokenScopeWriteOrganization)
DecodeJSON(t, resp, &data)
assert.True(t, data.Ok) req := NewRequest(t, "PUT", "/api/v1/orgs/org3/public_members/user2").AddTokenAuth(user2Token)
if assert.Len(t, data.Data, 1) { MakeRequest(t, req, http.StatusNoContent)
assert.Equal(t, "Empty", data.Data[0].Name) req = NewRequest(t, "DELETE", "/api/v1/orgs/org3/public_members/user2").AddTokenAuth(user2Token)
} MakeRequest(t, req, http.StatusNoContent)
// non admin but org owner could also change other member's status
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user2"})
require.False(t, user2.IsAdmin)
req = NewRequest(t, "PUT", "/api/v1/orgs/org3/public_members/user1").AddTokenAuth(user2Token)
MakeRequest(t, req, http.StatusNoContent)
req = NewRequest(t, "DELETE", "/api/v1/orgs/org3/public_members/user1").AddTokenAuth(user2Token)
MakeRequest(t, req, http.StatusNoContent)
})
t.Run("User4ChangeStatus", func(t *testing.T) {
user4Session := loginUser(t, "user4")
user4Token := getTokenForLoggedInUser(t, user4Session, auth_model.AccessTokenScopeWriteOrganization)
// user4 is a normal team member, they could change their own status
req := NewRequest(t, "PUT", "/api/v1/orgs/org3/public_members/user4").AddTokenAuth(user4Token)
MakeRequest(t, req, http.StatusNoContent)
req = NewRequest(t, "DELETE", "/api/v1/orgs/org3/public_members/user4").AddTokenAuth(user4Token)
MakeRequest(t, req, http.StatusNoContent)
req = NewRequest(t, "PUT", "/api/v1/orgs/org3/public_members/user1").AddTokenAuth(user4Token)
MakeRequest(t, req, http.StatusForbidden)
req = NewRequest(t, "DELETE", "/api/v1/orgs/org3/public_members/user1").AddTokenAuth(user4Token)
MakeRequest(t, req, http.StatusForbidden)
})
} }

View File

@ -21,29 +21,31 @@ import (
func TestAPITeamUser(t *testing.T) { func TestAPITeamUser(t *testing.T) {
defer tests.PrepareTestEnv(t)() defer tests.PrepareTestEnv(t)()
user2Session := loginUser(t, "user2")
user2Token := getTokenForLoggedInUser(t, user2Session, auth_model.AccessTokenScopeWriteOrganization)
normalUsername := "user2" t.Run("User2ReadUser1", func(t *testing.T) {
session := loginUser(t, normalUsername) req := NewRequest(t, "GET", "/api/v1/teams/1/members/user1").AddTokenAuth(user2Token)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadOrganization) MakeRequest(t, req, http.StatusNotFound)
req := NewRequest(t, "GET", "/api/v1/teams/1/members/user1"). })
AddTokenAuth(token)
MakeRequest(t, req, http.StatusNotFound)
req = NewRequest(t, "GET", "/api/v1/teams/1/members/user2"). t.Run("User2ReadSelf", func(t *testing.T) {
AddTokenAuth(token) // read self user
resp := MakeRequest(t, req, http.StatusOK) req := NewRequest(t, "GET", "/api/v1/teams/1/members/user2").AddTokenAuth(user2Token)
var user2 *api.User resp := MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &user2) var user2 *api.User
user2.Created = user2.Created.In(time.Local) DecodeJSON(t, resp, &user2)
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user2"}) user2.Created = user2.Created.In(time.Local)
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user2"})
expectedUser := convert.ToUser(db.DefaultContext, user, user) expectedUser := convert.ToUser(db.DefaultContext, user, user)
// test time via unix timestamp // test time via unix timestamp
assert.Equal(t, expectedUser.LastLogin.Unix(), user2.LastLogin.Unix()) assert.Equal(t, expectedUser.LastLogin.Unix(), user2.LastLogin.Unix())
assert.Equal(t, expectedUser.Created.Unix(), user2.Created.Unix()) assert.Equal(t, expectedUser.Created.Unix(), user2.Created.Unix())
expectedUser.LastLogin = user2.LastLogin expectedUser.LastLogin = user2.LastLogin
expectedUser.Created = user2.Created expectedUser.Created = user2.Created
assert.Equal(t, expectedUser, user2) assert.Equal(t, expectedUser, user2)
})
} }