diff --git a/models/forgejo_migrations/v16a_add_oidcsubjectformat_actions_unit_config.go b/models/forgejo_migrations/v16a_add_oidcsubjectformat_actions_unit_config.go new file mode 100644 index 0000000000..efe737ad5a --- /dev/null +++ b/models/forgejo_migrations/v16a_add_oidcsubjectformat_actions_unit_config.go @@ -0,0 +1,68 @@ +// Copyright 2026 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: GPL-3.0-or-later + +package forgejo_migrations + +import ( + "context" + "fmt" + + "forgejo.org/models/db" + "forgejo.org/modules/json" + "forgejo.org/modules/optional" + + "xorm.io/builder" + "xorm.io/xorm" +) + +func init() { + registerMigration(&Migration{ + Description: "add OIDCSubjectFormat=legacy-forgejo-v15 to all existing repositories with actions enabled", + Upgrade: setOIDCSubjectFormatLegacy15, + }) +} + +func setOIDCSubjectFormatLegacy15(x *xorm.Engine) error { + type Type int + const TypeActions Type = 10 // 10 Actions + type ActionsConfig struct { + DisabledWorkflows []string `json:",omitempty"` + OIDCSubjectFormat string + } + type RepoUnit struct { //revive:disable-line:exported + ID int64 `xorm:"pk"` + Type Type `xorm:"INDEX(s)"` + Config optional.Option[string] `xorm:"TEXT"` + } + + return db.Iterate( + db.DefaultContext, + builder.Eq{"type": TypeActions}, + func(ctx context.Context, unit *RepoUnit) error { + has, config := unit.Config.Get() + if !has { + config = "{}" + } + var actionsConfig ActionsConfig + if err := json.Unmarshal([]byte(config), &actionsConfig); err != nil { + return fmt.Errorf("failed to parse Actions config %q: %w", config, err) + } + + actionsConfig.OIDCSubjectFormat = "legacy-forgejo-v15" + + configBytes, err := json.Marshal(actionsConfig) + if err != nil { + return fmt.Errorf("failed to convert Actions config to JSON: %w", err) + } + r, err := db.GetEngine(ctx). + ID(unit.ID). + Cols("config"). + Update(&RepoUnit{Config: optional.Some(string(configBytes))}) + if err != nil { + return err + } else if r != 1 { + return fmt.Errorf("UPDATE expected to affect 1 row, but was %d", r) + } + return nil + }) +} diff --git a/models/forgejo_migrations/v16a_add_oidcsubjectformat_actions_unit_config_test.go b/models/forgejo_migrations/v16a_add_oidcsubjectformat_actions_unit_config_test.go new file mode 100644 index 0000000000..865c0e394b --- /dev/null +++ b/models/forgejo_migrations/v16a_add_oidcsubjectformat_actions_unit_config_test.go @@ -0,0 +1,61 @@ +// Copyright 2026 The Forgejo Authors. +// SPDX-License-Identifier: GPL-3.0-or-later + +package forgejo_migrations + +import ( + "testing" + + "forgejo.org/models/db" + migration_tests "forgejo.org/models/gitea_migrations/test" + "forgejo.org/modules/timeutil" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "xorm.io/xorm/convert" +) + +func Test_setOIDCSubjectFormatLegacy15(t *testing.T) { + type Type int + type UnitAccessMode int + type RepoUnit struct { //revive:disable-line:exported + ID int64 + RepoID int64 `xorm:"INDEX(s)"` + Type Type `xorm:"INDEX(s)"` + Config convert.Conversion `xorm:"TEXT"` + CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED"` + DefaultPermissions UnitAccessMode `xorm:"NOT NULL DEFAULT 0"` + } + x, deferable := migration_tests.PrepareTestEnv(t, 0, new(RepoUnit)) + defer deferable() + if x == nil || t.Failed() { + return + } + + require.NoError(t, setOIDCSubjectFormatLegacy15(x)) + + var records []map[string]string + require.NoError(t, + db.GetEngine(t.Context()). + Table("repo_unit"). + Select("`id`, `repo_id`, `config`"). + OrderBy("`id`"). + Find(&records)) + assert.Equal(t, []map[string]string{ + { + "config": "{\"OIDCSubjectFormat\":\"legacy-forgejo-v15\"}", + "id": "1", + "repo_id": "4", + }, + { + "config": "{\"DisabledWorkflows\":[\"renovate.yml\"],\"OIDCSubjectFormat\":\"legacy-forgejo-v15\"}", + "id": "2", + "repo_id": "5", + }, + { + "config": "", + "id": "3", + "repo_id": "6", + }, + }, records) +} diff --git a/models/gitea_migrations/fixtures/Test_setOIDCSubjectFormatLegacy15/repo_unit.yml b/models/gitea_migrations/fixtures/Test_setOIDCSubjectFormatLegacy15/repo_unit.yml new file mode 100644 index 0000000000..bcd732bc1e --- /dev/null +++ b/models/gitea_migrations/fixtures/Test_setOIDCSubjectFormatLegacy15/repo_unit.yml @@ -0,0 +1,14 @@ +- id: 1 + repo_id: 4 + type: 10 # actions + # config - null + created_unix: 1773518296 +- id: 2 + repo_id: 5 + type: 10 # actions + config: '{"DisabledWorkflows":["renovate.yml"]}' + created_unix: 1773518296 +- id: 3 + repo_id: 6 + type: 1 # code + created_unix: 1773518296 diff --git a/models/repo/repo_unit.go b/models/repo/repo_unit.go index 3db6dc95e8..2cde375775 100644 --- a/models/repo/repo_unit.go +++ b/models/repo/repo_unit.go @@ -220,8 +220,42 @@ func (cfg *PullRequestsConfig) GetDefaultUpdateStyle() UpdateStyle { return UpdateStyleMerge } +// Represents the current format for `sub` claim generation when using `enable-openid-connect: true` on an Action. +// +// We try to follow GitHub's format for the `sub` claim because it should allow third-party integrations, which may have +// existing knowledge and code that works with GitHub's OIDC tokens, to reuse that knowledge and code in the future to +// implement Forgejo support. As GitHub's format changes, we may implement those format changes to maintain that +// familarity. Forgejo isn't required to do so, though -- if future changes from GitHub don't make sense for Forgejo, +// or vice-versa, this format matching effort may be discarded. +type OIDCSubjectFormat string + +var ( + // Default is the current, most preferred method of generating an `sub` JWT claim for an Actions JWT token. It is + // an empty string which allows [ActionsConfig] to always default to the current preferred default value for new + // repositories. At present, it is a `sub` claim that contains information about the repository owner and + // repository where the action occurred, their immutable identifiers (ID numbers), and the event that triggered the + // Action. + // + // Immutable identifiers have been added since OIDCSubjectFormatLegacyForgejo15. The intent of adding them is to + // protect resource servers which may be requiring a specific subject claim from having that claim be impersonated + // when a user or repository are renamed or deleted. For example, if a JWT from my-org/my-repo is trusted, but then + // my-org is deleted and a new user takes ownership of the name my-org, they should not be granted the same trust. + // + // Example: repo:my-org-123456/my-repo-456789:ref:refs/heads/main + OIDCSubjectFormatDefault OIDCSubjectFormat // defaults to "" + + // The `sub` JWT claim generation that was shipped in Forgejo 15. Contains information about the repository owner + // and repository where the action occurred and the event that triggered the Action. + // + // Example: repo:my-org/my-repo:ref:refs/heads/main + OIDCSubjectFormatLegacyForgejo15 OIDCSubjectFormat = "legacy-forgejo-v15" +) + type ActionsConfig struct { DisabledWorkflows []string + + // Format of the OIDC 'sub' claim that will be used when `enable-openid-connect` is true in an Action. + OIDCSubjectFormat OIDCSubjectFormat `json:",omitempty"` } func (cfg *ActionsConfig) EnableWorkflow(file string) { diff --git a/services/actions/auth.go b/services/actions/auth.go index c147643504..2b276cc347 100644 --- a/services/actions/auth.go +++ b/services/actions/auth.go @@ -11,6 +11,7 @@ import ( "time" actions_model "forgejo.org/models/actions" + repo_model "forgejo.org/models/repo" "forgejo.org/modules/json" "forgejo.org/modules/log" "forgejo.org/modules/setting" @@ -30,21 +31,24 @@ type AuthorizationTokenClaims struct { } type IDTokenCustomClaims struct { - Actor string `json:"actor"` - BaseRef string `json:"base_ref"` - EventName string `json:"event_name"` - HeadRef string `json:"head_ref"` - Ref string `json:"ref"` - RefProtected string `json:"ref_protected"` - RefType string `json:"ref_type"` - Repository string `json:"repository"` - RepositoryOwner string `json:"repository_owner"` - RunAttempt string `json:"run_attempt"` - RunID string `json:"run_id"` - RunNumber string `json:"run_number"` - Sha string `json:"sha"` - Workflow string `json:"workflow"` - WorkflowRef string `json:"workflow_ref"` + Actor string `json:"actor"` + ActorID string `json:"actor_id"` + BaseRef string `json:"base_ref"` + EventName string `json:"event_name"` + HeadRef string `json:"head_ref"` + Ref string `json:"ref"` + RefProtected string `json:"ref_protected"` + RefType string `json:"ref_type"` + Repository string `json:"repository"` + RepositoryID string `json:"repository_id"` + RepositoryOwner string `json:"repository_owner"` + RepositoryOwnerID string `json:"repository_owner_id"` + RunAttempt string `json:"run_attempt"` + RunID string `json:"run_id"` + RunNumber string `json:"run_number"` + Sha string `json:"sha"` + Workflow string `json:"workflow"` + WorkflowRef string `json:"workflow_ref"` } type actionsCacheScope struct { @@ -59,7 +63,7 @@ const ( actionsCachePermissionWrite ) -func CreateAuthorizationToken(task *actions_model.ActionTask, gitGtx map[string]any, enableOpenIDConnect bool) (string, error) { +func CreateAuthorizationToken(task *actions_model.ActionTask, gitGtx map[string]any, enableOpenIDConnect bool, actionsConfig *repo_model.ActionsConfig) (string, error) { now := time.Now() taskID := task.ID runID := task.Job.RunID @@ -96,7 +100,16 @@ func CreateAuthorizationToken(task *actions_model.ActionTask, gitGtx map[string] } claims.OIDCExtra = oidcExtra - claims.OIDCSub = generateOIDCSub(gitGtx) + + switch actionsConfig.OIDCSubjectFormat { + case repo_model.OIDCSubjectFormatDefault: + claims.OIDCSub = generateOIDCSub(gitGtx) + case repo_model.OIDCSubjectFormatLegacyForgejo15: + claims.OIDCSub = legacyGenerateOIDCSub(gitGtx) + default: + return "", fmt.Errorf("unexpected oidc subject format: %q", actionsConfig.OIDCSubjectFormat) + } + claims.Scp = fmt.Sprintf("%s generate_id_token:%s", claims.Scp, runIDJobID) } @@ -120,21 +133,24 @@ func generateOIDCExtra(gitCtx map[string]any) (string, error) { } claims := IDTokenCustomClaims{ - Actor: ctxVal("actor"), - BaseRef: ctxVal("base_ref"), - EventName: ctxVal("event_name"), - HeadRef: ctxVal("head_ref"), - Ref: ctxVal("ref"), - RefProtected: ctxVal("ref_protected"), - RefType: ctxVal("ref_type"), - Repository: ctxVal("repository"), - RepositoryOwner: ctxVal("repository_owner"), - RunAttempt: ctxVal("run_attempt"), - RunID: ctxVal("run_id"), - RunNumber: ctxVal("run_number"), - Sha: ctxVal("sha"), - Workflow: ctxVal("workflow"), - WorkflowRef: ctxVal("workflow_ref"), + Actor: ctxVal("actor"), + ActorID: ctxVal("actor_id"), + BaseRef: ctxVal("base_ref"), + EventName: ctxVal("event_name"), + HeadRef: ctxVal("head_ref"), + Ref: ctxVal("ref"), + RefProtected: ctxVal("ref_protected"), + RefType: ctxVal("ref_type"), + Repository: ctxVal("repository"), + RepositoryID: ctxVal("repository_id"), + RepositoryOwner: ctxVal("repository_owner"), + RepositoryOwnerID: ctxVal("repository_owner_id"), + RunAttempt: ctxVal("run_attempt"), + RunID: ctxVal("run_id"), + RunNumber: ctxVal("run_number"), + Sha: ctxVal("sha"), + Workflow: ctxVal("workflow"), + WorkflowRef: ctxVal("workflow_ref"), } ret, err := json.Marshal(claims) @@ -146,6 +162,17 @@ func generateOIDCExtra(gitCtx map[string]any) (string, error) { } func generateOIDCSub(gitCtx map[string]any) string { + nameParts := strings.SplitN(gitCtx["repository"].(string), "/", 2) + repoName := nameParts[1] + switch gitCtx["event_name"] { + case "pull_request": + return fmt.Sprintf("repo:%s-%s/%s-%s:pull_request", gitCtx["repository_owner"], gitCtx["repository_owner_id"], repoName, gitCtx["repository_id"]) + default: + return fmt.Sprintf("repo:%s-%s/%s-%s:ref:%s", gitCtx["repository_owner"], gitCtx["repository_owner_id"], repoName, gitCtx["repository_id"], gitCtx["ref"]) + } +} + +func legacyGenerateOIDCSub(gitCtx map[string]any) string { switch gitCtx["event_name"] { case "pull_request": return fmt.Sprintf("repo:%s:pull_request", gitCtx["repository"]) diff --git a/services/actions/auth_test.go b/services/actions/auth_test.go index 82d4d2efd3..ce75efb655 100644 --- a/services/actions/auth_test.go +++ b/services/actions/auth_test.go @@ -8,6 +8,7 @@ import ( "testing" actions_model "forgejo.org/models/actions" + repo_model "forgejo.org/models/repo" "forgejo.org/modules/json" "forgejo.org/modules/setting" @@ -39,28 +40,32 @@ func TestCreateAuthorizationToken(t *testing.T) { name: "enableOpenIDConnect true", enableOpenIDConnect: true, gitCtx: map[string]any{ - "actor": "user1", - "base_ref": "master", - "event_name": "push", - "head_ref": "master", - "ref": "refs/heads/master", - "ref_protected": "false", - "ref_type": "branch", - "repository": "mpminardi/testing", - "repository_owner": "mpminardi", - "run_attempt": "1", - "run_id": "1", - "run_number": "1", - "sha": "pretend-sha", - "workflow": "test.yml", - "workflow_ref": "pretend-ref", + "actor": "user1", + "actor_id": "123", + "base_ref": "master", + "event_name": "push", + "head_ref": "master", + "ref": "refs/heads/master", + "ref_protected": "false", + "ref_type": "branch", + "repository": "mpminardi/testing", + "repository_id": "456", + "repository_owner": "mpminardi", + "repository_owner_id": "789", + "run_attempt": "1", + "run_id": "1", + "run_number": "1", + "sha": "pretend-sha", + "workflow": "test.yml", + "workflow_ref": "pretend-ref", }, }, } for _, tc := range testcases { t.Run(tc.name, func(t *testing.T) { - token, err := CreateAuthorizationToken(task, tc.gitCtx, tc.enableOpenIDConnect) + token, err := CreateAuthorizationToken(task, tc.gitCtx, tc.enableOpenIDConnect, + &repo_model.ActionsConfig{}) require.NoError(t, err) assert.NotEmpty(t, token) claims := jwt.MapClaims{} @@ -87,7 +92,7 @@ func TestCreateAuthorizationToken(t *testing.T) { assert.Contains(t, scp, "generate_id_token:1:2") oidcSubClaim, ok := claims["oidc_sub"] assert.True(t, ok, "Has oidc_sub claim in jwt token") - assert.Equal(t, "repo:mpminardi/testing:ref:refs/heads/master", oidcSubClaim) + assert.Equal(t, "repo:mpminardi-789/testing-456:ref:refs/heads/master", oidcSubClaim) oidcExtraClaim, ok := claims["oidc_extra"] assert.True(t, ok, "Has oidc_extra claim in jwt token") val, err := json.Marshal(tc.gitCtx) @@ -100,6 +105,27 @@ func TestCreateAuthorizationToken(t *testing.T) { _, ok = claims["oidc_extra"] assert.False(t, ok, "Does not have oidc_extra claim in jwt token") } + + token, err = CreateAuthorizationToken(task, tc.gitCtx, tc.enableOpenIDConnect, + &repo_model.ActionsConfig{OIDCSubjectFormat: repo_model.OIDCSubjectFormatLegacyForgejo15}) + require.NoError(t, err) + assert.NotEmpty(t, token) + claims = jwt.MapClaims{} + _, err = jwt.ParseWithClaims(token, claims, func(t *jwt.Token) (any, error) { + return setting.GetGeneralTokenSigningSecret(), nil + }) + require.NoError(t, err) + if tc.enableOpenIDConnect { + oidcSubClaim, ok := claims["oidc_sub"] + assert.True(t, ok, "Has oidc_sub claim in jwt token") + assert.Equal(t, "repo:mpminardi/testing:ref:refs/heads/master", oidcSubClaim) + } else { + assert.NotContains(t, scp, "generate_id_token") + _, ok := claims["oidc_sub"] + assert.False(t, ok, "Does not have oidc_sub claim in jwt token") + _, ok = claims["oidc_extra"] + assert.False(t, ok, "Does not have oidc_extra claim in jwt token") + } }) } } @@ -112,7 +138,7 @@ func TestParseAuthorizationToken(t *testing.T) { RunID: 1, }, } - token, err := CreateAuthorizationToken(task, map[string]any{}, false) + token, err := CreateAuthorizationToken(task, map[string]any{}, false, &repo_model.ActionsConfig{}) require.NoError(t, err) assert.NotEmpty(t, token) headers := http.Header{} @@ -133,23 +159,26 @@ func TestParseAuthorizationTokenClaims(t *testing.T) { }, } gitCtx := map[string]any{ - "actor": "user1", - "base_ref": "master", - "event_name": "push", - "head_ref": "master", - "ref": "refs/heads/master", - "ref_protected": "false", - "ref_type": "branch", - "repository": "mpminardi/testing", - "repository_owner": "mpminardi", - "run_attempt": "1", - "run_id": "1", - "run_number": "1", - "sha": "pretend-sha", - "workflow": "test.yml", - "workflow_ref": "pretend-ref", + "actor": "user1", + "actor_id": "123", + "base_ref": "master", + "event_name": "push", + "head_ref": "master", + "ref": "refs/heads/master", + "ref_protected": "false", + "ref_type": "branch", + "repository": "mpminardi/testing", + "repository_id": "456", + "repository_owner": "mpminardi", + "repository_owner_id": "789", + "run_attempt": "1", + "run_id": "1", + "run_number": "1", + "sha": "pretend-sha", + "workflow": "test.yml", + "workflow_ref": "pretend-ref", } - token, err := CreateAuthorizationToken(task, gitCtx, true) + token, err := CreateAuthorizationToken(task, gitCtx, true, &repo_model.ActionsConfig{OIDCSubjectFormat: repo_model.OIDCSubjectFormatLegacyForgejo15}) require.NoError(t, err) assert.NotEmpty(t, token) headers := http.Header{} @@ -180,6 +209,32 @@ func TestParseAuthorizationTokenNoAuthHeader(t *testing.T) { func TestGenerateOIDCSub(t *testing.T) { t.Run("pull_request event", func(t *testing.T) { sub := generateOIDCSub(map[string]any{ + "event_name": "pull_request", + "repository": "mpminardi/testing", + "ref": "refs/heads/master", + "repository_owner": "mpminardi", + "repository_owner_id": "123", + "repository_id": "456", + }) + assert.Equal(t, "repo:mpminardi-123/testing-456:pull_request", sub) + }) + + t.Run("other event", func(t *testing.T) { + sub := generateOIDCSub(map[string]any{ + "event_name": "random", + "repository": "mpminardi/testing", + "ref": "refs/heads/master", + "repository_owner": "mpminardi", + "repository_owner_id": "123", + "repository_id": "456", + }) + assert.Equal(t, "repo:mpminardi-123/testing-456:ref:refs/heads/master", sub) + }) +} + +func TestLegacyGenerateOIDCSub(t *testing.T) { + t.Run("pull_request event", func(t *testing.T) { + sub := legacyGenerateOIDCSub(map[string]any{ "event_name": "pull_request", "repository": "mpminardi/testing", "ref": "refs/heads/master", @@ -189,7 +244,7 @@ func TestGenerateOIDCSub(t *testing.T) { }) t.Run("other event", func(t *testing.T) { - sub := generateOIDCSub(map[string]any{ + sub := legacyGenerateOIDCSub(map[string]any{ "event_name": "random", "repository": "mpminardi/testing", "ref": "refs/heads/master", diff --git a/services/actions/context.go b/services/actions/context.go index 0313b11031..ecd7f44da2 100644 --- a/services/actions/context.go +++ b/services/actions/context.go @@ -6,6 +6,7 @@ package actions import ( "context" "fmt" + "strconv" actions_model "forgejo.org/models/actions" "forgejo.org/models/db" @@ -97,18 +98,21 @@ func GenerateGiteaContext(run *actions_model.ActionRun, job *actions_model.Actio gitContext, _ := githubContextToMap(gitContextObj) // standard contexts, see https://docs.github.com/en/actions/learn-github-actions/contexts#github-context - gitContext["action_status"] = "" // string, For a composite action, the current result of the composite action. - gitContext["actor"] = run.TriggerUser.Name // string, The username of the user that triggered the initial workflow run. If the workflow run is a re-run, this value may differ from github.triggering_actor. Any workflow re-runs will use the privileges of github.actor, even if the actor initiating the re-run (github.triggering_actor) has different privileges. - gitContext["env"] = "" // string, Path on the runner to the file that sets environment variables from workflow commands. This file is unique to the current step and is a different file for each step in a job. For more information, see "Workflow commands for GitHub Actions." - gitContext["path"] = "" // string, Path on the runner to the file that sets system PATH variables from workflow commands. This file is unique to the current step and is a different file for each step in a job. For more information, see "Workflow commands for GitHub Actions." - gitContext["ref_protected"] = false // boolean, true if branch protections are configured for the ref that triggered the workflow run. - gitContext["repository_owner"] = run.Repo.OwnerName // string, The repository owner's name. For example, Codertocat. - gitContext["repository"] = run.Repo.OwnerName + "/" + run.Repo.Name // string, The owner and repository name. For example, Codertocat/Hello-World. - gitContext["repositoryUrl"] = run.Repo.HTMLURL() // string, The Git URL to the repository. For example, git://github.com/codertocat/hello-world.git. - gitContext["secret_source"] = "Actions" // string, The source of a secret used in a workflow. Possible values are None, Actions, Dependabot, or Codespaces. - gitContext["server_url"] = setting.AppURL // string, The URL of the GitHub server. For example: https://github.com. - gitContext["triggering_actor"] = "" // string, The username of the user that initiated the workflow run. If the workflow run is a re-run, this value may differ from github.actor. Any workflow re-runs will use the privileges of github.actor, even if the actor initiating the re-run (github.triggering_actor) has different privileges. - gitContext["workflow"] = run.WorkflowID // string, The name of the workflow. If the workflow file doesn't specify a name, the value of this property is the full path of the workflow file in the repository. + gitContext["action_status"] = "" // string, For a composite action, the current result of the composite action. + gitContext["actor"] = run.TriggerUser.Name // string, The username of the user that triggered the initial workflow run. If the workflow run is a re-run, this value may differ from github.triggering_actor. Any workflow re-runs will use the privileges of github.actor, even if the actor initiating the re-run (github.triggering_actor) has different privileges. + gitContext["actor_id"] = strconv.FormatInt(run.TriggerUserID, 10) // string, Immutable unique identifier of the triggering user (unlike actor, which can be renamed) + gitContext["env"] = "" // string, Path on the runner to the file that sets environment variables from workflow commands. This file is unique to the current step and is a different file for each step in a job. For more information, see "Workflow commands for GitHub Actions." + gitContext["path"] = "" // string, Path on the runner to the file that sets system PATH variables from workflow commands. This file is unique to the current step and is a different file for each step in a job. For more information, see "Workflow commands for GitHub Actions." + gitContext["ref_protected"] = false // boolean, true if branch protections are configured for the ref that triggered the workflow run. + gitContext["repository_owner"] = run.Repo.OwnerName // string, The repository owner's name. For example, Codertocat. + gitContext["repository_owner_id"] = strconv.FormatInt(run.Repo.OwnerID, 10) // string, Immutable unique identifier for the repository owner (unlike repository_owner, which can change with a user rename) + gitContext["repository"] = run.Repo.OwnerName + "/" + run.Repo.Name // string, The owner and repository name. For example, Codertocat/Hello-World. + gitContext["repository_id"] = strconv.FormatInt(run.RepoID, 10) // string, Immutable unique identifier for the repository (unlike repository, which can be renamed) + gitContext["repositoryUrl"] = run.Repo.HTMLURL() // string, The Git URL to the repository. For example, git://github.com/codertocat/hello-world.git. + gitContext["secret_source"] = "Actions" // string, The source of a secret used in a workflow. Possible values are None, Actions, Dependabot, or Codespaces. + gitContext["server_url"] = setting.AppURL // string, The URL of the GitHub server. For example: https://github.com. + gitContext["triggering_actor"] = "" // string, The username of the user that initiated the workflow run. If the workflow run is a re-run, this value may differ from github.actor. Any workflow re-runs will use the privileges of github.actor, even if the actor initiating the re-run (github.triggering_actor) has different privileges. + gitContext["workflow"] = run.WorkflowID // string, The name of the workflow. If the workflow file doesn't specify a name, the value of this property is the full path of the workflow file in the repository. // additional contexts gitContext["gitea_default_actions_url"] = setting.Actions.DefaultActionsURL.URL() diff --git a/services/actions/context_test.go b/services/actions/context_test.go index 665f207219..5abd8afb00 100644 --- a/services/actions/context_test.go +++ b/services/actions/context_test.go @@ -36,12 +36,13 @@ func TestFindTaskNeeds(t *testing.T) { func TestGenerateGiteaContext(t *testing.T) { testUser := &user.User{ - ID: 1, + ID: 123, Name: "testuser", } testRepo := &repo.Repository{ - ID: 1, + ID: 456, + OwnerID: 789, OwnerName: "testowner", Name: "testrepo", } @@ -56,7 +57,9 @@ func TestGenerateGiteaContext(t *testing.T) { run := &actions_model.ActionRun{ ID: 1, Index: 42, + TriggerUserID: testUser.ID, TriggerUser: testUser, + RepoID: testRepo.ID, Repo: testRepo, TriggerEvent: "push", Ref: "refs/heads/main", @@ -70,12 +73,15 @@ func TestGenerateGiteaContext(t *testing.T) { require.NoError(t, err) assert.Equal(t, "testuser", context["actor"]) + assert.Equal(t, "123", context["actor_id"]) assert.Equal(t, setting.AppURL+"api/v1", context["api_url"]) assert.Equal(t, "push", context["event_name"]) assert.Equal(t, "refs/heads/main", context["ref"]) assert.Equal(t, "main", context["ref_name"]) assert.Equal(t, "branch", context["ref_type"]) + assert.Equal(t, "789", context["repository_owner_id"]) assert.Equal(t, "testowner/testrepo", context["repository"]) + assert.Equal(t, "456", context["repository_id"]) assert.Equal(t, "testowner", context["repository_owner"]) assert.Equal(t, "abc123def456", context["sha"]) assert.Equal(t, "42", context["run_number"]) diff --git a/services/actions/task.go b/services/actions/task.go index ce43180b7b..f2525e1b3a 100644 --- a/services/actions/task.go +++ b/services/actions/task.go @@ -10,6 +10,8 @@ import ( actions_model "forgejo.org/models/actions" "forgejo.org/models/db" + repo_model "forgejo.org/models/repo" + "forgejo.org/models/unit" actions_module "forgejo.org/modules/actions" "forgejo.org/modules/setting" "forgejo.org/modules/timeutil" @@ -68,7 +70,12 @@ func PickTask(ctx context.Context, runner *actions_model.ActionRunner, requestKe return fmt.Errorf("findTaskNeeds: %w", err) } - taskContext, err := generateTaskContext(t) + unit, err := t.Job.Run.Repo.GetUnit(ctx, unit.TypeActions) + if err != nil { + return fmt.Errorf("GetUnit: %w", err) + } + + taskContext, err := generateTaskContext(t, unit.ActionsConfig()) if err != nil { return fmt.Errorf("generateTaskContext: %w", err) } @@ -128,7 +135,12 @@ func RecoverTasks(ctx context.Context, tasks []*actions_model.ActionTask) ([]*ru return fmt.Errorf("findTaskNeeds: %w", err) } - taskContext, err := generateTaskContext(t) + unit, err := t.Job.Run.Repo.GetUnit(ctx, unit.TypeActions) + if err != nil { + return fmt.Errorf("GetUnit: %w", err) + } + + taskContext, err := generateTaskContext(t, unit.ActionsConfig()) if err != nil { return fmt.Errorf("generateTaskContext: %w", err) } @@ -151,7 +163,7 @@ func RecoverTasks(ctx context.Context, tasks []*actions_model.ActionTask) ([]*ru return retval, nil } -func generateTaskContext(t *actions_model.ActionTask) (*structpb.Struct, error) { +func generateTaskContext(t *actions_model.ActionTask, ac *repo_model.ActionsConfig) (*structpb.Struct, error) { run := t.Job.Run gitCtx, err := GenerateGiteaContext(run, t.Job) if err != nil { @@ -170,7 +182,7 @@ func generateTaskContext(t *actions_model.ActionTask) (*structpb.Struct, error) enableOpenIDConnect = false } - giteaRuntimeToken, err := CreateAuthorizationToken(t, gitCtx, enableOpenIDConnect) + giteaRuntimeToken, err := CreateAuthorizationToken(t, gitCtx, enableOpenIDConnect, ac) if err != nil { return nil, err } diff --git a/services/actions/task_test.go b/services/actions/task_test.go index f725a93674..0c2630f3a6 100644 --- a/services/actions/task_test.go +++ b/services/actions/task_test.go @@ -5,7 +5,7 @@ import ( "testing" actions_model "forgejo.org/models/actions" - "forgejo.org/models/repo" + repo_model "forgejo.org/models/repo" "forgejo.org/models/user" "github.com/stretchr/testify/require" @@ -27,7 +27,7 @@ jobs: Name: "testuser", } - testRepo := &repo.Repository{ + testRepo := &repo_model.Repository{ ID: 1, OwnerName: "testowner", Name: "testrepo", @@ -60,7 +60,7 @@ jobs: t.Run("openid connect enabled", func(t *testing.T) { task := createTask(fmt.Sprintf(workflowFormat, "true"), false, "push") - taskContext, err := generateTaskContext(task) + taskContext, err := generateTaskContext(task, &repo_model.ActionsConfig{}) require.NoError(t, err) require.NotEmpty(t, taskContext.Fields["forgejo_actions_id_token_request_token"].GetStringValue()) require.NotEmpty(t, taskContext.Fields["forgejo_actions_id_token_request_url"].GetStringValue()) @@ -70,7 +70,7 @@ jobs: t.Run("openid connect enabled from fork with pull_request_target event", func(t *testing.T) { task := createTask(fmt.Sprintf(workflowFormat, "true"), true, "pull_request_target") - taskContext, err := generateTaskContext(task) + taskContext, err := generateTaskContext(task, &repo_model.ActionsConfig{}) require.NoError(t, err) require.NotEmpty(t, taskContext.Fields["forgejo_actions_id_token_request_token"].GetStringValue()) require.NotEmpty(t, taskContext.Fields["forgejo_actions_id_token_request_url"].GetStringValue()) @@ -80,7 +80,7 @@ jobs: t.Run("openid connect enabled from fork with pull_request event", func(t *testing.T) { task := createTask(fmt.Sprintf(workflowFormat, "true"), true, "pull_request") - taskContext, err := generateTaskContext(task) + taskContext, err := generateTaskContext(task, &repo_model.ActionsConfig{}) require.NoError(t, err) require.Empty(t, taskContext.Fields["forgejo_actions_id_token_request_token"].GetStringValue()) require.Empty(t, taskContext.Fields["forgejo_actions_id_token_request_url"].GetStringValue()) @@ -90,7 +90,7 @@ jobs: t.Run("openid connect disabled", func(t *testing.T) { task := createTask(fmt.Sprintf(workflowFormat, "false"), false, "push") - taskContext, err := generateTaskContext(task) + taskContext, err := generateTaskContext(task, &repo_model.ActionsConfig{}) require.NoError(t, err) require.Empty(t, taskContext.Fields["forgejo_actions_id_token_request_token"].GetStringValue()) require.Empty(t, taskContext.Fields["forgejo_actions_id_token_request_url"].GetStringValue()) diff --git a/services/auth/method/action_runtime_token_test.go b/services/auth/method/action_runtime_token_test.go index f5bae45e96..4a4db62b3f 100644 --- a/services/auth/method/action_runtime_token_test.go +++ b/services/auth/method/action_runtime_token_test.go @@ -10,6 +10,7 @@ import ( "testing" actions_model "forgejo.org/models/actions" + repo_model "forgejo.org/models/repo" "forgejo.org/models/unittest" user_model "forgejo.org/models/user" "forgejo.org/services/actions" @@ -31,7 +32,7 @@ func TestActionRuntimeTokenVerify(t *testing.T) { RunID: 1, }, } - token, err := actions.CreateAuthorizationToken(task, map[string]any{}, false) + token, err := actions.CreateAuthorizationToken(task, map[string]any{}, false, &repo_model.ActionsConfig{}) require.NoError(t, err) req := http.Request{ diff --git a/tests/integration/api_actions_artifact_v4_test.go b/tests/integration/api_actions_artifact_v4_test.go index 62be33d552..707f980a07 100644 --- a/tests/integration/api_actions_artifact_v4_test.go +++ b/tests/integration/api_actions_artifact_v4_test.go @@ -15,6 +15,7 @@ import ( "time" actions_model "forgejo.org/models/actions" + repo_model "forgejo.org/models/repo" "forgejo.org/modules/storage" "forgejo.org/routers/api/actions" actions_service "forgejo.org/services/actions" @@ -43,7 +44,7 @@ func uploadArtifact(t *testing.T, body string) string { RunID: 792, }, } - token, err := actions_service.CreateAuthorizationToken(task, map[string]any{}, false) + token, err := actions_service.CreateAuthorizationToken(task, map[string]any{}, false, &repo_model.ActionsConfig{}) require.NoError(t, err) // acquire artifact upload url @@ -103,7 +104,7 @@ func TestActionsArtifactV4UploadSingleFileWrongChecksum(t *testing.T) { RunID: 792, }, } - token, err := actions_service.CreateAuthorizationToken(task, map[string]any{}, false) + token, err := actions_service.CreateAuthorizationToken(task, map[string]any{}, false, &repo_model.ActionsConfig{}) require.NoError(t, err) // acquire artifact upload url @@ -154,7 +155,7 @@ func TestActionsArtifactV4UploadSingleFileWithRetentionDays(t *testing.T) { RunID: 792, }, } - token, err := actions_service.CreateAuthorizationToken(task, map[string]any{}, false) + token, err := actions_service.CreateAuthorizationToken(task, map[string]any{}, false, &repo_model.ActionsConfig{}) require.NoError(t, err) // acquire artifact upload url @@ -209,7 +210,7 @@ func TestActionsArtifactV4UploadSingleFileWithPotentialHarmfulBlockID(t *testing RunID: 792, }, } - token, err := actions_service.CreateAuthorizationToken(task, map[string]any{}, false) + token, err := actions_service.CreateAuthorizationToken(task, map[string]any{}, false, &repo_model.ActionsConfig{}) require.NoError(t, err) // acquire artifact upload url @@ -279,7 +280,7 @@ func TestActionsArtifactV4UploadSingleFileWithChunksOutOfOrder(t *testing.T) { RunID: 792, }, } - token, err := actions_service.CreateAuthorizationToken(task, map[string]any{}, false) + token, err := actions_service.CreateAuthorizationToken(task, map[string]any{}, false, &repo_model.ActionsConfig{}) require.NoError(t, err) // acquire artifact upload url @@ -351,7 +352,7 @@ func TestActionsArtifactV4DownloadSingle(t *testing.T) { RunID: 792, }, } - token, err := actions_service.CreateAuthorizationToken(task, map[string]any{}, false) + token, err := actions_service.CreateAuthorizationToken(task, map[string]any{}, false, &repo_model.ActionsConfig{}) require.NoError(t, err) // acquire artifact upload url @@ -419,7 +420,7 @@ func TestActionsArtifactV4Delete(t *testing.T) { RunID: 792, }, } - token, err := actions_service.CreateAuthorizationToken(task, map[string]any{}, false) + token, err := actions_service.CreateAuthorizationToken(task, map[string]any{}, false, &repo_model.ActionsConfig{}) require.NoError(t, err) // delete artifact by name diff --git a/tests/integration/api_actions_id_token_test.go b/tests/integration/api_actions_id_token_test.go index 0fd478b9d2..f686682159 100644 --- a/tests/integration/api_actions_id_token_test.go +++ b/tests/integration/api_actions_id_token_test.go @@ -14,6 +14,7 @@ import ( actions_model "forgejo.org/models/actions" "forgejo.org/models/auth" "forgejo.org/models/db" + repo_model "forgejo.org/models/repo" "forgejo.org/modules/setting" api "forgejo.org/modules/structs" actions_service "forgejo.org/services/actions" @@ -48,9 +49,9 @@ func TestActionsIDToken(t *testing.T) { gitCtx, err := actions_service.GenerateGiteaContext(task.Job.Run, task.Job) require.NoError(t, err) - token, err := actions_service.CreateAuthorizationToken(task, gitCtx, true) + token, err := actions_service.CreateAuthorizationToken(task, gitCtx, true, &repo_model.ActionsConfig{}) require.NoError(t, err) - tokenWithoutOIDCAccess, err := actions_service.CreateAuthorizationToken(task, gitCtx, false) + tokenWithoutOIDCAccess, err := actions_service.CreateAuthorizationToken(task, gitCtx, false, &repo_model.ActionsConfig{}) require.NoError(t, err) // get JWKs information @@ -90,7 +91,7 @@ func TestActionsIDToken(t *testing.T) { assert.Equal(t, "792", claims["run_id"]) assert.Equal(t, "188", claims["run_number"]) assert.Equal(t, "c2d72f548424103f01ee1dc02889c1e2bff816b0", claims["sha"]) - assert.Equal(t, "repo:user5/repo4:ref:refs/heads/master", claims["sub"]) + assert.Equal(t, "repo:user5-5/repo4-4:ref:refs/heads/master", claims["sub"]) assert.Equal(t, "artifact.yaml", claims["workflow"]) assert.Equal(t, "user5/repo4/.forgejo/workflows/artifact.yaml@refs/heads/master", claims["workflow_ref"]) } @@ -157,7 +158,7 @@ func TestActionsIDToken(t *testing.T) { gitCtx, err := actions_service.GenerateGiteaContext(task.Job.Run, task.Job) require.NoError(t, err) - token, err := actions_service.CreateAuthorizationToken(task, gitCtx, true) + token, err := actions_service.CreateAuthorizationToken(task, gitCtx, true, &repo_model.ActionsConfig{}) require.NoError(t, err) req = NewRequest(t, "GET", "/api/actions/_apis/pipelines/workflows/abcde/idtoken?placeholder=true&audience=testingAud").AddTokenAuth(token) @@ -178,7 +179,7 @@ func TestActionsIDToken(t *testing.T) { gitCtx, err := actions_service.GenerateGiteaContext(task.Job.Run, task.Job) require.NoError(t, err) - token, err := actions_service.CreateAuthorizationToken(task, gitCtx, true) + token, err := actions_service.CreateAuthorizationToken(task, gitCtx, true, &repo_model.ActionsConfig{}) require.NoError(t, err) req = NewRequest(t, "GET", "/api/actions/_apis/pipelines/workflows/abcde/idtoken?placeholder=true&audience=testingAud").AddTokenAuth(token) diff --git a/tests/integration/api_actions_oidc_test.go b/tests/integration/api_actions_oidc_test.go index 71e96d7531..8bf3336986 100644 --- a/tests/integration/api_actions_oidc_test.go +++ b/tests/integration/api_actions_oidc_test.go @@ -45,7 +45,12 @@ func TestActionsOIDC(t *testing.T) { assert.Equal(t, setting.AppURL+"api/actions/.well-known/keys", config.JwksURI) assert.Equal(t, []string{"public"}, config.SubjectTypesSupported) assert.Equal(t, []string{"id_token"}, config.ResponseTypesSupported) - assert.Equal(t, []string{"sub", "aud", "exp", "iat", "iss", "nbf", "actor", "base_ref", "event_name", "head_ref", "ref", "ref_protected", "ref_type", "repository", "repository_owner", "run_attempt", "run_id", "run_number", "sha", "workflow", "workflow_ref"}, config.ClaimsSupported) + assert.Equal(t, []string{ + "sub", "aud", "exp", "iat", "iss", "nbf", "actor", "actor_id", "base_ref", "event_name", + "head_ref", "ref", "ref_protected", "ref_type", "repository", "repository_id", "repository_owner", + "repository_owner_id", "run_attempt", "run_id", "run_number", "sha", "workflow", "workflow_ref", + }, + config.ClaimsSupported) assert.Equal(t, []string{"RS256"}, config.IDTokenSigningAlgValuesSupported) assert.Equal(t, []string{"openid"}, config.ScopesSupported)