## 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>
327 lines
9.6 KiB
Go
327 lines
9.6 KiB
Go
// Copyright 2017 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package repo
|
|
|
|
import (
|
|
"context"
|
|
|
|
"code.gitea.io/gitea/models/db"
|
|
"code.gitea.io/gitea/models/perm"
|
|
"code.gitea.io/gitea/models/unit"
|
|
"code.gitea.io/gitea/modules/json"
|
|
"code.gitea.io/gitea/modules/setting"
|
|
"code.gitea.io/gitea/modules/timeutil"
|
|
"code.gitea.io/gitea/modules/util"
|
|
|
|
"xorm.io/xorm"
|
|
"xorm.io/xorm/convert"
|
|
)
|
|
|
|
// ErrUnitTypeNotExist represents a "UnitTypeNotExist" kind of error.
|
|
type ErrUnitTypeNotExist struct {
|
|
UT unit.Type
|
|
}
|
|
|
|
// IsErrUnitTypeNotExist checks if an error is a ErrUnitNotExist.
|
|
func IsErrUnitTypeNotExist(err error) bool {
|
|
_, ok := err.(ErrUnitTypeNotExist)
|
|
return ok
|
|
}
|
|
|
|
func (err ErrUnitTypeNotExist) Error() string {
|
|
return "Unit type does not exist: " + err.UT.LogString()
|
|
}
|
|
|
|
func (err ErrUnitTypeNotExist) Unwrap() error {
|
|
return util.ErrNotExist
|
|
}
|
|
|
|
// RepoUnit describes all units of a repository
|
|
type RepoUnit struct { //revive:disable-line:exported
|
|
ID int64
|
|
RepoID int64 `xorm:"INDEX(s)"`
|
|
Type unit.Type `xorm:"INDEX(s)"`
|
|
Config convert.Conversion `xorm:"TEXT"`
|
|
CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED"`
|
|
AnonymousAccessMode perm.AccessMode `xorm:"NOT NULL DEFAULT 0"`
|
|
EveryoneAccessMode perm.AccessMode `xorm:"NOT NULL DEFAULT 0"`
|
|
}
|
|
|
|
func init() {
|
|
db.RegisterModel(new(RepoUnit))
|
|
}
|
|
|
|
// UnitConfig describes common unit config
|
|
type UnitConfig struct{}
|
|
|
|
// FromDB fills up a UnitConfig from serialized format.
|
|
func (cfg *UnitConfig) FromDB(bs []byte) error {
|
|
return json.UnmarshalHandleDoubleEncode(bs, &cfg)
|
|
}
|
|
|
|
// ToDB exports a UnitConfig to a serialized format.
|
|
func (cfg *UnitConfig) ToDB() ([]byte, error) {
|
|
return json.Marshal(cfg)
|
|
}
|
|
|
|
// ExternalWikiConfig describes external wiki config
|
|
type ExternalWikiConfig struct {
|
|
ExternalWikiURL string
|
|
}
|
|
|
|
// FromDB fills up a ExternalWikiConfig from serialized format.
|
|
func (cfg *ExternalWikiConfig) FromDB(bs []byte) error {
|
|
return json.UnmarshalHandleDoubleEncode(bs, &cfg)
|
|
}
|
|
|
|
// ToDB exports a ExternalWikiConfig to a serialized format.
|
|
func (cfg *ExternalWikiConfig) ToDB() ([]byte, error) {
|
|
return json.Marshal(cfg)
|
|
}
|
|
|
|
// ExternalTrackerConfig describes external tracker config
|
|
type ExternalTrackerConfig struct {
|
|
ExternalTrackerURL string
|
|
ExternalTrackerFormat string
|
|
ExternalTrackerStyle string
|
|
ExternalTrackerRegexpPattern string
|
|
}
|
|
|
|
// FromDB fills up a ExternalTrackerConfig from serialized format.
|
|
func (cfg *ExternalTrackerConfig) FromDB(bs []byte) error {
|
|
return json.UnmarshalHandleDoubleEncode(bs, &cfg)
|
|
}
|
|
|
|
// ToDB exports a ExternalTrackerConfig to a serialized format.
|
|
func (cfg *ExternalTrackerConfig) ToDB() ([]byte, error) {
|
|
return json.Marshal(cfg)
|
|
}
|
|
|
|
// IssuesConfig describes issues config
|
|
type IssuesConfig struct {
|
|
EnableTimetracker bool
|
|
AllowOnlyContributorsToTrackTime bool
|
|
EnableDependencies bool
|
|
}
|
|
|
|
// FromDB fills up a IssuesConfig from serialized format.
|
|
func (cfg *IssuesConfig) FromDB(bs []byte) error {
|
|
return json.UnmarshalHandleDoubleEncode(bs, &cfg)
|
|
}
|
|
|
|
// ToDB exports a IssuesConfig to a serialized format.
|
|
func (cfg *IssuesConfig) ToDB() ([]byte, error) {
|
|
return json.Marshal(cfg)
|
|
}
|
|
|
|
// PullRequestsConfig describes pull requests config
|
|
type PullRequestsConfig struct {
|
|
IgnoreWhitespaceConflicts bool
|
|
AllowMerge bool
|
|
AllowRebase bool
|
|
AllowRebaseMerge bool
|
|
AllowSquash bool
|
|
AllowFastForwardOnly bool
|
|
AllowManualMerge bool
|
|
AutodetectManualMerge bool
|
|
AllowRebaseUpdate bool
|
|
DefaultDeleteBranchAfterMerge bool
|
|
DefaultMergeStyle MergeStyle
|
|
DefaultAllowMaintainerEdit bool
|
|
DefaultTargetBranch string
|
|
}
|
|
|
|
func DefaultPullRequestsConfig() *PullRequestsConfig {
|
|
cfg := &PullRequestsConfig{
|
|
AllowMerge: true,
|
|
AllowRebase: true,
|
|
AllowRebaseMerge: true,
|
|
AllowSquash: true,
|
|
AllowFastForwardOnly: true,
|
|
AllowRebaseUpdate: true,
|
|
DefaultAllowMaintainerEdit: true,
|
|
}
|
|
cfg.DefaultMergeStyle = MergeStyle(setting.Repository.PullRequest.DefaultMergeStyle)
|
|
cfg.DefaultMergeStyle = util.IfZero(cfg.DefaultMergeStyle, MergeStyleMerge)
|
|
return cfg
|
|
}
|
|
|
|
// FromDB fills up a PullRequestsConfig from serialized format.
|
|
func (cfg *PullRequestsConfig) FromDB(bs []byte) error {
|
|
// set default values for existing PullRequestConfig in DB
|
|
*cfg = *DefaultPullRequestsConfig()
|
|
return json.UnmarshalHandleDoubleEncode(bs, &cfg)
|
|
}
|
|
|
|
// ToDB exports a PullRequestsConfig to a serialized format.
|
|
func (cfg *PullRequestsConfig) ToDB() ([]byte, error) {
|
|
return json.Marshal(cfg)
|
|
}
|
|
|
|
// IsMergeStyleAllowed returns if merge style is allowed
|
|
func (cfg *PullRequestsConfig) IsMergeStyleAllowed(mergeStyle MergeStyle) bool {
|
|
return mergeStyle == MergeStyleMerge && cfg.AllowMerge ||
|
|
mergeStyle == MergeStyleRebase && cfg.AllowRebase ||
|
|
mergeStyle == MergeStyleRebaseMerge && cfg.AllowRebaseMerge ||
|
|
mergeStyle == MergeStyleSquash && cfg.AllowSquash ||
|
|
mergeStyle == MergeStyleFastForwardOnly && cfg.AllowFastForwardOnly ||
|
|
mergeStyle == MergeStyleManuallyMerged && cfg.AllowManualMerge
|
|
}
|
|
|
|
func DefaultPullRequestsUnit(repoID int64) RepoUnit {
|
|
return RepoUnit{RepoID: repoID, Type: unit.TypePullRequests, Config: DefaultPullRequestsConfig()}
|
|
}
|
|
|
|
// ProjectsMode represents the projects enabled for a repository
|
|
type ProjectsMode string
|
|
|
|
const (
|
|
// ProjectsModeRepo allows only repo-level projects
|
|
ProjectsModeRepo ProjectsMode = "repo"
|
|
// ProjectsModeOwner allows only owner-level projects
|
|
ProjectsModeOwner ProjectsMode = "owner"
|
|
// ProjectsModeAll allows both kinds of projects
|
|
ProjectsModeAll ProjectsMode = "all"
|
|
// ProjectsModeNone doesn't allow projects
|
|
ProjectsModeNone ProjectsMode = "none"
|
|
)
|
|
|
|
// ProjectsConfig describes projects config
|
|
type ProjectsConfig struct {
|
|
ProjectsMode ProjectsMode
|
|
}
|
|
|
|
// FromDB fills up a ProjectsConfig from serialized format.
|
|
func (cfg *ProjectsConfig) FromDB(bs []byte) error {
|
|
// TODO: remove GetProjectsMode, only use ProjectsMode
|
|
cfg.ProjectsMode = ProjectsModeAll
|
|
return json.UnmarshalHandleDoubleEncode(bs, &cfg)
|
|
}
|
|
|
|
// ToDB exports a ProjectsConfig to a serialized format.
|
|
func (cfg *ProjectsConfig) ToDB() ([]byte, error) {
|
|
return json.Marshal(cfg)
|
|
}
|
|
|
|
func (cfg *ProjectsConfig) GetProjectsMode() ProjectsMode {
|
|
if cfg.ProjectsMode != "" {
|
|
return cfg.ProjectsMode
|
|
}
|
|
|
|
return ProjectsModeAll
|
|
}
|
|
|
|
func (cfg *ProjectsConfig) IsProjectsAllowed(m ProjectsMode) bool {
|
|
projectsMode := cfg.GetProjectsMode()
|
|
|
|
if m == ProjectsModeNone {
|
|
return true
|
|
}
|
|
|
|
return projectsMode == m || projectsMode == ProjectsModeAll
|
|
}
|
|
|
|
// BeforeSet is invoked from XORM before setting the value of a field of this object.
|
|
func (r *RepoUnit) BeforeSet(colName string, val xorm.Cell) {
|
|
switch colName {
|
|
case "type":
|
|
r.Type = unit.Type(db.Cell2Int64(val))
|
|
switch r.Type {
|
|
case unit.TypeExternalWiki:
|
|
r.Config = new(ExternalWikiConfig)
|
|
case unit.TypeExternalTracker:
|
|
r.Config = new(ExternalTrackerConfig)
|
|
case unit.TypePullRequests:
|
|
r.Config = new(PullRequestsConfig)
|
|
case unit.TypeIssues:
|
|
r.Config = new(IssuesConfig)
|
|
case unit.TypeActions:
|
|
r.Config = new(ActionsConfig)
|
|
case unit.TypeProjects:
|
|
r.Config = new(ProjectsConfig)
|
|
case unit.TypeCode, unit.TypeReleases, unit.TypeWiki, unit.TypePackages:
|
|
fallthrough
|
|
default:
|
|
r.Config = new(UnitConfig)
|
|
}
|
|
case "config":
|
|
if *val == nil {
|
|
// XROM doesn't call FromDB if the value is nil, but we need to set default values for the config fields
|
|
_ = r.Config.FromDB(nil)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Unit returns Unit
|
|
func (r *RepoUnit) Unit() unit.Unit {
|
|
return unit.Units[r.Type]
|
|
}
|
|
|
|
// CodeConfig returns config for unit.TypeCode
|
|
func (r *RepoUnit) CodeConfig() *UnitConfig {
|
|
return r.Config.(*UnitConfig)
|
|
}
|
|
|
|
// PullRequestsConfig returns config for unit.TypePullRequests
|
|
func (r *RepoUnit) PullRequestsConfig() *PullRequestsConfig {
|
|
return r.Config.(*PullRequestsConfig)
|
|
}
|
|
|
|
// ReleasesConfig returns config for unit.TypeReleases
|
|
func (r *RepoUnit) ReleasesConfig() *UnitConfig {
|
|
return r.Config.(*UnitConfig)
|
|
}
|
|
|
|
// ExternalWikiConfig returns config for unit.TypeExternalWiki
|
|
func (r *RepoUnit) ExternalWikiConfig() *ExternalWikiConfig {
|
|
return r.Config.(*ExternalWikiConfig)
|
|
}
|
|
|
|
// IssuesConfig returns config for unit.TypeIssues
|
|
func (r *RepoUnit) IssuesConfig() *IssuesConfig {
|
|
return r.Config.(*IssuesConfig)
|
|
}
|
|
|
|
// ExternalTrackerConfig returns config for unit.TypeExternalTracker
|
|
func (r *RepoUnit) ExternalTrackerConfig() *ExternalTrackerConfig {
|
|
return r.Config.(*ExternalTrackerConfig)
|
|
}
|
|
|
|
// ActionsConfig returns config for unit.ActionsConfig
|
|
func (r *RepoUnit) ActionsConfig() *ActionsConfig {
|
|
return r.Config.(*ActionsConfig)
|
|
}
|
|
|
|
// ProjectsConfig returns config for unit.ProjectsConfig
|
|
func (r *RepoUnit) ProjectsConfig() *ProjectsConfig {
|
|
return r.Config.(*ProjectsConfig)
|
|
}
|
|
|
|
func getUnitsByRepoID(ctx context.Context, repoID int64) (units []*RepoUnit, err error) {
|
|
var tmpUnits []*RepoUnit
|
|
if err := db.GetEngine(ctx).Where("repo_id = ?", repoID).Find(&tmpUnits); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for _, u := range tmpUnits {
|
|
if !u.Type.UnitGlobalDisabled() {
|
|
units = append(units, u)
|
|
}
|
|
}
|
|
|
|
return units, nil
|
|
}
|
|
|
|
// UpdateRepoUnitConfig updates the config of the provided repo unit
|
|
func UpdateRepoUnitConfig(ctx context.Context, unit *RepoUnit) error {
|
|
_, err := db.GetEngine(ctx).ID(unit.ID).Cols("config").Update(unit)
|
|
return err
|
|
}
|
|
|
|
func UpdateRepoUnitPublicAccess(ctx context.Context, unit *RepoUnit) error {
|
|
_, err := db.GetEngine(ctx).Where("repo_id=? AND `type`=?", unit.RepoID, unit.Type).
|
|
Cols("anonymous_access_mode", "everyone_access_mode").Update(unit)
|
|
return err
|
|
}
|