refactor: carry the API token on the doer (#39690)

The token a request authenticated with now lives on the doer, so
permission and visibility checks apply its scope from the doer alone.
Replaces https://github.com/go-gitea/gitea/pull/39167, following
https://github.com/go-gitea/gitea/pull/39167#issuecomment-6063839619.

1. Public-only tokens get 404 instead of 403 for hidden users, orgs and
repos, like GitHub.
2. Org ownership, site admin, repo and org creation, generation and
transfer endpoints reject public-only tokens.
3. API pull request responses set `head.repo` to `null` and
`head.repo_id` to `-1` when the caller cannot see the head repository.
4. Public-only tokens see org memberships like anonymous callers, so
concealed members stay hidden.
5. Site admin access in activity feeds, follower lists, org members,
Actions run lists and packages needs a session or an `all`-scoped token.

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
silverwindandwxiaoguang authored and GitHub committed 2026-10-19 00:34:01 +00:00
1 parent 7e62dda791
commit 5264304a34
96 files changed
+583 -686

No files matched your search

+11 -23
View File
@@ -60,12 +60,6 @@ func (opts *SearchUserOptions) ToOrders() string {
return "id"
}
func (opts *SearchUserOptions) ApplyPublicOnly(publicOnly bool) {
if publicOnly {
opts.Visible = []structs.VisibleType{structs.VisibleTypePublic}
}
}
func (opts *SearchUserOptions) toSearchQueryBase(ctx context.Context) db.Session {
var cond builder.Cond
cond = builder.In("type", opts.Types)
@@ -177,23 +171,17 @@ func SearchUsers(ctx context.Context, opts SearchUserOptions) (users []*User, _
// BuildCanSeeUserCondition creates a condition which can be used to restrict results to users/orgs the actor can see
func BuildCanSeeUserCondition(actor *User) builder.Cond {
if actor != nil {
// If Admin - they see all users!
if !actor.IsAdmin {
// Users can see an organization they are a member of
cond := builder.In("`user`.id", builder.Select("org_id").From("org_user").Where(builder.Eq{"uid": actor.ID}))
if !actor.IsRestricted {
// Not-Restricted users can see public and limited users/organizations
cond = cond.Or(builder.In("`user`.visibility", structs.VisibleTypePublic, structs.VisibleTypeLimited))
}
// Don't forget about self
return cond.Or(builder.Eq{"`user`.id": actor.ID})
canViewPrivate := DoerCanViewPrivate(actor)
cond := builder.NewCond()
if actor != nil && !actor.IsAdmin && (canViewPrivate || actor.IsRestricted) {
cond = builder.In("`user`.id", builder.Select("org_id").From("org_user").Where(builder.Eq{"uid": actor.ID}))
if !actor.IsRestricted {
cond = cond.Or(builder.In("`user`.visibility", structs.VisibleTypePublic, structs.VisibleTypeLimited))
}
return nil
cond = cond.Or(builder.Eq{"`user`.id": actor.ID})
}
// Force visibility for privacy
// Not logged in - only public users
return builder.In("`user`.visibility", structs.VisibleTypePublic)
if !canViewPrivate {
cond = cond.And(builder.In("`user`.visibility", structs.VisibleTypePublic))
}
return cond
}
+7 -8
View File
@@ -175,7 +175,8 @@ type User struct {
// When the user model is used as a doer (all existing code does so), the doer can have extra details.
// * Actions task doer needs to bind to the task
// * Deploy-key doer needs to bind to the key
ExtDoerData ExtDoerData `xorm:"-"`
// * API token doer needs to bind to the token and its scope
ExtDoerData ExtDoerData `xorm:"-" json:"-"`
}
// Meta defines the meta information of a user, to be stored in the K/V table
@@ -445,11 +446,6 @@ func (u *User) IsTypeBot() bool {
return u.Type == UserTypeBot
}
// IsTokenAccessAllowed returns whether the user is an individual or a bot (which allows for token access)
func (u *User) IsTokenAccessAllowed() bool {
return u.Type == UserTypeIndividual || u.Type == UserTypeBot
}
// EmailTo returns a string suitable to be put into a e-mail `To:` header.
func (u *User) EmailTo() string {
sanitizedDisplayName := globalVars().emailToReplacer.Replace(u.DisplayName())
@@ -1446,11 +1442,11 @@ func GetAdminUser(ctx context.Context) (*User, error) {
}
func isUserVisibleToViewerCond(viewer *User) builder.Cond {
if viewer != nil && viewer.IsAdmin {
if DoerIsSiteAdminFullAccess(viewer) {
return builder.NewCond()
}
if viewer == nil || viewer.IsRestricted {
if !DoerCanViewPrivate(viewer) || viewer.IsRestricted {
return builder.Eq{
"`user`.visibility": structs.VisibleTypePublic,
}
@@ -1484,6 +1480,9 @@ func isUserVisibleToViewerCond(viewer *User) builder.Cond {
// IsUserVisibleToViewer check if viewer is able to see user profile
func IsUserVisibleToViewer(ctx context.Context, u, viewer *User) bool {
if !DoerTokenAllowsOwner(viewer, u) {
return false
}
if viewer != nil && (viewer.IsAdmin || viewer.ID == u.ID) {
return true
}
+88
View File
@@ -4,8 +4,11 @@
package user
import (
"errors"
"strconv"
"strings"
"gitea.dev/models/auth"
)
type ExtDoerData interface {
@@ -44,3 +47,88 @@ func (e *extDoerDeployKey) DecodeFromString(s string) (err error) {
e.DeployKeyID, err = strconv.ParseInt(idStr, 10, 64)
return err
}
const (
CredentialAccessToken = "access-token"
CredentialOAuth2Grant = "oauth2-grant"
)
type extDoerToken struct {
credential string // empty for package registry tokens
scope auth.AccessTokenScope
publicOnly bool
}
var _ ExtDoerData = (*extDoerToken)(nil)
func NewTokenExtDoerData(credentialKind string, credentialID int64, scope auth.AccessTokenScope) ExtDoerData {
publicOnly, err := scope.PublicOnly()
e := &extDoerToken{scope: scope, publicOnly: publicOnly || err != nil}
if credentialKind != "" {
e.credential = credentialKind + ":" + strconv.FormatInt(credentialID, 10)
}
return e
}
func (e *extDoerToken) EncodeToString() string {
return e.credential + "|" + string(e.scope)
}
func (e *extDoerToken) DecodeFromString(s string) error {
credential, scope, ok := strings.Cut(s, "|")
if !ok {
return errors.New("invalid token doer data")
}
publicOnly, err := auth.AccessTokenScope(scope).PublicOnly()
if err != nil {
return err
}
e.credential, e.scope, e.publicOnly = credential, auth.AccessTokenScope(scope), publicOnly
return nil
}
func getDoerToken(u *User) *extDoerToken {
if u == nil {
return nil
}
token, _ := u.ExtDoerData.(*extDoerToken)
return token
}
func GetDoerTokenScope(u *User) (auth.AccessTokenScope, bool) {
if token := getDoerToken(u); token != nil {
return token.scope, true
}
return "", false
}
func IsPublicOnlyDoer(u *User) bool {
token := getDoerToken(u)
return token != nil && token.publicOnly
}
func DoerCanViewPrivate(doer *User) bool {
return doer != nil && !IsPublicOnlyDoer(doer)
}
func DoerIsSiteAdminFullAccess(doer *User) bool {
if !DoerCanViewPrivate(doer) || !doer.IsAdmin {
return false
}
scope, hasScope := GetDoerTokenScope(doer)
return !hasScope || scope == auth.AccessTokenScopeAll
}
func DoerTokenAllowsOwner(doer, owner *User) bool {
return !IsPublicOnlyDoer(doer) || owner.Visibility.IsPublic()
}
func GetDoerCredential(u *User) string {
if u == nil || u.ExtDoerData == nil {
return ""
}
if token := getDoerToken(u); token != nil {
return token.credential
}
return u.ExtDoerData.EncodeToString()
}
+9 -7
View File
@@ -115,15 +115,17 @@ func GetSystemUserByID(uid int64) *User {
return nil
}
func GetDoerPermissionUser(ctx context.Context, id int64, extDoerData string) (u *User, _ error) {
if id > 0 {
return GetUserByID(ctx, id)
}
switch id {
case ActionsUserID:
func GetDoerPermissionUser(ctx context.Context, id int64, extDoerData string) (u *User, err error) {
switch {
case id > 0:
if u, err = GetUserByID(ctx, id); err != nil || extDoerData == "" {
return u, err
}
u.ExtDoerData = &extDoerToken{}
case id == ActionsUserID:
u = NewActionsUser()
u.ExtDoerData = &extDoerGiteaActions{}
case DeployKeyUserID:
case id == DeployKeyUserID:
u = NewDeployKeyUser()
u.ExtDoerData = &extDoerDeployKey{}
default:
+14
View File
@@ -6,6 +6,8 @@ package user
import (
"testing"
"gitea.dev/models/unittest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -37,3 +39,15 @@ func TestSystemUser(t *testing.T) {
assert.Equal(t, int64(-1), uid)
assert.Equal(t, "Ghost", u.Name)
}
func TestGetDoerPermissionUserCarriesToken(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
u, err := GetDoerPermissionUser(t.Context(), 2, NewTokenExtDoerData(CredentialAccessToken, 9, "public-only,read:repository").EncodeToString())
require.NoError(t, err)
assert.True(t, IsPublicOnlyDoer(u))
assert.Equal(t, "access-token:9", GetDoerCredential(u))
_, err = GetDoerPermissionUser(t.Context(), 2, "access-token:9|bogus")
assert.Error(t, err)
}
+9
View File
@@ -215,6 +215,11 @@ func TestSearchUsers(t *testing.T) {
testUserSuccess(user_model.SearchUserOptions{ListOptions: db.ListOptions{Page: 1}, IsTwoFactorEnabled: optional.Some(true)},
[]int64{24})
restrictedUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 29})
testUserSuccess(user_model.SearchUserOptions{Actor: restrictedUser}, []int64{29})
restrictedUser.ExtDoerData = user_model.NewTokenExtDoerData(user_model.CredentialAccessToken, 1, "public-only,read:user")
testUserSuccess(user_model.SearchUserOptions{Actor: restrictedUser}, []int64{29})
}
func TestEmailNotificationPreferences(t *testing.T) {
@@ -525,6 +530,10 @@ func TestIsUserVisibleToViewer(t *testing.T) {
test(user20, user1, true)
test(user31, user1, true)
test(user33, user1, true)
publicOnlyAdmin := *user1
publicOnlyAdmin.ExtDoerData = user_model.NewTokenExtDoerData(user_model.CredentialAccessToken, 1, "public-only,read:user")
test(user31, &publicOnlyAdmin, false)
test(user33, &publicOnlyAdmin, false)
// non admin viewer
test(user4, user4, true)