feat: add "Forgejo Actions (Local)" authorized integration UI (#12672)

Extracts the separate concepts for different UIs out of the original implementation, and then adds the new UI for Forgejo Actions (Local).  Manual end-to-end testing was performed on all variations of the "workflow file", "git reference", and "event" filter options as well.  They're covered by test automation, but not in an end-to-end manner.

## Checklist

The [contributor guide](https://forgejo.org/docs/next/contributor/) contains information that will be helpful to first time contributors. All work and communication must conform to Forgejo's [AI Agreement](https://codeberg.org/forgejo/governance/src/branch/main/AIAgreement.md). There also are a few [conditions for merging Pull Requests in Forgejo repositories](https://codeberg.org/forgejo/governance/src/branch/main/PullRequestsAgreement.md). You are also welcome to join the [Forgejo development chatroom](https://matrix.to/#/#forgejo-development:matrix.org).

### Tests for Go changes

- I added test coverage for Go changes...
  - [x] in their respective `*_test.go` for unit tests.
  - [ ] in the `tests/integration` directory if it involves interactions with a live Forgejo server.
- I ran...
  - [x] `make pr-go` before pushing

### Tests for JavaScript changes

- I added test coverage for JavaScript changes...
  - [ ] in `web_src/js/*.test.js` if it can be unit tested.
  - [x] in `tests/e2e/*.test.e2e.js` if it requires interactions with a live Forgejo server (see also the [developer guide for JavaScript testing](https://codeberg.org/forgejo/forgejo/src/branch/forgejo/tests/e2e/README.md#end-to-end-tests)).

### Documentation

- [ ] I created a pull request [to the documentation](https://codeberg.org/forgejo/docs) to explain to Forgejo users how to use this change.
- [x] I did not document these changes and I do not expect someone else to do it.
    - Documentation is next up after this change is complete.

### Release notes

- [x] This change will be noticed by a Forgejo user or admin (feature, bug fix, performance, etc.). I suggest to include a release note for this change.
- [ ] This change is not visible to a Forgejo user or admin (refactor, dependency upgrade, etc.). I think there is no need to add a release note for this change.

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/12672
Reviewed-by: Andreas Ahlenstorf <aahlenst@noreply.codeberg.org>
This commit is contained in:
Mathieu Fenniak 2026-05-23 16:26:28 +02:00 committed by Mathieu Fenniak
parent 03d336de44
commit 6d522ecba0
15 changed files with 1142 additions and 184 deletions

View file

@ -191,6 +191,8 @@ type SearchRepoOptions struct {
// Retrieve multiple repositories by their owner name & repository name, similar to [GetRepositoryByOwnerAndName]
// but in bulk.
OwnerAndName [][2]string
// Filter to repositories with this unit enabled.
EnabledUnit optional.Option[unit.Type]
}
// UserOwnedRepoCond returns user ownered repositories
@ -518,6 +520,10 @@ func SearchRepositoryCondition(opts *SearchRepoOptions) builder.Cond {
}
}
if has, unit := opts.EnabledUnit.Get(); has {
cond = cond.And(builder.In("`repository`.id", builder.Select("`repo_unit`.repo_id").From("repo_unit").Where(builder.Eq{"`repo_unit`.type": unit})))
}
return cond
}

View file

@ -10,6 +10,7 @@ import (
"forgejo.org/models/db"
repo_model "forgejo.org/models/repo"
"forgejo.org/models/unit"
"forgejo.org/models/unittest"
"forgejo.org/models/user"
"forgejo.org/modules/optional"
@ -199,6 +200,11 @@ func getTestCases() []struct {
opts: &repo_model.SearchRepoOptions{ListOptions: db.ListOptions{Page: 1, PageSize: 10}, OwnerAndName: [][2]string{}},
count: 0,
},
{
name: "ActionsEnabled",
opts: &repo_model.SearchRepoOptions{ListOptions: db.ListOptions{Page: 1, PageSize: 10}, EnabledUnit: optional.Some(unit.TypeActions)},
count: 3,
},
}
return testCases

View file

@ -348,10 +348,21 @@
"settings.authorized_integration.specified_repos_and_public_only": "Authorized integrations with specified repositories cannot be combined with the public-only scope.",
"settings.authorized_integration.specified_repos_and_invalid_scope": "Authorized integrations with specified repositories can only be used with the read:issue, write:issue, read:repository, and write:repository scopes.",
"settings.authorized_integration.add": "Add authorized integration",
"settings.authorized_integration.generic": "Generic JWT Source",
"settings.authorized_integration.delete.header": "Delete authorized integration",
"settings.authorized_integration.delete.body": "Deleting an authorized integration will revoke access to your account for the integrating application. This is permanent, and cannot be undone. Creating a new authorized integration will not have the same Audience (<code>aud</code>) claim. Continue?",
"settings.authorized_integration.deleted": "Authorized integration has been deleted successfully.",
"settings.authorized_integration.forgejo_actions_local.select_repo": "Select",
"settings.authorized_integration.forgejo_actions_local.select_repository": "Select repository: (Forgejo Actions must be enabled on repository)",
"settings.authorized_integration.forgejo_actions_local.description": "Forgejo Actions will be able to access Forgejo via this Authorized Integration from a single selected repository, and if the execution meets conditions defined below.",
"settings.authorized_integration.forgejo_actions_local.repo.required": "Forgejo Actions source repository must be selected.",
"settings.authorized_integration.forgejo_actions_local.workflow_file.label": "Workflow file (without directory):",
"settings.authorized_integration.forgejo_actions_local.workflow_file.help": "If empty, any workflow will be permitted. See <a href=\"%[1]s\">%[2]s</a> documentation for pattern syntax.<br>Examples: <code>testing.yml</code>, <code>test-*.yml</code>.",
"settings.authorized_integration.forgejo_actions_local.workflow_file.error": "Error parsing workflow file: %s",
"settings.authorized_integration.forgejo_actions_local.git_ref.label": "Git reference:",
"settings.authorized_integration.forgejo_actions_local.git_ref.help": "If empty, any reference will be permitted. See <a href=\"%[1]s\">%[2]s</a> documentation for pattern syntax.<br>Examples: <code>refs/heads/main</code>, <code>refs/pull/*/head</code>.",
"settings.authorized_integration.forgejo_actions_local.git_ref.error": "Error parsing git reference: %s",
"settings.authorized_integration.forgejo_actions_local.event.label": "Event:",
"settings.authorized_integration.forgejo_actions_local.event.help": "If no events are selected, any event will be permitted.",
"webauthn.insert_key": "Insert your security key",
"webauthn.sign_in": "Press the button on your security key. If your security key has no button, re-insert it.",
"webauthn.press_button": "Please press the button on your security key…",

View file

@ -4,37 +4,114 @@
package setting
import (
"bytes"
"errors"
"fmt"
"io"
"html/template"
"net/http"
"slices"
"strings"
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/json"
"forgejo.org/modules/log"
"forgejo.org/modules/optional"
"forgejo.org/modules/setting"
"forgejo.org/modules/templates"
"forgejo.org/modules/util"
"forgejo.org/modules/web"
"forgejo.org/modules/web/middleware"
auth_service "forgejo.org/services/auth"
"forgejo.org/services/authz"
"forgejo.org/services/context"
"code.forgejo.org/go-chi/binding"
"xorm.io/builder"
)
const (
tplAuthorizedIntegrationList base.TplName = "user/settings/authorized_integrations"
tplAuthorizedIntegrationViewGeneric base.TplName = "user/settings/authorized_integrations/generic/view"
tplAuthorizedIntegrationList base.TplName = "user/settings/authorized_integrations"
)
var authorizedIntegrationUIs = []authorizedIntegrationUIImpl{
actionsLocalUI{},
genericUI{},
}
// Encapsulates the implementation of each authorized integration's UI.
type authorizedIntegrationUIImpl interface {
// The URL path, and value of the UI field in the authorized integration.
UIIdentifier() auth_model.AuthorizedIntegrationUI
// When rendered in the "Add authorized integration" list, the Icon to use.
Icon(size int) template.HTML
// When rendered in the "Add authorized integration" list, the Label to use.
Label(ctx *templates.Context) template.HTML
// HTML template used when rendering this UI.
editTemplate() base.TplName
// When rendering editTemplate, populateTemplateContext will be invoked to allow the UI to perform backend data
// fetches as needed to populate `ctx.Data`. The current form will be available as `ctx.Data["Form"]`.
populateTemplateContext(ctx *context.Context)
// Form object used when rendering and processing this UI.
form() authorizedIntegrationUIForm
// If an error occurs, typically in [convertForm] when evaluating if the inputs provided by the user are sufficient
// to create claim rules, populateError will be invoked. If it returns [true] then the [editTemplate] will
// re-render with the updated context, allowing each UI to handle form errors and display validation results to the
// user. If it returns false this indicates the error isn't recognized by the UI, and the error will be inspected
// by the base form processing and may result in a form error or a server error.
populateError(ctx *context.Context, err error) (handled bool)
}
// Contains all the form data going to the browser when loading an authorized integration, and returning to the server
// when validating and saving an authorized integration.
//
// Every UI-specific form must embed a [baseAuthorizedIntegrationForm] which contains information common to every
// authorized integration -- identifying information and permission information. Forms must not conflict on field name
// binding with the fields in [baseAuthorizedIntegrationForm].
type authorizedIntegrationUIForm interface {
// Check whether the form is empty. When loading the new & edit pages, this is used to identify if the form data
// needs to be loaded from the database objects or initialized for a new create.
isEmpty() bool
// Access the embedded baseAuthorizedIntegrationForm
baseForm() *baseAuthorizedIntegrationForm
// Populate the form from a persisted state, given the stored authorized integration's issuer & claim rules.
populateForm(ctx *context.Context, issuer string, claimRules *auth_model.ClaimRules) error
// Convert the form into the issuer and claim rules that will be saved with the authorized integration.
convertForm(ctx *context.Context) (issuer string, claimRules *auth_model.ClaimRules, err error)
// Initialize the form with values appropriate for a new authorized integration.
initNew()
}
// Middleware that resolves the "ui" parameter into ctx.Data. Access the resolved UI interface via [authorizedIntegrationUI].
func BindAuthorizedIntegrationUI(ctx *context.Context) {
var ui authorizedIntegrationUIImpl
uiString := ctx.Params(":ui")
for _, check := range authorizedIntegrationUIs {
if auth_model.AuthorizedIntegrationUI(uiString) == check.UIIdentifier() {
ui = check
break
}
}
if ui == nil {
ctx.NotFound("invalid UI", fmt.Errorf("invalid UI: %q is not a supported Authorized Integration UI", uiString))
return
}
ctx.Data["AuthorizedIntegrationUI"] = ui
}
func authorizedIntegrationUI(ctx *context.Context) authorizedIntegrationUIImpl {
return ctx.Data["AuthorizedIntegrationUI"].(authorizedIntegrationUIImpl)
}
// Middleware that acts like `web.Bind`, but uses the authorized integration UI's to work with a specific form type
// defined by the [AuthorizedIntegrationUI].
func DynamicBindAuthorizedIntegrationForm(ctx *context.Context) {
ui := authorizedIntegrationUI(ctx)
formObj := ui.form()
data := middleware.GetContextData(ctx)
binding.Bind(ctx.Req, formObj)
web.SetForm(data, formObj)
}
func ListAuthorizedIntegrations(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("settings.authorized_integrations")
ctx.Data["PageIsSettingsAuthorizedIntegrations"] = true
@ -46,39 +123,11 @@ func ListAuthorizedIntegrations(ctx *context.Context) {
return
}
ctx.Data["AuthorizedIntegrations"] = ais
ctx.Data["UIs"] = authorizedIntegrationUIs
ctx.HTML(http.StatusOK, tplAuthorizedIntegrationList)
}
type AuthorizedIntegrationForm struct {
// Top data in UI, descriptive information about the Authorized Integration:
Name string
Description string
Audience string
// Middle data in UI, how JWTs are validated by this Authorized Integration:
Issuer string // Future: Issuer is likely to be replaced with more-specific fields on non-generic UIs
ClaimRules string // Future: ClaimRules is only required when aiUI == "generic"
// Bottom data in the UI, what authorization is permitted by this Authorized Integration:
Resource string // all, public-only, repo-specific
SelectedRepo []string // slice of ownername/reponame for repo-specific
ScopeAll bool
Scope []string
// Values used for repo-specific repository multi-select UI, not stored in Authorized Integration:
RepoSearch string
AddSelectedRepo string // add a repo to SelectedRepo
RemoveSelectedRepo string // remove a repo from SelectedRepo
Page int // repo search page
SetPage int // repo search buttons
}
func (f *AuthorizedIntegrationForm) isEmpty() bool {
return f.Name == "" && f.Description == "" && f.Audience == "" && f.Issuer == "" &&
f.ClaimRules == "" && f.Resource == "" && f.SelectedRepo == nil && f.Scope == nil
}
func getAuthorizedIntegration(ctx *context.Context) *auth_model.AuthorizedIntegration {
aiUIString := ctx.Params("ui")
aiUI, err := auth_model.ParseAuthorizedIntegrationUI(aiUIString)
@ -106,7 +155,7 @@ func EditAuthorizedIntegration(ctx *context.Context) {
return
}
form := web.GetForm(ctx).(*AuthorizedIntegrationForm)
form := web.GetForm(ctx).(authorizedIntegrationUIForm)
if form.isEmpty() {
repos, err := auth_model.GetRepositoriesAccessibleWithIntegration(ctx, ai.ID)
if err != nil {
@ -114,11 +163,16 @@ func EditAuthorizedIntegration(ctx *context.Context) {
return
}
form, err = copyAuthorizedIntegrationToForm(ctx, ai, repos)
err = form.populateForm(ctx, ai.Issuer, ai.ClaimRules)
if err != nil {
ctx.ServerError("copyAuthorizedIntegrationToForm", err)
return
}
err = form.baseForm().copyAuthorizedIntegrationToForm(ctx, ai, repos)
if err != nil {
ctx.ServerError("BaseForm().copyAuthorizedIntegrationToForm", err)
return
}
}
ctx.Data["Form"] = form
@ -126,7 +180,7 @@ func EditAuthorizedIntegration(ctx *context.Context) {
}
func EditAuthorizedIntegrationPost(ctx *context.Context) {
form := web.GetForm(ctx).(*AuthorizedIntegrationForm)
form := web.GetForm(ctx).(authorizedIntegrationUIForm)
ctx.Data["Form"] = form // make form available for template render on any error
ai := getAuthorizedIntegration(ctx)
@ -134,7 +188,15 @@ func EditAuthorizedIntegrationPost(ctx *context.Context) {
return
}
rr, err := copyFormToAuthorizedIntegration(ctx, form, ai)
issuer, claimRules, err := form.convertForm(ctx)
if err != nil {
editAuthorizedIntegrationErrorHandler(ctx, err)
return
}
ai.Issuer = issuer
ai.ClaimRules = claimRules
rr, err := form.baseForm().copyFormToAuthorizedIntegration(ctx, ai)
if err != nil {
editAuthorizedIntegrationErrorHandler(ctx, err)
return
@ -149,10 +211,10 @@ func EditAuthorizedIntegrationPost(ctx *context.Context) {
}
func NewAuthorizedIntegration(ctx *context.Context) {
form := web.GetForm(ctx).(*AuthorizedIntegrationForm)
form := web.GetForm(ctx).(authorizedIntegrationUIForm)
if form.isEmpty() {
form.Resource = "all"
form.ClaimRules = string("{\n \"rules\":[]\n}")
form.initNew()
form.baseForm().InitNew()
}
ctx.Data["Form"] = form
ctx.Data["IsNew"] = true
@ -161,15 +223,24 @@ func NewAuthorizedIntegration(ctx *context.Context) {
}
func NewAuthorizedIntegrationPost(ctx *context.Context) {
form := web.GetForm(ctx).(*AuthorizedIntegrationForm)
form := web.GetForm(ctx).(authorizedIntegrationUIForm)
ctx.Data["Form"] = form // make form available for template render on any error
ctx.Data["IsNew"] = true
ai := &auth_model.AuthorizedIntegration{
UserID: ctx.Doer.ID,
UI: auth_model.AuthorizedIntegrationUIGeneric,
UI: authorizedIntegrationUI(ctx).UIIdentifier(),
}
rr, err := copyFormToAuthorizedIntegration(ctx, form, ai)
issuer, claimRules, err := form.convertForm(ctx)
if err != nil {
editAuthorizedIntegrationErrorHandler(ctx, err)
return
}
ai.Issuer = issuer
ai.ClaimRules = claimRules
rr, err := form.baseForm().copyFormToAuthorizedIntegration(ctx, ai)
if err != nil {
editAuthorizedIntegrationErrorHandler(ctx, err)
return
@ -181,10 +252,19 @@ func NewAuthorizedIntegrationPost(ctx *context.Context) {
}
ctx.Flash.Success(ctx.Tr("settings.authorized_integration.create_success", ai.Name))
ctx.Redirect(setting.AppSubURL + fmt.Sprintf("/user/settings/authorized-integrations/generic/%d", ai.ID))
ctx.Redirect(setting.AppSubURL + fmt.Sprintf("/user/settings/authorized-integrations/%s/%d", authorizedIntegrationUI(ctx).UIIdentifier(), ai.ID))
}
func editAuthorizedIntegrationErrorHandler(ctx *context.Context, err error) {
if authorizedIntegrationUI(ctx).populateError(ctx, err) {
EditAuthorizedIntegrationRenderCommon(ctx)
return
}
// Note: auth_service.ErrInvalidClaimRules is not handled here. If a UI created claim rules that the auth service
// identified as invalid, it indicates a logic bug or unhandled case in the UI implementation, and will be treated
// as a server error because the base implementation here doesn't know what UI fields would be responsible for these
// invalid claim rules. (Excepting the Generic UI, which handles this case itself.)
var errMissingField *auth_service.MissingFieldError
switch {
case errors.As(err, &errMissingField):
@ -200,7 +280,11 @@ func editAuthorizedIntegrationErrorHandler(ctx *context.Context, err error) {
EditAuthorizedIntegrationRenderCommon(ctx)
return
case errors.Is(err, auth_service.ErrInvalidIssuer):
ctx.Data["Err_Issuer"] = true
// ErrInvalidIssuer is a little awkward if it reaches here. Most UIs should prevent the user from entering
// something that would cause auth service to find the OIDC issuer to be invalid, but if we've reached here that
// hasn't happened. Validating the issuer performs remote HTTP calls so it's possible that this is a transient
// error, or the state of the remote has changed (used to be valid, isn't currently). We'll flash the error
// here as it might be useful to the user but we don't know how to highlight the relevant fields for the error.
ctx.Flash.Error(ctx.Tr("settings.authorized_integration.issuer.invalid", err.Error()), true)
EditAuthorizedIntegrationRenderCommon(ctx)
return
@ -230,125 +314,6 @@ func editAuthorizedIntegrationErrorHandler(ctx *context.Context, err error) {
ctx.ServerError("UpdateAuthorizedIntegration", err)
}
func copyAuthorizedIntegrationToForm(ctx *context.Context, ai *auth_model.AuthorizedIntegration, rr []*auth_model.AuthorizedIntegResourceRepo) (*AuthorizedIntegrationForm, error) {
form := &AuthorizedIntegrationForm{
Name: ai.Name,
Description: ai.Description,
Audience: ai.Audience,
Issuer: ai.Issuer, // Future: Issuer is only required when ai.UI == "generic"
}
if ai.ResourceAllRepos {
publicOnly, err := ai.Scope.PublicOnly()
if err != nil {
return nil, err
}
if publicOnly {
form.Resource = "public-only"
} else {
form.Resource = "all"
}
} else {
form.Resource = "repo-specific"
}
form.Scope = ai.Scope.StringSlice()
scopeAll, err := ai.Scope.HasScope(auth_model.AccessTokenScopeAll)
if err != nil {
return nil, err
}
form.ScopeAll = scopeAll
// Future: ClaimRules is only required when aiUI == "generic"
claimRulesJSON, err := json.MarshalIndent(ai.ClaimRules, "", " ")
if err != nil {
return nil, err
}
form.ClaimRules = string(claimRulesJSON)
form.SelectedRepo = []string{}
if len(rr) != 0 {
repoIDs := make([]int64, len(rr))
for _, r := range rr {
repoIDs = append(repoIDs, r.RepoID)
}
repos, err := db.GetByIDs(ctx, "id", repoIDs, &repo_model.Repository{})
if err != nil {
return nil, err
}
for _, r := range rr {
repo := repos[r.RepoID]
// Repos associated with an authorized integration should already be visible to the 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 nil, err
}
if permission.HasAccess() {
form.SelectedRepo = append(form.SelectedRepo, fmt.Sprintf("%s/%s", repo.OwnerName, repo.Name))
}
}
}
return form, nil
}
func copyFormToAuthorizedIntegration(ctx *context.Context, form *AuthorizedIntegrationForm, ai *auth_model.AuthorizedIntegration) ([]*auth_model.AuthorizedIntegResourceRepo, error) {
ai.Name = form.Name
ai.Description = form.Description
// ui=Generic, to be refactored later
ai.Issuer = form.Issuer
var claimRules *auth_model.ClaimRules
reader := bytes.NewReader([]byte(form.ClaimRules))
decoder := json.NewDecoder(reader)
decoder.DisallowUnknownFields() // prevent typo fields from being ignored to make errors easier to identify
if err := decoder.Decode(&claimRules); err != nil {
return nil, fmt.Errorf("%w: %w", auth_service.ErrInvalidClaimRules, err)
}
// json.Decoder doesn't guarantee that all of the reader is consumed, which can lead to weird situations
// where the UI appears to work correctly if extra content is in the form field, but it won't be parsed,
// misleading users. Detect if anything other than io.EOF comes out of further decodings:
var extra any
if err := decoder.Decode(&extra); err != io.EOF {
if err == nil {
return nil, fmt.Errorf("%w: unexpected trailing content: %s", auth_service.ErrInvalidClaimRules, extra)
}
return nil, fmt.Errorf("%w: error after JSON value: %w", auth_service.ErrInvalidClaimRules, err)
}
ai.ClaimRules = claimRules
scopeRaw := strings.Join(form.Scope, ",")
var resourceRepos []*auth_model.AuthorizedIntegResourceRepo
switch form.Resource {
case "all":
ai.ResourceAllRepos = true
case "public-only":
ai.ResourceAllRepos = true
scopeRaw = fmt.Sprintf("%s,%s", scopeRaw, auth_model.AccessTokenScopePublicOnly)
case "repo-specific":
ai.ResourceAllRepos = false
selectedRepos, err := getSelectedRepos(ctx, form.SelectedRepo)
if err != nil {
return nil, err
}
for _, repo := range selectedRepos {
resourceRepos = append(resourceRepos, &auth_model.AuthorizedIntegResourceRepo{RepoID: repo.ID})
}
}
scope, err := auth_model.AccessTokenScope(scopeRaw).Normalize()
if err != nil {
return nil, err
}
ai.Scope = scope
return resourceRepos, nil
}
func EditAuthorizedIntegrationRenderCommon(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("settings.authorized_integrations")
ctx.Data["PageIsSettingsAuthorizedIntegrations"] = true
@ -370,12 +335,19 @@ func EditAuthorizedIntegrationRenderCommon(ctx *context.Context) {
ctx.Data["Categories"] = categories
repoMultiSelect(ctx)
if ctx.Written() {
return
}
authorizedIntegrationUI(ctx).populateTemplateContext(ctx)
if ctx.Written() {
return
}
ctx.HTML(http.StatusOK, tplAuthorizedIntegrationViewGeneric)
ctx.HTML(http.StatusOK, authorizedIntegrationUI(ctx).editTemplate())
}
func repoMultiSelect(ctx *context.Context) {
form := ctx.Data["Form"].(*AuthorizedIntegrationForm)
form := ctx.Data["Form"].(authorizedIntegrationUIForm).baseForm()
if form.AddSelectedRepo != "" {
form.SelectedRepo = append(form.SelectedRepo, form.AddSelectedRepo)

View file

@ -0,0 +1,289 @@
// Copyright 2026 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
package setting
import (
"errors"
"fmt"
"html/template"
"net/http"
"strconv"
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/models/unit"
"forgejo.org/modules/base"
"forgejo.org/modules/log"
"forgejo.org/modules/optional"
"forgejo.org/modules/svg"
"forgejo.org/modules/templates"
auth_service "forgejo.org/services/auth"
"forgejo.org/services/context"
"github.com/gobwas/glob"
)
var (
_ authorizedIntegrationUIImpl = actionsLocalUI{}
_ authorizedIntegrationUIForm = &actionsLocalAuthorizedIntegrationForm{}
errInvalidWorkflowFileGlob = errors.New("invalid workflow file glob")
errInvalidGitRefGlob = errors.New("invalid git ref glob")
)
type actionsLocalUI struct{}
func (actionsLocalUI) UIIdentifier() auth_model.AuthorizedIntegrationUI {
return auth_model.AuthorizedIntegrationUIForgejoActionsLocal
}
func (actionsLocalUI) Icon(size int) template.HTML {
return svg.RenderHTML("gitea-forgejo", size, "img")
}
func (actionsLocalUI) Label(ctx *templates.Context) template.HTML {
return ctx.Locale.Tr("settings.authorized_integration.ui.forgejo_actions_local")
}
func (actionsLocalUI) editTemplate() base.TplName {
return "user/settings/authorized_integrations/actions_local/view"
}
func (actionsLocalUI) populateTemplateContext(ctx *context.Context) {
// Varient of the base template's repoSingleSelect, except it supports a single-select, and filters repositories to
// only those that enable Action unit.
form := ctx.Data["Form"].(*actionsLocalAuthorizedIntegrationForm)
if form.SourceRepo != "" {
selectedRepos, err := getSelectedRepos(ctx, []string{form.SourceRepo})
if err != nil {
ctx.Error(http.StatusBadRequest, "getSelectedRepos")
return
} else if len(selectedRepos) != 1 {
ctx.Error(http.StatusInternalServerError, "unexpected selectedRepos len")
return
}
ctx.Data["SourceRepo"] = selectedRepos[0]
} else {
repoSearchText := form.ActionRepoSearch
page := 1
// Pagination on the repo search has form submit buttons that send the `set_page` param. It's then encoded into the
// page in the hidden input `page` which we fall back to, if anything else causes a form get (eg. adding or removing
// a repo).
if form.ActionSetPage > 0 {
page = form.ActionSetPage
} else if form.ActionPage > 0 {
page = form.ActionPage
}
pageSize := 10
repoSearch := &repo_model.SearchRepoOptions{
Actor: ctx.Doer,
Keyword: repoSearchText,
Private: true,
Archived: optional.Some(false),
EnabledUnit: optional.Some(unit.TypeActions),
// Restrict repositories to those owned by, or collaborated with, by the user. Repo-specific access tokens
// could theoretically be created on any public repository as well, but there wouldn't be much point to that and
// it would really balloon the search results to an impractical number of repos.
OwnerID: ctx.Doer.ID,
ListOptions: db.ListOptions{
Page: page,
PageSize: pageSize,
},
OrderBy: db.SearchOrderByAlphabetically, // match sorting in getSelectedRepos for consistency
}
cond := repo_model.SearchRepositoryCondition(repoSearch)
repos, count, err := repo_model.SearchRepositoryByCondition(ctx, repoSearch, cond, false)
if err != nil {
log.Error("SearchRepository: %v", err)
ctx.JSON(http.StatusInternalServerError, nil)
return
}
ctx.Data["ActionsRepos"] = repos
pager := context.NewPagination(int(count), pageSize, page, 3)
pager.SetDefaultParams(ctx)
ctx.Data["ActionsPage"] = pager
}
}
func (actionsLocalUI) form() authorizedIntegrationUIForm {
return &actionsLocalAuthorizedIntegrationForm{}
}
func (actionsLocalUI) populateError(ctx *context.Context, err error) (handled bool) {
var errMissingField *auth_service.MissingFieldError
switch {
case errors.As(err, &errMissingField):
switch errMissingField.Field {
case "SourceRepo":
ctx.Data["Err_SourceRepo"] = true
ctx.Flash.Error(ctx.Tr("settings.authorized_integration.forgejo_actions_local.repo.required"), true)
return true
default:
// Unrecognized field; fallback to server error handling.
return false
}
case errors.Is(err, errInvalidWorkflowFileGlob):
ctx.Data["Err_WorkflowFile"] = true
ctx.Flash.Error(ctx.Tr("settings.authorized_integration.forgejo_actions_local.workflow_file.error", err), true)
return true
case errors.Is(err, errInvalidGitRefGlob):
ctx.Data["Err_GitRef"] = true
ctx.Flash.Error(ctx.Tr("settings.authorized_integration.forgejo_actions_local.git_ref.error", err), true)
return true
}
return false
}
type actionsLocalAuthorizedIntegrationForm struct {
baseAuthorizedIntegrationForm
SourceRepo string // formatted as ownername/reponame
WorkflowFile string
GitRef string
Event []string
// Values used for source repo search & selection, not stored in Authorized Integration. Must avoid conflicting
// with similar form values in baseAuthorizedIntegrationForm.
ActionRepoSearch string
ActionPage int // repo search page
ActionSetPage int // repo search buttons
}
func (g *actionsLocalAuthorizedIntegrationForm) baseForm() *baseAuthorizedIntegrationForm {
return &g.baseAuthorizedIntegrationForm
}
func (g *actionsLocalAuthorizedIntegrationForm) isEmpty() bool {
return g.baseAuthorizedIntegrationForm.isEmpty() && g.SourceRepo == "" && g.WorkflowFile == "" && g.GitRef == ""
}
func (g *actionsLocalAuthorizedIntegrationForm) populateForm(ctx *context.Context, issuer string, claimRules *auth_model.ClaimRules) error {
var err error
var repositoryID, repositoryOwnerID int64
for _, cr := range claimRules.Rules {
switch {
case cr.Claim == "repository_id" && cr.Comparison == auth_model.ClaimEqual:
repositoryID, err = strconv.ParseInt(cr.Value, 10, 64)
if err != nil {
return fmt.Errorf("unexpected claim rule value on claim %q: %#v", cr.Claim, err)
}
case cr.Claim == "repository_owner_id" && cr.Comparison == auth_model.ClaimEqual:
repositoryOwnerID, err = strconv.ParseInt(cr.Value, 10, 64)
if err != nil {
return fmt.Errorf("unexpected claim rule value on claim %q: %#v", cr.Claim, err)
}
case cr.Claim == "workflow" && cr.Comparison == auth_model.ClaimGlob:
g.WorkflowFile = cr.Value
case cr.Claim == "ref" && cr.Comparison == auth_model.ClaimGlob:
g.GitRef = cr.Value
case cr.Claim == "event_name" && cr.Comparison == auth_model.ClaimIn:
g.Event = cr.Values
default:
return fmt.Errorf("unexpected claim rule: %#v", cr)
}
}
if repositoryID == 0 && repositoryOwnerID == 0 {
g.SourceRepo = ""
} else {
repo, err := repo_model.GetRepositoryByID(ctx, repositoryID)
if err != nil {
return fmt.Errorf("unable to load repo: %w", err)
}
permission, err := access_model.GetUserRepoPermission(ctx, repo, ctx.Doer)
if err != nil {
return err
} else if !permission.HasAccess() || repo.OwnerID != repositoryOwnerID {
return fmt.Errorf("repository could not be loaded")
}
g.SourceRepo = fmt.Sprintf("%s/%s", repo.OwnerName, repo.Name)
}
return nil
}
func (g *actionsLocalAuthorizedIntegrationForm) convertForm(ctx *context.Context) (issuer string, claimRules *auth_model.ClaimRules, err error) {
issuer = "urn:forgejo:authorized-integrations:actions"
rules := []auth_model.ClaimRule{}
if g.SourceRepo == "" {
return "", nil, &auth_service.MissingFieldError{Field: "SourceRepo"}
}
selectedRepos, err := getSelectedRepos(ctx, []string{g.SourceRepo})
if err != nil {
return "", nil, err
} else if len(selectedRepos) != 1 {
return "", nil, fmt.Errorf("expected only one repo, but received %d", len(selectedRepos))
}
repo := selectedRepos[0]
// We'll translate the selected repo into two claim rules -- matching the repo's owner, and the repository,
// based upon their immutable IDs (not their changeable names). This is a little bit inflexible as it means
// that the authorized integration will stop accepting JWTs from the repository if the owner is changed (for
// example, the repository is transferred), which we could permit if we just matched based upon the repository
// ID. Matching on both fields is a bit more security conservative though; a repository transfer could be part
// of stealing control of a repo from one admin to another, and it seems a little safer to reduce the repo's
// access in that case and require the authorized integration owner to take some action to reestablish trust.
rules = append(rules,
auth_model.ClaimRule{
Claim: "repository_id",
Comparison: auth_model.ClaimEqual,
Value: fmt.Sprintf("%d", repo.ID),
},
auth_model.ClaimRule{
Claim: "repository_owner_id",
Comparison: auth_model.ClaimEqual,
Value: fmt.Sprintf("%d", repo.OwnerID),
})
if g.WorkflowFile != "" {
_, err := glob.Compile(g.WorkflowFile)
if err != nil {
return "", nil, fmt.Errorf("%w: %w", errInvalidWorkflowFileGlob, err)
}
rules = append(rules, auth_model.ClaimRule{
Claim: "workflow",
Comparison: auth_model.ClaimGlob,
Value: g.WorkflowFile,
})
}
if g.GitRef != "" {
_, err := glob.Compile(g.GitRef)
if err != nil {
return "", nil, fmt.Errorf("%w: %w", errInvalidGitRefGlob, err)
}
rules = append(rules, auth_model.ClaimRule{
Claim: "ref",
Comparison: auth_model.ClaimGlob,
Value: g.GitRef,
})
}
if len(g.Event) != 0 {
rules = append(rules, auth_model.ClaimRule{
Claim: "event_name",
Comparison: auth_model.ClaimIn,
Values: g.Event,
})
}
// Safety check -- authorized integrations do support having empty claim rules, but it should never be the case for
// the Actions Local UI to create this situation:
if len(rules) == 0 {
return "", nil, fmt.Errorf("unexpected: Actions Local UI didn't define any claim rules")
}
claimRules = &auth_model.ClaimRules{Rules: rules}
return issuer, claimRules, nil
}
func (g *actionsLocalAuthorizedIntegrationForm) initNew() {
}

View file

@ -0,0 +1,232 @@
// Copyright 2026 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
package setting
import (
"errors"
"testing"
auth_model "forgejo.org/models/auth"
repo_model "forgejo.org/models/repo"
"forgejo.org/models/unittest"
user_model "forgejo.org/models/user"
"forgejo.org/modules/templates"
auth_service "forgejo.org/services/auth"
"forgejo.org/services/context"
"forgejo.org/services/contexttest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func makeLocalContext(t *testing.T) *context.Context {
ctx, _ := contexttest.MockContext(t, "user/settings/authorized-integrations/forgejo-actions-local/new",
contexttest.MockContextOption{Render: templates.HTMLRenderer()})
ctx.Doer = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
return ctx
}
func TestLocalPopulateTemplateContext(t *testing.T) {
ui := actionsLocalUI{}
t.Run("search for repos", func(t *testing.T) {
form := &actionsLocalAuthorizedIntegrationForm{}
ctx := makeLocalContext(t)
ctx.Data["Form"] = form
ui.populateTemplateContext(ctx)
require.False(t, ctx.Written()) // no error written to ctx
actionsRepos := ctx.Data["ActionsRepos"].(repo_model.RepositoryList)
require.Len(t, actionsRepos, 3)
assert.Equal(t, "repo1", actionsRepos[0].Name)
assert.Equal(t, "test_action_run_search", actionsRepos[1].Name)
assert.Equal(t, "test_workflows", actionsRepos[2].Name)
pager := ctx.Data["ActionsPage"].(*context.Pagination)
assert.Equal(t, 3, pager.Paginater.Total())
})
t.Run("has repo in form", func(t *testing.T) {
form := &actionsLocalAuthorizedIntegrationForm{
SourceRepo: "user2/repo1",
}
ctx := makeLocalContext(t)
ctx.Data["Form"] = form
ui.populateTemplateContext(ctx)
require.False(t, ctx.Written()) // no error written to ctx
repo := ctx.Data["SourceRepo"].(*repo_model.Repository)
assert.EqualValues(t, 1, repo.ID)
})
}
func TestLocalPopulateError(t *testing.T) {
ui := actionsLocalUI{}
t.Run("unrecognized error", func(t *testing.T) {
ctx := makeLocalContext(t)
assert.False(t, ui.populateError(ctx, errors.New("some other error")))
})
t.Run("unrecognized field error", func(t *testing.T) {
ctx := makeLocalContext(t)
assert.False(t, ui.populateError(ctx, &auth_service.MissingFieldError{Field: "Description"}))
})
t.Run("workflow field error", func(t *testing.T) {
ctx := makeLocalContext(t)
assert.True(t, ui.populateError(ctx, errInvalidWorkflowFileGlob))
assert.True(t, ctx.Data["Err_WorkflowFile"].(bool))
})
t.Run("git ref field error", func(t *testing.T) {
ctx := makeLocalContext(t)
assert.True(t, ui.populateError(ctx, errInvalidGitRefGlob))
assert.True(t, ctx.Data["Err_GitRef"].(bool))
})
}
func TestLocalPopulateForm(t *testing.T) {
form := &actionsLocalAuthorizedIntegrationForm{}
issuer := "urn:forgejo:authorized-integrations:actions"
t.Run("fully populated claim rules", func(t *testing.T) {
cr := &auth_model.ClaimRules{
Rules: []auth_model.ClaimRule{
{Claim: "repository_id", Comparison: auth_model.ClaimEqual, Value: "2"},
{Claim: "repository_owner_id", Comparison: auth_model.ClaimEqual, Value: "2"},
{Claim: "workflow", Comparison: auth_model.ClaimGlob, Value: ".forgejo/workflows/*.yml"},
{Claim: "ref", Comparison: auth_model.ClaimGlob, Value: "refs/tags/v*"},
{Claim: "event_name", Comparison: auth_model.ClaimIn, Values: []string{"push"}},
},
}
require.NoError(t, form.populateForm(makeLocalContext(t), issuer, cr))
assert.Equal(t, "user2/repo2", form.SourceRepo)
assert.Equal(t, ".forgejo/workflows/*.yml", form.WorkflowFile)
assert.Equal(t, "refs/tags/v*", form.GitRef)
assert.Equal(t, []string{"push"}, form.Event)
})
t.Run("mismatched repository owner", func(t *testing.T) {
cr := &auth_model.ClaimRules{
Rules: []auth_model.ClaimRule{
{Claim: "repository_id", Comparison: auth_model.ClaimEqual, Value: "2"},
{Claim: "repository_owner_id", Comparison: auth_model.ClaimEqual, Value: "200"},
},
}
require.ErrorContains(t, form.populateForm(makeLocalContext(t), issuer, cr), "repository could not be loaded")
})
t.Run("repo with no visibility", func(t *testing.T) {
cr := &auth_model.ClaimRules{
Rules: []auth_model.ClaimRule{
{Claim: "repository_id", Comparison: auth_model.ClaimEqual, Value: "7"},
{Claim: "repository_owner_id", Comparison: auth_model.ClaimEqual, Value: "10"},
},
}
require.ErrorContains(t, form.populateForm(makeLocalContext(t), issuer, cr), "repository could not be loaded")
})
}
func TestLocalConvertForm(t *testing.T) {
t.Run("empty", func(t *testing.T) {
form := &actionsLocalAuthorizedIntegrationForm{}
_, _, err := form.convertForm(makeLocalContext(t))
require.ErrorContains(t, err, "missing field SourceRepo")
})
t.Run("repo with no visibilty", func(t *testing.T) {
form := &actionsLocalAuthorizedIntegrationForm{
SourceRepo: "user10/repo7",
}
_, _, err := form.convertForm(makeLocalContext(t))
require.ErrorContains(t, err, "one or more of the repositories couldn't be found by owner & name")
})
t.Run("valid repo", func(t *testing.T) {
form := &actionsLocalAuthorizedIntegrationForm{
SourceRepo: "user2/repo2",
}
issuer, claimRules, err := form.convertForm(makeLocalContext(t))
require.NoError(t, err)
assert.Equal(t, "urn:forgejo:authorized-integrations:actions", issuer)
assert.Equal(t, &auth_model.ClaimRules{
Rules: []auth_model.ClaimRule{
{Claim: "repository_id", Comparison: auth_model.ClaimEqual, Value: "2"},
{Claim: "repository_owner_id", Comparison: auth_model.ClaimEqual, Value: "2"},
},
}, claimRules)
})
t.Run("valid workflow file", func(t *testing.T) {
form := &actionsLocalAuthorizedIntegrationForm{
SourceRepo: "user2/repo2",
WorkflowFile: ".forgejo/workflows/test-*.yml",
}
issuer, claimRules, err := form.convertForm(makeLocalContext(t))
require.NoError(t, err)
assert.Equal(t, "urn:forgejo:authorized-integrations:actions", issuer)
assert.Equal(t, &auth_model.ClaimRules{
Rules: []auth_model.ClaimRule{
{Claim: "repository_id", Comparison: auth_model.ClaimEqual, Value: "2"},
{Claim: "repository_owner_id", Comparison: auth_model.ClaimEqual, Value: "2"},
{Claim: "workflow", Comparison: auth_model.ClaimGlob, Value: ".forgejo/workflows/test-*.yml"},
},
}, claimRules)
})
t.Run("invalid workflow file", func(t *testing.T) {
form := &actionsLocalAuthorizedIntegrationForm{
SourceRepo: "user2/repo2",
WorkflowFile: ".forgejo/workflows/test-*[",
}
_, _, err := form.convertForm(makeLocalContext(t))
require.ErrorContains(t, err, "invalid workflow file glob: unexpected end of input")
})
t.Run("valid git ref", func(t *testing.T) {
form := &actionsLocalAuthorizedIntegrationForm{
SourceRepo: "user2/repo2",
GitRef: "refs/tags/v*",
}
issuer, claimRules, err := form.convertForm(makeLocalContext(t))
require.NoError(t, err)
assert.Equal(t, "urn:forgejo:authorized-integrations:actions", issuer)
assert.Equal(t, &auth_model.ClaimRules{
Rules: []auth_model.ClaimRule{
{Claim: "repository_id", Comparison: auth_model.ClaimEqual, Value: "2"},
{Claim: "repository_owner_id", Comparison: auth_model.ClaimEqual, Value: "2"},
{Claim: "ref", Comparison: auth_model.ClaimGlob, Value: "refs/tags/v*"},
},
}, claimRules)
})
t.Run("invalid git ref", func(t *testing.T) {
form := &actionsLocalAuthorizedIntegrationForm{
SourceRepo: "user2/repo2",
GitRef: "refs/[",
}
_, _, err := form.convertForm(makeLocalContext(t))
require.ErrorContains(t, err, "invalid git ref glob: unexpected end of input")
})
t.Run("valid event", func(t *testing.T) {
form := &actionsLocalAuthorizedIntegrationForm{
SourceRepo: "user2/repo2",
Event: []string{"push", "pull_request"},
}
issuer, claimRules, err := form.convertForm(makeLocalContext(t))
require.NoError(t, err)
assert.Equal(t, "urn:forgejo:authorized-integrations:actions", issuer)
assert.Equal(t, &auth_model.ClaimRules{
Rules: []auth_model.ClaimRule{
{Claim: "repository_id", Comparison: auth_model.ClaimEqual, Value: "2"},
{Claim: "repository_owner_id", Comparison: auth_model.ClaimEqual, Value: "2"},
{Claim: "event_name", Comparison: auth_model.ClaimIn, Values: []string{"push", "pull_request"}},
},
}, claimRules)
})
}

View file

@ -0,0 +1,134 @@
// Copyright 2026 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
package setting
import (
"fmt"
"strings"
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/services/context"
)
type baseAuthorizedIntegrationForm struct {
// Top data in UI, descriptive information about the Authorized Integration:
Name string
Description string
Audience string
// // Middle data in UI, how JWTs are validated by this Authorized Integration:
// Issuer string // Future: Issuer is likely to be replaced with more-specific fields on non-generic UIs
// ClaimRules string // Future: ClaimRules is only required when aiUI == "generic"
// Bottom data in the UI, what authorization is permitted by this Authorized Integration:
Resource string // all, public-only, repo-specific
SelectedRepo []string // slice of ownername/reponame for repo-specific
ScopeAll bool
Scope []string
// Values used for repo-specific repository multi-select UI, not stored in Authorized Integration:
RepoSearch string
AddSelectedRepo string // add a repo to SelectedRepo
RemoveSelectedRepo string // remove a repo from SelectedRepo
Page int // repo search page
SetPage int // repo search buttons
}
func (f *baseAuthorizedIntegrationForm) isEmpty() bool {
return f.Name == "" && f.Description == "" && f.Audience == "" &&
f.Resource == "" && f.SelectedRepo == nil && f.Scope == nil
}
func (f *baseAuthorizedIntegrationForm) copyAuthorizedIntegrationToForm(ctx *context.Context, ai *auth_model.AuthorizedIntegration, rr []*auth_model.AuthorizedIntegResourceRepo) error {
f.Name = ai.Name
f.Description = ai.Description
f.Audience = ai.Audience
if ai.ResourceAllRepos {
publicOnly, err := ai.Scope.PublicOnly()
if err != nil {
return err
}
if publicOnly {
f.Resource = "public-only"
} else {
f.Resource = "all"
}
} else {
f.Resource = "repo-specific"
}
f.Scope = ai.Scope.StringSlice()
scopeAll, err := ai.Scope.HasScope(auth_model.AccessTokenScopeAll)
if err != nil {
return err
}
f.ScopeAll = scopeAll
f.SelectedRepo = []string{}
if len(rr) != 0 {
repoIDs := make([]int64, len(rr))
for i, r := range rr {
repoIDs[i] = r.RepoID
}
repos, err := db.GetByIDs(ctx, "id", repoIDs, &repo_model.Repository{})
if err != nil {
return err
}
for _, r := range rr {
repo := repos[r.RepoID]
// Repos associated with an authorized integration should already be visible to the 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 err
}
if permission.HasAccess() {
f.SelectedRepo = append(f.SelectedRepo, fmt.Sprintf("%s/%s", repo.OwnerName, repo.Name))
}
}
}
return nil
}
func (f *baseAuthorizedIntegrationForm) copyFormToAuthorizedIntegration(ctx *context.Context, ai *auth_model.AuthorizedIntegration) ([]*auth_model.AuthorizedIntegResourceRepo, error) {
ai.Name = f.Name
ai.Description = f.Description
scopeRaw := strings.Join(f.Scope, ",")
var resourceRepos []*auth_model.AuthorizedIntegResourceRepo
switch f.Resource {
case "all":
ai.ResourceAllRepos = true
case "public-only":
ai.ResourceAllRepos = true
scopeRaw = fmt.Sprintf("%s,%s", scopeRaw, auth_model.AccessTokenScopePublicOnly)
case "repo-specific":
ai.ResourceAllRepos = false
selectedRepos, err := getSelectedRepos(ctx, f.SelectedRepo)
if err != nil {
return nil, err
}
for _, repo := range selectedRepos {
resourceRepos = append(resourceRepos, &auth_model.AuthorizedIntegResourceRepo{RepoID: repo.ID})
}
}
scope, err := auth_model.AccessTokenScope(scopeRaw).Normalize()
if err != nil {
return nil, err
}
ai.Scope = scope
return resourceRepos, nil
}
func (f *baseAuthorizedIntegrationForm) InitNew() {
f.Resource = "all"
}

View file

@ -0,0 +1,115 @@
// Copyright 2026 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
package setting
import (
"bytes"
"errors"
"fmt"
"html/template"
"io"
auth_model "forgejo.org/models/auth"
"forgejo.org/modules/base"
"forgejo.org/modules/json"
"forgejo.org/modules/svg"
"forgejo.org/modules/templates"
auth_service "forgejo.org/services/auth"
"forgejo.org/services/context"
)
var (
_ authorizedIntegrationUIImpl = genericUI{}
_ authorizedIntegrationUIForm = &genericAuthorizedIntegrationForm{}
)
type genericUI struct{}
func (genericUI) UIIdentifier() auth_model.AuthorizedIntegrationUI {
return auth_model.AuthorizedIntegrationUIGeneric
}
func (genericUI) Icon(size int) template.HTML {
return svg.RenderHTML("octicon-cloud", size, "img")
}
func (genericUI) Label(ctx *templates.Context) template.HTML {
return ctx.Locale.Tr("settings.authorized_integration.ui.generic")
}
func (genericUI) editTemplate() base.TplName {
return "user/settings/authorized_integrations/generic/view"
}
func (genericUI) populateTemplateContext(ctx *context.Context) {
}
func (genericUI) form() authorizedIntegrationUIForm {
return &genericAuthorizedIntegrationForm{}
}
func (genericUI) populateError(ctx *context.Context, err error) (handled bool) {
switch {
case errors.Is(err, auth_service.ErrInvalidIssuer):
ctx.Data["Err_Issuer"] = true
ctx.Flash.Error(ctx.Tr("settings.authorized_integration.issuer.invalid", err.Error()), true)
return true
case errors.Is(err, auth_service.ErrInvalidClaimRules):
ctx.Data["Err_ClaimRules"] = true
ctx.Flash.Error(ctx.Tr("settings.authorized_integration.claim_rules.invalid", err.Error()), true)
return true
}
return false
}
type genericAuthorizedIntegrationForm struct {
baseAuthorizedIntegrationForm
Issuer string
ClaimRules string
}
func (g *genericAuthorizedIntegrationForm) baseForm() *baseAuthorizedIntegrationForm {
return &g.baseAuthorizedIntegrationForm
}
func (g *genericAuthorizedIntegrationForm) isEmpty() bool {
return g.baseAuthorizedIntegrationForm.isEmpty() && g.Issuer == "" && g.ClaimRules == ""
}
func (g *genericAuthorizedIntegrationForm) populateForm(ctx *context.Context, issuer string, claimRules *auth_model.ClaimRules) error {
g.Issuer = issuer
claimRulesJSON, err := json.MarshalIndent(claimRules, "", " ")
if err != nil {
return err
}
g.ClaimRules = string(claimRulesJSON)
return nil
}
func (g *genericAuthorizedIntegrationForm) convertForm(ctx *context.Context) (issuer string, claimRules *auth_model.ClaimRules, err error) {
issuer = g.Issuer
reader := bytes.NewReader([]byte(g.ClaimRules))
decoder := json.NewDecoder(reader)
decoder.DisallowUnknownFields() // prevent typo fields from being ignored to make errors easier to identify
if err := decoder.Decode(&claimRules); err != nil {
return "", nil, fmt.Errorf("%w: %w", auth_service.ErrInvalidClaimRules, err)
}
// json.Decoder doesn't guarantee that all of the reader is consumed, which can lead to weird situations
// where the UI appears to work correctly if extra content is in the form field, but it won't be parsed,
// misleading users. Detect if anything other than io.EOF comes out of further decodings:
var extra any
if err := decoder.Decode(&extra); err != io.EOF {
if err == nil {
return "", nil, fmt.Errorf("%w: unexpected trailing content: %s", auth_service.ErrInvalidClaimRules, extra)
}
return "", nil, fmt.Errorf("%w: error after JSON value: %w", auth_service.ErrInvalidClaimRules, err)
}
return issuer, claimRules, nil
}
func (g *genericAuthorizedIntegrationForm) initNew() {
g.ClaimRules = string("{\n \"rules\":[]\n}")
}

View file

@ -674,12 +674,12 @@ func registerRoutes(m *web.Route) {
m.Group("/authorized-integrations", func() {
m.Group("/{ui}", func() {
m.Combo("/new").
Get(web.Bind(user_setting.AuthorizedIntegrationForm{}), user_setting.NewAuthorizedIntegration).
Post(web.Bind(user_setting.AuthorizedIntegrationForm{}), user_setting.NewAuthorizedIntegrationPost)
Get(user_setting.NewAuthorizedIntegration).
Post(user_setting.NewAuthorizedIntegrationPost)
m.Combo("/{id}").
Get(web.Bind(user_setting.AuthorizedIntegrationForm{}), user_setting.EditAuthorizedIntegration).
Post(web.Bind(user_setting.AuthorizedIntegrationForm{}), user_setting.EditAuthorizedIntegrationPost)
})
Get(user_setting.EditAuthorizedIntegration).
Post(user_setting.EditAuthorizedIntegrationPost)
}, user_setting.BindAuthorizedIntegrationUI, user_setting.DynamicBindAuthorizedIntegrationForm)
m.Post("/delete", user_setting.DeleteAuthorizedIntegration)
m.Get("", user_setting.ListAuthorizedIntegrations)
})

View file

@ -0,0 +1,123 @@
{{template "user/settings/authorized_integrations/view_head" .}}
<h4 class="ui top attached header {{if .Err_SourceRepo}}error{{end}}">
{{ctx.Locale.Tr "settings.authorized_integration.ui.forgejo_actions_local"}}
</h4>
<div class="ui attached bottom segment">
<p>{{ctx.Locale.Tr "settings.authorized_integration.forgejo_actions_local.description"}}</p>
{{if eq .Form.SourceRepo ""}}
<div class="tw-flex tw-flex-wrap tw-gap-8">
<!-- left-hand side: repo list from a search -->
<div class="ui tab active list tw-flex-1">
<h5 id="action-select-repo">
{{ctx.Locale.Tr "settings.authorized_integration.forgejo_actions_local.select_repository"}}
</h5>
<div class="ui small fluid action left icon input tw-mb-3">
<input type="search" name="action_repo_search" spellcheck="false" placeholder="{{ctx.Locale.Tr "search.repo_kind"}}" value="{{.Form.ActionRepoSearch}}">
<i class="icon">{{svg "octicon-search" 16}}</i>
<button class="ui small icon button" aria-label="{{ctx.Locale.Tr "search.search"}}" type="submit" name="action_set_page" value="1" formnovalidate="true" formmethod="get">
{{svg "octicon-search" 16}}
</button>
</div>
{{if eq (len .ActionsRepos) 0}}
{{ctx.Locale.Tr "settings.access_token.no_repositories_found"}}
{{else}}
<div class="tw-grid tw-items-center" style="grid-template-columns: min-content 1fr min-content;">
{{range .ActionsRepos}}
{{template "user/settings/repo_icon" .}}
<div class="text truncate">
{{.FullName}}
</div>
<button class="ui primary button tw-ml-2 tw-my-1 tiny" type="submit" aria-label="{{ctx.Locale.Tr "repo.editor.add" .FullName}}" formnovalidate="true" name="source_repo" value="{{.FullName}}" formmethod="get">
{{ctx.Locale.Tr "settings.authorized_integration.forgejo_actions_local.select_repo"}}
</button>
{{end}}
</div>
{{end}}
{{/* Can't use base/paginate template include here because all the pagination links in
that template are simple <a href=...> links. Here, we need to turn them into form
buttons so that we can submit the current form. If a user just changed a value (eg. set
the token name, changed a selected permission) and then clicked a pagination button, the
new value that they changed needs to be submitted. base/paginate would allow preserving
old values from before the change, but not new updates. Implementing here also allows
the use of smaller styling. */}}
{{with .ActionsPage.Paginater}}
<input type="hidden" name="page" value="{{.Current}}">
<div class="center page buttons">
<div class="ui borderless pagination menu mini">
<button class="item navigation {{if .IsFirst}}disabled{{end}}" type="submit" formnovalidate="true" name="action_set_page" value="1" formmethod="get">
{{svg "gitea-double-chevron-left" 16 "tw-mr-1"}}
<span class="navigation_label">{{ctx.Locale.Tr "admin.first_page"}}</span>
</button>
<button class="item navigation {{if not .HasPrevious}}disabled{{end}}" type="submit" formnovalidate="true" name="action_set_page" value="{{.Previous}}" formmethod="get">
{{svg "octicon-chevron-left" 16 "tw-mr-1"}}
<span class="navigation_label">{{ctx.Locale.Tr "repo.issues.previous"}}</span>
</button>
{{range .Pages}}
{{if eq .Num -1}}
<a class="disabled item">...</a>
{{else}}
<button class="item navigation {{if .IsCurrent}}active{{end}}" type="submit" formnovalidate="true" name="action_set_page" value="{{.Num}}" formmethod="get">
{{.Num}}
</button>
{{end}}
{{end}}
<button class="item navigation {{if not .HasNext}}disabled{{end}}" type="submit" formnovalidate="true" name="action_set_page" value="{{.Next}}" formmethod="get">
<span class="navigation_label">{{ctx.Locale.Tr "repo.issues.next"}}</span>
{{svg "octicon-chevron-right" 16 "tw-ml-1"}}
</button>
<button class="item navigation {{if .IsLast}}disabled{{end}}" type="submit" formnovalidate="true" name="action_set_page" value="{{.TotalPages}}" formmethod="get">
<span class="navigation_label">{{ctx.Locale.Tr "admin.last_page"}}</span>
{{svg "gitea-double-chevron-right" 16 "tw-ml-1"}}
</button>
</div>
</div>
{{end}}
</div>
</div>
{{else}}
<div class="field tw-flex tw-items-center">
<div class="tw-mr-2">Source Repository:</div>
{{template "user/settings/repo_icon" .SourceRepo}}
<div class="text truncate">
{{.SourceRepo.FullName}}
</div>
<button class="ui primary button tw-ml-2 tw-my-1 tiny" formnovalidate="true" formmethod="get" name="source_repo" value="">
Change
</button>
<input type="hidden" name="source_repo" value="{{.Form.SourceRepo}}">
</div>
{{end}}
<div class="field {{if .Err_WorkflowFile}}error{{end}}">
<label for="workflow_file">{{ctx.Locale.Tr "settings.authorized_integration.forgejo_actions_local.workflow_file.label"}}</label>
<input id="workflow_file" name="workflow_file" value="{{.Form.WorkflowFile}}">
<span class="help">{{ctx.Locale.Tr "settings.authorized_integration.forgejo_actions_local.workflow_file.help" "https://pkg.go.dev/github.com/gobwas/glob#Compile" "github.com/gobwas/glob"}}</span>
</div>
<div class="field {{if .Err_GitRef}}error{{end}}">
<label for="git_ref">{{ctx.Locale.Tr "settings.authorized_integration.forgejo_actions_local.git_ref.label"}}</label>
<input id="git_ref" name="git_ref" value="{{.Form.GitRef}}">
<span class="help">{{ctx.Locale.Tr "settings.authorized_integration.forgejo_actions_local.git_ref.help" "https://pkg.go.dev/github.com/gobwas/glob#Compile" "github.com/gobwas/glob"}}</span>
</div>
<div class="field">
<label for="event">{{ctx.Locale.Tr "settings.authorized_integration.forgejo_actions_local.event.label"}}</label>
<select id="event" name="event" multiple class="ui selection dropdown">
<option value="pull_request" {{if (SliceUtils.Contains .Form.Event "pull_request")}}selected{{end}}>pull_request</option>
<option value="push" {{if (SliceUtils.Contains .Form.Event "push")}}selected{{end}}>push</option>
<option value="issues" {{if (SliceUtils.Contains .Form.Event "issues")}}selected{{end}}>issues</option>
<option value="pull_request_target" {{if (SliceUtils.Contains .Form.Event "pull_request_target")}}selected{{end}}>pull_request_target</option>
<option value="release" {{if (SliceUtils.Contains .Form.Event "release")}}selected{{end}}>release</option>
<option value="schedule" {{if (SliceUtils.Contains .Form.Event "schedule")}}selected{{end}}>schedule</option>
<option value="workflow_dispatch" {{if (SliceUtils.Contains .Form.Event "workflow_dispatch")}}selected{{end}}>workflow_dispatch</option>
</select>
<span class="help">{{ctx.Locale.Tr "settings.authorized_integration.forgejo_actions_local.event.help"}}</span>
</div>
</div>
{{template "user/settings/authorized_integrations/view_footer" .}}

View file

@ -14,7 +14,6 @@
<textarea id="claim_rules" name="claim_rules" class="tw-hidden">{{.Form.ClaimRules}}</textarea>
{{template "shared/codemirror_container" .}}
</div>
</div>
{{template "user/settings/authorized_integrations/view_footer" .}}

View file

@ -1,6 +1,8 @@
<div class="menu">
<a class="item" href="./authorized-integrations/generic/new">
{{svg "octicon-cloud" 20}}
{{ctx.Locale.Tr "settings.authorized_integration.generic"}}
</a>
{{range .UIs}}
<a class="item" href="./authorized-integrations/{{.UIIdentifier}}/new">
{{.Icon 20}}
{{.Label ctx}}
</a>
{{end}}
</div>

View file

@ -46,7 +46,7 @@
</h5>
<div class="ui small fluid action left icon input tw-mb-3">
<input type="search" name="repo_search" spellcheck="false" {{if eq .Autofocus "search"}}autofocus{{end}} placeholder="{{ctx.Locale.Tr "search.repo_kind"}}" value="{{.repo_search}}">
<input type="search" name="repo_search" spellcheck="false" {{if eq .Autofocus "search"}}autofocus{{end}} placeholder="{{ctx.Locale.Tr "search.repo_kind"}}" value="{{.Form.RepoSearch}}">
<i class="icon">{{svg "octicon-search" 16}}</i>
<button class="ui small icon button" aria-label="{{ctx.Locale.Tr "search.search"}}" type="submit" name="set_page" value="1" formnovalidate="true" formmethod="get" formaction="#resource-repo-specific">
{{svg "octicon-search" 16}}

View file

@ -196,6 +196,7 @@ test('User: Add specific repo access token', async ({browser}, workerInfo) => {
await expect(page.getByRole('textbox', {name: /^Token name/})).toHaveValue(tokenName);
await expect(page.getByRole('radio', {name: 'Specific repositories'})).toBeChecked();
await expect(page.getByRole('combobox', {name: 'repository'})).toHaveValue('read:repository');
await expect(page.getByPlaceholder('Search repos…')).toHaveValue('big_test_private_4'); // search box still populated after reload
// Add the big_test_private_4 repo.
await page.getByRole('button', {name: 'Add org17/big_test_private_4'}).click();
@ -409,6 +410,7 @@ test('User: Edit authorized integration specific repo', async ({browser}, worker
await expect(page.getByRole('textbox', {name: 'Name'})).toHaveValue(/^Example AI/);
await expect(page.getByRole('radio', {name: 'Specific repositories'})).toBeChecked();
await expect(page.getByRole('combobox', {name: 'repository'})).toHaveValue('write:repository');
await expect(page.getByPlaceholder('Search repos…')).toHaveValue('big_test_private_4'); // search box still populated after reload
// Add the big_test_private_4 repo.
await page.getByRole('button', {name: 'Add org17/big_test_private_4'}).click();
@ -442,7 +444,7 @@ test('User: Add authorized integration', async ({browser}, workerInfo) => {
await page.goto('/user/settings/authorized-integrations');
await page.getByRole('menu').filter({hasText: 'Add authorized integration'}).click();
await page.getByRole('menuitem', {name: 'Generic JWT Source'}).click();
await page.getByRole('menuitem', {name: 'Generic JWT'}).click();
await expect(page.getByRole('textbox', {name: 'Name'})).toHaveValue('');
await expect(page.getByRole('textbox', {name: 'Description'})).toHaveValue('');
@ -482,7 +484,7 @@ test('User: Add authorized integration validation error', async ({browser}, work
await page.goto('/user/settings/authorized-integrations');
await page.getByRole('menu').filter({hasText: 'Add authorized integration'}).click();
await page.getByRole('menuitem', {name: 'Generic JWT Source'}).click();
await page.getByRole('menuitem', {name: 'Generic JWT'}).click();
await page.getByRole('textbox', {name: 'Name'}).fill('\t\t');
await page.getByRole('textbox', {name: 'Issuer (iss Claim)'}).fill('urn:forgejo:authorized-integrations:actions');
@ -511,3 +513,70 @@ test('User: Add authorized integration validation error', async ({browser}, work
const deleteFlashText = await page.locator('.ui.message.flash-success').textContent();
expect(deleteFlashText?.trim()).toBe('Authorized integration has been deleted successfully.');
});
test('User: Add authorized integration (actions local)', async ({browser}, workerInfo) => {
const page = await login({browser}, workerInfo);
await page.goto('/user/settings/authorized-integrations');
await page.getByRole('menu').filter({hasText: 'Add authorized integration'}).click();
await page.getByRole('menuitem', {name: 'Forgejo Actions (Local)'}).click();
await expect(page.getByRole('textbox', {name: 'Name'})).toHaveValue('');
await expect(page.getByRole('textbox', {name: 'Description'})).toHaveValue('');
await page.getByRole('textbox', {name: 'Name'}).fill('New Forgejo Actions (Local) Integration!');
await page.getByRole('textbox', {name: 'Description'}).fill('Description that carefully describes things.');
await page.getByRole('combobox', {name: 'repository'}).selectOption('read:repository');
await page.getByRole('button', {name: 'Create authorized integration'}).click();
// Didn't select a repository as the source, so an error is expected:
await expect(page.locator('.flash-error')).toContainText('Forgejo Actions source repository must be selected.');
// Verify that initial search results for the "Select repository:" section are visible
const actionsLocalSectionGetter = async () => await page.locator('.ui.attached.segment').filter({has: page.locator('p', {hasText: 'Forgejo Actions will be able to access Forgejo'})});
let actionsLocalSection = await actionsLocalSectionGetter();
await expect(actionsLocalSection.getByText('user2/diff-test')).toBeVisible();
await expect(actionsLocalSection.getByText('user2/huge-diff-test')).toBeVisible(); // another repo, will be used to verify search worked
await actionsLocalSection.getByPlaceholder('Search repos…').fill('huge-diff');
await actionsLocalSection.getByRole('button', {name: 'Search…'}).click();
// verify search results visible:
actionsLocalSection = await actionsLocalSectionGetter();
await expect(actionsLocalSection.getByText('user2/huge-diff-test')).toBeVisible();
await expect(actionsLocalSection.getByText('user2/diff-test')).toBeHidden();
// after performing a search, verify that other fields haven't lost their form values:
await expect(page.getByRole('textbox', {name: 'Name'})).toHaveValue('New Forgejo Actions (Local) Integration!');
await expect(page.getByRole('combobox', {name: 'repository'})).toHaveValue('read:repository');
await expect(actionsLocalSection.getByPlaceholder('Search repos…')).toHaveValue('huge-diff'); // search box still populated after reload
// Add the big_test_private_4 repo.
await actionsLocalSection.getByRole('button', {name: 'Add user2/huge-diff-test'}).click();
actionsLocalSection = await actionsLocalSectionGetter();
await expect(actionsLocalSection.getByText('Source repository:')).toBeVisible();
await expect(actionsLocalSection.getByText('user2/huge-diff-test')).toBeVisible();
await page.getByRole('button', {name: 'Create authorized integration'}).click();
// Create will reload the page with a success banner, and the audience now populated; re-verify the Forgejo Actions repo selection as well:
await expect(page.getByRole('textbox', {name: 'Name'})).toHaveValue('New Forgejo Actions (Local) Integration!');
await expect(page.getByRole('textbox', {name: 'Audience (aud Claim)'})).toHaveValue(/^u:[0-9]+/);
actionsLocalSection = await actionsLocalSectionGetter();
await expect(actionsLocalSection.getByText('Source repository:')).toBeVisible();
await expect(actionsLocalSection.getByText('user2/huge-diff-test')).toBeVisible();
// Flash banner:
await expect(page.locator('.ui.message.flash-success')).toBeVisible();
const flashText = await page.locator('.ui.message.flash-success').textContent();
expect(flashText?.trim()).toBe('Created authorized integration: New Forgejo Actions (Local) Integration!');
// Delete the added integration, minimizing left-over test data and also validating the delete UI:
await page.goto('/user/settings/authorized-integrations');
await page.locator('.flex-item')
.filter({has: page.locator('.flex-item-title', {hasText: 'New Forgejo Actions (Local) Integration!'})})
.getByRole('button', {name: 'Delete'}).click();
await page.getByRole('button', {name: 'Yes'}).click();
await expect(page.locator('.ui.message.flash-success')).toBeVisible();
const deleteFlashText = await page.locator('.ui.message.flash-success').textContent();
expect(deleteFlashText?.trim()).toBe('Authorized integration has been deleted successfully.');
});

View file

@ -2,6 +2,6 @@ import $ from 'jquery';
import {createCodemirror} from './codemirror.ts';
export function initAuthorizedIntegrationClaimRuleEditor() {
if (!$('.user.authorized-integrations').length) return;
if (!$('.user.authorized-integrations #claim_rules').length) return;
const _promise = createCodemirror($('#claim_rules')[0], 'claims.json', {language: 'JSON'});
}