From 0eca229d152a9f0ec19c936bfec22dd576af5aaa Mon Sep 17 00:00:00 2001 From: Mathieu Fenniak Date: Tue, 24 Feb 2026 19:00:25 -0700 Subject: [PATCH] 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. --- models/organization/team_repo.go | 5 ++ routers/api/v1/org/team.go | 14 +++- tests/integration/api_team_test.go | 78 ++++++++++++++++++- .../team.yml | 4 + .../team_repo.yml | 15 ++++ .../team_user.yml | 5 ++ 6 files changed, 116 insertions(+), 5 deletions(-) create mode 100644 tests/integration/fixtures/TestAPIGetTeamReposAccessTokenResources/team.yml create mode 100644 tests/integration/fixtures/TestAPIGetTeamReposAccessTokenResources/team_repo.yml create mode 100644 tests/integration/fixtures/TestAPIGetTeamReposAccessTokenResources/team_user.yml diff --git a/models/organization/team_repo.go b/models/organization/team_repo.go index 334b139808..9331537e19 100644 --- a/models/organization/team_repo.go +++ b/models/organization/team_repo.go @@ -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) } diff --git a/routers/api/v1/org/team.go b/routers/api/v1/org/team.go index cc08133afe..1fdaca02bd 100644 --- a/routers/api/v1/org/team.go +++ b/routers/api/v1/org/team.go @@ -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) } diff --git a/tests/integration/api_team_test.go b/tests/integration/api_team_test.go index 9413406b42..5332f6ba83 100644 --- a/tests/integration/api_team_test.go +++ b/tests/integration/api_team_test.go @@ -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 diff --git a/tests/integration/fixtures/TestAPIGetTeamReposAccessTokenResources/team.yml b/tests/integration/fixtures/TestAPIGetTeamReposAccessTokenResources/team.yml new file mode 100644 index 0000000000..19b2a3bc29 --- /dev/null +++ b/tests/integration/fixtures/TestAPIGetTeamReposAccessTokenResources/team.yml @@ -0,0 +1,4 @@ +- + id: 26 + org_id: 3 + includes_all_repositories: false diff --git a/tests/integration/fixtures/TestAPIGetTeamReposAccessTokenResources/team_repo.yml b/tests/integration/fixtures/TestAPIGetTeamReposAccessTokenResources/team_repo.yml new file mode 100644 index 0000000000..285514a4f2 --- /dev/null +++ b/tests/integration/fixtures/TestAPIGetTeamReposAccessTokenResources/team_repo.yml @@ -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 diff --git a/tests/integration/fixtures/TestAPIGetTeamReposAccessTokenResources/team_user.yml b/tests/integration/fixtures/TestAPIGetTeamReposAccessTokenResources/team_user.yml new file mode 100644 index 0000000000..8fb6e84ca6 --- /dev/null +++ b/tests/integration/fixtures/TestAPIGetTeamReposAccessTokenResources/team_user.yml @@ -0,0 +1,5 @@ +- + id: 30 + org_id: 3 + team_id: 26 + uid: 2