jojo/cmd/admin_user_generate_authorized_integration.go

205 lines
5.4 KiB
Go
Raw Normal View History

feat: add CLI command 'admin user create-authorized-integration' (#12299) Allows the creation of an authorized integration as a Forgejo administrator, either for development testing or to support server-automation. Clipping out the CLI config options, looks like: ``` NAME: forgejo admin user create-authorized-integration - Create an authorized integration for a specific user USAGE: forgejo admin user create-authorized-integration [options] OPTIONS: --username string, -u string Username --issuer string JWT issuer ('iss' claim), example: https://forgejo.example.org/api/actions --claim-eq string=string [ --claim-eq string=string ] Zero-or-more claim equality checks, formatted as claim=value, example: "actor=someuser" --claim-glob string=string [ --claim-glob string=string ] Zero-or-more claim glob checks, formatted as claim=value, example: "sub=repo:forgejo/*:pull_request" --scope string [ --scope string ] One-or-more scopes to apply to access token, examples: "all", "read:issue", "write:repository" (default: "all") --repo string [ --repo string ] Zero-or-more specific repositories that can be accessed, or "all" to allow access to all repositories, example: "owner1/repo1" (default: "all") ``` As an example, this will create an authorized integration that will permit Codeberg's Forgejo Actions to generate trusted JWTs that can access the local user `mfenniak`: ```bash $ ./forgejo admin user create-authorized-integration \ --username mfenniak \ --issuer https://codeberg.org/api/actions \ --claim-eq sub=repo:mfenniak/forgejo-runner-testrepo:pull_request \ --scope read:user { "message": "Authorized integration was successfully created.", "issuer": "https://codeberg.org/api/actions", "audience": "u:1:c97d83bc-fa4e-4db3-b898-414cd5b6ce33", "claim_rules": [ { "description": "\"sub\" = \"repo:mfenniak/forgejo-runner-testrepo:pull_request\"", "claim": "sub", "compare": "eq", "value": "repo:mfenniak/forgejo-runner-testrepo:pull_request" } ] } ``` The output is a JSON document to aid in use in automation. The `audience` field is the audience generated by Forgejo that must be used by the remote to generate the JWT. Continuing this example to the client-side, a matching Forgejo Action like this in the `mfenniak/forgejo-runner-testrepo` repo, for a `pull_request` event, then it will be able to access the Forgejo server that the authorized integration was created on like this: ```yaml on: pull_request: enable-openid-connect: true jobs: job1: runs-on: docker steps: - name: Fetch JWT id: jwt run: | set -eux -o pipefail set +x jwt=$(curl --fail \ -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=u:1:c97d83bc-fa4e-4db3-b898-414cd5b6ce33" \ | jq -r ".value") echo "::add-mask::$jwt" set -x echo "jwt=$jwt" >> $FORGEJO_OUTPUT - name: API call to Forgejo run: | curl \ -v --fail \ -H "Authorization: bearer ${{ steps.jwt.outputs.jwt }}" \ "https://example.org/api/v1/user" | jq ``` CLI command is tested manually. Supporting functions have associated unit tests. ## 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 ### 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. - CLI update should be automatic in docs -- more detailed Authorized Integration documentation is on my project plan. ### 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/12299 Reviewed-by: Andreas Ahlenstorf <aahlenst@noreply.codeberg.org>
2026-04-28 21:32:45 +02:00
// Copyright 2026 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
package cmd
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"strings"
auth_model "forgejo.org/models/auth"
"forgejo.org/models/db"
"forgejo.org/models/repo"
user_model "forgejo.org/models/user"
"forgejo.org/modules/json"
"forgejo.org/services/authz"
"github.com/urfave/cli/v3"
)
func microcmdUserCreateAuthorizedIntegration() *cli.Command {
return &cli.Command{
Name: "create-authorized-integration",
Usage: "Create an authorized integration for a specific user",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "username",
Aliases: []string{"u"},
Usage: "Username",
Required: true,
},
// JWT validation:
&cli.StringFlag{
Name: "issuer",
Usage: `JWT issuer ('iss' claim), example: https://forgejo.example.org/api/actions`,
Required: true,
},
&cli.StringMapFlag{
Name: "claim-eq",
Value: map[string]string{},
Usage: `Zero-or-more claim equality checks, formatted as claim=value, example: "actor=someuser"`,
},
&cli.StringMapFlag{
Name: "claim-glob",
Value: map[string]string{},
Usage: `Zero-or-more claim glob checks, formatted as claim=value, example: "sub=repo:forgejo/*:pull_request"`,
},
// nested claim support omitted for now -- pretty complex for a CLI
// Permissions available on successful auth:
&cli.StringSliceFlag{
Name: "scope",
Value: []string{"all"},
Usage: `One-or-more scopes to apply to access token, examples: "all", "read:issue", "write:repository"`,
},
&cli.StringSliceFlag{
Name: "repo",
Value: []string{"all"},
Usage: `Zero-or-more specific repositories that can be accessed, or "all" to allow access to all repositories, example: "owner1/repo1"`,
},
},
Before: noDanglingArgs,
Action: runCreateAuthorizedIntegration,
}
}
func runCreateAuthorizedIntegration(ctx context.Context, c *cli.Command) error {
if !c.IsSet("username") {
return errors.New("you must provide a username to generate a token for")
}
ctx, cancel := installSignals(ctx)
defer cancel()
if err := initDB(ctx); err != nil {
return err
}
user, err := user_model.GetUserByName(ctx, c.String("username"))
if err != nil {
return err
}
ai := &auth_model.AuthorizedIntegration{
UserID: user.ID,
}
var rules []auth_model.ClaimRule
ai.Issuer = c.String("issuer")
for claim, value := range c.StringMap("claim-eq") {
rules = append(rules, auth_model.ClaimRule{
Claim: claim,
Comparison: auth_model.ClaimEqual,
Value: value,
})
}
for claim, value := range c.StringMap("claim-glob") {
rules = append(rules, auth_model.ClaimRule{
Claim: claim,
Comparison: auth_model.ClaimGlob,
Value: value,
})
}
ai.ClaimRules = &auth_model.ClaimRules{Rules: rules}
scopes := strings.Join(c.StringSlice("scope"), ",")
accessTokenScope, err := auth_model.AccessTokenScope(scopes).Normalize()
if err != nil {
return fmt.Errorf("invalid access token scope provided: %w", err)
}
ai.Scope = accessTokenScope
allRepos := false
repos := []*repo.Repository{}
for _, repoName := range c.StringSlice("repo") {
if repoName == "all" {
allRepos = true
} else {
split := strings.Split(repoName, "/")
if len(split) != 2 {
return fmt.Errorf("invalid repo name: %q", split)
}
owner := split[0]
name := split[1]
repo, err := repo.GetRepositoryByOwnerAndName(ctx, owner, name)
if err != nil {
return err
}
repos = append(repos, repo)
}
}
ai.ResourceAllRepos = allRepos
rr := make([]*auth_model.AuthorizedIntegResourceRepo, len(repos))
for i := range repos {
rr[i] = &auth_model.AuthorizedIntegResourceRepo{RepoID: repos[i].ID}
}
if err := authz.ValidateAuthorizedIntegration(ai, rr); err != nil {
return err
}
err = db.WithTx(ctx, func(ctx context.Context) error {
if err := auth_model.InsertAuthorizedIntegration(ctx, ai); err != nil {
return err
}
if !allRepos {
if err := auth_model.InsertAuthorizedIntegrationResourceRepos(ctx, ai.ID, rr); err != nil {
return err
}
}
return nil
})
if err != nil {
return err
}
type ClaimRuleDescription struct {
Description string `json:"description"`
Claim string `json:"claim"`
Comparison auth_model.ClaimComparison `json:"compare"`
Value string `json:"value"`
}
output := struct {
Message string `json:"message"`
Issuer string `json:"issuer"`
Audience string `json:"audience"`
ClaimRules []ClaimRuleDescription `json:"claim_rules"`
}{
Message: "Authorized integration was successfully created.",
Issuer: ai.Issuer,
Audience: ai.Audience,
}
for _, cr := range ai.ClaimRules.Rules {
var description string
switch cr.Comparison {
case auth_model.ClaimEqual:
description = fmt.Sprintf("%q = %q", cr.Claim, cr.Value)
case auth_model.ClaimGlob:
description = fmt.Sprintf("%q matches %q", cr.Claim, cr.Value)
}
output.ClaimRules = append(output.ClaimRules, ClaimRuleDescription{
Description: description,
Claim: cr.Claim,
Comparison: cr.Comparison,
Value: cr.Value,
})
}
raw, err := json.Marshal(output)
if err != nil {
return err
}
var indent bytes.Buffer
if err := json.Indent(&indent, raw, "", " "); err != nil {
return err
}
os.Stdout.Write(indent.Bytes())
return nil
}