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>
pull/22491/head^2
Tomeamis 2025-04-13 10:07:29 +07: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 171 additions and 132 deletions

@ -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)

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

@ -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")
normalUsername := "user2" user2Token := getTokenForLoggedInUser(t, user2Session, auth_model.AccessTokenScopeWriteOrganization)
session := loginUser(t, normalUsername)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadOrganization) t.Run("User2ReadUser1", func(t *testing.T) {
req := NewRequest(t, "GET", "/api/v1/teams/1/members/user1"). req := NewRequest(t, "GET", "/api/v1/teams/1/members/user1").AddTokenAuth(user2Token)
AddTokenAuth(token) MakeRequest(t, req, http.StatusNotFound)
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
assert.Equal(t, expectedUser.LastLogin.Unix(), user2.LastLogin.Unix()) // test time via unix timestamp
assert.Equal(t, expectedUser.Created.Unix(), user2.Created.Unix()) assert.Equal(t, expectedUser.LastLogin.Unix(), user2.LastLogin.Unix())
expectedUser.LastLogin = user2.LastLogin assert.Equal(t, expectedUser.Created.Unix(), user2.Created.Unix())
expectedUser.Created = user2.Created expectedUser.LastLogin = user2.LastLogin
expectedUser.Created = user2.Created
assert.Equal(t, expectedUser, user2)
assert.Equal(t, expectedUser, user2)
})
} }