diff --git a/go.mod b/go.mod index 094aeeb6bc84d..ed6f0535ccaaf 100644 --- a/go.mod +++ b/go.mod @@ -317,7 +317,7 @@ replace github.com/hashicorp/go-version => github.com/6543/go-version v1.3.1 replace github.com/shurcooL/vfsgen => github.com/lunny/vfsgen v0.0.0-20220105142115-2c99e1ffdfa0 -replace github.com/nektos/act => gitea.com/gitea/act v0.261.4 +replace github.com/nektos/act => gitea.com/gitea/act v0.261.5 // TODO: the only difference is in `PutObject`: the fork doesn't use `NewVerifyingReader(r, sha256.New(), oid, expectedSize)`, need to figure out why replace github.com/charmbracelet/git-lfs-transfer => gitea.com/gitea/git-lfs-transfer v0.2.0 diff --git a/go.sum b/go.sum index cde24edce8aa7..6be3eb345af70 100644 --- a/go.sum +++ b/go.sum @@ -16,8 +16,8 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= git.sr.ht/~mariusor/go-xsd-duration v0.0.0-20220703122237-02e73435a078 h1:cliQ4HHsCo6xi2oWZYKWW4bly/Ory9FuTpFPRxj/mAg= git.sr.ht/~mariusor/go-xsd-duration v0.0.0-20220703122237-02e73435a078/go.mod h1:g/V2Hjas6Z1UHUp4yIx6bATpNzJ7DYtD0FG3+xARWxs= -gitea.com/gitea/act v0.261.4 h1:Tf9eLlvsYFtKcpuxlMvf9yT3g4Hshb2Beqw6C1STuH8= -gitea.com/gitea/act v0.261.4/go.mod h1:Pg5C9kQY1CEA3QjthjhlrqOC/QOT5NyWNjOjRHw23Ok= +gitea.com/gitea/act v0.261.5 h1:o4cWLYTy1T5819CCZoBpc9rf0Y8Xev8MatMJUsM7IUY= +gitea.com/gitea/act v0.261.5/go.mod h1:Pg5C9kQY1CEA3QjthjhlrqOC/QOT5NyWNjOjRHw23Ok= gitea.com/gitea/git-lfs-transfer v0.2.0 h1:baHaNoBSRaeq/xKayEXwiDQtlIjps4Ac/Ll4KqLMB40= gitea.com/gitea/git-lfs-transfer v0.2.0/go.mod h1:UrXUCm3xLQkq15fu7qlXHUMlrhdlXHoi13KH2Dfiits= gitea.com/go-chi/binding v0.0.0-20240430071103-39a851e106ed h1:EZZBtilMLSZNWtHHcgq2mt6NSGhJSZBuduAlinMEmso= diff --git a/models/actions/permissions.go b/models/actions/permissions.go new file mode 100644 index 0000000000000..3a0f0206865de --- /dev/null +++ b/models/actions/permissions.go @@ -0,0 +1,217 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "errors" + "fmt" + + "gopkg.in/yaml.v3" +) + +type Permission int + +const ( + PermissionUnspecified Permission = iota + PermissionNone + PermissionRead + PermissionWrite +) + +// Per https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idpermissions +type Permissions struct { + Actions Permission `yaml:"actions"` + Checks Permission `yaml:"checks"` + Contents Permission `yaml:"contents"` + Deployments Permission `yaml:"deployments"` + IDToken Permission `yaml:"id-token"` + Issues Permission `yaml:"issues"` + Discussions Permission `yaml:"discussions"` + Packages Permission `yaml:"packages"` + Pages Permission `yaml:"pages"` + PullRequests Permission `yaml:"pull-requests"` + RepositoryProjects Permission `yaml:"repository-projects"` + SecurityEvents Permission `yaml:"security-events"` + Statuses Permission `yaml:"statuses"` +} + +// WorkflowPermissions parses a workflow and returns +// a Permissions struct representing the permissions set +// at the workflow (i.e. file) level +func WorkflowPermissions(contents []byte) (Permissions, error) { + p := struct { + Permissions Permissions `yaml:"permissions"` + }{} + err := yaml.Unmarshal(contents, &p) + return p.Permissions, err +} + +// Given the contents of a workflow, JobPermissions +// returns a Permissions object representing the permissions +// of THE FIRST job in the file. +func JobPermissions(contents []byte) (Permissions, error) { + p := struct { + Jobs []struct { + Permissions Permissions `yaml:"permissions"` + } `yaml:"jobs"` + }{} + err := yaml.Unmarshal(contents, &p) + if len(p.Jobs) > 0 { + return p.Jobs[0].Permissions, err + } + return Permissions{}, errors.New("no jobs detected in workflow") +} + +func (p *Permission) UnmarshalYAML(unmarshal func(any) error) error { + var data string + if err := unmarshal(&data); err != nil { + return err + } + + switch data { + case "none": + *p = PermissionNone + case "read": + *p = PermissionRead + case "write": + *p = PermissionWrite + default: + return fmt.Errorf("invalid permission: %s", data) + } + + return nil +} + +// DefaultAccessPermissive is the default "permissive" set granted to actions on repositories +// per https://docs.github.com/en/actions/security-guides/automatic-token-authentication#permissions-for-the-github_token +// That page also lists a "metadata" permission that I can't find mentioned anywhere else. +// However, it seems to always have "read" permission, so it doesn't really matter. +// Interestingly, it doesn't list "Discussions", so we assume "write" for permissive and "none" for restricted. +var DefaultAccessPermissive = Permissions{ + Actions: PermissionWrite, + Checks: PermissionWrite, + Contents: PermissionWrite, + Deployments: PermissionWrite, + IDToken: PermissionNone, + Issues: PermissionWrite, + Discussions: PermissionWrite, + Packages: PermissionWrite, + Pages: PermissionWrite, + PullRequests: PermissionWrite, + RepositoryProjects: PermissionWrite, + SecurityEvents: PermissionWrite, + Statuses: PermissionWrite, +} + +// DefaultAccessRestricted is the default "restrictive" set granted. See docs for +// DefaultAccessPermissive above. +// +// This is not currently used, since Gitea does not have a permissive/restricted setting. +var DefaultAccessRestricted = Permissions{ + Actions: PermissionNone, + Checks: PermissionNone, + Contents: PermissionWrite, + Deployments: PermissionNone, + IDToken: PermissionNone, + Issues: PermissionNone, + Discussions: PermissionNone, + Packages: PermissionRead, + Pages: PermissionNone, + PullRequests: PermissionNone, + RepositoryProjects: PermissionNone, + SecurityEvents: PermissionNone, + Statuses: PermissionNone, +} + +var ReadAllPermissions = Permissions{ + Actions: PermissionRead, + Checks: PermissionRead, + Contents: PermissionRead, + Deployments: PermissionRead, + IDToken: PermissionRead, + Issues: PermissionRead, + Discussions: PermissionRead, + Packages: PermissionRead, + Pages: PermissionRead, + PullRequests: PermissionRead, + RepositoryProjects: PermissionRead, + SecurityEvents: PermissionRead, + Statuses: PermissionRead, +} + +var WriteAllPermissions = Permissions{ + Actions: PermissionWrite, + Checks: PermissionWrite, + Contents: PermissionWrite, + Deployments: PermissionWrite, + IDToken: PermissionWrite, + Issues: PermissionWrite, + Discussions: PermissionWrite, + Packages: PermissionWrite, + Pages: PermissionWrite, + PullRequests: PermissionWrite, + RepositoryProjects: PermissionWrite, + SecurityEvents: PermissionWrite, + Statuses: PermissionWrite, +} + +// FromYAML takes a yaml.Node representing a permissions +// definition and parses it into a Permissions struct +func (p *Permissions) FromYAML(rawPermissions *yaml.Node) error { + switch rawPermissions.Kind { + case yaml.ScalarNode: + var val string + err := rawPermissions.Decode(&val) + if err != nil { + return err + } + if val == "read-all" { + *p = ReadAllPermissions + } + if val == "write-all" { + *p = WriteAllPermissions + } + return fmt.Errorf("unexpected `permissions` value: %v", rawPermissions) + case yaml.MappingNode: + var perms Permissions + err := rawPermissions.Decode(&perms) + if err != nil { + return err + } + return nil + case 0: + *p = Permissions{} + return nil + default: + return fmt.Errorf("invalid permissions value: %v", rawPermissions) + } +} + +func merge[T comparable](a, b T) T { + var zero T + if a == zero { + return b + } + return a +} + +// Merge merges two Permission values +// +// Already set values take precedence over `other`. +// I.e. you want to call jobLevel.Permissions.Merge(topLevel.Permissions) +func (p *Permissions) Merge(other Permissions) { + p.Actions = merge(p.Actions, other.Actions) + p.Checks = merge(p.Checks, other.Checks) + p.Contents = merge(p.Contents, other.Contents) + p.Deployments = merge(p.Deployments, other.Deployments) + p.IDToken = merge(p.IDToken, other.IDToken) + p.Issues = merge(p.Issues, other.Issues) + p.Discussions = merge(p.Discussions, other.Discussions) + p.Packages = merge(p.Packages, other.Packages) + p.Pages = merge(p.Pages, other.Pages) + p.PullRequests = merge(p.PullRequests, other.PullRequests) + p.RepositoryProjects = merge(p.RepositoryProjects, other.RepositoryProjects) + p.SecurityEvents = merge(p.SecurityEvents, other.SecurityEvents) + p.Statuses = merge(p.Statuses, other.Statuses) +} diff --git a/models/actions/run.go b/models/actions/run.go index 5f077940c5612..fa932014d5b44 100644 --- a/models/actions/run.go +++ b/models/actions/run.go @@ -47,6 +47,7 @@ type ActionRun struct { EventPayload string `xorm:"LONGTEXT"` TriggerEvent string // the trigger event defined in the `on` configuration of the triggered workflow Status Status `xorm:"index"` + Permissions Permissions `xorm:"-"` Version int `xorm:"version default 0"` // Status could be updated concomitantly, so an optimistic lock is needed // Started and Stopped is used for recording last run time, if rerun happened, they will be reset to 0 Started timeutil.TimeStamp @@ -83,6 +84,38 @@ func (run *ActionRun) WorkflowLink() string { return fmt.Sprintf("%s/actions/?workflow=%s", run.Repo.Link(), run.WorkflowID) } +func (run *ActionRun) RefShaBaseRefAndHeadRef() (string, string, string, string) { + var ref, sha, baseRef, headRef string + + ref = run.Ref + sha = run.CommitSHA + + if pullPayload, err := run.GetPullRequestEventPayload(); err == nil && pullPayload.PullRequest != nil && pullPayload.PullRequest.Base != nil && pullPayload.PullRequest.Head != nil { + baseRef = pullPayload.PullRequest.Base.Ref + headRef = pullPayload.PullRequest.Head.Ref + + // if the TriggerEvent is pull_request_target, ref and sha need to be set according to the base of pull request + // In GitHub's documentation, ref should be the branch or tag that triggered workflow. But when the TriggerEvent is pull_request_target, + // the ref will be the base branch. + if run.TriggerEvent == "pull_request_target" { + ref = git.BranchPrefix + pullPayload.PullRequest.Base.Name + sha = pullPayload.PullRequest.Base.Sha + } + } + return ref, sha, baseRef, headRef +} + +func (run *ActionRun) EventName() string { + // TriggerEvent is added in https://github.com/go-gitea/gitea/pull/25229 + // This fallback is for the old ActionRun that doesn't have the TriggerEvent field + // and should be removed in 1.22 + eventName := run.TriggerEvent + if eventName == "" { + eventName = run.Event.Event() + } + return eventName +} + // RefLink return the url of run's ref func (run *ActionRun) RefLink() string { refName := git.RefName(run.Ref) @@ -314,7 +347,7 @@ func InsertRun(ctx context.Context, run *ActionRun, jobs []*jobparser.SingleWork hasWaiting = true } job.Name = util.EllipsisDisplayString(job.Name, 255) - runJobs = append(runJobs, &ActionRunJob{ + runJob := &ActionRunJob{ RunID: run.ID, RepoID: run.RepoID, OwnerID: run.OwnerID, @@ -326,7 +359,19 @@ func InsertRun(ctx context.Context, run *ActionRun, jobs []*jobparser.SingleWork Needs: needs, RunsOn: job.RunsOn(), Status: status, - }) + } + runJobs = append(runJobs, runJob) + + // Parse the job's permissions + if err := job.RawPermissions.Decode(&runJob.Permissions); err != nil { + return err + } + + // Merge the job's permissions with the workflow permissions. + // Job permissions take precedence. + runJob.Permissions.Merge(run.Permissions) + + runJobs = append(runJobs, runJob) } if err := db.Insert(ctx, runJobs); err != nil { return err diff --git a/models/actions/run_job.go b/models/actions/run_job.go index d0dfd10db6b61..3f57aeb6dd665 100644 --- a/models/actions/run_job.go +++ b/models/actions/run_job.go @@ -30,11 +30,12 @@ type ActionRunJob struct { Name string `xorm:"VARCHAR(255)"` Attempt int64 WorkflowPayload []byte - JobID string `xorm:"VARCHAR(255)"` // job id in workflow, not job's id - Needs []string `xorm:"JSON TEXT"` - RunsOn []string `xorm:"JSON TEXT"` - TaskID int64 // the latest task of the job - Status Status `xorm:"index"` + JobID string `xorm:"VARCHAR(255)"` // job id in workflow, not job's id + Needs []string `xorm:"JSON TEXT"` + RunsOn []string `xorm:"JSON TEXT"` + Permissions Permissions `xorm:"JSON TEXT"` + TaskID int64 // the latest task of the job + Status Status `xorm:"index"` Started timeutil.TimeStamp Stopped timeutil.TimeStamp Created timeutil.TimeStamp `xorm:"created"` @@ -84,6 +85,10 @@ func (job *ActionRunJob) LoadAttributes(ctx context.Context) error { return job.Run.LoadAttributes(ctx) } +func (job *ActionRunJob) MayCreateIDToken() bool { + return job.Permissions.IDToken == PermissionWrite +} + func GetRunJobByID(ctx context.Context, id int64) (*ActionRunJob, error) { var job ActionRunJob has, err := db.GetEngine(ctx).Where("id=?", id).Get(&job) diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index 31b035eb3129a..c6575c4f3be10 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -381,6 +381,7 @@ func prepareMigrationTasks() []*migration { newMigration(317, "Add new index for action for heatmap", v1_24.AddNewIndexForUserDashboard), newMigration(318, "Add anonymous_access_mode for repo_unit", v1_24.AddRepoUnitAnonymousAccessMode), newMigration(319, "Add ExclusiveOrder to Label table", v1_24.AddExclusiveOrderColumnToLabelTable), + newMigration(320, "Add Permissions to Actions Task", v1_24.AddPermissions), } return preparedMigrations } diff --git a/models/migrations/v1_24/v320.go b/models/migrations/v1_24/v320.go new file mode 100644 index 0000000000000..cf7540085f802 --- /dev/null +++ b/models/migrations/v1_24/v320.go @@ -0,0 +1,43 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_24 //nolint + +import ( + "xorm.io/xorm" +) + +// Permission copied from models.actions.Permission +type Permission int + +const ( + PermissionUnspecified Permission = iota + PermissionNone + PermissionRead + PermissionWrite +) + +// Permissions copied from models.actions.Permissions +type Permissions struct { + Actions Permission `yaml:"actions"` + Checks Permission `yaml:"checks"` + Contents Permission `yaml:"contents"` + Deployments Permission `yaml:"deployments"` + IDToken Permission `yaml:"id-token"` + Issues Permission `yaml:"issues"` + Discussions Permission `yaml:"discussions"` + Packages Permission `yaml:"packages"` + Pages Permission `yaml:"pages"` + PullRequests Permission `yaml:"pull-requests"` + RepositoryProjects Permission `yaml:"repository-projects"` + SecurityEvents Permission `yaml:"security-events"` + Statuses Permission `yaml:"statuses"` +} + +func AddPermissions(x *xorm.Engine) error { + type ActionRunJob struct { + Permissions Permissions `xorm:"JSON TEXT"` + } + + return x.Sync(new(ActionRunJob)) +} diff --git a/modules/actions/permissions.go b/modules/actions/permissions.go new file mode 100644 index 0000000000000..3a0f0206865de --- /dev/null +++ b/modules/actions/permissions.go @@ -0,0 +1,217 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "errors" + "fmt" + + "gopkg.in/yaml.v3" +) + +type Permission int + +const ( + PermissionUnspecified Permission = iota + PermissionNone + PermissionRead + PermissionWrite +) + +// Per https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idpermissions +type Permissions struct { + Actions Permission `yaml:"actions"` + Checks Permission `yaml:"checks"` + Contents Permission `yaml:"contents"` + Deployments Permission `yaml:"deployments"` + IDToken Permission `yaml:"id-token"` + Issues Permission `yaml:"issues"` + Discussions Permission `yaml:"discussions"` + Packages Permission `yaml:"packages"` + Pages Permission `yaml:"pages"` + PullRequests Permission `yaml:"pull-requests"` + RepositoryProjects Permission `yaml:"repository-projects"` + SecurityEvents Permission `yaml:"security-events"` + Statuses Permission `yaml:"statuses"` +} + +// WorkflowPermissions parses a workflow and returns +// a Permissions struct representing the permissions set +// at the workflow (i.e. file) level +func WorkflowPermissions(contents []byte) (Permissions, error) { + p := struct { + Permissions Permissions `yaml:"permissions"` + }{} + err := yaml.Unmarshal(contents, &p) + return p.Permissions, err +} + +// Given the contents of a workflow, JobPermissions +// returns a Permissions object representing the permissions +// of THE FIRST job in the file. +func JobPermissions(contents []byte) (Permissions, error) { + p := struct { + Jobs []struct { + Permissions Permissions `yaml:"permissions"` + } `yaml:"jobs"` + }{} + err := yaml.Unmarshal(contents, &p) + if len(p.Jobs) > 0 { + return p.Jobs[0].Permissions, err + } + return Permissions{}, errors.New("no jobs detected in workflow") +} + +func (p *Permission) UnmarshalYAML(unmarshal func(any) error) error { + var data string + if err := unmarshal(&data); err != nil { + return err + } + + switch data { + case "none": + *p = PermissionNone + case "read": + *p = PermissionRead + case "write": + *p = PermissionWrite + default: + return fmt.Errorf("invalid permission: %s", data) + } + + return nil +} + +// DefaultAccessPermissive is the default "permissive" set granted to actions on repositories +// per https://docs.github.com/en/actions/security-guides/automatic-token-authentication#permissions-for-the-github_token +// That page also lists a "metadata" permission that I can't find mentioned anywhere else. +// However, it seems to always have "read" permission, so it doesn't really matter. +// Interestingly, it doesn't list "Discussions", so we assume "write" for permissive and "none" for restricted. +var DefaultAccessPermissive = Permissions{ + Actions: PermissionWrite, + Checks: PermissionWrite, + Contents: PermissionWrite, + Deployments: PermissionWrite, + IDToken: PermissionNone, + Issues: PermissionWrite, + Discussions: PermissionWrite, + Packages: PermissionWrite, + Pages: PermissionWrite, + PullRequests: PermissionWrite, + RepositoryProjects: PermissionWrite, + SecurityEvents: PermissionWrite, + Statuses: PermissionWrite, +} + +// DefaultAccessRestricted is the default "restrictive" set granted. See docs for +// DefaultAccessPermissive above. +// +// This is not currently used, since Gitea does not have a permissive/restricted setting. +var DefaultAccessRestricted = Permissions{ + Actions: PermissionNone, + Checks: PermissionNone, + Contents: PermissionWrite, + Deployments: PermissionNone, + IDToken: PermissionNone, + Issues: PermissionNone, + Discussions: PermissionNone, + Packages: PermissionRead, + Pages: PermissionNone, + PullRequests: PermissionNone, + RepositoryProjects: PermissionNone, + SecurityEvents: PermissionNone, + Statuses: PermissionNone, +} + +var ReadAllPermissions = Permissions{ + Actions: PermissionRead, + Checks: PermissionRead, + Contents: PermissionRead, + Deployments: PermissionRead, + IDToken: PermissionRead, + Issues: PermissionRead, + Discussions: PermissionRead, + Packages: PermissionRead, + Pages: PermissionRead, + PullRequests: PermissionRead, + RepositoryProjects: PermissionRead, + SecurityEvents: PermissionRead, + Statuses: PermissionRead, +} + +var WriteAllPermissions = Permissions{ + Actions: PermissionWrite, + Checks: PermissionWrite, + Contents: PermissionWrite, + Deployments: PermissionWrite, + IDToken: PermissionWrite, + Issues: PermissionWrite, + Discussions: PermissionWrite, + Packages: PermissionWrite, + Pages: PermissionWrite, + PullRequests: PermissionWrite, + RepositoryProjects: PermissionWrite, + SecurityEvents: PermissionWrite, + Statuses: PermissionWrite, +} + +// FromYAML takes a yaml.Node representing a permissions +// definition and parses it into a Permissions struct +func (p *Permissions) FromYAML(rawPermissions *yaml.Node) error { + switch rawPermissions.Kind { + case yaml.ScalarNode: + var val string + err := rawPermissions.Decode(&val) + if err != nil { + return err + } + if val == "read-all" { + *p = ReadAllPermissions + } + if val == "write-all" { + *p = WriteAllPermissions + } + return fmt.Errorf("unexpected `permissions` value: %v", rawPermissions) + case yaml.MappingNode: + var perms Permissions + err := rawPermissions.Decode(&perms) + if err != nil { + return err + } + return nil + case 0: + *p = Permissions{} + return nil + default: + return fmt.Errorf("invalid permissions value: %v", rawPermissions) + } +} + +func merge[T comparable](a, b T) T { + var zero T + if a == zero { + return b + } + return a +} + +// Merge merges two Permission values +// +// Already set values take precedence over `other`. +// I.e. you want to call jobLevel.Permissions.Merge(topLevel.Permissions) +func (p *Permissions) Merge(other Permissions) { + p.Actions = merge(p.Actions, other.Actions) + p.Checks = merge(p.Checks, other.Checks) + p.Contents = merge(p.Contents, other.Contents) + p.Deployments = merge(p.Deployments, other.Deployments) + p.IDToken = merge(p.IDToken, other.IDToken) + p.Issues = merge(p.Issues, other.Issues) + p.Discussions = merge(p.Discussions, other.Discussions) + p.Packages = merge(p.Packages, other.Packages) + p.Pages = merge(p.Pages, other.Pages) + p.PullRequests = merge(p.PullRequests, other.PullRequests) + p.RepositoryProjects = merge(p.RepositoryProjects, other.RepositoryProjects) + p.SecurityEvents = merge(p.SecurityEvents, other.SecurityEvents) + p.Statuses = merge(p.Statuses, other.Statuses) +} diff --git a/routers/api/v1/actions/oidc.go b/routers/api/v1/actions/oidc.go new file mode 100644 index 0000000000000..cc9b35d1f38b0 --- /dev/null +++ b/routers/api/v1/actions/oidc.go @@ -0,0 +1,154 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +// OIDC provider for Gitea Actions +package actions + +import ( + "fmt" + "net/http" + + actions_model "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/timeutil" + auth_service "code.gitea.io/gitea/services/auth" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/oauth2_provider" + + "github.com/golang-jwt/jwt/v5" +) + +type IDTokenResponse struct { + Value string `json:"value"` + Count int `json:"count"` +} + +type IDTokenErrorResponse struct { + ErrorDescription string `json:"error_description"` +} + +type IDToken struct { + jwt.RegisteredClaims + + Ref string `json:"ref,omitempty"` + SHA string `json:"sha,omitempty"` + Repository string `json:"repository,omitempty"` + RepositoryOwner string `json:"repository_owner,omitempty"` + RepositoryOwnerID int `json:"repository_owner_id,omitempty"` + RunID int `json:"run_id,omitempty"` + RunNumber int `json:"run_number,omitempty"` + RunAttempt int `json:"run_attempt,omitempty"` + RepositoryVisibility string `json:"repository_visibility,omitempty"` + RepositoryID int `json:"repository_id,omitempty"` + ActorID int `json:"actor_id,omitempty"` + Actor string `json:"actor,omitempty"` + Workflow string `json:"workflow,omitempty"` + EventName string `json:"event_name,omitempty"` + RefType git.RefType `json:"ref_type,omitempty"` + HeadRef string `json:"head_ref,omitempty"` + BaseRef string `json:"base_ref,omitempty"` + + // Github's OIDC tokens have all of these, but I wasn't sure how + // to populate them. Leaving them here to make future work easier. + + /* + WorkflowRef string `json:"workflow_ref,omitempty"` + WorkflowSHA string `json:"workflow_sha,omitempty"` + JobWorkflowRef string `json:"job_workflow_ref,omitempty"` + JobWorkflowSHA string `json:"job_workflow_sha,omitempty"` + RunnerEnvironment string `json:"runner_environment,omitempty"` + */ +} + +func GenerateOIDCToken(ctx *context.APIContext) { + if ctx.Doer == nil || ctx.Data["AuthedMethod"] != (&auth_service.OAuth2{}).Name() || ctx.Data["IsActionsToken"] != true { + ctx.PlainText(http.StatusUnauthorized, "no valid authorization") + return + } + + task := ctx.Data["ActionsTask"].(*actions_model.ActionTask) + if err := task.LoadJob(ctx); err != nil { + ctx.PlainText(http.StatusUnauthorized, "no valid authorization") + return + } + + if mayCreateToken := task.Job.MayCreateIDToken(); !mayCreateToken { + ctx.PlainText(http.StatusUnauthorized, "no valid authorization") + return + } + + if err := task.Job.LoadAttributes(ctx); err != nil { + ctx.PlainText(http.StatusUnauthorized, "no valid authorization") + return + } + + if err := task.Job.Run.LoadAttributes(ctx); err != nil { + ctx.PlainText(http.StatusUnauthorized, "no valid authorization") + return + } + + if err := task.Job.Run.Repo.LoadAttributes(ctx); err != nil { + ctx.PlainText(http.StatusUnauthorized, "no valid authorization") + return + } + + eventName := task.Job.Run.EventName() + ref, sha, baseRef, headRef := task.Job.Run.RefShaBaseRefAndHeadRef() + + jwtAudience := jwt.ClaimStrings{task.Job.Run.Repo.Owner.HTMLURL()} + requestedAudience := ctx.Req.URL.Query().Get("audience") + if requestedAudience != "" { + jwtAudience = append(jwtAudience, requestedAudience) + } + + // generate OIDC token + issueTime := timeutil.TimeStampNow() + expirationTime := timeutil.TimeStampNow().Add(15 * 60) + notBeforeTime := timeutil.TimeStampNow().Add(-15 * 60) + idToken := &IDToken{ + RegisteredClaims: jwt.RegisteredClaims{ + Issuer: setting.AppURL, + Audience: jwtAudience, + ExpiresAt: jwt.NewNumericDate(expirationTime.AsTime()), + NotBefore: jwt.NewNumericDate(notBeforeTime.AsTime()), + IssuedAt: jwt.NewNumericDate(issueTime.AsTime()), + Subject: fmt.Sprintf("repo:%s:ref:%s", task.Job.Run.Repo.FullName(), ref), + }, + Ref: ref, + SHA: sha, + Repository: task.Job.Run.Repo.FullName(), + RepositoryOwner: task.Job.Run.Repo.OwnerName, + RepositoryOwnerID: int(task.Job.Run.Repo.OwnerID), + RunID: int(task.Job.RunID), + RunNumber: int(task.Job.Run.Index), + RunAttempt: int(task.Job.Attempt), + RepositoryID: int(task.Job.Run.RepoID), + ActorID: int(task.Job.Run.TriggerUserID), + Actor: task.Job.Run.TriggerUser.Name, + Workflow: task.Job.Run.WorkflowID, + EventName: eventName, + RefType: git.RefName(task.Job.Run.Ref).RefType(), + BaseRef: baseRef, + HeadRef: headRef, + } + + if task.Job.Run.Repo.IsPrivate { + idToken.RepositoryVisibility = "private" + } else { + idToken.RepositoryVisibility = "public" + } + + signedIDToken, err := oauth2_provider.SignToken(idToken, oauth2_provider.DefaultSigningKey) + if err != nil { + ctx.JSON(http.StatusInternalServerError, &IDTokenErrorResponse{ + ErrorDescription: "unable to sign token", + }) + return + } + + ctx.JSON(http.StatusOK, IDTokenResponse{ + Value: signedIDToken, + Count: len(signedIDToken), + }) +} diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 5cd08a36181e2..9de67874db940 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -82,6 +82,7 @@ import ( "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/web" + actions_router "code.gitea.io/gitea/routers/api/v1/actions" "code.gitea.io/gitea/routers/api/v1/activitypub" "code.gitea.io/gitea/routers/api/v1/admin" "code.gitea.io/gitea/routers/api/v1/misc" @@ -1126,6 +1127,8 @@ func Routes() *web.Router { }) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser), reqToken()) + m.Get("/actions/id-token/request", actions_router.GenerateOIDCToken) + // Repositories (requires repo scope, org scope) m.Post("/org/{org}/repos", // FIXME: we need org in context diff --git a/services/actions/notifier_helper.go b/services/actions/notifier_helper.go index d179134798267..f6d088fc314c4 100644 --- a/services/actions/notifier_helper.go +++ b/services/actions/notifier_helper.go @@ -353,6 +353,13 @@ func handleWorkflows( } } + wp, err := actions_model.WorkflowPermissions(dwf.Content) + if err != nil { + log.Error("WorkflowPermissions: %v", err) + continue + } + run.Permissions = wp + if err := actions_model.InsertRun(ctx, run, jobs); err != nil { log.Error("InsertRun: %v", err) continue diff --git a/services/oauth2_provider/token.go b/services/oauth2_provider/token.go index 383bcdb3eb3c3..3e73946186e6f 100644 --- a/services/oauth2_provider/token.go +++ b/services/oauth2_provider/token.go @@ -58,9 +58,7 @@ func ParseToken(jwtToken string, signingKey JWTSigningKey) (*Token, error) { // SignToken signs the token with the JWT secret func (token *Token) SignToken(signingKey JWTSigningKey) (string, error) { token.IssuedAt = jwt.NewNumericDate(time.Now()) - jwtToken := jwt.NewWithClaims(signingKey.SigningMethod(), token) - signingKey.PreProcessToken(jwtToken) - return jwtToken.SignedString(signingKey.SignKey()) + return SignToken(token, signingKey) } // OIDCToken represents an OpenID Connect id_token @@ -88,6 +86,10 @@ type OIDCToken struct { // SignToken signs an id_token with the (symmetric) client secret key func (token *OIDCToken) SignToken(signingKey JWTSigningKey) (string, error) { token.IssuedAt = jwt.NewNumericDate(time.Now()) + return SignToken(token, signingKey) +} + +func SignToken(token jwt.Claims, signingKey JWTSigningKey) (string, error) { jwtToken := jwt.NewWithClaims(signingKey.SigningMethod(), token) signingKey.PreProcessToken(jwtToken) return jwtToken.SignedString(signingKey.SignKey())