From 3a1668071323a59f5c67eb4c635b10bbeeae10dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20L=2E=20Hansen?= Date: Mon, 3 Jul 2023 17:14:07 -0700 Subject: [PATCH 1/6] Refactor token signing method in OAuth2 service --- services/auth/source/oauth2/token.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/services/auth/source/oauth2/token.go b/services/auth/source/oauth2/token.go index 3405619d3fa59..fda27b777fc3e 100644 --- a/services/auth/source/oauth2/token.go +++ b/services/auth/source/oauth2/token.go @@ -64,9 +64,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 @@ -94,6 +92,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()) From 31e8ad3c04d465a4ae8880dfe512e473aee36e88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20L=2E=20Hansen?= Date: Sun, 6 Aug 2023 14:12:42 -0700 Subject: [PATCH 2/6] feat: Add basic permissions support for actions This depends on a few changes in act and act_runner: https://gitea.com/gitea/act_runner/pulls/272 https://gitea.com/gitea/act/pulls/73 --- go.mod | 2 +- go.sum | 6 +- models/actions/permissions.go | 217 ++++++++++++++++++++++++++++ models/actions/run.go | 16 +- models/actions/run_job.go | 11 +- models/migrations/migrations.go | 2 + models/migrations/v1_21/v276.go | 18 +++ services/actions/notifier_helper.go | 7 + 8 files changed, 269 insertions(+), 10 deletions(-) create mode 100644 models/actions/permissions.go create mode 100644 models/migrations/v1_21/v276.go diff --git a/go.mod b/go.mod index a6e83df05a720..1da5dc444f410 100644 --- a/go.mod +++ b/go.mod @@ -303,7 +303,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.243.4 +replace github.com/nektos/act => gitea.com/sorenisanerd/act v0.246.2-0.20230806181409-a9e947b70bf6 exclude github.com/gofrs/uuid v3.2.0+incompatible diff --git a/go.sum b/go.sum index 5e32daf0c14de..0e2e4d69b11ae 100644 --- a/go.sum +++ b/go.sum @@ -51,11 +51,11 @@ codeberg.org/gusted/mcaptcha v0.0.0-20220723083913-4f3072e1d570 h1:TXbikPqa7YRtf codeberg.org/gusted/mcaptcha v0.0.0-20220723083913-4f3072e1d570/go.mod h1:IIAjsijsd8q1isWX8MACefDEgTQslQ4stk2AeeTt3kM= dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= +dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= 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.243.4 h1:MuBHBLCJfpa6mzwwvs4xqQynrSP2RRzpHpWfTV16PmI= -gitea.com/gitea/act v0.243.4/go.mod h1:mabw6AZAiDgxGlK83orWLrNERSPvgBJzEUS3S7u2bHI= gitea.com/go-chi/binding v0.0.0-20230415142243-04b515c6d669 h1:RUBX+MK/TsDxpHmymaOaydfigEbbzqUnG1OTZU/HAeo= gitea.com/go-chi/binding v0.0.0-20230415142243-04b515c6d669/go.mod h1:77TZu701zMXWJFvB8gvTbQ92zQ3DQq/H7l5wAEjQRKc= gitea.com/go-chi/cache v0.0.0-20210110083709-82c4c9ce2d5e/go.mod h1:k2V/gPDEtXGjjMGuBJiapffAXTv76H4snSmlJRLUhH0= @@ -69,6 +69,8 @@ gitea.com/lunny/dingtalk_webhook v0.0.0-20171025031554-e3534c89ef96 h1:+wWBi6Qfr gitea.com/lunny/dingtalk_webhook v0.0.0-20171025031554-e3534c89ef96/go.mod h1:VyMQP6ue6MKHM8UsOXfNfuMKD0oSAWZdXVcpHIN2yaY= gitea.com/lunny/levelqueue v0.4.2-0.20230414023320-3c0159fe0fe4 h1:IFT+hup2xejHqdhS7keYWioqfmxdnfblFDTGoOwcZ+o= gitea.com/lunny/levelqueue v0.4.2-0.20230414023320-3c0159fe0fe4/go.mod h1:HBqmLbz56JWpfEGG0prskAV97ATNRoj5LDmPicD22hU= +gitea.com/sorenisanerd/act v0.246.2-0.20230806181409-a9e947b70bf6 h1:ANNwt5ZqFG7FhDjdwCfsfoi7zlEV7uAfbrYTV5R8CNg= +gitea.com/sorenisanerd/act v0.246.2-0.20230806181409-a9e947b70bf6/go.mod h1:tfannUyz3cgmq1P1o69KW1AMB1aSlNOMzlswHkRjzcQ= gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a h1:lSA0F4e9A2NcQSqGqTOXqu2aRi/XEQxDCBwM8yJtE6s= gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a/go.mod h1:EXuID2Zs0pAQhH8yz+DNjUbjppKQzKFAn28TMYPB6IU= gitee.com/travelliu/dm v1.8.11192/go.mod h1:DHTzyhCrM843x9VdKVbZ+GKXGRbKM2sJ4LxihRxShkE= diff --git a/models/actions/permissions.go b/models/actions/permissions.go new file mode 100644 index 0000000000000..a5c0058a3b8ea --- /dev/null +++ b/models/actions/permissions.go @@ -0,0 +1,217 @@ +// Copyright 2023 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(interface{}) 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 8078613fb8f5b..568150870e180 100644 --- a/models/actions/run.go +++ b/models/actions/run.go @@ -45,6 +45,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 timeutil.TimeStamp Stopped timeutil.TimeStamp @@ -280,7 +281,7 @@ func InsertRun(ctx context.Context, run *ActionRun, jobs []*jobparser.SingleWork hasWaiting = true } job.Name, _ = util.SplitStringAtByteN(job.Name, 255) - runJobs = append(runJobs, &ActionRunJob{ + runJob := &ActionRunJob{ RunID: run.ID, RepoID: run.RepoID, OwnerID: run.OwnerID, @@ -292,7 +293,18 @@ func InsertRun(ctx context.Context, run *ActionRun, jobs []*jobparser.SingleWork Needs: needs, RunsOn: job.RunsOn(), Status: status, - }) + } + + // 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 4b8664077dca9..c1e65d42cd1e5 100644 --- a/models/actions/run_job.go +++ b/models/actions/run_job.go @@ -28,11 +28,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"` diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index f0a8b05d5337d..1c49d5072dfb7 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -532,6 +532,8 @@ var migrations = []Migration{ NewMigration("Add Actions artifacts expiration date", v1_21.AddExpiredUnixColumnInActionArtifactTable), // v275 -> v276 NewMigration("Add ScheduleID for ActionRun", v1_21.AddScheduleIDForActionRun), + // v276 -> v277 + NewMigration("Add Permissions to Actions Task", v1_21.AddPermissions), } // GetCurrentDBVersion returns the current db version diff --git a/models/migrations/v1_21/v276.go b/models/migrations/v1_21/v276.go new file mode 100644 index 0000000000000..08f0a16a44cc9 --- /dev/null +++ b/models/migrations/v1_21/v276.go @@ -0,0 +1,18 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_21 //nolint + +import ( + actions_model "code.gitea.io/gitea/models/actions" + + "xorm.io/xorm" +) + +func AddPermissions(x *xorm.Engine) error { + type ActionRunJob struct { + Permissions actions_model.Permissions `xorm:"JSON TEXT"` + } + + return x.Sync(new(ActionRunJob)) +} diff --git a/services/actions/notifier_helper.go b/services/actions/notifier_helper.go index ff00e48c644d1..94deaeb4efb8a 100644 --- a/services/actions/notifier_helper.go +++ b/services/actions/notifier_helper.go @@ -268,6 +268,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 From 8271be528b5dead2dfcb853cfda0b41fb04c02c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20L=2E=20Hansen?= Date: Sun, 6 Aug 2023 14:17:14 -0700 Subject: [PATCH 3/6] Extract {base_,}{sha,ref} and EventName logic --- models/actions/run.go | 32 +++++++++++++++++++++++++++++ routers/api/actions/runner/utils.go | 27 ++---------------------- 2 files changed, 34 insertions(+), 25 deletions(-) diff --git a/models/actions/run.go b/models/actions/run.go index 568150870e180..40370b7642295 100644 --- a/models/actions/run.go +++ b/models/actions/run.go @@ -72,6 +72,38 @@ func (run *ActionRun) Link() string { return fmt.Sprintf("%s/actions/runs/%d", run.Repo.Link(), run.Index) } +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) diff --git a/routers/api/actions/runner/utils.go b/routers/api/actions/runner/utils.go index 24432ab6b202d..1cfe0a4435b53 100644 --- a/routers/api/actions/runner/utils.go +++ b/routers/api/actions/runner/utils.go @@ -117,31 +117,8 @@ func generateTaskContext(t *actions_model.ActionTask) *structpb.Struct { event := map[string]any{} _ = json.Unmarshal([]byte(t.Job.Run.EventPayload), &event) - // 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 := t.Job.Run.TriggerEvent - if eventName == "" { - eventName = t.Job.Run.Event.Event() - } - - baseRef := "" - headRef := "" - ref := t.Job.Run.Ref - sha := t.Job.Run.CommitSHA - if pullPayload, err := t.Job.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 t.Job.Run.TriggerEvent == actions_module.GithubEventPullRequestTarget { - ref = git.BranchPrefix + pullPayload.PullRequest.Base.Name - sha = pullPayload.PullRequest.Base.Sha - } - } - + eventName := t.Job.Run.EventName() + ref, sha, baseRef, headRef := t.Job.Run.RefShaBaseRefAndHeadRef() refName := git.RefName(ref) taskContext, err := structpb.NewStruct(map[string]any{ From 724e138bd865b8fef1117a830ec05fb3ac0fc22f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20L=2E=20Hansen?= Date: Sun, 6 Aug 2023 14:19:42 -0700 Subject: [PATCH 4/6] feat: Add OIDC provider for actions --- models/actions/run_job.go | 4 + routers/api/actions/runner/utils.go | 15 ++- routers/api/v1/api.go | 2 + routers/api/v1/oidc.go | 154 ++++++++++++++++++++++++++++ services/auth/oauth2.go | 1 + 5 files changed, 174 insertions(+), 2 deletions(-) create mode 100644 routers/api/v1/oidc.go diff --git a/models/actions/run_job.go b/models/actions/run_job.go index c1e65d42cd1e5..c4ff88e92c018 100644 --- a/models/actions/run_job.go +++ b/models/actions/run_job.go @@ -72,6 +72,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/routers/api/actions/runner/utils.go b/routers/api/actions/runner/utils.go index 1cfe0a4435b53..cd6003d4997d8 100644 --- a/routers/api/actions/runner/utils.go +++ b/routers/api/actions/runner/utils.go @@ -121,7 +121,7 @@ func generateTaskContext(t *actions_model.ActionTask) *structpb.Struct { ref, sha, baseRef, headRef := t.Job.Run.RefShaBaseRefAndHeadRef() refName := git.RefName(ref) - taskContext, err := structpb.NewStruct(map[string]any{ + contextMap := map[string]any{ // standard contexts, see https://docs.github.com/en/actions/learn-github-actions/contexts#github-context "action": "", // string, The name of the action currently running, or the id of a step. GitHub removes special characters, and uses the name __run when the current step runs a script without an id. If you use the same action more than once in the same job, the name will include a suffix with the sequence number with underscore before it. For example, the first script you run will have the name __run, and the second script will be named __run_2. Similarly, the second invocation of actions/checkout will be actionscheckout2. "action_path": "", // string, The path where an action is located. This property is only supported in composite actions. You can use this path to access files located in the same repository as the action. @@ -160,7 +160,18 @@ func generateTaskContext(t *actions_model.ActionTask) *structpb.Struct { // additional contexts "gitea_default_actions_url": setting.Actions.DefaultActionsURL.URL(), - }) + } + + if t.Job.MayCreateIDToken() { + // The "a=1" is a dummy variable. If an audience is passed to + // github/core.js's getIdToken(), it appends it to the URL as "&audience=". + // If the URL doesn't at least have a '?', the "&audience=" part will be + // interpreted as part of the path. + contextMap["actions_id_token_request_url"] = fmt.Sprintf("%sapi/v1/actions/id-token/request?a=1", setting.AppURL) + contextMap["actions_id_token_request_token"] = t.Token + } + + taskContext, err := structpb.NewStruct(contextMap) if err != nil { log.Error("structpb.NewStruct failed: %v", err) } diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index ca74a23a4b89e..3d40e43ed6528 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1020,6 +1020,8 @@ func Routes() *web.Route { }, reqToken()) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser), reqToken()) + m.Get("/actions/id-token/request", generateOIDCToken) + // Repositories (requires repo scope, org scope) m.Post("/org/{org}/repos", tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization, auth_model.AccessTokenScopeCategoryRepository), diff --git a/routers/api/v1/oidc.go b/routers/api/v1/oidc.go new file mode 100644 index 0000000000000..13a014dd7df6b --- /dev/null +++ b/routers/api/v1/oidc.go @@ -0,0 +1,154 @@ +// Copyright 2016 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +// OIDC provider for Gitea Actions +package v1 + +import ( + "fmt" + "net/http" + + actions_model "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/modules/context" + "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/auth/source/oauth2" + + "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 string `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.SignToken(idToken, oauth2.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/services/auth/oauth2.go b/services/auth/oauth2.go index 6572d661e87f9..9631becf33f1f 100644 --- a/services/auth/oauth2.go +++ b/services/auth/oauth2.go @@ -103,6 +103,7 @@ func (o *OAuth2) userIDFromToken(ctx context.Context, tokenSHA string, store Dat store.GetData()["IsActionsToken"] = true store.GetData()["ActionsTaskID"] = task.ID + store.GetData()["ActionsTask"] = task return user_model.ActionsUserID } From fc80a8a1cc7ea0bf7be0bef4738059d3340e5929 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20L=2E=20Hansen?= Date: Sat, 16 Sep 2023 13:00:39 -0700 Subject: [PATCH 5/6] Copy Permissions struct actions_model Referencing it could cause inconsistencies if it changes later. --- models/migrations/v1_21/v276.go | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/models/migrations/v1_21/v276.go b/models/migrations/v1_21/v276.go index 08f0a16a44cc9..48dd70cfa61c0 100644 --- a/models/migrations/v1_21/v276.go +++ b/models/migrations/v1_21/v276.go @@ -4,14 +4,39 @@ package v1_21 //nolint import ( - actions_model "code.gitea.io/gitea/models/actions" - "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 actions_model.Permissions `xorm:"JSON TEXT"` + Permissions Permissions `xorm:"JSON TEXT"` } return x.Sync(new(ActionRunJob)) From e192676cd573c00011f92bcbc00f6c160ea71788 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20L=2E=20Hansen?= Date: Sat, 16 Sep 2023 13:01:01 -0700 Subject: [PATCH 6/6] *looks at a calendar* --- routers/api/v1/oidc.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/routers/api/v1/oidc.go b/routers/api/v1/oidc.go index 13a014dd7df6b..0e9390a8c16a9 100644 --- a/routers/api/v1/oidc.go +++ b/routers/api/v1/oidc.go @@ -1,4 +1,4 @@ -// Copyright 2016 The Gitea Authors. All rights reserved. +// Copyright 2023 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT // OIDC provider for Gitea Actions