feat: implement fine-grained access tokens in /user/starred & /users/{username}/starred

This commit is contained in:
Mathieu Fenniak
2026-03-01 17:05:53 +01:00
committed by Mathieu Fenniak
parent 5582faa0c7
commit 9c748e87e1
3 changed files with 89 additions and 4 deletions
+2 -1
View File
@@ -17,13 +17,14 @@ import (
)
// GetStarredRepos returns the repos starred by a particular user
func GetStarredRepos(ctx context.Context, userID int64, private bool, listOptions db.ListOptions) ([]*Repository, error) {
func GetStarredRepos(ctx context.Context, userID int64, private bool, listOptions db.ListOptions, reducer RepositoryAuthorizationReducer) ([]*Repository, error) {
sess := db.GetEngine(ctx).
Where("star.uid=?", userID).
Join("LEFT", "star", "`repository`.id=`star`.repo_id")
if !private {
sess = sess.And("is_private=?", false)
}
sess = sess.And(reducer.RepoReadAccessFilter())
if listOptions.Page != 0 {
sess = db.SetSessionPagination(sess, &listOptions)
+6 -3
View File
@@ -6,7 +6,6 @@
package user
import (
std_context "context"
"net/http"
"forgejo.org/models/db"
@@ -22,14 +21,18 @@ import (
// getStarredRepos returns the repos that the user with the specified userID has
// starred
func getStarredRepos(ctx std_context.Context, user *user_model.User, private bool, listOptions db.ListOptions) ([]*api.Repository, error) {
starredRepos, err := repo_model.GetStarredRepos(ctx, user.ID, private, listOptions)
func getStarredRepos(ctx *context.APIContext, user *user_model.User, private bool, listOptions db.ListOptions) ([]*api.Repository, error) {
starredRepos, err := repo_model.GetStarredRepos(ctx, user.ID, private, listOptions, ctx.Reducer)
if err != nil {
return nil, err
}
repos := make([]*api.Repository, len(starredRepos))
for i, starred := range starredRepos {
// Resource filtering is implemented above in the call to GetStarredRepos, and doesn't need to be taken into
// account here:
//
// nosemgrep: forgejo-api-use-resource-GetUserRepoPermission
permission, err := access_model.GetUserRepoPermission(ctx, starred, user)
if err != nil {
return nil, err
+81
View File
@@ -116,3 +116,84 @@ func TestAPIStar(t *testing.T) {
})
})
}
func TestAPIStarRepoAccessTokenResources(t *testing.T) {
defer tests.PrepareTestEnv(t)()
var repos []api.Repository
// Test cases repo1, repo2, repo16 -- create a star on each of them so that we can inspect through /starred and see
// if the repos are visible or not with different access tokens.
session := loginUser(t, "user2")
writeToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteUser, auth_model.AccessTokenScopeWriteRepository)
for _, r := range []string{"repo1", "repo2", "repo16"} {
MakeRequest(t,
NewRequest(t, "PUT", fmt.Sprintf("/api/v1/user/starred/user2/%s", r)).AddTokenAuth(writeToken),
http.StatusNoContent)
}
find := func() (bool, bool, bool) {
foundRepo1 := false // public repo1
foundRepo2 := false // private repo2
foundRepo16 := false // second public repo used in fine-grain testing, included as baseline
for _, repo := range repos {
switch repo.Name {
case "repo1":
foundRepo1 = true
case "repo2":
foundRepo2 = true
case "repo16":
foundRepo16 = true
}
}
return foundRepo1, foundRepo2, foundRepo16
}
t.Run("all access token", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
allToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadUser)
req := NewRequest(t, "GET", "/api/v1/users/user2/starred").AddTokenAuth(allToken)
resp := MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &repos)
foundRepo1, foundRepo2, foundRepo16 := find()
assert.True(t, foundRepo1) // public repo1
assert.True(t, foundRepo2) // private repo2
assert.True(t, foundRepo16) // private repo16, used in fine-grain testing, included as baseline
})
t.Run("public-only access token", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
publicOnlyToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopePublicOnly, auth_model.AccessTokenScopeReadUser)
req := NewRequest(t, "GET", "/api/v1/users/user2/starred").AddTokenAuth(publicOnlyToken)
resp := MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &repos)
foundRepo1, foundRepo2, foundRepo16 := find()
assert.True(t, foundRepo1) // public repo1
assert.False(t, foundRepo2) // private repo2
assert.False(t, foundRepo16) // private repo16
})
t.Run("specific repo access token", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
repo2OnlyToken := createFineGrainedRepoAccessToken(t, "user2",
[]auth_model.AccessTokenScope{auth_model.AccessTokenScopeReadUser},
[]int64{2},
)
req := NewRequest(t, "GET", "/api/v1/users/user2/starred").AddTokenAuth(repo2OnlyToken)
resp := MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &repos)
foundRepo1, foundRepo2, foundRepo16 := find()
assert.True(t, foundRepo1) // public repo1, allowed as it's public and read-access only
assert.True(t, foundRepo2) // private repo2, allowed inside fine-grain
assert.False(t, foundRepo16) // private repo16, denied outside fine-grain
})
}