feat: implement fine-grained access tokens in /teams/{id}/repos

**Breaking*: /teams/{id}/repos previously allowed read access to private
repositories even if a "public-only" access token was in-use.  This has
been restricted to only return public repositories in this case.
This commit is contained in:
Mathieu Fenniak 2026-02-24 19:00:25 -07:00
parent cac675bc21
commit 0eca229d15
No known key found for this signature in database
6 changed files with 116 additions and 5 deletions

View file

@ -34,6 +34,8 @@ func HasTeamRepo(ctx context.Context, orgID, teamID, repoID int64) bool {
type SearchTeamRepoOptions struct {
db.ListOptions
TeamID int64
// Filters repositories based upon optional authorization restrictions.
AuthorizationReducer repo_model.RepositoryAuthorizationReducer
}
// GetRepositories returns paginated repositories in team of organization.
@ -46,6 +48,9 @@ func GetTeamRepositories(ctx context.Context, opts *SearchTeamRepoOptions) (repo
Where(builder.Eq{"team_id": opts.TeamID}),
)
}
if opts.AuthorizationReducer != nil {
sess = sess.Where(opts.AuthorizationReducer.RepoReadAccessFilter())
}
if opts.PageSize > 0 {
sess.Limit(opts.PageSize, (opts.Page-1)*opts.PageSize)
}

View file

@ -572,8 +572,9 @@ func GetTeamRepos(ctx *context.APIContext) {
team := ctx.Org.Team
teamRepos, err := organization.GetTeamRepositories(ctx, &organization.SearchTeamRepoOptions{
ListOptions: utils.GetListOptions(ctx),
TeamID: team.ID,
ListOptions: utils.GetListOptions(ctx),
TeamID: team.ID,
AuthorizationReducer: ctx.Reducer,
})
if err != nil {
ctx.Error(http.StatusInternalServerError, "GetTeamRepos", err)
@ -581,10 +582,15 @@ func GetTeamRepos(ctx *context.APIContext) {
}
repos := make([]*api.Repository, len(teamRepos))
for i, repo := range teamRepos {
permission, err := access_model.GetUserRepoPermission(ctx, repo, ctx.Doer)
permission, err := access_model.GetUserRepoPermissionWithReducer(ctx, repo, ctx.Doer, ctx.Reducer)
if err != nil {
ctx.Error(http.StatusInternalServerError, "GetTeamRepos", err)
ctx.Error(http.StatusInternalServerError, "GetUserRepoPermissionWithReducer", err)
return
} else if !permission.HasAccess() {
// It shouldn't happen that a repo is returned from GetTeamRepositories which we have no access to at all.
// Due to the pagination of the API it doesn't make sense to skip it, as we wouldn't be giving the right
// number of results back to the API consumer.
ctx.Error(http.StatusInternalServerError, "InvalidAuthorizationReducer", "Repository was available from GetTeamRepositories, but not readable.")
}
repos[i] = convert.ToRepo(ctx, repo, permission)
}

View file

@ -295,6 +295,82 @@ func TestAPITeamSearch(t *testing.T) {
MakeRequest(t, req, http.StatusForbidden)
}
func TestAPIGetTeamReposAccessTokenResources(t *testing.T) {
defer unittest.OverrideFixtures("tests/integration/fixtures/TestAPIGetTeamReposAccessTokenResources")()
defer tests.PrepareTestEnv(t)()
var repos []api.Repository
// Test cases org3/repo21 (public), org3/repo3 (private), org3/repo5 (private) --
// TestAPIGetTeamReposAccessTokenResources fixtures create a team w/ ID=26 that contains all three repos.
session := loginUser(t, "user2")
find := func() (bool, bool, bool) {
foundRepo21 := false // public org3/repo21
foundRepo3 := false // private org3/repo3
foundRepo5 := false // second private repo org3/repo5 used in fine-grain testing, included as baseline
for _, repo := range repos {
switch repo.Name {
case "repo21":
foundRepo21 = true
case "repo3":
foundRepo3 = true
case "repo5":
foundRepo5 = true
}
}
return foundRepo21, foundRepo3, foundRepo5
}
t.Run("all access token", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
allToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadOrganization)
req := NewRequest(t, "GET", "/api/v1/teams/26/repos").AddTokenAuth(allToken)
resp := MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &repos)
foundRepo21, foundRepo3, foundRepo5 := find()
assert.True(t, foundRepo21) // public org3/repo21
assert.True(t, foundRepo3) // private org3/repo3
assert.True(t, foundRepo5) // private org3/repo5, 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.AccessTokenScopeReadOrganization)
req := NewRequest(t, "GET", "/api/v1/teams/26/repos").AddTokenAuth(publicOnlyToken)
resp := MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &repos)
foundRepo21, foundRepo3, foundRepo5 := find()
assert.True(t, foundRepo21) // public org3/repo21
assert.False(t, foundRepo3) // private org3/repo3
assert.False(t, foundRepo5) // private org3/repo5
})
t.Run("specific repo access token", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
repo2OnlyToken := createFineGrainedRepoAccessToken(t, "user2",
[]auth_model.AccessTokenScope{auth_model.AccessTokenScopeReadOrganization},
[]int64{3},
)
req := NewRequest(t, "GET", "/api/v1/teams/26/repos").AddTokenAuth(repo2OnlyToken)
resp := MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &repos)
foundRepo21, foundRepo3, foundRepo5 := find()
assert.True(t, foundRepo21) // public org3/repo21, allowed as it's public and read-access only
assert.True(t, foundRepo3) // private org3/repo3, allowed inside fine-grain
assert.False(t, foundRepo5) // private org3/repo5, denied outside fine-grain
})
}
func TestAPIGetTeamRepo(t *testing.T) {
defer tests.PrepareTestEnv(t)()
@ -325,7 +401,7 @@ func TestAPIGetTeamRepoAccessTokenResources(t *testing.T) {
defer tests.PrepareTestEnv(t)()
// Test cases org3/repo21 (public), org3/repo3 (private), org3/repo5 (private) --
// TestAPIGetTeamReposAccessTokenResources fixtures create a team w/ ID=26 that contains all three repos.
// TestAPIGetTeamRepoAccessTokenResources fixtures create a team w/ ID=26 that contains all three repos.
session := loginUser(t, "user2")
var repo api.Repository

View file

@ -0,0 +1,4 @@
-
id: 26
org_id: 3
includes_all_repositories: false

View file

@ -0,0 +1,15 @@
-
id: 20
org_id: 3
team_id: 26
repo_id: 32 # org3/repo21 - public
-
id: 21
org_id: 3
team_id: 26
repo_id: 3 # org3/repo3 - private
-
id: 22
org_id: 3
team_id: 26
repo_id: 5 # org3/repo5 - private

View file

@ -0,0 +1,5 @@
-
id: 30
org_id: 3
team_id: 26
uid: 2