diff --git a/.semgrep/config/auth.yaml b/.semgrep/config/auth.yaml index fdd0a5cf72..09dacf28c1 100644 --- a/.semgrep/config/auth.yaml +++ b/.semgrep/config/auth.yaml @@ -76,3 +76,36 @@ rules: paths: include: - "/routers/api/**/*.go" + + - id: forgejo-api-direct-IsAdmin-check + patterns: + - pattern: | + ctx.Doer.IsAdmin + languages: + - go + message: | + ctx.Doer.IsAdmin does not take into account limited API access tokens. Use ctx.IsUserSiteAdmin() instead. + fix: | + ctx.IsUserSiteAdmin() + severity: ERROR + paths: + include: + - "/routers/api/**/*.go" + + - id: forgejo-api-direct-repo-Admin-check + patterns: + - pattern: | + ctx.Repo.IsAdmin() + - pattern: | + ctx.Repo.IsOwner() + languages: + - go + message: | + ctx.Repo.IsAdmin/IsOwner() does not take into account limited API access tokens. Use ctx.IsUserRepoAdmin() instead. + fix: | + ctx.IsUserRepoAdmin() + severity: ERROR + paths: + include: + - "/routers/api/**/*.go" + diff --git a/routers/api/packages/helper/helper.go b/routers/api/packages/helper/helper.go index 47d1f18623..7d82571b68 100644 --- a/routers/api/packages/helper/helper.go +++ b/routers/api/packages/helper/helper.go @@ -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 { diff --git a/routers/api/v1/TestTokenRequiresScopes/access_token.yml b/routers/api/shared/TestReducer/access_token.yml similarity index 100% rename from routers/api/v1/TestTokenRequiresScopes/access_token.yml rename to routers/api/shared/TestReducer/access_token.yml diff --git a/routers/api/v1/main_test.go b/routers/api/shared/main_test.go similarity index 93% rename from routers/api/v1/main_test.go rename to routers/api/shared/main_test.go index 7553b68bfd..a328ac53cb 100644 --- a/routers/api/v1/main_test.go +++ b/routers/api/shared/main_test.go @@ -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" diff --git a/routers/api/shared/middleware.go b/routers/api/shared/middleware.go index efd252728e..f657dae026 100644 --- a/routers/api/shared/middleware.go +++ b/routers/api/shared/middleware.go @@ -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.", }) diff --git a/routers/api/shared/middleware_test.go b/routers/api/shared/middleware_test.go index 3c5fc4d00d..52771eb0e7 100644 --- a/routers/api/shared/middleware_test.go +++ b/routers/api/shared/middleware_test.go @@ -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) + }) }) } diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index b680dd50ac..ce013e7fdb 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -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 } diff --git a/routers/api/v1/api_test.go b/routers/api/v1/api_test.go deleted file mode 100644 index 4b6ae948f3..0000000000 --- a/routers/api/v1/api_test.go +++ /dev/null @@ -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) - }) - }) -} diff --git a/routers/api/v1/notify/threads.go b/routers/api/v1/notify/threads.go index 9f0e416698..2035bccfd8 100644 --- a/routers/api/v1/notify/threads.go +++ b/routers/api/v1/notify/threads.go @@ -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 } diff --git a/routers/api/v1/org/member.go b/routers/api/v1/org/member.go index c6e67b2ab9..48785f1656 100644 --- a/routers/api/v1/org/member.go +++ b/routers/api/v1/org/member.go @@ -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) diff --git a/routers/api/v1/org/org.go b/routers/api/v1/org/org.go index e69a24bc0b..a52095bcfe 100644 --- a/routers/api/v1/org/org.go +++ b/routers/api/v1/org/org.go @@ -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) diff --git a/routers/api/v1/org/team.go b/routers/api/v1/org/team.go index 1fdaca02bd..489b22892a 100644 --- a/routers/api/v1/org/team.go +++ b/routers/api/v1/org/team.go @@ -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 } diff --git a/routers/api/v1/repo/branch.go b/routers/api/v1/repo/branch.go index e043448590..88cefb78a6 100644 --- a/routers/api/v1/repo/branch.go +++ b/routers/api/v1/repo/branch.go @@ -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 diff --git a/routers/api/v1/repo/issue_comment.go b/routers/api/v1/repo/issue_comment.go index 8a0cf241da..ceebb41f9e 100644 --- a/routers/api/v1/repo/issue_comment.go +++ b/routers/api/v1/repo/issue_comment.go @@ -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 } diff --git a/routers/api/v1/repo/issue_subscription.go b/routers/api/v1/repo/issue_subscription.go index 33654dc136..0fa435e2f5 100644 --- a/routers/api/v1/repo/issue_subscription.go +++ b/routers/api/v1/repo/issue_subscription.go @@ -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 } diff --git a/routers/api/v1/repo/issue_tracked_time.go b/routers/api/v1/repo/issue_tracked_time.go index 0fb96fc319..cc961ca364 100644 --- a/routers/api/v1/repo/issue_tracked_time.go +++ b/routers/api/v1/repo/issue_tracked_time.go @@ -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}) diff --git a/routers/api/v1/repo/key.go b/routers/api/v1/repo/key.go index 2abf95a189..36fe2080d4 100644 --- a/routers/api/v1/repo/key.go +++ b/routers/api/v1/repo/key.go @@ -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) diff --git a/routers/api/v1/repo/migrate.go b/routers/api/v1/repo/migrate.go index 874a05a3ac..272806bfb0 100644 --- a/routers/api/v1/repo/migrate.go +++ b/routers/api/v1/repo/migrate.go @@ -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 diff --git a/routers/api/v1/repo/pull_review.go b/routers/api/v1/repo/pull_review.go index 76201f3f6e..97c5dcee78 100644 --- a/routers/api/v1/repo/pull_review.go +++ b/routers/api/v1/repo/pull_review.go @@ -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 } diff --git a/routers/api/v1/repo/repo.go b/routers/api/v1/repo/repo.go index ad665770bf..e182f207f9 100644 --- a/routers/api/v1/repo/repo.go +++ b/routers/api/v1/repo/repo.go @@ -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 diff --git a/routers/api/v1/repo/transfer.go b/routers/api/v1/repo/transfer.go index 72cfeaf902..96f1919055 100644 --- a/routers/api/v1/repo/transfer.go +++ b/routers/api/v1/repo/transfer.go @@ -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 diff --git a/routers/api/v1/user/hook.go b/routers/api/v1/user/hook.go index 1c44204470..f84138caa9 100644 --- a/routers/api/v1/user/hook.go +++ b/routers/api/v1/user/hook.go @@ -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 } diff --git a/routers/api/v1/user/key.go b/routers/api/v1/user/key.go index d8b5dfdfe9..94af893a0e 100644 --- a/routers/api/v1/user/key.go +++ b/routers/api/v1/user/key.go @@ -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) diff --git a/routers/api/v1/user/repo.go b/routers/api/v1/user/repo.go index 4d23b5c643..92d86e10b0 100644 --- a/routers/api/v1/user/repo.go +++ b/routers/api/v1/user/repo.go @@ -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)) } } diff --git a/routers/api/v1/user/user.go b/routers/api/v1/user/user.go index 7bb4db69c5..9083774c0e 100644 --- a/routers/api/v1/user/user.go +++ b/routers/api/v1/user/user.go @@ -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{ diff --git a/services/context/api.go b/services/context/api.go index 27458f3768..434da29906 100644 --- a/services/context/api.go +++ b/services/context/api.go @@ -450,11 +450,17 @@ func (ctx *APIContext) NotFoundOrServerError(logMsg string, errCheck func(error) // IsUserSiteAdmin returns true if current user is a site admin func (ctx *APIContext) IsUserSiteAdmin() bool { + if !ctx.Reducer.AllowAdminOverride() { + return false + } return ctx.IsSigned && ctx.Doer.IsAdmin } // IsUserRepoAdmin returns true if current user is admin in current repo func (ctx *APIContext) IsUserRepoAdmin() bool { + if !ctx.Reducer.AllowAdminOverride() { + return false + } return ctx.Repo.IsAdmin() } diff --git a/services/context/api_test.go b/services/context/api_test.go index 9916160f8d..a8e7baf5f7 100644 --- a/services/context/api_test.go +++ b/services/context/api_test.go @@ -9,8 +9,12 @@ import ( "strconv" "testing" + perm_model "forgejo.org/models/perm" + access_model "forgejo.org/models/perm/access" + user_model "forgejo.org/models/user" "forgejo.org/modules/setting" "forgejo.org/modules/test" + "forgejo.org/services/authz" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -74,3 +78,78 @@ func TestAcceptsGithubResponse(t *testing.T) { assert.True(t, ctx.AcceptsGithubResponse()) }) } + +func TestIsUserSiteAdmin(t *testing.T) { + makeCtx := func(t *testing.T, reducer authz.AuthorizationReducer) *APIContext { + req := httptest.NewRequest("GET", "/", nil) + resp := httptest.NewRecorder() + base, baseCleanUp := NewBaseContext(resp, req) + t.Cleanup(baseCleanUp) + ctx := &APIContext{Base: base, Reducer: reducer} + // setup ctx with an admin, and the test cases will modify to false in various ways + ctx.IsSigned = true + ctx.Doer = &user_model.User{IsAdmin: true} + return ctx + } + + defaultReducer := authz.NewMockAuthorizationReducer(t) + defaultReducer.On("AllowAdminOverride").Return(true) + + t.Run("not authenticated", func(t *testing.T) { + ctx := makeCtx(t, defaultReducer) + ctx.IsSigned = false + assert.False(t, ctx.IsUserSiteAdmin()) + }) + + t.Run("non-admin", func(t *testing.T) { + ctx := makeCtx(t, defaultReducer) + ctx.Doer.IsAdmin = false + assert.False(t, ctx.IsUserSiteAdmin()) + }) + + t.Run("admin", func(t *testing.T) { + ctx := makeCtx(t, defaultReducer) + assert.True(t, ctx.IsUserSiteAdmin()) + }) + + t.Run("admin w/ reducer", func(t *testing.T) { + reducer := authz.NewMockAuthorizationReducer(t) + reducer.On("AllowAdminOverride").Return(false) + ctx := makeCtx(t, reducer) + assert.False(t, ctx.IsUserSiteAdmin()) + }) +} + +func TestIsUserRepoAdmin(t *testing.T) { + makeCtx := func(t *testing.T, reducer authz.AuthorizationReducer) *APIContext { + req := httptest.NewRequest("GET", "/", nil) + resp := httptest.NewRecorder() + base, baseCleanUp := NewBaseContext(resp, req) + t.Cleanup(baseCleanUp) + ctx := &APIContext{Base: base, Reducer: reducer} + // setup ctx with a repo admin, and the test cases will modify to false in various ways + ctx.Repo = &Repository{Permission: access_model.Permission{AccessMode: perm_model.AccessModeAdmin}} + return ctx + } + + defaultReducer := authz.NewMockAuthorizationReducer(t) + defaultReducer.On("AllowAdminOverride").Return(true) + + t.Run("non-admin", func(t *testing.T) { + ctx := makeCtx(t, defaultReducer) + ctx.Repo.Permission.AccessMode = perm_model.AccessModeWrite + assert.False(t, ctx.IsUserRepoAdmin()) + }) + + t.Run("admin", func(t *testing.T) { + ctx := makeCtx(t, defaultReducer) + assert.True(t, ctx.IsUserRepoAdmin()) + }) + + t.Run("admin w/ reducer", func(t *testing.T) { + reducer := authz.NewMockAuthorizationReducer(t) + reducer.On("AllowAdminOverride").Return(false) + ctx := makeCtx(t, reducer) + assert.False(t, ctx.IsUserRepoAdmin()) + }) +} diff --git a/tests/integration/api_admin_test.go b/tests/integration/api_admin_test.go index e83630c919..ae5a5b46ae 100644 --- a/tests/integration/api_admin_test.go +++ b/tests/integration/api_admin_test.go @@ -97,6 +97,43 @@ func TestAPISudoUser(t *testing.T) { assert.Equal(t, normalUsername, user.UserName) } +// Variation of TestAPISudoUser which verifies the usability of `sudo` with various access token restrictions. +func TestAPISudoUserAuthorizationReducer(t *testing.T) { + defer tests.PrepareTestEnv(t)() + adminUsername := "user1" + normalUsername := "user2" + session := loginUser(t, adminUsername) + + test := func(t *testing.T, token string, expectedStatus int) { + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/user?sudo=%s", normalUsername)). + AddTokenAuth(token) + resp := MakeRequest(t, req, expectedStatus) + if expectedStatus == http.StatusOK { + var user api.User + DecodeJSON(t, resp, &user) + assert.Equal(t, normalUsername, user.UserName) + } + } + + t.Run("all access token", func(t *testing.T) { + allToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadUser) + test(t, allToken, http.StatusOK) + }) + + t.Run("public-only access token", func(t *testing.T) { + publicOnlyToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopePublicOnly, auth_model.AccessTokenScopeReadUser) + test(t, publicOnlyToken, http.StatusForbidden) + }) + + t.Run("specific repo access token", func(t *testing.T) { + repo2OnlyToken := createFineGrainedRepoAccessToken(t, adminUsername, + []auth_model.AccessTokenScope{auth_model.AccessTokenScopeReadUser}, + []int64{2}, + ) + test(t, repo2OnlyToken, http.StatusForbidden) + }) +} + func TestAPISudoUserForbidden(t *testing.T) { defer tests.PrepareTestEnv(t)() adminUsername := "user1" diff --git a/tests/integration/api_issue_tracked_time_test.go b/tests/integration/api_issue_tracked_time_test.go index 5e8b383437..cf0bab2e52 100644 --- a/tests/integration/api_issue_tracked_time_test.go +++ b/tests/integration/api_issue_tracked_time_test.go @@ -142,3 +142,53 @@ func TestAPIAddTrackedTimes(t *testing.T) { assert.Equal(t, user2.ID, apiNewTime.UserID) assert.EqualValues(t, 947688818, apiNewTime.Created.Unix()) } + +// Listing tracked times w/ `/repos/{owner}/{repo}/times/{user}` requires repository admin or site admin permissions (or +// to just list yourself). This test is a variation of [TestAPIGetTrackedTimes] which uses the `/{user}` endpoint with +// various access token restrictions, validating this API's implementation, but also validating that public-only and +// repo-scoped access tokens don't have admin access. +func TestAPIGetTrackedTimesAuthorizationReducer(t *testing.T) { + defer tests.PrepareTestEnv(t)() + adminUsername := "user1" + normalUsername := "user2" + session := loginUser(t, adminUsername) + + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + issue2 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 2}) + require.NoError(t, issue2.LoadRepo(db.DefaultContext)) + + test := func(t *testing.T, token string, expectedStatus int) { + req := NewRequest(t, "GET", + fmt.Sprintf("/api/v1/repos/%s/%s/times/%s", user2.Name, issue2.Repo.Name, normalUsername)). + AddTokenAuth(token) + resp := MakeRequest(t, req, expectedStatus) + if expectedStatus == http.StatusOK { + var apiTimes api.TrackedTimeList + DecodeJSON(t, resp, &apiTimes) + assert.Len(t, apiTimes, 3) + } + } + + t.Run("all access token", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + allToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository) + test(t, allToken, http.StatusOK) + }) + + t.Run("public-only access token", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + publicOnlyToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopePublicOnly, auth_model.AccessTokenScopeReadRepository) + test(t, publicOnlyToken, http.StatusForbidden) + }) + + t.Run("specific repo access token", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + repo2OnlyToken := createFineGrainedRepoAccessToken(t, adminUsername, + []auth_model.AccessTokenScope{auth_model.AccessTokenScopeReadRepository}, + []int64{issue2.RepoID}, + ) + test(t, repo2OnlyToken, http.StatusForbidden) + }) +}