Skip to content

OIDC provider #33945

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 31 commits into
base: main
Choose a base branch
from
Open
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
3a16680
Refactor token signing method in OAuth2 service
sorenisanerd Jul 4, 2023
31e8ad3
feat: Add basic permissions support for actions
sorenisanerd Aug 6, 2023
8271be5
Extract {base_,}{sha,ref} and EventName logic
sorenisanerd Aug 6, 2023
724e138
feat: Add OIDC provider for actions
sorenisanerd Aug 6, 2023
fc80a8a
Copy Permissions struct actions_model
sorenisanerd Sep 16, 2023
e192676
*looks at a calendar*
sorenisanerd Sep 16, 2023
69ce797
Merge remote-tracking branch 'origin/main' into oidc-provider
sorenisanerd Sep 16, 2023
66dca88
Merge branch 'main' into oidc-provider
scubbo Mar 20, 2025
747dcc1
Add Migration with updated name
scubbo Mar 20, 2025
32e618a
Remove old (inconsistently-named) migration
scubbo Mar 20, 2025
647bdf5
Update Copyright dates
scubbo Mar 20, 2025
0625616
Lint-fix
scubbo Mar 20, 2025
f66c4b9
More lintfixes
scubbo Mar 20, 2025
3cde888
Move `permissions.go` to `modules/actions`
scubbo Mar 20, 2025
6395824
Merge remote-tracking branch 'origin/main' into oidc-provider
scubbo Mar 20, 2025
828e2d5
Correction to package-name for migration
scubbo Mar 20, 2025
3ac43c6
Merge branch 'main' into oidc-provider
scubbo Mar 25, 2025
74ace80
Merge branch 'main' into oidc-provider
scubbo Mar 28, 2025
8221de2
Add required models/actions/permissions file
scubbo Mar 28, 2025
50c2a21
Use personal tag of act
scubbo Mar 28, 2025
95c31fb
Remove routers/api/actions/runner/utils
scubbo Mar 28, 2025
8f02684
Use appropriate type for RefType
scubbo Mar 28, 2025
236745d
Merge branch 'main' into oidc-provider
scubbo Apr 5, 2025
c028254
Use renamed oauth2_provider for signing token
scubbo Apr 5, 2025
d77b250
Reformatting
scubbo Apr 5, 2025
f014369
Use gitea/act
scubbo Apr 5, 2025
f952190
PR comments
scubbo Apr 7, 2025
7b98be8
Reverting Copyright date updates
scubbo Apr 7, 2025
702f640
Move OIDC API into subfolder
scubbo Apr 7, 2025
c477e0e
Merge branch 'main' into oidc-provider
scubbo Apr 7, 2025
7094798
Merge remote-tracking branch 'origin/main' into oidc-provider
scubbo Apr 11, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -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
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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=
217 changes: 217 additions & 0 deletions models/actions/permissions.go
Original file line number Diff line number Diff line change
@@ -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)
}
49 changes: 47 additions & 2 deletions models/actions/run.go
Original file line number Diff line number Diff line change
@@ -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
15 changes: 10 additions & 5 deletions models/actions/run_job.go
Original file line number Diff line number Diff line change
@@ -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)
1 change: 1 addition & 0 deletions models/migrations/migrations.go
Original file line number Diff line number Diff line change
@@ -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
}
43 changes: 43 additions & 0 deletions models/migrations/v1_24/v320.go
Original file line number Diff line number Diff line change
@@ -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))
}
217 changes: 217 additions & 0 deletions modules/actions/permissions.go
Original file line number Diff line number Diff line change
@@ -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)
}
154 changes: 154 additions & 0 deletions routers/api/v1/actions/oidc.go
Original file line number Diff line number Diff line change
@@ -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),
})
}
3 changes: 3 additions & 0 deletions routers/api/v1/api.go
Original file line number Diff line number Diff line change
@@ -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
7 changes: 7 additions & 0 deletions services/actions/notifier_helper.go
Original file line number Diff line number Diff line change
@@ -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
8 changes: 5 additions & 3 deletions services/oauth2_provider/token.go
Original file line number Diff line number Diff line change
@@ -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())