From 9c748e87e120c8d4492715c68e3be8b2799d472c Mon Sep 17 00:00:00 2001 From: Mathieu Fenniak Date: Tue, 24 Feb 2026 18:58:04 -0700 Subject: [PATCH] feat: implement fine-grained access tokens in /user/starred & /users/{username}/starred --- models/repo/user_repo.go | 3 +- routers/api/v1/user/star.go | 9 ++- tests/integration/api_user_star_test.go | 81 +++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 4 deletions(-) diff --git a/models/repo/user_repo.go b/models/repo/user_repo.go index ca02c1e3f0..743e50bb35 100644 --- a/models/repo/user_repo.go +++ b/models/repo/user_repo.go @@ -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) diff --git a/routers/api/v1/user/star.go b/routers/api/v1/user/star.go index 19fa49f2ad..ea50080804 100644 --- a/routers/api/v1/user/star.go +++ b/routers/api/v1/user/star.go @@ -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 diff --git a/tests/integration/api_user_star_test.go b/tests/integration/api_user_star_test.go index 27e5b9e0ca..1e6b4400ae 100644 --- a/tests/integration/api_user_star_test.go +++ b/tests/integration/api_user_star_test.go @@ -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 + }) +}