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

This commit is contained in:
Mathieu Fenniak 2026-02-24 18:57:24 -07:00 committed by Mathieu Fenniak
parent 9c748e87e1
commit c89504d573
3 changed files with 89 additions and 4 deletions

View file

@ -38,7 +38,7 @@ func GetStarredRepos(ctx context.Context, userID int64, private bool, listOption
}
// GetWatchedRepos returns the repos watched by a particular user
func GetWatchedRepos(ctx context.Context, userID int64, private bool, listOptions db.ListOptions) ([]*Repository, int64, error) {
func GetWatchedRepos(ctx context.Context, userID int64, private bool, listOptions db.ListOptions, reducer RepositoryAuthorizationReducer) ([]*Repository, int64, error) {
sess := db.GetEngine(ctx).
Where("watch.user_id=?", userID).
And("`watch`.mode<>?", WatchModeDont).
@ -46,6 +46,7 @@ func GetWatchedRepos(ctx context.Context, userID int64, private bool, listOption
if !private {
sess = sess.And("is_private=?", false)
}
sess = sess.And(reducer.RepoReadAccessFilter())
if listOptions.Page != 0 {
sess = db.SetSessionPagination(sess, &listOptions)

View file

@ -4,7 +4,6 @@
package user
import (
std_context "context"
"net/http"
"forgejo.org/models/db"
@ -18,14 +17,18 @@ import (
)
// getWatchedRepos returns the repos that the user with the specified userID is watching
func getWatchedRepos(ctx std_context.Context, user *user_model.User, private bool, listOptions db.ListOptions) ([]*api.Repository, int64, error) {
watchedRepos, total, err := repo_model.GetWatchedRepos(ctx, user.ID, private, listOptions)
func getWatchedRepos(ctx *context.APIContext, user *user_model.User, private bool, listOptions db.ListOptions) ([]*api.Repository, int64, error) {
watchedRepos, total, err := repo_model.GetWatchedRepos(ctx, user.ID, private, listOptions, ctx.Reducer)
if err != nil {
return nil, 0, err
}
repos := make([]*api.Repository, len(watchedRepos))
for i, watched := range watchedRepos {
// Resource filtering is implemented above in the call to GetWatchedRepos, and doesn't need to be taken into
// account here:
//
// nosemgrep: forgejo-api-use-resource-GetUserRepoPermission
permission, err := access_model.GetUserRepoPermission(ctx, watched, user)
if err != nil {
return nil, 0, err

View file

@ -86,3 +86,84 @@ func TestAPIWatch(t *testing.T) {
MakeRequest(t, req, http.StatusNoContent)
})
}
func TestAPIWatchRepoAccessTokenResources(t *testing.T) {
defer tests.PrepareTestEnv(t)()
var repos []api.Repository
// Test cases repo1, repo2, repo16 -- create a subscription on each of them so that we can inspect through
// /subscriptions 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/repos/user2/%s/subscription", r)).AddTokenAuth(writeToken),
http.StatusOK)
}
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/subscriptions").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/subscriptions").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/subscriptions").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
})
}