mirror of
https://github.com/go-gitea/gitea.git
synced 2026-04-18 08:25:07 +02:00
## Overview This PR introduces granular permission controls for Gitea Actions tokens (`GITEA_TOKEN`), aligning Gitea's security model with GitHub Actions standards while maintaining compatibility with Gitea's unique repository unit system. It addresses the need for finer access control by allowing administrators and repository owners to define default token permissions, set maximum permission ceilings, and control cross-repository access within organizations. ## Key Features ### 1. Granular Token Permissions - **Standard Keyword Support**: Implements support for the `permissions:` keyword in workflow and job YAML files (e.g., `contents: read`, `issues: write`). - **Permission Modes**: - **Permissive**: Default write access for most units (backwards compatible). - **Restricted**: Default read-only access for `contents` and `packages`, with no access to other units. - ~~**Custom**: Allows defining specific default levels for each unit type (Code, Issues, PRs, Packages, etc.).~~**EDIT removed UI was confusing** - **Clamping Logic**: Workflow-defined permissions are automatically "clamped" by repository or organization-level maximum settings. Workflows cannot escalate their own permissions beyond these limits. ### 2. Organization & Repository Settings - **Settings UI**: Added new settings pages at both Organization and Repository levels to manage Actions token defaults and maximums. - **Inheritance**: Repositories can be configured to "Follow organization-level configuration," simplifying management across large organizations. - **Cross-Repository Access**: Added a policy to control whether Actions workflows can access other repositories or packages within the same organization. This can be set to "None," "All," or restricted to a "Selected" list of repositories. ### 3. Security Hardening - **Fork Pull Request Protection**: Tokens for workflows triggered by pull requests from forks are strictly enforced as read-only, regardless of repository settings. - ~~**Package Access**: Actions tokens can now only access packages explicitly linked to a repository, with cross-repo access governed by the organization's security policy.~~ **EDIT removed https://github.com/go-gitea/gitea/pull/36173#issuecomment-3873675346** - **Git Hook Integration**: Propagates Actions Task IDs to git hooks to ensure that pushes performed by Actions tokens respect the specific permissions granted at runtime. ### 4. Technical Implementation - **Permission Persistence**: Parsed permissions are calculated at job creation and stored in the `action_run_job` table. This ensures the token's authority is deterministic throughout the job's lifecycle. - **Parsing Priority**: Implemented a priority system in the YAML parser where the broad `contents` scope is applied first, allowing granular scopes like `code` or `releases` to override it for precise control. - **Re-runs**: Permissions are re-evaluated during a job re-run to incorporate any changes made to repository settings in the interim. ### How to Test 1. **Unit Tests**: Run `go test ./services/actions/...` and `go test ./models/repo/...` to verify parsing logic and permission clamping. 2. **Integration Tests**: Comprehensive tests have been added to `tests/integration/actions_job_token_test.go` covering: - Permissive vs. Restricted mode behavior. - YAML `permissions:` keyword evaluation. - Organization cross-repo access policies. - Resource access (Git, API, and Packages) under various permission configs. 3. **Manual Verification**: - Navigate to **Site/Org/Repo Settings -> Actions -> General**. - Change "Default Token Permissions" and verify that newly triggered workflows reflect these changes in their `GITEA_TOKEN` capabilities. - Attempt a cross-repo API call from an Action and verify the Org policy is enforced. ## Documentation Added a PR in gitea's docs for this : https://gitea.com/gitea/docs/pulls/318 ## UI: <img width="1366" height="619" alt="Screenshot 2026-01-24 174112" src="https://github.com/user-attachments/assets/bfa29c9a-4ea5-4346-9410-16d491ef3d44" /> <img width="1360" height="621" alt="Screenshot 2026-01-24 174048" src="https://github.com/user-attachments/assets/d5ec46c8-9a13-4874-a6a4-fb379936cef5" /> /fixes #24635 /claim #24635 --------- Signed-off-by: Excellencedev <ademiluyisuccessandexcellence@gmail.com> Signed-off-by: ChristopherHX <christopher.homberger@web.de> Signed-off-by: silverwind <me@silverwind.io> Signed-off-by: wxiaoguang <wxiaoguang@gmail.com> Co-authored-by: ChristopherHX <christopher.homberger@web.de> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: silverwind <me@silverwind.io> Co-authored-by: Zettat123 <zettat123@gmail.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
287 lines
8.8 KiB
Go
287 lines
8.8 KiB
Go
// Copyright 2022 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package actions
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"slices"
|
|
"time"
|
|
|
|
"code.gitea.io/gitea/models/db"
|
|
repo_model "code.gitea.io/gitea/models/repo"
|
|
"code.gitea.io/gitea/modules/actions/jobparser"
|
|
"code.gitea.io/gitea/modules/timeutil"
|
|
"code.gitea.io/gitea/modules/util"
|
|
|
|
"xorm.io/builder"
|
|
)
|
|
|
|
// ActionRunJob represents a job of a run
|
|
type ActionRunJob struct {
|
|
ID int64
|
|
RunID int64 `xorm:"index"`
|
|
Run *ActionRun `xorm:"-"`
|
|
RepoID int64 `xorm:"index(repo_concurrency)"`
|
|
Repo *repo_model.Repository `xorm:"-"`
|
|
OwnerID int64 `xorm:"index"`
|
|
CommitSHA string `xorm:"index"`
|
|
IsForkPullRequest bool
|
|
Name string `xorm:"VARCHAR(255)"`
|
|
Attempt int64
|
|
|
|
// WorkflowPayload is act/jobparser.SingleWorkflow for act/jobparser.Parse
|
|
// it should contain exactly one job with global workflow fields for this model
|
|
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"`
|
|
|
|
RawConcurrency string // raw concurrency from job YAML's "concurrency" section
|
|
|
|
// IsConcurrencyEvaluated is only valid/needed when this job's RawConcurrency is not empty.
|
|
// If RawConcurrency can't be evaluated (e.g. depend on other job's outputs or have errors), this field will be false.
|
|
// If RawConcurrency has been successfully evaluated, this field will be true, ConcurrencyGroup and ConcurrencyCancel are also set.
|
|
IsConcurrencyEvaluated bool
|
|
|
|
ConcurrencyGroup string `xorm:"index(repo_concurrency) NOT NULL DEFAULT ''"` // evaluated concurrency.group
|
|
ConcurrencyCancel bool `xorm:"NOT NULL DEFAULT FALSE"` // evaluated concurrency.cancel-in-progress
|
|
|
|
// TokenPermissions stores the explicit permissions from workflow/job YAML (no org/repo clamps applied).
|
|
// Org/repo clamps are enforced when the token is used at runtime.
|
|
// It is JSON-encoded repo_model.ActionsTokenPermissions and may be empty if not specified.
|
|
TokenPermissions *repo_model.ActionsTokenPermissions `xorm:"JSON TEXT"`
|
|
|
|
Started timeutil.TimeStamp
|
|
Stopped timeutil.TimeStamp
|
|
Created timeutil.TimeStamp `xorm:"created"`
|
|
Updated timeutil.TimeStamp `xorm:"updated index"`
|
|
}
|
|
|
|
func init() {
|
|
db.RegisterModel(new(ActionRunJob))
|
|
}
|
|
|
|
func (job *ActionRunJob) Duration() time.Duration {
|
|
return calculateDuration(job.Started, job.Stopped, job.Status)
|
|
}
|
|
|
|
func (job *ActionRunJob) LoadRun(ctx context.Context) error {
|
|
if job.Run == nil {
|
|
run, err := GetRunByRepoAndID(ctx, job.RepoID, job.RunID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
job.Run = run
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (job *ActionRunJob) LoadRepo(ctx context.Context) error {
|
|
if job.Repo == nil {
|
|
repo, err := repo_model.GetRepositoryByID(ctx, job.RepoID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
job.Repo = repo
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// LoadAttributes load Run if not loaded
|
|
func (job *ActionRunJob) LoadAttributes(ctx context.Context) error {
|
|
if job == nil {
|
|
return nil
|
|
}
|
|
|
|
if err := job.LoadRun(ctx); err != nil {
|
|
return err
|
|
}
|
|
|
|
return job.Run.LoadAttributes(ctx)
|
|
}
|
|
|
|
// ParseJob parses the job structure from the ActionRunJob.WorkflowPayload
|
|
func (job *ActionRunJob) ParseJob() (*jobparser.Job, error) {
|
|
// job.WorkflowPayload is a SingleWorkflow created from an ActionRun's workflow, which exactly contains this job's YAML definition.
|
|
// Ideally it shouldn't be called "Workflow", it is just a job with global workflow fields + trigger
|
|
parsedWorkflows, err := jobparser.Parse(job.WorkflowPayload)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("job %d single workflow: unable to parse: %w", job.ID, err)
|
|
} else if len(parsedWorkflows) != 1 {
|
|
return nil, fmt.Errorf("job %d single workflow: not single workflow", job.ID)
|
|
}
|
|
_, workflowJob := parsedWorkflows[0].Job()
|
|
if workflowJob == nil {
|
|
// it shouldn't happen, and since the callers don't check nil, so return an error instead of nil
|
|
return nil, util.ErrorWrap(util.ErrNotExist, "job %d single workflow: payload doesn't contain a job", job.ID)
|
|
}
|
|
return workflowJob, nil
|
|
}
|
|
|
|
func GetRunJobByRepoAndID(ctx context.Context, repoID, jobID int64) (*ActionRunJob, error) {
|
|
var job ActionRunJob
|
|
has, err := db.GetEngine(ctx).Where("id=? AND repo_id=?", jobID, repoID).Get(&job)
|
|
if err != nil {
|
|
return nil, err
|
|
} else if !has {
|
|
return nil, fmt.Errorf("run job with id %d: %w", jobID, util.ErrNotExist)
|
|
}
|
|
|
|
return &job, nil
|
|
}
|
|
|
|
func GetRunJobByRunAndID(ctx context.Context, runID, jobID int64) (*ActionRunJob, error) {
|
|
var job ActionRunJob
|
|
has, err := db.GetEngine(ctx).Where("id=? AND run_id=?", jobID, runID).Get(&job)
|
|
if err != nil {
|
|
return nil, err
|
|
} else if !has {
|
|
return nil, fmt.Errorf("run job with id %d: %w", jobID, util.ErrNotExist)
|
|
}
|
|
|
|
return &job, nil
|
|
}
|
|
|
|
func GetRunJobsByRunID(ctx context.Context, runID int64) (ActionJobList, error) {
|
|
var jobs []*ActionRunJob
|
|
if err := db.GetEngine(ctx).Where("run_id=?", runID).OrderBy("id").Find(&jobs); err != nil {
|
|
return nil, err
|
|
}
|
|
return jobs, nil
|
|
}
|
|
|
|
func UpdateRunJob(ctx context.Context, job *ActionRunJob, cond builder.Cond, cols ...string) (int64, error) {
|
|
e := db.GetEngine(ctx)
|
|
|
|
sess := e.ID(job.ID)
|
|
if len(cols) > 0 {
|
|
sess.Cols(cols...)
|
|
}
|
|
|
|
if cond != nil {
|
|
sess.Where(cond)
|
|
}
|
|
|
|
affected, err := sess.Update(job)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
if affected == 0 || (!slices.Contains(cols, "status") && job.Status == 0) {
|
|
return affected, nil
|
|
}
|
|
|
|
if slices.Contains(cols, "status") && job.Status.IsWaiting() {
|
|
// if the status of job changes to waiting again, increase tasks version.
|
|
if err := IncreaseTaskVersion(ctx, job.OwnerID, job.RepoID); err != nil {
|
|
return 0, err
|
|
}
|
|
}
|
|
|
|
if job.RunID == 0 {
|
|
var err error
|
|
if job, err = GetRunJobByRepoAndID(ctx, job.RepoID, job.ID); err != nil {
|
|
return 0, err
|
|
}
|
|
}
|
|
|
|
{
|
|
// Other goroutines may aggregate the status of the run and update it too.
|
|
// So we need load the run and its jobs before updating the run.
|
|
run, err := GetRunByRepoAndID(ctx, job.RepoID, job.RunID)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
jobs, err := GetRunJobsByRunID(ctx, job.RunID)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
run.Status = AggregateJobStatus(jobs)
|
|
if run.Started.IsZero() && run.Status.IsRunning() {
|
|
run.Started = timeutil.TimeStampNow()
|
|
}
|
|
if run.Stopped.IsZero() && run.Status.IsDone() {
|
|
run.Stopped = timeutil.TimeStampNow()
|
|
}
|
|
if err := UpdateRun(ctx, run, "status", "started", "stopped"); err != nil {
|
|
return 0, fmt.Errorf("update run %d: %w", run.ID, err)
|
|
}
|
|
}
|
|
|
|
return affected, nil
|
|
}
|
|
|
|
func AggregateJobStatus(jobs []*ActionRunJob) Status {
|
|
allSuccessOrSkipped := len(jobs) != 0
|
|
allSkipped := len(jobs) != 0
|
|
var hasFailure, hasCancelled, hasWaiting, hasRunning, hasBlocked bool
|
|
for _, job := range jobs {
|
|
allSuccessOrSkipped = allSuccessOrSkipped && (job.Status == StatusSuccess || job.Status == StatusSkipped)
|
|
allSkipped = allSkipped && job.Status == StatusSkipped
|
|
hasFailure = hasFailure || job.Status == StatusFailure
|
|
hasCancelled = hasCancelled || job.Status == StatusCancelled
|
|
hasWaiting = hasWaiting || job.Status == StatusWaiting
|
|
hasRunning = hasRunning || job.Status == StatusRunning
|
|
hasBlocked = hasBlocked || job.Status == StatusBlocked
|
|
}
|
|
switch {
|
|
case allSkipped:
|
|
return StatusSkipped
|
|
case allSuccessOrSkipped:
|
|
return StatusSuccess
|
|
case hasCancelled:
|
|
return StatusCancelled
|
|
case hasRunning:
|
|
return StatusRunning
|
|
case hasWaiting:
|
|
return StatusWaiting
|
|
case hasFailure:
|
|
return StatusFailure
|
|
case hasBlocked:
|
|
return StatusBlocked
|
|
default:
|
|
return StatusUnknown // it shouldn't happen
|
|
}
|
|
}
|
|
|
|
func CancelPreviousJobsByJobConcurrency(ctx context.Context, job *ActionRunJob) (jobsToCancel []*ActionRunJob, _ error) {
|
|
if job.RawConcurrency == "" {
|
|
return nil, nil
|
|
}
|
|
if !job.IsConcurrencyEvaluated {
|
|
return nil, nil
|
|
}
|
|
if job.ConcurrencyGroup == "" {
|
|
return nil, nil
|
|
}
|
|
|
|
statusFindOption := []Status{StatusWaiting, StatusBlocked}
|
|
if job.ConcurrencyCancel {
|
|
statusFindOption = append(statusFindOption, StatusRunning)
|
|
}
|
|
runs, jobs, err := GetConcurrentRunsAndJobs(ctx, job.RepoID, job.ConcurrencyGroup, statusFindOption)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("find concurrent runs and jobs: %w", err)
|
|
}
|
|
jobs = slices.DeleteFunc(jobs, func(j *ActionRunJob) bool { return j.ID == job.ID })
|
|
jobsToCancel = append(jobsToCancel, jobs...)
|
|
|
|
// cancel runs in the same concurrency group
|
|
for _, run := range runs {
|
|
jobs, err := db.Find[ActionRunJob](ctx, FindRunJobOptions{
|
|
RunID: run.ID,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("find run %d jobs: %w", run.ID, err)
|
|
}
|
|
jobsToCancel = append(jobsToCancel, jobs...)
|
|
}
|
|
|
|
return CancelJobs(ctx, jobsToCancel)
|
|
}
|