feat: remove admin-level permissions from repo-specific & public-only access tokens (#11468)

This PR is part of a series (#11311).

If the user authenticating to an API call is a Forgejo site administrator, or a Forgejo repo administrator, a wide variety of permission and ownership checks in the API are either bypassed, or are bypassable.  If a user has created an access token with restricted resources, I understand the intent of the user is to create a token which has a layer of risk reduction in the event that the token is lost/leaked to an attacker.  For this reason, it makes sense to me that restricted scope access tokens shouldn't inherit the owner's administrator access.

My intent is that repo-specific access tokens [will only be able to access specific authorization scopes](https://codeberg.org/forgejo/design/issues/50#issuecomment-11093951), probably: `repository:read`, `repository:write`, `issue:read`, `issue:write`, (`organization:read` / `user:read` maybe).  This means that *most* admin access is not intended to be affected by this because repo-specific access tokens won't have, for example, `admin:write` scope.  However, administrative access still grants elevated permissions in some areas that are relevant to these scopes, and need to be restricted:

- The `?sudo=otheruser` query parameter allows site administrators to impersonate other users in the API.
- Repository management rules are different for a site administrator, allowing them to create repos for another user, create repos in another organization, migrate a repository to an arbitrary owner, and transfer a repository to a prviate organization.
- Administrators have access to extra data through some APIs which would be in scope: the detailed configuration of branch protection rules, the some details of repository deploy keys (which repo, and which scope -- seems odd), (user:read -- user SSH keys, activity feeds of private users, user profiles of private users, user webhook configurations).
- Pull request reviews have additional perms for repo administrators, including the ability to dismiss PR reviews, delete PR reviews, and view draft PR reviews.
- Repo admins and site admins can comment on locked issues, and related to comments can edit or delete other user's comments and attachments.
- Repo admins can manage and view logged time on behalf of other users.

A handful of these permissions may make sense for repo-specific access tokens, but most of them clearly exceed the risk that would be expected from creating a limited scope access token.  I'd generally prefer to take a restrictive approach, and we can relax it if real-world use-cases come in -- users will have a workaround of creating an access token without repo-specific restrictions if they are blocked from needed access.

**Breaking:** The administration restrictions introduced in this PR affect both repo-specific access tokens, and existing public-only access tokens.

## Checklist

The [contributor guide](https://forgejo.org/docs/next/contributor/) contains information that will be helpful to first time contributors. There also are a few [conditions for merging Pull Requests in Forgejo repositories](https://codeberg.org/forgejo/governance/src/branch/main/PullRequestsAgreement.md). You are also welcome to join the [Forgejo development chatroom](https://matrix.to/#/#forgejo-development:matrix.org).

### Tests for Go changes

(can be removed for JavaScript changes)

- I added test coverage for Go changes...
  - [x] in their respective `*_test.go` for unit tests.
  - [x] in the `tests/integration` directory if it involves interactions with a live Forgejo server.
- I ran...
  - [x] `make pr-go` before pushing

### Documentation

- [ ] I created a pull request [to the documentation](https://codeberg.org/forgejo/docs) to explain to Forgejo users how to use this change.
- [x] I did not document these changes and I do not expect someone else to do it.

### Release notes

- [x] This change will be noticed by a Forgejo user or admin (feature, bug fix, performance, etc.). I suggest to include a release note for this change.
    - Although repo-specific access tokens are not yet exposed to end users, the breaking changes to public-only tokens will be visible to users and require release notes.
- [ ] This change is not visible to a Forgejo user or admin (refactor, dependency upgrade, etc.). I think there is no need to add a release note for this change.

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/11468
Reviewed-by: Andreas Ahlenstorf <aahlenst@noreply.codeberg.org>
Co-authored-by: Mathieu Fenniak <mathieu@fenniak.net>
Co-committed-by: Mathieu Fenniak <mathieu@fenniak.net>
This commit is contained in:
Mathieu Fenniak 2026-03-04 16:17:41 +01:00 committed by Mathieu Fenniak
parent 0dd594baca
commit 99984dac4d
29 changed files with 483 additions and 349 deletions

View file

@ -28,7 +28,7 @@ func LogAndProcessError(ctx *context.Context, status int, obj any, cb func(strin
// LogAndProcessError is always wrapped in a `apiError` call, so we need to skip two frames
log.ErrorWithSkip(2, message)
if setting.IsProd && (ctx.Doer == nil || !ctx.Doer.IsAdmin) {
if setting.IsProd && (ctx.Doer == nil || !ctx.IsUserSiteAdmin()) {
message = ""
}
} else {

View file

@ -1,7 +1,7 @@
// Copyright 2026 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
package v1
package shared
import (
"testing"

View file

@ -35,7 +35,8 @@ func Middlewares() (stack []any) {
checkDeprecatedAuthMethods,
// Get user from session if logged in.
apiAuth(buildAuthGroup()),
apiAuthentication(buildAuthGroup()),
apiAuthorization,
verifyAuthWithOptions(&common.VerifyOptions{
SignInRequired: setting.Service.RequireSignInView,
}),
@ -55,7 +56,7 @@ func buildAuthGroup() *auth.Group {
return group
}
func apiAuth(authMethod auth.Method) func(*context.APIContext) {
func apiAuthentication(authMethod auth.Method) func(*context.APIContext) {
return func(ctx *context.APIContext) {
ar, err := common.AuthShared(ctx.Base, nil, authMethod)
if err != nil {
@ -65,8 +66,30 @@ func apiAuth(authMethod auth.Method) func(*context.APIContext) {
ctx.Doer = ar.Doer
ctx.IsSigned = ar.Doer != nil
ctx.IsBasicAuth = ar.IsBasicAuth
if ctx.Reducer == nil {
// Ensure ctx.Reducer isn't nil, but has no impact:
}
}
func apiAuthorization(ctx *context.APIContext) {
scope, scopeExists := ctx.Data["ApiTokenScope"].(auth_model.AccessTokenScope)
if scopeExists {
publicOnly, err := scope.PublicOnly()
if err != nil {
ctx.Error(http.StatusForbidden, "tokenRequiresScope", "parsing public resource scope failed: "+err.Error())
return
}
ctx.PublicOnly = publicOnly
}
reducer, reducerExists := ctx.Data["ApiTokenReducer"].(authz.AuthorizationReducer)
if reducerExists {
ctx.Reducer = reducer
} else {
// No "ApiTokenReducer" will be populated if the auth method wasn't an PAT. In this case, we populate
// `ctx.Reducer` so no nil checks are needed, and we respect the scope `PublicOnly()` so that it it's safe to
// just rely on `ctx.Reducer` to account for public-only access:
if ctx.PublicOnly {
ctx.Reducer = &authz.PublicReposAuthorizationReducer{}
} else {
ctx.Reducer = &authz.AllAccessAuthorizationReducer{}
}
}
@ -143,7 +166,7 @@ func verifyAuthWithOptions(options *common.VerifyOptions) func(ctx *context.APIC
}
if options.AdminRequired {
if !ctx.Doer.IsAdmin {
if !ctx.IsUserSiteAdmin() {
ctx.JSON(http.StatusForbidden, map[string]string{
"message": "You have no permission to request for this.",
})

View file

@ -5,21 +5,33 @@ package shared
import (
"bytes"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
auth_model "forgejo.org/models/auth"
"forgejo.org/models/db"
"forgejo.org/models/unittest"
"forgejo.org/modules/json"
"forgejo.org/modules/jwtx"
"forgejo.org/modules/test"
"forgejo.org/modules/web"
"forgejo.org/routers/common"
"forgejo.org/services/auth/source/oauth2"
"forgejo.org/services/authz"
"forgejo.org/services/context"
"github.com/golang-jwt/jwt/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestReducer(t *testing.T) {
defer unittest.OverrideFixtures("routers/api/shared/TestReducer")()
require.NoError(t, unittest.PrepareTestDatabase())
makeRecorder := func() *httptest.ResponseRecorder {
buff := bytes.NewBufferString("")
recorder := httptest.NewRecorder()
@ -32,9 +44,11 @@ func TestReducer(t *testing.T) {
r.Use(Middlewares()...)
type ReducerInfo struct {
IsSigned bool
IsNil bool
IsAllAccess bool
IsSigned bool
IsNil bool
IsAllAccess bool
IsPublicAccess bool
IsSpecificAccess bool
}
r.Get("/api/test", func(ctx *context.APIContext) {
@ -46,23 +60,206 @@ func TestReducer(t *testing.T) {
_, isAllAccess := ctx.Reducer.(*authz.AllAccessAuthorizationReducer)
retval.IsAllAccess = isAllAccess
_, isPublicAccess := ctx.Reducer.(*authz.PublicReposAuthorizationReducer)
retval.IsPublicAccess = isPublicAccess
_, isSpecificAccess := ctx.Reducer.(*authz.SpecificReposAuthorizationReducer)
retval.IsSpecificAccess = isSpecificAccess
ctx.JSON(http.StatusOK, retval)
})
// shared middleware ensures that `APIContext.Reducer` is not nil, and so that's the only test required in this
// package's scope -- an anonymous request and `Reducer` is not nil:
t.Run("anonymous", func(t *testing.T) {
recorder := makeRecorder()
req, err := http.NewRequest("GET", "http://localhost:8000/api/test", nil)
t.Run("Basic Auth w/ PAT", func(t *testing.T) {
t.Run("unrestricted access token", func(t *testing.T) {
recorder := makeRecorder()
req, err := http.NewRequest("GET", "http://localhost:8000/api/test", nil)
req.SetBasicAuth("token", "4a0c970da8bf58408a8c22264b2ac1ff47dadcce")
require.NoError(t, err)
r.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code)
var reducerInfo ReducerInfo
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &reducerInfo))
assert.True(t, reducerInfo.IsSigned)
assert.False(t, reducerInfo.IsNil)
assert.True(t, reducerInfo.IsAllAccess)
assert.False(t, reducerInfo.IsPublicAccess)
assert.False(t, reducerInfo.IsSpecificAccess)
})
t.Run("public-only access token", func(t *testing.T) {
recorder := makeRecorder()
req, err := http.NewRequest("GET", "http://localhost:8000/api/test", nil)
req.SetBasicAuth("token", "83909b5b978acc5620ae0c7b0e55b548da2e26b5")
require.NoError(t, err)
r.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code)
var reducerInfo ReducerInfo
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &reducerInfo))
assert.True(t, reducerInfo.IsSigned)
assert.False(t, reducerInfo.IsNil)
assert.False(t, reducerInfo.IsAllAccess)
assert.True(t, reducerInfo.IsPublicAccess)
assert.False(t, reducerInfo.IsSpecificAccess)
})
t.Run("specific-repo access token", func(t *testing.T) {
recorder := makeRecorder()
req, err := http.NewRequest("GET", "http://localhost:8000/api/test", nil)
req.SetBasicAuth("token", "46088605ec804b43ebd15cef1b3f210c31b066dd")
require.NoError(t, err)
r.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code)
var reducerInfo ReducerInfo
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &reducerInfo))
assert.True(t, reducerInfo.IsSigned)
assert.False(t, reducerInfo.IsNil)
assert.False(t, reducerInfo.IsAllAccess)
assert.False(t, reducerInfo.IsPublicAccess)
assert.True(t, reducerInfo.IsSpecificAccess)
})
})
t.Run("Token Auth", func(t *testing.T) {
t.Run("unrestricted access token", func(t *testing.T) {
recorder := makeRecorder()
req, err := http.NewRequest("GET", "http://localhost:8000/api/test", nil)
req.Header.Set("Authorization", "token 4a0c970da8bf58408a8c22264b2ac1ff47dadcce")
require.NoError(t, err)
r.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code)
var reducerInfo ReducerInfo
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &reducerInfo))
assert.True(t, reducerInfo.IsSigned)
assert.False(t, reducerInfo.IsNil)
assert.True(t, reducerInfo.IsAllAccess)
assert.False(t, reducerInfo.IsPublicAccess)
assert.False(t, reducerInfo.IsSpecificAccess)
})
t.Run("public-only access token", func(t *testing.T) {
recorder := makeRecorder()
req, err := http.NewRequest("GET", "http://localhost:8000/api/test", nil)
req.Header.Set("Authorization", "token 83909b5b978acc5620ae0c7b0e55b548da2e26b5")
require.NoError(t, err)
r.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code)
var reducerInfo ReducerInfo
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &reducerInfo))
assert.True(t, reducerInfo.IsSigned)
assert.False(t, reducerInfo.IsNil)
assert.False(t, reducerInfo.IsAllAccess)
assert.True(t, reducerInfo.IsPublicAccess)
assert.False(t, reducerInfo.IsSpecificAccess)
})
t.Run("specific-repo access token", func(t *testing.T) {
recorder := makeRecorder()
req, err := http.NewRequest("GET", "http://localhost:8000/api/test", nil)
req.Header.Set("Authorization", "token 46088605ec804b43ebd15cef1b3f210c31b066dd")
require.NoError(t, err)
r.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code)
var reducerInfo ReducerInfo
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &reducerInfo))
assert.True(t, reducerInfo.IsSigned)
assert.False(t, reducerInfo.IsNil)
assert.False(t, reducerInfo.IsAllAccess)
assert.False(t, reducerInfo.IsPublicAccess)
assert.True(t, reducerInfo.IsSpecificAccess)
})
})
t.Run("OAuth", func(t *testing.T) {
signingKey, err := jwtx.CreateSigningKey("HS256", make([]byte, 32))
require.NoError(t, err)
r.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code)
defer test.MockVariableValue(&oauth2.DefaultSigningKey, signingKey)()
var reducerInfo ReducerInfo
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &reducerInfo))
t.Run("unrestricted grant", func(t *testing.T) {
grant := &auth_model.OAuth2Grant{
UserID: 2,
ApplicationID: 100, // fake, but required here for unique constraint
Scope: "write:repository",
}
_, err = db.GetEngine(t.Context()).Insert(grant)
require.NoError(t, err)
assert.False(t, reducerInfo.IsSigned)
assert.False(t, reducerInfo.IsNil)
assert.True(t, reducerInfo.IsAllAccess)
token := oauth2.Token{
GrantID: grant.ID,
Type: oauth2.TypeAccessToken,
Counter: 100,
RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(time.Now().Add(-1 * time.Hour)),
ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)),
},
}
signed, err := token.SignToken(oauth2.DefaultSigningKey)
require.NoError(t, err)
recorder := makeRecorder()
req, err := http.NewRequest("GET", "http://localhost:8000/api/test", nil)
req.Header.Set("Authorization", fmt.Sprintf("bearer %s", signed))
require.NoError(t, err)
r.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code)
var reducerInfo ReducerInfo
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &reducerInfo))
assert.True(t, reducerInfo.IsSigned)
assert.False(t, reducerInfo.IsNil)
assert.True(t, reducerInfo.IsAllAccess)
assert.False(t, reducerInfo.IsPublicAccess)
assert.False(t, reducerInfo.IsSpecificAccess)
})
t.Run("public-only grant", func(t *testing.T) {
grant := &auth_model.OAuth2Grant{
UserID: 2,
ApplicationID: 101, // fake, but required here for unique constraint
Scope: "write:repository public-only",
}
_, err = db.GetEngine(t.Context()).Insert(grant)
require.NoError(t, err)
token := oauth2.Token{
GrantID: grant.ID,
Type: oauth2.TypeAccessToken,
Counter: 100,
RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(time.Now().Add(-1 * time.Hour)),
ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)),
},
}
signed, err := token.SignToken(oauth2.DefaultSigningKey)
require.NoError(t, err)
recorder := makeRecorder()
req, err := http.NewRequest("GET", "http://localhost:8000/api/test", nil)
req.Header.Set("Authorization", fmt.Sprintf("bearer %s", signed))
require.NoError(t, err)
r.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code)
var reducerInfo ReducerInfo
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &reducerInfo))
assert.True(t, reducerInfo.IsSigned)
assert.False(t, reducerInfo.IsNil)
assert.False(t, reducerInfo.IsAllAccess)
assert.True(t, reducerInfo.IsPublicAccess)
assert.False(t, reducerInfo.IsSpecificAccess)
})
})
}

View file

@ -86,7 +86,6 @@ import (
"forgejo.org/routers/api/v1/user"
"forgejo.org/services/actions"
"forgejo.org/services/auth"
"forgejo.org/services/authz"
"forgejo.org/services/context"
"forgejo.org/services/forms"
redirect_service "forgejo.org/services/redirect"
@ -105,7 +104,7 @@ func sudo() func(ctx *context.APIContext) {
}
if len(sudo) > 0 {
if ctx.IsSigned && ctx.Doer.IsAdmin {
if ctx.IsSigned && ctx.IsUserSiteAdmin() {
user, err := user_model.GetUserByName(ctx, sudo)
if err != nil {
if user_model.IsErrUserNotExist(err) {
@ -356,30 +355,6 @@ func tokenRequiresScopes(requiredScopeCategories ...auth_model.AccessTokenScopeC
}
ctx.Data["requiredScopeCategories"] = requiredScopeCategories
// check if scope only applies to public resources
publicOnly, err := scope.PublicOnly()
if err != nil {
ctx.Error(http.StatusForbidden, "tokenRequiresScope", "parsing public resource scope failed: "+err.Error())
return
}
// assign to true so that those searching should only filter public repositories/users/organizations
ctx.PublicOnly = publicOnly
reducer, ok := ctx.Data["ApiTokenReducer"].(authz.AuthorizationReducer)
if ok {
ctx.Reducer = reducer
} else {
// No "ApiTokenReducer" will be populated if the auth method wasn't an PAT. In this case, we populate
// `ctx.Reducer` so no nil checks are needed, and we respect the scope `PublicOnly()` so that it it's safe
// to just rely on `ctx.Reducer` to account for public-only access:
if ctx.PublicOnly {
ctx.Reducer = &authz.PublicReposAuthorizationReducer{}
} else {
ctx.Reducer = &authz.AllAccessAuthorizationReducer{}
}
}
}
}
@ -825,7 +800,7 @@ func individualPermsChecker(ctx *context.APIContext) {
if ctx.ContextUser.IsIndividual() {
switch ctx.ContextUser.Visibility {
case api.VisibleTypePrivate:
if ctx.Doer == nil || (ctx.ContextUser.ID != ctx.Doer.ID && !ctx.Doer.IsAdmin) {
if ctx.Doer == nil || (ctx.ContextUser.ID != ctx.Doer.ID && !ctx.IsUserSiteAdmin()) {
ctx.NotFound("Visit Project", nil)
return
}

View file

@ -1,266 +0,0 @@
// Copyright 2026 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
package v1
import (
"bytes"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
auth_model "forgejo.org/models/auth"
"forgejo.org/models/db"
"forgejo.org/models/unittest"
"forgejo.org/modules/json"
"forgejo.org/modules/jwtx"
"forgejo.org/modules/test"
"forgejo.org/modules/web"
"forgejo.org/routers/api/shared"
"forgejo.org/routers/common"
"forgejo.org/services/auth/source/oauth2"
"forgejo.org/services/authz"
"forgejo.org/services/context"
"github.com/golang-jwt/jwt/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestTokenRequiresScopes(t *testing.T) {
defer unittest.OverrideFixtures("routers/api/v1/TestTokenRequiresScopes")()
require.NoError(t, unittest.PrepareTestDatabase())
makeRecorder := func() *httptest.ResponseRecorder {
buff := bytes.NewBufferString("")
recorder := httptest.NewRecorder()
recorder.Body = buff
return recorder
}
r := web.NewRoute()
r.Use(common.ProtocolMiddlewares()...)
r.Use(shared.Middlewares()...)
type ReducerInfo struct {
IsSigned bool
IsNil bool
IsAllAccess bool
IsPublicAccess bool
IsSpecificAccess bool
}
r.Get("/api/test", tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository), func(ctx *context.APIContext) {
retval := ReducerInfo{
IsSigned: ctx.IsSigned,
IsNil: ctx.Reducer == nil,
}
_, isAllAccess := ctx.Reducer.(*authz.AllAccessAuthorizationReducer)
retval.IsAllAccess = isAllAccess
_, isPublicAccess := ctx.Reducer.(*authz.PublicReposAuthorizationReducer)
retval.IsPublicAccess = isPublicAccess
_, isSpecificAccess := ctx.Reducer.(*authz.SpecificReposAuthorizationReducer)
retval.IsSpecificAccess = isSpecificAccess
ctx.JSON(http.StatusOK, retval)
})
t.Run("Basic Auth w/ PAT", func(t *testing.T) {
t.Run("unrestricted access token", func(t *testing.T) {
recorder := makeRecorder()
req, err := http.NewRequest("GET", "http://localhost:8000/api/test", nil)
req.SetBasicAuth("token", "4a0c970da8bf58408a8c22264b2ac1ff47dadcce")
require.NoError(t, err)
r.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code)
var reducerInfo ReducerInfo
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &reducerInfo))
assert.True(t, reducerInfo.IsSigned)
assert.False(t, reducerInfo.IsNil)
assert.True(t, reducerInfo.IsAllAccess)
assert.False(t, reducerInfo.IsPublicAccess)
assert.False(t, reducerInfo.IsSpecificAccess)
})
t.Run("public-only access token", func(t *testing.T) {
recorder := makeRecorder()
req, err := http.NewRequest("GET", "http://localhost:8000/api/test", nil)
req.SetBasicAuth("token", "83909b5b978acc5620ae0c7b0e55b548da2e26b5")
require.NoError(t, err)
r.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code)
var reducerInfo ReducerInfo
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &reducerInfo))
assert.True(t, reducerInfo.IsSigned)
assert.False(t, reducerInfo.IsNil)
assert.False(t, reducerInfo.IsAllAccess)
assert.True(t, reducerInfo.IsPublicAccess)
assert.False(t, reducerInfo.IsSpecificAccess)
})
t.Run("specific-repo access token", func(t *testing.T) {
recorder := makeRecorder()
req, err := http.NewRequest("GET", "http://localhost:8000/api/test", nil)
req.SetBasicAuth("token", "46088605ec804b43ebd15cef1b3f210c31b066dd")
require.NoError(t, err)
r.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code)
var reducerInfo ReducerInfo
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &reducerInfo))
assert.True(t, reducerInfo.IsSigned)
assert.False(t, reducerInfo.IsNil)
assert.False(t, reducerInfo.IsAllAccess)
assert.False(t, reducerInfo.IsPublicAccess)
assert.True(t, reducerInfo.IsSpecificAccess)
})
})
t.Run("Token Auth", func(t *testing.T) {
t.Run("unrestricted access token", func(t *testing.T) {
recorder := makeRecorder()
req, err := http.NewRequest("GET", "http://localhost:8000/api/test", nil)
req.Header.Set("Authorization", "token 4a0c970da8bf58408a8c22264b2ac1ff47dadcce")
require.NoError(t, err)
r.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code)
var reducerInfo ReducerInfo
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &reducerInfo))
assert.True(t, reducerInfo.IsSigned)
assert.False(t, reducerInfo.IsNil)
assert.True(t, reducerInfo.IsAllAccess)
assert.False(t, reducerInfo.IsPublicAccess)
assert.False(t, reducerInfo.IsSpecificAccess)
})
t.Run("public-only access token", func(t *testing.T) {
recorder := makeRecorder()
req, err := http.NewRequest("GET", "http://localhost:8000/api/test", nil)
req.Header.Set("Authorization", "token 83909b5b978acc5620ae0c7b0e55b548da2e26b5")
require.NoError(t, err)
r.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code)
var reducerInfo ReducerInfo
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &reducerInfo))
assert.True(t, reducerInfo.IsSigned)
assert.False(t, reducerInfo.IsNil)
assert.False(t, reducerInfo.IsAllAccess)
assert.True(t, reducerInfo.IsPublicAccess)
assert.False(t, reducerInfo.IsSpecificAccess)
})
t.Run("specific-repo access token", func(t *testing.T) {
recorder := makeRecorder()
req, err := http.NewRequest("GET", "http://localhost:8000/api/test", nil)
req.Header.Set("Authorization", "token 46088605ec804b43ebd15cef1b3f210c31b066dd")
require.NoError(t, err)
r.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code)
var reducerInfo ReducerInfo
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &reducerInfo))
assert.True(t, reducerInfo.IsSigned)
assert.False(t, reducerInfo.IsNil)
assert.False(t, reducerInfo.IsAllAccess)
assert.False(t, reducerInfo.IsPublicAccess)
assert.True(t, reducerInfo.IsSpecificAccess)
})
})
t.Run("OAuth", func(t *testing.T) {
signingKey, err := jwtx.CreateSigningKey("HS256", make([]byte, 32))
require.NoError(t, err)
defer test.MockVariableValue(&oauth2.DefaultSigningKey, signingKey)()
t.Run("unrestricted grant", func(t *testing.T) {
grant := &auth_model.OAuth2Grant{
UserID: 2,
ApplicationID: 100, // fake, but required here for unique constraint
Scope: "write:repository",
}
_, err = db.GetEngine(t.Context()).Insert(grant)
require.NoError(t, err)
token := oauth2.Token{
GrantID: grant.ID,
Type: oauth2.TypeAccessToken,
Counter: 100,
RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(time.Now().Add(-1 * time.Hour)),
ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)),
},
}
signed, err := token.SignToken(oauth2.DefaultSigningKey)
require.NoError(t, err)
recorder := makeRecorder()
req, err := http.NewRequest("GET", "http://localhost:8000/api/test", nil)
req.Header.Set("Authorization", fmt.Sprintf("bearer %s", signed))
require.NoError(t, err)
r.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code)
var reducerInfo ReducerInfo
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &reducerInfo))
assert.True(t, reducerInfo.IsSigned)
assert.False(t, reducerInfo.IsNil)
assert.True(t, reducerInfo.IsAllAccess)
assert.False(t, reducerInfo.IsPublicAccess)
assert.False(t, reducerInfo.IsSpecificAccess)
})
t.Run("public-only grant", func(t *testing.T) {
grant := &auth_model.OAuth2Grant{
UserID: 2,
ApplicationID: 101, // fake, but required here for unique constraint
Scope: "write:repository public-only",
}
_, err = db.GetEngine(t.Context()).Insert(grant)
require.NoError(t, err)
token := oauth2.Token{
GrantID: grant.ID,
Type: oauth2.TypeAccessToken,
Counter: 100,
RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(time.Now().Add(-1 * time.Hour)),
ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)),
},
}
signed, err := token.SignToken(oauth2.DefaultSigningKey)
require.NoError(t, err)
recorder := makeRecorder()
req, err := http.NewRequest("GET", "http://localhost:8000/api/test", nil)
req.Header.Set("Authorization", fmt.Sprintf("bearer %s", signed))
require.NoError(t, err)
r.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code)
var reducerInfo ReducerInfo
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &reducerInfo))
assert.True(t, reducerInfo.IsSigned)
assert.False(t, reducerInfo.IsNil)
assert.False(t, reducerInfo.IsAllAccess)
assert.True(t, reducerInfo.IsPublicAccess)
assert.False(t, reducerInfo.IsSpecificAccess)
})
})
}

View file

@ -112,7 +112,7 @@ func getThread(ctx *context.APIContext) *activities_model.Notification {
}
return nil
}
if n.UserID != ctx.Doer.ID && !ctx.Doer.IsAdmin {
if n.UserID != ctx.Doer.ID && !ctx.IsUserSiteAdmin() {
ctx.Error(http.StatusForbidden, "GetNotificationByID", fmt.Errorf("only user itself and admin are allowed to read/change this thread %d", n.ID))
return nil
}

View file

@ -152,7 +152,7 @@ func IsMember(ctx *context.APIContext) {
if err != nil {
ctx.Error(http.StatusInternalServerError, "IsOrgMember", err)
return
} else if userIsMember || ctx.Doer.IsAdmin {
} else if userIsMember || ctx.IsUserSiteAdmin() {
userToCheckIsMember, err := ctx.Org.Organization.IsOrgMember(ctx, userToCheck.ID)
if err != nil {
ctx.Error(http.StatusInternalServerError, "IsOrgMember", err)

View file

@ -27,7 +27,7 @@ import (
func listUserOrgs(ctx *context.APIContext, u *user_model.User) {
listOptions := utils.GetListOptions(ctx)
showPrivate := ctx.IsSigned && (ctx.Doer.IsAdmin || ctx.Doer.ID == u.ID)
showPrivate := ctx.IsSigned && (ctx.IsUserSiteAdmin() || ctx.Doer.ID == u.ID)
opts := organization.FindOrgOptions{
ListOptions: listOptions,
@ -199,7 +199,7 @@ func GetAll(ctx *context.APIContext) {
vMode := []api.VisibleType{api.VisibleTypePublic}
if ctx.IsSigned && !ctx.PublicOnly {
vMode = append(vMode, api.VisibleTypeLimited)
if ctx.Doer.IsAdmin {
if ctx.IsUserSiteAdmin() {
vMode = append(vMode, api.VisibleTypePrivate)
}
}
@ -483,7 +483,7 @@ func ListOrgActivityFeeds(ctx *context.APIContext) {
includePrivate := false
if ctx.IsSigned {
if ctx.Doer.IsAdmin {
if ctx.IsUserSiteAdmin() {
includePrivate = true
} else {
org := organization.OrgFromUser(ctx.ContextUser)

View file

@ -405,7 +405,7 @@ func GetTeamMembers(ctx *context.APIContext) {
if err != nil {
ctx.Error(http.StatusInternalServerError, "IsOrganizationMember", err)
return
} else if !isMember && !ctx.Doer.IsAdmin {
} else if !isMember && !ctx.IsUserSiteAdmin() {
ctx.NotFound()
return
}
@ -822,7 +822,7 @@ func SearchTeam(ctx *context.APIContext) {
}
// Only admin is allowed to search for all teams
if !ctx.Doer.IsAdmin {
if !ctx.IsUserSiteAdmin() {
opts.UserID = ctx.Doer.ID
}

View file

@ -79,7 +79,7 @@ func GetBranch(ctx *context.APIContext) {
return
}
br, err := convert.ToBranch(ctx, ctx.Repo.Repository, branch.Name, c, branchProtection, ctx.Doer, ctx.Repo.IsAdmin())
br, err := convert.ToBranch(ctx, ctx.Repo.Repository, branch.Name, c, branchProtection, ctx.Doer, ctx.IsUserRepoAdmin())
if err != nil {
ctx.Error(http.StatusInternalServerError, "convert.ToBranch", err)
return
@ -283,7 +283,7 @@ func CreateBranch(ctx *context.APIContext) {
return
}
br, err := convert.ToBranch(ctx, ctx.Repo.Repository, branch.Name, commit, branchProtection, ctx.Doer, ctx.Repo.IsAdmin())
br, err := convert.ToBranch(ctx, ctx.Repo.Repository, branch.Name, commit, branchProtection, ctx.Doer, ctx.IsUserRepoAdmin())
if err != nil {
ctx.Error(http.StatusInternalServerError, "convert.ToBranch", err)
return
@ -378,7 +378,7 @@ func ListBranches(ctx *context.APIContext) {
}
branchProtection := rules.GetFirstMatched(branches[i].Name)
apiBranch, err := convert.ToBranch(ctx, ctx.Repo.Repository, branches[i].Name, c, branchProtection, ctx.Doer, ctx.Repo.IsAdmin())
apiBranch, err := convert.ToBranch(ctx, ctx.Repo.Repository, branches[i].Name, c, branchProtection, ctx.Doer, ctx.IsUserRepoAdmin())
if err != nil {
ctx.Error(http.StatusInternalServerError, "convert.ToBranch", err)
return

View file

@ -418,7 +418,7 @@ func CreateIssueComment(ctx *context.APIContext) {
return
}
if issue.IsLocked && !ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) && !ctx.Doer.IsAdmin {
if issue.IsLocked && !ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) && !ctx.IsUserSiteAdmin() {
ctx.Error(http.StatusForbidden, "CreateIssueComment", errors.New(ctx.Locale.TrString("repo.issues.comment_on_locked")))
return
}

View file

@ -127,7 +127,7 @@ func setIssueSubscription(ctx *context.APIContext, watch bool) {
}
// only admin and user for itself can change subscription
if user.ID != ctx.Doer.ID && !ctx.Doer.IsAdmin {
if user.ID != ctx.Doer.ID && !ctx.IsUserSiteAdmin() {
ctx.Error(http.StatusForbidden, "User", fmt.Errorf("%s is not permitted to change subscriptions for %s", ctx.Doer.Name, user.Name))
return
}

View file

@ -109,7 +109,7 @@ func ListTrackedTimes(ctx *context.APIContext) {
return
}
cantSetUser := !ctx.Doer.IsAdmin &&
cantSetUser := !ctx.IsUserSiteAdmin() &&
opts.UserID != ctx.Doer.ID &&
!ctx.IsUserRepoWriter([]unit.Type{unit.TypeIssues})
@ -203,7 +203,7 @@ func AddTime(ctx *context.APIContext) {
user := ctx.Doer
if form.User != "" {
if (ctx.IsUserRepoAdmin() && ctx.Doer.Name != form.User) || ctx.Doer.IsAdmin {
if (ctx.IsUserRepoAdmin() && ctx.Doer.Name != form.User) || ctx.IsUserSiteAdmin() {
// allow only RepoAdmin, Admin and User to add time
user, err = user_model.GetUserByName(ctx, form.User)
if err != nil {
@ -371,7 +371,7 @@ func DeleteTime(ctx *context.APIContext) {
return
}
if !ctx.Doer.IsAdmin && time.UserID != ctx.Doer.ID {
if !ctx.IsUserSiteAdmin() && time.UserID != ctx.Doer.ID {
// Only Admin and User itself can delete their time
ctx.Status(http.StatusForbidden)
return
@ -437,7 +437,7 @@ func ListTrackedTimesByUser(ctx *context.APIContext) {
return
}
if !ctx.IsUserRepoAdmin() && !ctx.Doer.IsAdmin && ctx.Doer.ID != user.ID {
if !ctx.IsUserRepoAdmin() && !ctx.IsUserSiteAdmin() && ctx.Doer.ID != user.ID {
ctx.Error(http.StatusForbidden, "", errors.New("query by user not allowed; not enough rights"))
return
}
@ -538,7 +538,7 @@ func ListTrackedTimesByRepository(ctx *context.APIContext) {
return
}
cantSetUser := !ctx.Doer.IsAdmin &&
cantSetUser := !ctx.IsUserSiteAdmin() &&
opts.UserID != ctx.Doer.ID &&
!ctx.IsUserRepoWriter([]unit.Type{unit.TypeIssues})

View file

@ -104,7 +104,7 @@ func ListDeployKeys(ctx *context.APIContext) {
return
}
apiKeys[i] = convert.ToDeployKey(apiLink, keys[i])
if ctx.Doer.IsAdmin || ((ctx.Repo.Repository.ID == keys[i].RepoID) && (ctx.Doer.ID == ctx.Repo.Owner.ID)) {
if ctx.IsUserSiteAdmin() || ((ctx.Repo.Repository.ID == keys[i].RepoID) && (ctx.Doer.ID == ctx.Repo.Owner.ID)) {
apiKeys[i], _ = appendPrivateInformation(ctx, apiKeys[i], keys[i], ctx.Repo.Repository)
}
}
@ -166,7 +166,7 @@ func GetDeployKey(ctx *context.APIContext) {
apiLink := composeDeployKeysAPILink(ctx.Repo.Owner.Name, ctx.Repo.Repository.Name)
apiKey := convert.ToDeployKey(apiLink, key)
if ctx.Doer.IsAdmin || ((ctx.Repo.Repository.ID == key.RepoID) && (ctx.Doer.ID == ctx.Repo.Owner.ID)) {
if ctx.IsUserSiteAdmin() || ((ctx.Repo.Repository.ID == key.RepoID) && (ctx.Doer.ID == ctx.Repo.Owner.ID)) {
apiKey, _ = appendPrivateInformation(ctx, apiKey, key, ctx.Repo.Repository)
}
ctx.JSON(http.StatusOK, apiKey)

View file

@ -91,7 +91,7 @@ func Migrate(ctx *context.APIContext) {
return
}
if !ctx.Doer.IsAdmin {
if !ctx.IsUserSiteAdmin() {
if !repoOwner.IsOrganization() && ctx.Doer.ID != repoOwner.ID {
ctx.Error(http.StatusForbidden, "", "Given user is not an organization.")
return

View file

@ -402,7 +402,7 @@ func DeletePullReview(ctx *context.APIContext) {
ctx.NotFound()
return
}
if !ctx.Doer.IsAdmin && ctx.Doer.ID != review.ReviewerID {
if !ctx.IsUserSiteAdmin() && ctx.Doer.ID != review.ReviewerID {
ctx.Error(http.StatusForbidden, "only admin and user itself can delete a review", nil)
return
}
@ -699,7 +699,7 @@ func prepareSingleReview(ctx *context.APIContext) (*issues_model.Review, *issues
}
// make sure that the user has access to this review if it is pending
if review.Type == issues_model.ReviewTypePending && review.ReviewerID != ctx.Doer.ID && !ctx.Doer.IsAdmin {
if review.Type == issues_model.ReviewTypePending && review.ReviewerID != ctx.Doer.ID && !ctx.IsUserSiteAdmin() {
ctx.NotFound("GetReviewByID")
return nil, nil, true
}
@ -1080,7 +1080,7 @@ func DeletePullReviewComment(ctx *context.APIContext) {
}
func dismissReview(ctx *context.APIContext, msg string, isDismiss, dismissPriors bool) {
if !ctx.Repo.IsAdmin() {
if !ctx.IsUserRepoAdmin() {
ctx.Error(http.StatusForbidden, "", "Must be repo admin")
return
}

View file

@ -419,12 +419,12 @@ func Generate(ctx *context.APIContext) {
return
}
if !ctx.Doer.IsAdmin && !ctxUser.IsOrganization() {
if !ctx.IsUserSiteAdmin() && !ctxUser.IsOrganization() {
ctx.Error(http.StatusForbidden, "", "Only admin can generate repository for other user.")
return
}
if !ctx.Doer.IsAdmin {
if !ctx.IsUserSiteAdmin() {
canCreate, err := organization.OrgFromUser(ctxUser).CanCreateOrgRepo(ctx, ctx.Doer.ID)
if err != nil {
ctx.ServerError("CanCreateOrgRepo", err)
@ -534,7 +534,7 @@ func CreateOrgRepo(ctx *context.APIContext) {
return
}
if !ctx.Doer.IsAdmin {
if !ctx.IsUserSiteAdmin() {
canCreate, err := org.CanCreateOrgRepo(ctx, ctx.Doer.ID)
if err != nil {
ctx.Error(http.StatusInternalServerError, "CanCreateOrgRepo", err)
@ -776,7 +776,7 @@ func updateBasicProperties(ctx *context.APIContext, opts api.EditRepoOption) err
visibilityChanged = repo.IsPrivate != *opts.Private
// when ForcePrivate enabled, you could change public repo to private, but only admin users can change private to public
if visibilityChanged && setting.Repository.ForcePrivate && !*opts.Private && !ctx.Doer.IsAdmin {
if visibilityChanged && setting.Repository.ForcePrivate && !*opts.Private && !ctx.IsUserSiteAdmin() {
err := errors.New("cannot change private repository to public")
ctx.Error(http.StatusUnprocessableEntity, "Force Private enabled", err)
return err

View file

@ -72,7 +72,7 @@ func Transfer(ctx *context.APIContext) {
}
if newOwner.Type == user_model.UserTypeOrganization {
if !ctx.Doer.IsAdmin && newOwner.Visibility == api.VisibleTypePrivate && !organization.OrgFromUser(newOwner).HasMemberWithUserID(ctx, ctx.Doer.ID) {
if !ctx.IsUserSiteAdmin() && newOwner.Visibility == api.VisibleTypePrivate && !organization.OrgFromUser(newOwner).HasMemberWithUserID(ctx, ctx.Doer.ID) {
// The user shouldn't know about this organization
ctx.Error(http.StatusNotFound, "", "The new owner does not exist or cannot be found")
return

View file

@ -70,7 +70,7 @@ func GetHook(ctx *context.APIContext) {
return
}
if !ctx.Doer.IsAdmin && hook.OwnerID != ctx.Doer.ID {
if !ctx.IsUserSiteAdmin() && hook.OwnerID != ctx.Doer.ID {
ctx.NotFound()
return
}

View file

@ -90,7 +90,7 @@ func listPublicKeys(ctx *context.APIContext, user *user_model.User) {
apiKeys := make([]*api.PublicKey, len(keys))
for i := range keys {
apiKeys[i] = convert.ToPublicKey(apiLink, keys[i])
if ctx.Doer.IsAdmin || ctx.Doer.ID == keys[i].OwnerID {
if ctx.IsUserSiteAdmin() || ctx.Doer.ID == keys[i].OwnerID {
apiKeys[i], _ = appendPrivateInformation(ctx, apiKeys[i], keys[i], user)
}
}
@ -200,7 +200,7 @@ func GetPublicKey(ctx *context.APIContext) {
apiLink := composePublicKeysAPILink()
apiKey := convert.ToPublicKey(apiLink, key)
if ctx.Doer.IsAdmin || ctx.Doer.ID == key.OwnerID {
if ctx.IsUserSiteAdmin() || ctx.Doer.ID == key.OwnerID {
apiKey, _ = appendPrivateInformation(ctx, apiKey, key, ctx.Doer)
}
ctx.JSON(http.StatusOK, apiKey)
@ -226,7 +226,7 @@ func CreateUserPublicKey(ctx *context.APIContext, form api.CreateKeyOption, uid
}
apiLink := composePublicKeysAPILink()
apiKey := convert.ToPublicKey(apiLink, key)
if ctx.Doer.IsAdmin || ctx.Doer.ID == key.OwnerID {
if ctx.IsUserSiteAdmin() || ctx.Doer.ID == key.OwnerID {
apiKey, _ = appendPrivateInformation(ctx, apiKey, key, ctx.Doer)
}
ctx.JSON(http.StatusCreated, apiKey)

View file

@ -43,7 +43,7 @@ func listUserRepos(ctx *context.APIContext, u *user_model.User, private bool) {
ctx.Error(http.StatusInternalServerError, "GetUserRepoPermissionWithReducer", err)
return
}
if ctx.IsSigned && ctx.Doer.IsAdmin || permission.HasAccess() {
if ctx.IsSigned && ctx.IsUserSiteAdmin() || permission.HasAccess() {
apiRepos = append(apiRepos, convert.ToRepo(ctx, repos[i], permission))
}
}

View file

@ -214,7 +214,7 @@ func ListUserActivityFeeds(ctx *context.APIContext) {
// "404":
// "$ref": "#/responses/notFound"
includePrivate := ctx.IsSigned && (ctx.Doer.IsAdmin || ctx.Doer.ID == ctx.ContextUser.ID)
includePrivate := ctx.IsSigned && (ctx.IsUserSiteAdmin() || ctx.Doer.ID == ctx.ContextUser.ID)
listOptions := utils.GetListOptions(ctx)
opts := activities_model.GetFeedsOptions{