diff --git a/models/repo/repo.go b/models/repo/repo.go index 11442350dd..cdb30aa1a9 100644 --- a/models/repo/repo.go +++ b/models/repo/repo.go @@ -15,10 +15,12 @@ import ( "strconv" "strings" + auth_model "forgejo.org/models/auth" "forgejo.org/models/db" "forgejo.org/models/unit" user_model "forgejo.org/models/user" "forgejo.org/modules/cache" + "forgejo.org/modules/container" "forgejo.org/modules/git" "forgejo.org/modules/log" "forgejo.org/modules/markup" @@ -1001,3 +1003,55 @@ func UpdateRepoIssueNumbers(ctx context.Context, repoID int64, isPull, isClosed }) return nil } + +// Bulk load of all the repo_model.Repository objects for the repository resources that can be accessed by the given +// access tokens. Any access tokens which are not repository-specific tokens will not be present in the map. An +// optional filter function can be used to remove repositories (based upon a user visibility check, for example) before +// the map is constructed -- return `true` for repos to include. +func BulkGetRepositoriesForAccessTokens(ctx context.Context, tokens []*auth_model.AccessToken, filter func(*Repository) (bool, error)) (map[int64][]*Repository, error) { + // Load all the AccessTokenResourceRepo for the tokens that we're returning: + allRepoIDs := container.Set[int64]{} + repoResourcesByTokenID, err := auth_model.GetRepositoriesAccessibleWithTokens(ctx, tokens) + if err != nil { + return nil, fmt.Errorf("failed to fetch repositories for tokens: %w", err) + } + + // Load all the Repository models that are referenced by the AccessTokenResourceRepo's: + for _, repoResources := range repoResourcesByTokenID { + for _, repoResource := range repoResources { + allRepoIDs.Add(repoResource.RepoID) + } + } + reposByID, err := GetRepositoriesMapByIDs(ctx, allRepoIDs.Slice()) + if err != nil { + return nil, fmt.Errorf("failed to fetch repositories: %w", err) + } + + if filter != nil { + // Rebuild reposByID, filtering it with the provided filter function. It's more efficient to do this here, + // rather than returning the data and allowing the caller to filter it, because this guarantees one invocation + // per repository. `reposByTokenID` could have the same repository referenced by multiple access tokens. + tmp := reposByID + reposByID = make(map[int64]*Repository, len(tmp)) + for id, repo := range tmp { + if ok, err := filter(repo); err != nil { + return nil, fmt.Errorf("error filtering repo %d: %w", repo.ID, err) + } else if ok { + reposByID[id] = repo + } + } + } + + // Prepare a lookup map to access the repositories by token ID: + reposByTokenID := make(map[int64][]*Repository) + for tokenID, repoResources := range repoResourcesByTokenID { + for _, repoResource := range repoResources { + repo, ok := reposByID[repoResource.RepoID] + if ok { + reposByTokenID[tokenID] = append(reposByTokenID[tokenID], repo) + } + } + } + + return reposByTokenID, nil +} diff --git a/options/locale_next/locale_en-US.json b/options/locale_next/locale_en-US.json index d358fcc424..78aac13010 100644 --- a/options/locale_next/locale_en-US.json +++ b/options/locale_next/locale_en-US.json @@ -191,6 +191,7 @@ "settings.twofa_reenroll": "Re-enroll two-factor authentication", "settings.twofa_reenroll.description": "Re-enroll your two-factor authentication", "settings.must_enable_2fa": "This Forgejo instance requires users to enable two-factor authentication before they can access their accounts.", + "settings.specific_repo_access": "Repository Access", "error.must_enable_2fa": "This Forgejo instance requires users to enable two-factor authentication before they can access their accounts. Enable it at: %s", "avatar.constraints_hint": "Custom avatar may not exceed %[1]s in size or be larger than %[2]dx%[3]d pixels", "user.ghost.tooltip": "This user has been deleted, or cannot be matched.", diff --git a/routers/api/v1/user/app.go b/routers/api/v1/user/app.go index b223d35332..bc4f6eda98 100644 --- a/routers/api/v1/user/app.go +++ b/routers/api/v1/user/app.go @@ -18,7 +18,6 @@ import ( "forgejo.org/models/db" access_model "forgejo.org/models/perm/access" repo_model "forgejo.org/models/repo" - "forgejo.org/modules/container" "forgejo.org/modules/optional" api "forgejo.org/modules/structs" "forgejo.org/modules/web" @@ -66,42 +65,34 @@ func ListAccessTokens(ctx *context.APIContext) { } // Load all the AccessTokenResourceRepo for the tokens that we're returning: - allRepoIDs := container.Set[int64]{} - repoResourcesByTokenID, err := auth_model.GetRepositoriesAccessibleWithTokens(ctx, tokens) - if err != nil { - ctx.InternalServerError(err) - return - } - - // Load all the Repository models that are referenced by the AccessTokenResourceRepo's: - for _, repoResources := range repoResourcesByTokenID { - for _, repoResource := range repoResources { - allRepoIDs.Add(repoResource.RepoID) - } - } - reposByID, err := repo_model.GetRepositoriesMapByIDs(ctx, allRepoIDs.Slice()) - if err != nil { - ctx.InternalServerError(err) - return - } - - // Prepare a lookup map to access the repositories by token ID: - reposByTokenID := make(map[int64][]*api.RepositoryMeta) - for tokenID, repoResources := range repoResourcesByTokenID { - for _, repoResource := range repoResources { - repo, ok := reposByID[repoResource.RepoID] - if !ok { - // Shouldn't be possible with the foreign key on `AccessTokenResourceRepo` to the repository table. - ctx.Error(http.StatusInternalServerError, "reposById", "missing repository") - return + repoModelsByTokenID, err := repo_model.BulkGetRepositoriesForAccessTokens(ctx, tokens, + func(repo *repo_model.Repository) (bool, error) { + // Repos associated with a repo-specific access token should already be visible to the token owner, but it's + // possible that access has changed, such as a removed collaborator on a repo -- don't provide info on that + // repo if so. + permission, err := access_model.GetUserRepoPermissionWithReducer(ctx, repo, ctx.Doer, ctx.Reducer) + if err != nil { + return false, err } - reposByTokenID[tokenID] = append(reposByTokenID[tokenID], &api.RepositoryMeta{ + return permission.HasAccess(), nil + }) + if err != nil { + ctx.InternalServerError(err) + return + } + // Convert map[int64]*Repository -> map[int64]*RepositoryMeta... + reposByTokenID := make(map[int64][]*api.RepositoryMeta) + for tokenID, repoModels := range repoModelsByTokenID { + repos := make([]*api.RepositoryMeta, len(repoModels)) + for i, repo := range repoModels { + repos[i] = &api.RepositoryMeta{ ID: repo.ID, Name: repo.Name, Owner: repo.OwnerName, FullName: repo.FullName(), - }) + } } + reposByTokenID[tokenID] = repos } apiTokens := make([]*api.AccessToken, len(tokens)) diff --git a/routers/web/user/setting/applications.go b/routers/web/user/setting/applications.go index 359f0c1dce..a5c38b8dc3 100644 --- a/routers/web/user/setting/applications.go +++ b/routers/web/user/setting/applications.go @@ -9,6 +9,8 @@ import ( auth_model "forgejo.org/models/auth" "forgejo.org/models/db" + access_model "forgejo.org/models/perm/access" + repo_model "forgejo.org/models/repo" "forgejo.org/modules/base" "forgejo.org/modules/log" "forgejo.org/modules/setting" @@ -112,6 +114,11 @@ func RegenerateApplication(ctx *context.Context) { ctx.JSONRedirect(setting.AppSubURL + "/user/settings/applications") } +type TokenWithResources struct { + Token *auth_model.AccessToken + Repositories []*repo_model.Repository +} + func loadApplicationsData(ctx *context.Context) { ctx.Data["AccessTokenScopePublicOnly"] = auth_model.AccessTokenScopePublicOnly tokens, err := db.Find[auth_model.AccessToken](ctx, auth_model.ListAccessTokensOptions{UserID: ctx.Doer.ID}) @@ -119,7 +126,33 @@ func loadApplicationsData(ctx *context.Context) { ctx.ServerError("ListAccessTokens", err) return } - ctx.Data["Tokens"] = tokens + + // Load all the AccessTokenResourceRepo for the tokens that we're returning: + reposByTokenID, err := repo_model.BulkGetRepositoriesForAccessTokens(ctx, tokens, + func(repo *repo_model.Repository) (bool, error) { + // Repos associated with a repo-specific access token should already be visible to the token owner, but it's + // possible that access has changed, such as a removed collaborator on a repo -- don't provide info on that + // repo if so. + permission, err := access_model.GetUserRepoPermission(ctx, repo, ctx.Doer) + if err != nil { + return false, err + } + return permission.HasAccess(), nil + }) + if err != nil { + ctx.ServerError("BulkGetRepositoriesForAccessTokens", err) + return + } + + tokensWithResources := make([]*TokenWithResources, len(tokens)) + for i := range tokens { + tokensWithResources[i] = &TokenWithResources{ + Token: tokens[i], + Repositories: reposByTokenID[tokens[i].ID], + } + } + + ctx.Data["TokensWithResources"] = tokensWithResources ctx.Data["EnableOAuth2"] = setting.OAuth2.Enabled ctx.Data["IsAdmin"] = ctx.Doer.IsAdmin if setting.OAuth2.Enabled { diff --git a/templates/user/settings/applications.tmpl b/templates/user/settings/applications.tmpl index b77bcf067b..59d352eab3 100644 --- a/templates/user/settings/applications.tmpl +++ b/templates/user/settings/applications.tmpl @@ -8,27 +8,34 @@
{{ctx.Locale.Tr "settings.tokens_desc"}}
- {{range .Tokens}} + {{range .TokensWithResources}}
- + {{svg "fontawesome-send" 32}}
- {{.Name}} -

- {{ctx.Locale.Tr "settings.repo_and_org_access"}}: - {{if .DisplayPublicOnly}} +

{{.Token.Name}} + {{if .Token.ResourceAllRepos}} +

{{ctx.Locale.Tr "settings.repo_and_org_access"}}:

+ {{if .Token.DisplayPublicOnly}} {{ctx.Locale.Tr "settings.permissions_public_only"}} {{else}} {{ctx.Locale.Tr "settings.permissions_access_all"}} {{end}} -

+ {{else}} +

{{ctx.Locale.Tr "settings.specific_repo_access"}}:

+ + {{end}}

{{ctx.Locale.Tr "settings.permissions_list"}}

    - {{range .Scope.StringSlice}} + {{range .Token.Scope.StringSlice}} {{if (ne . $.AccessTokenScopePublicOnly)}}
  • {{.}}
  • {{end}} @@ -36,15 +43,15 @@
-

{{ctx.Locale.Tr "settings.added_on" (DateUtils.AbsoluteShort .CreatedUnix)}} — {{svg "octicon-info"}} {{if .HasUsed}}{{ctx.Locale.Tr "settings.last_used"}} {{DateUtils.AbsoluteShort .UpdatedUnix}}{{else}}{{ctx.Locale.Tr "settings.no_activity"}}{{end}}

+

{{ctx.Locale.Tr "settings.added_on" (DateUtils.AbsoluteShort .Token.CreatedUnix)}} — {{svg "octicon-info"}} {{if .Token.HasUsed}}{{ctx.Locale.Tr "settings.last_used"}} {{DateUtils.AbsoluteShort .Token.UpdatedUnix}}{{else}}{{ctx.Locale.Tr "settings.no_activity"}}{{end}}

- - diff --git a/tests/integration/user_test.go b/tests/integration/user_test.go index 2eb82f1c8a..4c8e7c9458 100644 --- a/tests/integration/user_test.go +++ b/tests/integration/user_test.go @@ -281,6 +281,35 @@ func TestAccessTokenRegenerate(t *testing.T) { assert.NotEqual(t, "TestAccessToken", latestTokenName) } +func TestAccessTokenResourceRepos(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + session := loginUser(t, "user2") + + // Before creating a repo-specific access token, we shouldn't have the "Repository Access:" list in the personal + // access token page: + req := NewRequest(t, "GET", "/user/settings/applications") + resp := session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + htmlDoc.AssertSelection(t, htmlDoc.FindByText(".user-setting-content p", "Repository Access:"), false) + + // Then we create a repo-specific access token. We give it access to two repos, user2/repo2, but also user30/empty, + // a private repo owned by someone else... We'll pretend user2 used to be a collaborator on this repo and + // previously had access to view it, but doesn't anymore. + createFineGrainedRepoAccessToken(t, "user2", + []auth_model.AccessTokenScope{auth_model.AccessTokenScopeReadUser}, + []int64{2, 52}, + ) + + // Now we have "Repository Access:"... + req = NewRequest(t, "GET", "/user/settings/applications") + resp = session.MakeRequest(t, req, http.StatusOK) + htmlDoc = NewHTMLParser(t, resp.Body) + htmlDoc.AssertSelection(t, htmlDoc.FindByText(".user-setting-content p", "Repository Access:"), true) + htmlDoc.AssertSelection(t, htmlDoc.FindByText(".user-setting-content a", "user2/repo2"), true) // link to repo + htmlDoc.AssertSelection(t, htmlDoc.FindByText(".user-setting-content a", "user30/empty"), false) // missing - user2 has no visibility +} + func findLatestTokenID(t *testing.T, session *TestSession) (string, int) { req := NewRequest(t, "GET", "/user/settings/applications") resp := session.MakeRequest(t, req, http.StatusOK)