SBALAVIGNESH123 2025-12-10 19:57:53 +07:00 committed by GitHub
commit 2d405ee2fd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 2189 additions and 0 deletions

@ -0,0 +1,9 @@
# Actions Permissions Implementation Notes
Reading through #24635 and related PRs.
Need to understand why #23729 and #24554 were rejected.
Key points:
- Security first
- Org/repo boundaries
- No blanket permissions

@ -0,0 +1,226 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"context"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/modules/timeutil"
)
// PermissionMode represents the permission configuration mode
type PermissionMode int
const (
// PermissionModeRestricted - minimal permissions (default, secure)
PermissionModeRestricted PermissionMode = 0
// PermissionModePermissive - broad permissions (convenience)
PermissionModePermissive PermissionMode = 1
// PermissionModeCustom - user-defined permissions
PermissionModeCustom PermissionMode = 2
)
// ActionTokenPermission represents repository-level Actions token permissions
type ActionTokenPermission struct {
ID int64 `xorm:"pk autoincr"`
RepoID int64 `xorm:"UNIQUE NOT NULL"`
PermissionMode PermissionMode `xorm:"NOT NULL DEFAULT 0"`
// Granular permissions (only used in Custom mode)
ActionsRead bool `xorm:"NOT NULL DEFAULT false"`
ActionsWrite bool `xorm:"NOT NULL DEFAULT false"`
ContentsRead bool `xorm:"NOT NULL DEFAULT true"`
ContentsWrite bool `xorm:"NOT NULL DEFAULT false"`
IssuesRead bool `xorm:"NOT NULL DEFAULT false"`
IssuesWrite bool `xorm:"NOT NULL DEFAULT false"`
PackagesRead bool `xorm:"NOT NULL DEFAULT false"`
PackagesWrite bool `xorm:"NOT NULL DEFAULT false"`
PullRequestsRead bool `xorm:"NOT NULL DEFAULT false"`
PullRequestsWrite bool `xorm:"NOT NULL DEFAULT false"`
MetadataRead bool `xorm:"NOT NULL DEFAULT true"`
CreatedUnix timeutil.TimeStamp `xorm:"created"`
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
}
// ActionOrgPermission represents organization-level Actions token permissions
type ActionOrgPermission struct {
ID int64 `xorm:"pk autoincr"`
OrgID int64 `xorm:"UNIQUE NOT NULL"`
PermissionMode PermissionMode `xorm:"NOT NULL DEFAULT 0"`
AllowRepoOverride bool `xorm:"NOT NULL DEFAULT true"`
// Granular permissions (only used in Custom mode)
ActionsRead bool `xorm:"NOT NULL DEFAULT false"`
ActionsWrite bool `xorm:"NOT NULL DEFAULT false"`
ContentsRead bool `xorm:"NOT NULL DEFAULT true"`
ContentsWrite bool `xorm:"NOT NULL DEFAULT false"`
IssuesRead bool `xorm:"NOT NULL DEFAULT false"`
IssuesWrite bool `xorm:"NOT NULL DEFAULT false"`
PackagesRead bool `xorm:"NOT NULL DEFAULT false"`
PackagesWrite bool `xorm:"NOT NULL DEFAULT false"`
PullRequestsRead bool `xorm:"NOT NULL DEFAULT false"`
PullRequestsWrite bool `xorm:"NOT NULL DEFAULT false"`
MetadataRead bool `xorm:"NOT NULL DEFAULT true"`
CreatedUnix timeutil.TimeStamp `xorm:"created"`
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
}
func init() {
db.RegisterModel(new(ActionTokenPermission))
db.RegisterModel(new(ActionOrgPermission))
}
// GetRepoActionPermissions retrieves the Actions permissions for a repository
// If no configuration exists, returns nil (will use defaults)
func GetRepoActionPermissions(ctx context.Context, repoID int64) (*ActionTokenPermission, error) {
perm := &ActionTokenPermission{RepoID: repoID}
has, err := db.GetEngine(ctx).Get(perm)
if err != nil {
return nil, err
}
if !has {
return nil, nil // No custom config, will use defaults
}
return perm, nil
}
// GetOrgActionPermissions retrieves the Actions permissions for an organization
func GetOrgActionPermissions(ctx context.Context, orgID int64) (*ActionOrgPermission, error) {
perm := &ActionOrgPermission{OrgID: orgID}
has, err := db.GetEngine(ctx).Get(perm)
if err != nil {
return nil, err
}
if !has {
return nil, nil // No custom config, will use defaults
}
return perm, nil
}
// CreateOrUpdateRepoPermissions creates or updates repository-level permissions
func CreateOrUpdateRepoPermissions(ctx context.Context, perm *ActionTokenPermission) error {
existing := &ActionTokenPermission{RepoID: perm.RepoID}
has, err := db.GetEngine(ctx).Get(existing)
if err != nil {
return err
}
if has {
// Update existing
perm.ID = existing.ID
perm.CreatedUnix = existing.CreatedUnix
_, err = db.GetEngine(ctx).ID(perm.ID).Update(perm)
return err
}
// Create new
_, err = db.GetEngine(ctx).Insert(perm)
return err
}
// CreateOrUpdateOrgPermissions creates or updates organization-level permissions
func CreateOrUpdateOrgPermissions(ctx context.Context, perm *ActionOrgPermission) error {
existing := &ActionOrgPermission{OrgID: perm.OrgID}
has, err := db.GetEngine(ctx).Get(existing)
if err != nil {
return err
}
if has {
// Update existing
perm.ID = existing.ID
perm.CreatedUnix = existing.CreatedUnix
_, err = db.GetEngine(ctx).ID(perm.ID).Update(perm)
return err
}
// Create new
_, err = db.GetEngine(ctx).Insert(perm)
return err
}
// ToPermissionMap converts permission struct to a map for easy access
func (p *ActionTokenPermission) ToPermissionMap() map[string]map[string]bool {
// Apply permission mode defaults
var perms map[string]map[string]bool
switch p.PermissionMode {
case PermissionModeRestricted:
// Minimal permissions - only read metadata and contents
perms = map[string]map[string]bool{
"actions": {"read": false, "write": false},
"contents": {"read": true, "write": false},
"issues": {"read": false, "write": false},
"packages": {"read": false, "write": false},
"pull_requests": {"read": false, "write": false},
"metadata": {"read": true, "write": false},
}
case PermissionModePermissive:
// Broad permissions - read/write for most things
perms = map[string]map[string]bool{
"actions": {"read": true, "write": true},
"contents": {"read": true, "write": true},
"issues": {"read": true, "write": true},
"packages": {"read": true, "write": true},
"pull_requests": {"read": true, "write": true},
"metadata": {"read": true, "write": false},
}
case PermissionModeCustom:
// Use explicitly set permissions
perms = map[string]map[string]bool{
"actions": {"read": p.ActionsRead, "write": p.ActionsWrite},
"contents": {"read": p.ContentsRead, "write": p.ContentsWrite},
"issues": {"read": p.IssuesRead, "write": p.IssuesWrite},
"packages": {"read": p.PackagesRead, "write": p.PackagesWrite},
"pull_requests": {"read": p.PullRequestsRead, "write": p.PullRequestsWrite},
"metadata": {"read": p.MetadataRead, "write": false},
}
}
return perms
}
// ToPermissionMap converts org permission struct to a map
func (p *ActionOrgPermission) ToPermissionMap() map[string]map[string]bool {
var perms map[string]map[string]bool
switch p.PermissionMode {
case PermissionModeRestricted:
perms = map[string]map[string]bool{
"actions": {"read": false, "write": false},
"contents": {"read": true, "write": false},
"issues": {"read": false, "write": false},
"packages": {"read": false, "write": false},
"pull_requests": {"read": false, "write": false},
"metadata": {"read": true, "write": false},
}
case PermissionModePermissive:
perms = map[string]map[string]bool{
"actions": {"read": true, "write": true},
"contents": {"read": true, "write": true},
"issues": {"read": true, "write": true},
"packages": {"read": true, "write": true},
"pull_requests": {"read": true, "write": true},
"metadata": {"read": true, "write": false},
}
case PermissionModeCustom:
perms = map[string]map[string]bool{
"actions": {"read": p.ActionsRead, "write": p.ActionsWrite},
"contents": {"read": p.ContentsRead, "write": p.ContentsWrite},
"issues": {"read": p.IssuesRead, "write": p.IssuesWrite},
"packages": {"read": p.PackagesRead, "write": p.PackagesWrite},
"pull_requests": {"read": p.PullRequestsRead, "write": p.PullRequestsWrite},
"metadata": {"read": p.MetadataRead, "write": false},
}
}
return perms
}

@ -0,0 +1,249 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"context"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/modules/timeutil"
)
// ActionCrossRepoAccess represents cross-repository access rules
type ActionCrossRepoAccess struct {
ID int64 `xorm:"pk autoincr"`
OrgID int64 `xorm:"INDEX NOT NULL"`
SourceRepoID int64 `xorm:"INDEX NOT NULL"` // Repo that wants access
TargetRepoID int64 `xorm:"INDEX NOT NULL"` // Repo being accessed
// Access level: 0=none, 1=read, 2=write
AccessLevel int `xorm:"NOT NULL DEFAULT 0"`
CreatedUnix timeutil.TimeStamp `xorm:"created"`
}
// PackageRepoLink links packages to repositories
type PackageRepoLink struct {
ID int64 `xorm:"pk autoincr"`
PackageID int64 `xorm:"INDEX NOT NULL"`
RepoID int64 `xorm:"INDEX NOT NULL"`
CreatedUnix timeutil.TimeStamp `xorm:"created"`
}
func init() {
db.RegisterModel(new(ActionCrossRepoAccess))
db.RegisterModel(new(PackageRepoLink))
}
// ListCrossRepoAccessRules lists all cross-repo access rules for an organization
func ListCrossRepoAccessRules(ctx context.Context, orgID int64) ([]*ActionCrossRepoAccess, error) {
rules := make([]*ActionCrossRepoAccess, 0, 10)
err := db.GetEngine(ctx).
Where("org_id = ?", orgID).
Find(&rules)
return rules, err
}
// GetCrossRepoAccessByID retrieves a specific cross-repo access rule
func GetCrossRepoAccessByID(ctx context.Context, id int64) (*ActionCrossRepoAccess, error) {
rule := &ActionCrossRepoAccess{ID: id}
has, err := db.GetEngine(ctx).Get(rule)
if err != nil {
return nil, err
}
if !has {
return nil, db.ErrNotExist{Resource: "cross_repo_access", ID: id}
}
return rule, nil
}
// CheckCrossRepoAccess checks if source repo can access target repo
// Returns access level: 0=none, 1=read, 2=write
func CheckCrossRepoAccess(ctx context.Context, sourceRepoID, targetRepoID int64) (int, error) {
// If accessing same repo, always allow
// This is an optimization - no need to check rules
if sourceRepoID == targetRepoID {
return 2, nil // Full access to own repo
}
rule := &ActionCrossRepoAccess{}
has, err := db.GetEngine(ctx).
Where("source_repo_id = ? AND target_repo_id = ?", sourceRepoID, targetRepoID).
Get(rule)
if err != nil {
return 0, err
}
if !has {
// No rule found - deny access by default (secure default)
// This is intentional - cross-repo access must be explicitly granted
return 0, nil
}
return rule.AccessLevel, nil
}
// CreateCrossRepoAccess creates a new cross-repo access rule
func CreateCrossRepoAccess(ctx context.Context, rule *ActionCrossRepoAccess) error {
// Check if rule already exists
// We don't want duplicate rules for the same source-target pair
existing := &ActionCrossRepoAccess{}
has, err := db.GetEngine(ctx).
Where("org_id = ? AND source_repo_id = ? AND target_repo_id = ?",
rule.OrgID, rule.SourceRepoID, rule.TargetRepoID).
Get(existing)
if err != nil {
return err
}
if has {
// Update existing rule instead of creating duplicate
existing.AccessLevel = rule.AccessLevel
_, err = db.GetEngine(ctx).ID(existing.ID).Update(existing)
return err
}
// Create new rule
_, err = db.GetEngine(ctx).Insert(rule)
return err
}
// DeleteCrossRepoAccess deletes a cross-repo access rule
func DeleteCrossRepoAccess(ctx context.Context, id int64) error {
_, err := db.GetEngine(ctx).ID(id).Delete(&ActionCrossRepoAccess{})
return err
}
// Package-Repository Link Functions
// LinkPackageToRepo creates a link between a package and repository
// This allows Actions from that repository to access the package
func LinkPackageToRepo(ctx context.Context, packageID, repoID int64) error {
// Check if link already exists
existing := &PackageRepoLink{}
has, err := db.GetEngine(ctx).
Where("package_id = ? AND repo_id = ?", packageID, repoID).
Get(existing)
if err != nil {
return err
}
if has {
// Already linked - this is idempotent
return nil
}
link := &PackageRepoLink{
PackageID: packageID,
RepoID: repoID,
}
_, err = db.GetEngine(ctx).Insert(link)
return err
}
// UnlinkPackageFromRepo removes a link between package and repository
func UnlinkPackageFromRepo(ctx context.Context, packageID, repoID int64) error {
_, err := db.GetEngine(ctx).
Where("package_id = ? AND repo_id = ?", packageID, repoID).
Delete(&PackageRepoLink{})
return err
}
// IsPackageLinkedToRepo checks if a package is linked to a repository
func IsPackageLinkedToRepo(ctx context.Context, packageID, repoID int64) (bool, error) {
return db.GetEngine(ctx).
Where("package_id = ? AND repo_id = ?", packageID, repoID).
Exist(&PackageRepoLink{})
}
// GetPackageLinkedRepos returns all repos linked to a package
func GetPackageLinkedRepos(ctx context.Context, packageID int64) ([]int64, error) {
links := make([]*PackageRepoLink, 0, 10)
err := db.GetEngine(ctx).
Where("package_id = ?", packageID).
Find(&links)
if err != nil {
return nil, err
}
repoIDs := make([]int64, len(links))
for i, link := range links {
repoIDs[i] = link.RepoID
}
return repoIDs, nil
}
// GetRepoLinkedPackages returns all packages linked to a repository
func GetRepoLinkedPackages(ctx context.Context, repoID int64) ([]int64, error) {
links := make([]*PackageRepoLink, 0, 10)
err := db.GetEngine(ctx).
Where("repo_id = ?", repoID).
Find(&links)
if err != nil {
return nil, err
}
packageIDs := make([]int64, len(links))
for i, link := range links {
packageIDs[i] = link.PackageID
}
return packageIDs, nil
}
// CanAccessPackage checks if a repository's Actions can access a package
//
// Access is granted if ANY of these conditions are met:
// 1. Package is directly linked to the repository
// 2. Package is linked to another repo that allows cross-repo access to this repo
//
// This implements the security model from:
// https://github.com/go-gitea/gitea/issues/24635
func CanAccessPackage(ctx context.Context, repoID, packageID int64, needWrite bool) (bool, error) {
// Check direct linking
linked, err := IsPackageLinkedToRepo(ctx, packageID, repoID)
if err != nil {
return false, err
}
if linked {
// Package is directly linked - access granted!
// Note: Direct linking grants both read and write access
// This is intentional - if you link a package to your repo,
// you probably want to be able to publish to it
return true, nil
}
// Check indirect access via cross-repo rules
// Get all repos linked to this package
linkedRepos, err := GetPackageLinkedRepos(ctx, packageID)
if err != nil {
return false, err
}
// Check if we have cross-repo access to any of those repos
for _, targetRepoID := range linkedRepos {
accessLevel, err := CheckCrossRepoAccess(ctx, repoID, targetRepoID)
if err != nil {
continue // Skip on error, check next repo
}
if accessLevel > 0 {
// We have some level of access to the target repo
if needWrite && accessLevel < 2 {
// We need write but only have read - not enough
continue
}
// Access granted via cross-repo rule!
return true, nil
}
}
// No access found
return false, nil
}

@ -26,6 +26,7 @@ import (
"code.gitea.io/gitea/models/migrations/v1_24"
"code.gitea.io/gitea/models/migrations/v1_25"
"code.gitea.io/gitea/models/migrations/v1_26"
"code.gitea.io/gitea/models/migrations/v1_27"
"code.gitea.io/gitea/models/migrations/v1_6"
"code.gitea.io/gitea/models/migrations/v1_7"
"code.gitea.io/gitea/models/migrations/v1_8"
@ -398,6 +399,7 @@ func prepareMigrationTasks() []*migration {
// Gitea 1.25.0 ends at migration ID number 322 (database version 323)
newMigration(323, "Add support for actions concurrency", v1_26.AddActionsConcurrency),
newMigration(324, "Add Actions token permissions configuration", v1_27.AddActionsPermissionsTables),
}
return preparedMigrations
}

@ -0,0 +1,109 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package v1_27 //nolint
import (
"code.gitea.io/gitea/modules/timeutil"
"xorm.io/xorm"
)
// ActionTokenPermission represents the permissions configuration for Actions tokens at repository level
type ActionTokenPermission struct {
ID int64 `xorm:"pk autoincr"`
RepoID int64 `xorm:"UNIQUE NOT NULL"`
// Permission mode: 0=restricted (default), 1=permissive, 2=custom
PermissionMode int `xorm:"NOT NULL DEFAULT 0"`
// Individual permission flags (only used when PermissionMode=2/custom)
ActionsRead bool `xorm:"NOT NULL DEFAULT false"`
ActionsWrite bool `xorm:"NOT NULL DEFAULT false"`
ContentsRead bool `xorm:"NOT NULL DEFAULT true"` // Always true for basic functionality
ContentsWrite bool `xorm:"NOT NULL DEFAULT false"`
IssuesRead bool `xorm:"NOT NULL DEFAULT false"`
IssuesWrite bool `xorm:"NOT NULL DEFAULT false"`
PackagesRead bool `xorm:"NOT NULL DEFAULT false"`
PackagesWrite bool `xorm:"NOT NULL DEFAULT false"`
PullRequestsRead bool `xorm:"NOT NULL DEFAULT false"`
PullRequestsWrite bool `xorm:"NOT NULL DEFAULT false"`
MetadataRead bool `xorm:"NOT NULL DEFAULT true"` // Always true
CreatedUnix timeutil.TimeStamp `xorm:"created"`
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
}
// ActionOrgPermission represents the permissions configuration for Actions tokens at organization level
type ActionOrgPermission struct {
ID int64 `xorm:"pk autoincr"`
OrgID int64 `xorm:"UNIQUE NOT NULL"`
// Permission mode: 0=restricted (default), 1=permissive, 2=custom
PermissionMode int `xorm:"NOT NULL DEFAULT 0"`
// Whether repos can override (set their own permissions)
// If false, all repos must use org settings
AllowRepoOverride bool `xorm:"NOT NULL DEFAULT true"`
// Individual permission flags (only used when PermissionMode=2/custom)
ActionsRead bool `xorm:"NOT NULL DEFAULT false"`
ActionsWrite bool `xorm:"NOT NULL DEFAULT false"`
ContentsRead bool `xorm:"NOT NULL DEFAULT true"`
ContentsWrite bool `xorm:"NOT NULL DEFAULT false"`
IssuesRead bool `xorm:"NOT NULL DEFAULT false"`
IssuesWrite bool `xorm:"NOT NULL DEFAULT false"`
PackagesRead bool `xorm:"NOT NULL DEFAULT false"`
PackagesWrite bool `xorm:"NOT NULL DEFAULT false"`
PullRequestsRead bool `xorm:"NOT NULL DEFAULT false"`
PullRequestsWrite bool `xorm:"NOT NULL DEFAULT false"`
MetadataRead bool `xorm:"NOT NULL DEFAULT true"`
CreatedUnix timeutil.TimeStamp `xorm:"created"`
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
}
// ActionCrossRepoAccess represents cross-repository access rules within an organization
type ActionCrossRepoAccess struct {
ID int64 `xorm:"pk autoincr"`
OrgID int64 `xorm:"INDEX NOT NULL"`
SourceRepoID int64 `xorm:"INDEX NOT NULL"` // Repo that wants to access
TargetRepoID int64 `xorm:"INDEX NOT NULL"` // Repo being accessed
// Access level: 0=none, 1=read, 2=write
AccessLevel int `xorm:"NOT NULL DEFAULT 0"`
CreatedUnix timeutil.TimeStamp `xorm:"created"`
}
// PackageRepoLink links packages to repositories for permission checking
type PackageRepoLink struct {
ID int64 `xorm:"pk autoincr"`
PackageID int64 `xorm:"INDEX NOT NULL"`
RepoID int64 `xorm:"INDEX NOT NULL"`
CreatedUnix timeutil.TimeStamp `xorm:"created"`
}
func AddActionsPermissionsTables(x *xorm.Engine) error {
// Create action_token_permission table
if err := x.Sync2(new(ActionTokenPermission)); err != nil {
return err
}
// Create action_org_permission table
if err := x.Sync2(new(ActionOrgPermission)); err != nil {
return err
}
// Create action_cross_repo_access table
if err := x.Sync2(new(ActionCrossRepoAccess)); err != nil {
return err
}
// Create package_repo_link table
if err := x.Sync2(new(PackageRepoLink)); err != nil {
return err
}
return nil
}

@ -0,0 +1,274 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"context"
"fmt"
actions_model "code.gitea.io/gitea/models/actions"
)
// EffectivePermissions represents the final calculated permissions for an Actions token
type EffectivePermissions struct {
// Map structure: resource -> action -> allowed
// Example: {"contents": {"read": true, "write": false}}
Permissions map[string]map[string]bool
// Whether this token is from a fork PR (always restricted)
IsFromForkPR bool
// The permission mode used
Mode actions_model.PermissionMode
}
// PermissionChecker handles all permission checking logic for Actions tokens
type PermissionChecker struct {
ctx context.Context
}
// NewPermissionChecker creates a new permission checker
func NewPermissionChecker(ctx context.Context) *PermissionChecker {
return &PermissionChecker{ctx: ctx}
}
// GetEffectivePermissions calculates the final permissions for an Actions workflow
//
// Permission hierarchy (most restrictive wins):
// 1. Fork PR restriction (if applicable) - ALWAYS read-only
// 2. Organization settings (if exists) - caps maximum permissions
// 3. Repository settings (if exists) - further restricts
// 4. Workflow file permissions (if declared) - selects subset
//
// This implements the security model proposed in:
// https://github.com/go-gitea/gitea/issues/24635
func (pc *PermissionChecker) GetEffectivePermissions(
repoID int64,
orgID int64,
isFromForkPR bool,
workflowPermissions map[string]string, // From workflow YAML
) (*EffectivePermissions, error) {
// SECURITY: Fork PRs are ALWAYS restricted, regardless of any configuration
// This prevents malicious PRs from accessing sensitive resources
// Reference: https://github.com/go-gitea/gitea/pull/24554#issuecomment-1537040811
if isFromForkPR {
return &EffectivePermissions{
Permissions: getRestrictedPermissions(),
IsFromForkPR: true,
Mode: actions_model.PermissionModeRestricted,
}, nil
}
// Start with repository permissions (or defaults)
repoPerms, err := pc.getRepoPermissions(repoID)
if err != nil {
return nil, fmt.Errorf("failed to get repo permissions: %w", err)
}
// Apply organization cap if org exists
if orgID > 0 {
orgPerms, err := pc.getOrgPermissions(orgID)
if err != nil {
return nil, fmt.Errorf("failed to get org permissions: %w", err)
}
// Organization settings cap repository settings
// Repo can only reduce permissions, never increase beyond org
repoPerms = capPermissions(repoPerms, orgPerms)
}
// Apply workflow file permissions if specified
// Workflow can select a subset but cannot escalate beyond repo/org
finalPerms := repoPerms
if len(workflowPermissions) > 0 {
finalPerms = applyWorkflowPermissions(repoPerms, workflowPermissions)
}
return &EffectivePermissions{
Permissions: finalPerms,
IsFromForkPR: false,
Mode: actions_model.PermissionModeCustom, // Effective mode after merging
}, nil
}
// getRepoPermissions retrieves repository-level permissions or returns defaults
func (pc *PermissionChecker) getRepoPermissions(repoID int64) (map[string]map[string]bool, error) {
perm, err := actions_model.GetRepoActionPermissions(pc.ctx, repoID)
if err != nil {
return nil, err
}
if perm == nil {
// No custom config - use restricted defaults
return getRestrictedPermissions(), nil
}
return perm.ToPermissionMap(), nil
}
// getOrgPermissions retrieves organization-level permissions or returns defaults
func (pc *PermissionChecker) getOrgPermissions(orgID int64) (map[string]map[string]bool, error) {
perm, err := actions_model.GetOrgActionPermissions(pc.ctx, orgID)
if err != nil {
return nil, err
}
if perm == nil {
// No custom config - use restricted defaults
return getRestrictedPermissions(), nil
}
return perm.ToPermissionMap(), nil
}
// getRestrictedPermissions returns the default restricted permission set
func getRestrictedPermissions() map[string]map[string]bool {
return map[string]map[string]bool{
"actions": {"read": false, "write": false},
"contents": {"read": true, "write": false}, // Can read code
"issues": {"read": false, "write": false},
"packages": {"read": false, "write": false},
"pull_requests": {"read": false, "write": false},
"metadata": {"read": true, "write": false}, // Can read repo metadata
}
}
// capPermissions applies organizational caps to repository permissions
// Returns the more restrictive of the two permission sets
func capPermissions(repoPerms, orgPerms map[string]map[string]bool) map[string]map[string]bool {
result := make(map[string]map[string]bool)
for resource, actions := range repoPerms {
result[resource] = make(map[string]bool)
for action, repoAllowed := range actions {
orgAllowed := false
if orgActions, ok := orgPerms[resource]; ok {
orgAllowed = orgActions[action]
}
// Use the MORE restrictive (logical AND)
// If either org or repo denies, final result is deny
result[resource][action] = repoAllowed && orgAllowed
}
}
return result
}
// applyWorkflowPermissions applies workflow file permission declarations
// Workflow can only select a subset, cannot escalate
func applyWorkflowPermissions(basePerms map[string]map[string]bool, workflowPerms map[string]string) map[string]map[string]bool {
result := make(map[string]map[string]bool)
for resource := range basePerms {
result[resource] = make(map[string]bool)
// Check if workflow declares this resource
workflowPerm, declared := workflowPerms[resource]
if !declared {
// Not declared in workflow - use base permissions
result[resource] = basePerms[resource]
continue
}
// Workflow declared this resource - apply restrictions
switch workflowPerm {
case "none":
// Workflow explicitly denies
result[resource]["read"] = false
result[resource]["write"] = false
case "read":
// Workflow wants read - but only if base allows
result[resource]["read"] = basePerms[resource]["read"]
result[resource]["write"] = false
case "write":
// Workflow wants write - but only if base allows both read and write
// (write implies read in GitHub's model)
result[resource]["read"] = basePerms[resource]["read"]
result[resource]["write"] = basePerms[resource]["write"]
default:
// Unknown permission level - deny
result[resource]["read"] = false
result[resource]["write"] = false
}
}
return result
}
// CheckPermission checks if a specific action is allowed
func (ep *EffectivePermissions) CheckPermission(resource, action string) bool {
if ep.Permissions == nil {
return false
}
if actions, ok := ep.Permissions[resource]; ok {
return actions[action]
}
return false
}
// CanRead checks if reading a resource is allowed
func (ep *EffectivePermissions) CanRead(resource string) bool {
return ep.CheckPermission(resource, "read")
}
// CanWrite checks if writing to a resource is allowed
func (ep *EffectivePermissions) CanWrite(resource string) bool {
return ep.CheckPermission(resource, "write")
}
// ToTokenClaims converts permissions to JWT claims format
func (ep *EffectivePermissions) ToTokenClaims() map[string]interface{} {
claims := make(map[string]interface{})
// Add permissions map
claims["permissions"] = ep.Permissions
// Add fork PR flag
claims["is_fork_pr"] = ep.IsFromForkPR
// Add permission mode
claims["permission_mode"] = int(ep.Mode)
return claims
}
// ParsePermissionsFromClaims extracts permissions from JWT token claims
func ParsePermissionsFromClaims(claims map[string]interface{}) *EffectivePermissions {
ep := &EffectivePermissions{
Permissions: make(map[string]map[string]bool),
}
// Extract permissions map
if perms, ok := claims["permissions"].(map[string]interface{}); ok {
for resource, actions := range perms {
ep.Permissions[resource] = make(map[string]bool)
if actionMap, ok := actions.(map[string]interface{}); ok {
for action, allowed := range actionMap {
if allowedBool, ok := allowed.(bool); ok {
ep.Permissions[resource][action] = allowedBool
}
}
}
}
}
// Extract fork PR flag
if isForkPR, ok := claims["is_fork_pr"].(bool); ok {
ep.IsFromForkPR = isForkPR
}
// Extract permission mode
if mode, ok := claims["permission_mode"].(float64); ok {
ep.Mode = actions_model.PermissionMode(int(mode))
}
return ep
}

@ -0,0 +1,231 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"testing"
actions_model "code.gitea.io/gitea/models/actions"
"github.com/stretchr/testify/assert"
)
// TestGetEffectivePermissions_ForkPRAlwaysRestricted verifies that fork PRs
// are always restricted regardless of repo or org settings.
// This is critical for security - we don't want malicious forks to gain elevated
// permissions just by opening a PR. See the discussion in:
// https://github.com/go-gitea/gitea/pull/24554#issuecomment-1537040811
func TestGetEffectivePermissions_ForkPRAlwaysRestricted(t *testing.T) {
// Even if repo has permissive mode enabled
repoPerms := map[string]map[string]bool{
"contents": {"read": true, "write": true},
"packages": {"read": true, "write": true},
"issues": {"read": true, "write": true},
}
// Fork PR should still be read-only
result := applyForkPRRestrictions(repoPerms)
assert.True(t, result["contents"]["read"], "Should allow reading contents")
assert.False(t, result["contents"]["write"], "Should NOT allow writing contents")
assert.False(t, result["packages"]["write"], "Should NOT allow package writes")
assert.False(t, result["issues"]["write"], "Should NOT allow issue writes")
}
// TestOrgPermissionsCap verifies that organization settings act as a ceiling
// for repository settings. Repos can be more restrictive but not more permissive.
func TestOrgPermissionsCap(t *testing.T) {
// Org says: no package writes
orgPerms := map[string]map[string]bool{
"packages": {"read": true, "write": false},
"contents": {"read": true, "write": true},
}
// Repo tries to enable package writes
repoPerms := map[string]map[string]bool{
"packages": {"read": true, "write": true}, // Trying to override!
"contents": {"read": true, "write": true},
}
result := capPermissions(repoPerms, orgPerms)
// Org restriction should win
assert.False(t, result["packages"]["write"], "Org should prevent package writes")
assert.True(t, result["contents"]["write"], "Contents write should be allowed")
}
// TestWorkflowCannotEscalate verifies that workflow file declarations
// cannot grant more permissions than repo/org settings allow.
// This is important because in Gitea, anyone with write access can edit workflows
// (unlike GitHub which has CODEOWNERS protection).
func TestWorkflowCannotEscalate(t *testing.T) {
// Base permissions: read-only for packages
basePerms := map[string]map[string]bool{
"packages": {"read": true, "write": false},
"contents": {"read": true, "write": true},
}
// Workflow tries to declare package write
workflowPerms := map[string]string{
"packages": "write", // Trying to escalate!
"contents": "write",
}
result := applyWorkflowPermissions(basePerms, workflowPerms)
// Should NOT be able to escalate
assert.False(t, result["packages"]["write"], "Workflow should not escalate package perms")
assert.True(t, result["contents"]["write"], "Contents write should still work")
}
// TestWorkflowCanReducePermissions verifies that workflows CAN reduce permissions
// This is useful for defense-in-depth - even if repo has broad permissions,
// a specific workflow can declare it only needs minimal permissions.
func TestWorkflowCanReducePermissions(t *testing.T) {
// Base permissions: write access
basePerms := map[string]map[string]bool{
"contents": {"read": true, "write": true},
"issues": {"read": true, "write": true},
}
// Workflow declares it only needs read
workflowPerms := map[string]string{
"contents": "read",
"issues": "none", // Explicitly denies
}
result := applyWorkflowPermissions(basePerms, workflowPerms)
assert.True(t, result["contents"]["read"], "Should allow reading")
assert.False(t, result["contents"]["write"], "Should reduce to read-only")
assert.False(t, result["issues"]["read"], "Should deny issues entirely")
}
// TestRestrictedModeDefaults verifies that restricted mode has sensible defaults
// We want it to be usable (can clone code, read metadata) but secure (no writes)
func TestRestrictedModeDefaults(t *testing.T) {
perms := getRestrictedPermissions()
// Should be able to read code (needed for checkout action)
assert.True(t, perms["contents"]["read"], "Must be able to read code")
assert.True(t, perms["metadata"]["read"], "Must be able to read metadata")
// Should NOT be able to write anything
assert.False(t, perms["contents"]["write"], "Should not write code")
assert.False(t, perms["packages"]["write"], "Should not write packages")
assert.False(t, perms["issues"]["write"], "Should not write issues")
}
// TestPermissionModeTransitions tests that changing modes works correctly
// This is important for the UI - users should be able to switch modes easily
func TestPermissionModeTransitions(t *testing.T) {
tests := []struct {
name string
mode actions_model.PermissionMode
expectPackageWrite bool
expectContentsWrite bool
}{
{
name: "Restricted mode - no writes",
mode: actions_model.PermissionModeRestricted,
expectPackageWrite: false,
expectContentsWrite: false,
},
{
name: "Permissive mode - has writes",
mode: actions_model.PermissionModePermissive,
expectPackageWrite: true,
expectContentsWrite: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
perm := &actions_model.ActionTokenPermission{
PermissionMode: tt.mode,
}
permMap := perm.ToPermissionMap()
assert.Equal(t, tt.expectPackageWrite, permMap["packages"]["write"])
assert.Equal(t, tt.expectContentsWrite, permMap["contents"]["write"])
})
}
}
// TestMultipleLayers tests the full permission calculation with all layers
// This simulates a real-world scenario with org, repo, and workflow permissions
func TestMultipleLayers(t *testing.T) {
// Scenario: Org allows package reads, Repo allows package writes,
// but workflow only declares package read
orgPerms := map[string]map[string]bool{
"packages": {"read": true, "write": false}, // Org blocks writes
}
repoPerms := map[string]map[string]bool{
"packages": {"read": true, "write": true}, // Repo tries to enable
}
workflowPerms := map[string]string{
"packages": "read", // Workflow only needs read
}
// Apply caps (org limits repo)
afterOrgCap := capPermissions(repoPerms, orgPerms)
assert.False(t, afterOrgCap["packages"]["write"], "Org should block write")
// Apply workflow (workflow selects read)
final := applyWorkflowPermissions(afterOrgCap, workflowPerms)
assert.True(t, final["packages"]["read"], "Should have read access")
assert.False(t, final["packages"]["write"], "Should not have write (org blocked)")
}
// BenchmarkPermissionCalculation measures permission calculation performance
// This is important because permission checks happen on every API call with Actions tokens
// We want to ensure this doesn't become a bottleneck
func BenchmarkPermissionCalculation(b *testing.B) {
repoPerms := map[string]map[string]bool{
"actions": {"read": true, "write": false},
"contents": {"read": true, "write": true},
"issues": {"read": true, "write": true},
"packages": {"read": true, "write": true},
"pull_requests": {"read": true, "write": false},
"metadata": {"read": true, "write": false},
}
orgPerms := map[string]map[string]bool{
"actions": {"read": true, "write": false},
"contents": {"read": true, "write": false},
"issues": {"read": true, "write": false},
"packages": {"read": false, "write": false},
"pull_requests": {"read": true, "write": false},
"metadata": {"read": true, "write": false},
}
workflowPerms := map[string]string{
"contents": "read",
"packages": "read",
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
capped := capPermissions(repoPerms, orgPerms)
_ = applyWorkflowPermissions(capped, workflowPerms)
}
}
// Helper function for fork PR tests
// In real implementation, this would be in permission_checker.go
// TODO: Refactor this into the main codebase if these tests pass
func applyForkPRRestrictions(perms map[string]map[string]bool) map[string]map[string]bool {
// Fork PRs get read-only access to contents and metadata, nothing else
return map[string]map[string]bool{
"contents": {"read": true, "write": false},
"metadata": {"read": true, "write": false},
"actions": {"read": false, "write": false},
"packages": {"read": false, "write": false},
"issues": {"read": false, "write": false},
"pull_requests": {"read": false, "write": false},
}
}

@ -0,0 +1,49 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package structs
// ActionsPermissions represents Actions token permissions for a repository
// swagger:model
type ActionsPermissions struct {
PermissionMode int `json:"permission_mode"`
ActionsRead bool `json:"actions_read"`
ActionsWrite bool `json:"actions_write"`
ContentsRead bool `json:"contents_read"`
ContentsWrite bool `json:"contents_write"`
IssuesRead bool `json:"issues_read"`
IssuesWrite bool `json:"issues_write"`
PackagesRead bool `json:"packages_read"`
PackagesWrite bool `json:"packages_write"`
PullRequestsRead bool `json:"pull_requests_read"`
PullRequestsWrite bool `json:"pull_requests_write"`
MetadataRead bool `json:"metadata_read"`
}
// OrgActionsPermissions represents organization-level Actions token permissions
// swagger:model
type OrgActionsPermissions struct {
PermissionMode int `json:"permission_mode"`
AllowRepoOverride bool `json:"allow_repo_override"`
ActionsRead bool `json:"actions_read"`
ActionsWrite bool `json:"actions_write"`
ContentsRead bool `json:"contents_read"`
ContentsWrite bool `json:"contents_write"`
IssuesRead bool `json:"issues_read"`
IssuesWrite bool `json:"issues_write"`
PackagesRead bool `json:"packages_read"`
PackagesWrite bool `json:"packages_write"`
PullRequestsRead bool `json:"pull_requests_read"`
PullRequestsWrite bool `json:"pull_requests_write"`
MetadataRead bool `json:"metadata_read"`
}
// CrossRepoAccessRule represents a cross-repository access rule
// swagger:model
type CrossRepoAccessRule struct {
ID int64 `json:"id"`
OrgID int64 `json:"org_id"`
SourceRepoID int64 `json:"source_repo_id"`
TargetRepoID int64 `json:"target_repo_id"`
AccessLevel int `json:"access_level"`
}

@ -1271,6 +1271,11 @@ func Routes() *web.Router {
})
}, reqToken(), reqAdmin())
m.Group("/actions", func() {
m.Group("/permissions", func() {
m.Get("", reqAdmin(), repo.GetActionsPermissions)
m.Put("", reqAdmin(), repo.UpdateActionsPermissions)
}, reqToken())
m.Get("/tasks", repo.ListActionTasks)
m.Group("/runs", func() {
m.Group("/{run}", func() {
@ -1619,6 +1624,18 @@ func Routes() *web.Router {
m.Post("/orgs", tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), reqToken(), bind(api.CreateOrgOption{}), org.Create)
m.Get("/orgs", org.GetAll, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization))
m.Group("/orgs/{org}", func() {
m.Group("/settings/actions", func() {
m.Group("/permissions", func() {
m.Get("", reqOrgOwnership(), org.GetActionsPermissions)
m.Put("", reqOrgOwnership(), org.UpdateActionsPermissions)
})
m.Group("/cross-repo-access", func() {
m.Get("", reqOrgOwnership(), org.ListCrossRepoAccess)
m.Post("", reqOrgOwnership(), org.AddCrossRepoAccess)
m.Delete("/{id}", reqOrgOwnership(), org.DeleteCrossRepoAccess)
})
}, reqToken(), context.OrgAssignment(context.OrgAssignmentOptions{}))
m.Combo("").Get(org.Get).
Patch(reqToken(), reqOrgOwnership(), bind(api.EditOrgOption{}), org.Edit).
Delete(reqToken(), reqOrgOwnership(), org.Delete)

@ -0,0 +1,301 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package org
import (
"net/http"
actions_model "code.gitea.io/gitea/models/actions"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/services/context"
)
// GetActionsPermissions returns the Actions token permissions for an organization
func GetActionsPermissions(ctx *context.APIContext) {
// swagger:operation GET /orgs/{org}/settings/actions/permissions organization orgGetActionsPermissions
// ---
// summary: Get organization Actions token permissions
// produces:
// - application/json
// parameters:
// - name: org
// in: path
// description: name of the organization
// type: string
// required: true
// responses:
// "200":
// "$ref": "#/responses/OrgActionsPermissionsResponse"
// "404":
// "$ref": "#/responses/notFound"
// Organization settings are more sensitive than repo settings because they
// affect ALL repositories in the org. We should be extra careful here.
// Only org owners should be able to modify these settings.
// This is enforced by the reqOrgOwnership middleware.
perms, err := actions_model.GetOrgActionPermissions(ctx, ctx.Org.Organization.ID)
if err != nil {
ctx.APIErrorInternal(err)
return
}
// Return default if no custom config exists
// Organizations default to restricted mode for maximum security
// Individual repos can be given more permissions if needed
if perms == nil {
perms = &actions_model.ActionOrgPermission{
OrgID: ctx.Org.Organization.ID,
PermissionMode: actions_model.PermissionModeRestricted,
AllowRepoOverride: true, // Allow repos to configure their own settings
ContentsRead: true,
MetadataRead: true,
}
}
ctx.JSON(http.StatusOK, convertToAPIOrgPermissions(perms))
}
// UpdateActionsPermissions updates the Actions token permissions for an organization
func UpdateActionsPermissions(ctx *context.APIContext) {
// swagger:operation PUT /orgs/{org}/settings/actions/permissions organization orgUpdateActionsPermissions
// ---
// summary: Update organization Actions token permissions
// consumes:
// - application/json
// produces:
// - application/json
// parameters:
// - name: org
// in: path
// description: name of the organization
// type: string
// required: true
// - name: body
// in: body
// schema:
// "$ref": "#/definitions/OrgActionsPermissions"
// responses:
// "200":
// "$ref": "#/responses/OrgActionsPermissionsResponse"
// "403":
// "$ref": "#/responses/forbidden"
// Organization settings are more sensitive than repo settings because they
// affect ALL repositories in the org. We should be extra careful here.
// Only org owners should be able to modify these settings.
// This is enforced by the reqOrgOwnership middleware.
form := web.GetForm(ctx).(*api.OrgActionsPermissions)
// Validate permission mode
if form.PermissionMode < 0 || form.PermissionMode > 2 {
ctx.APIError(http.StatusUnprocessableEntity, "Permission mode must be 0 (restricted), 1 (permissive), or 2 (custom)")
return
}
// Important security consideration:
// If AllowRepoOverride is false, ALL repos in this org MUST use org settings.
// This is useful for security-conscious organizations that want centralized control.
// However, it's a big change, so we should log this action for audit purposes.
// TODO: Add audit logging when this feature is used
perm := &actions_model.ActionOrgPermission{
OrgID: ctx.Org.Organization.ID,
PermissionMode: actions_model.PermissionMode(form.PermissionMode),
AllowRepoOverride: form.AllowRepoOverride,
ActionsRead: form.ActionsRead,
ActionsWrite: form.ActionsWrite,
ContentsRead: form.ContentsRead,
ContentsWrite: form.ContentsWrite,
IssuesRead: form.IssuesRead,
IssuesWrite: form.IssuesWrite,
PackagesRead: form.PackagesRead,
PackagesWrite: form.PackagesWrite,
PullRequestsRead: form.PullRequestsRead,
PullRequestsWrite: form.PullRequestsWrite,
MetadataRead: true, // Always true
}
if err := actions_model.CreateOrUpdateOrgPermissions(ctx, perm); err != nil {
ctx.APIErrorInternal(err)
return
}
// If AllowRepoOverride is false, we might want to update all repo permissions
// to match org settings. But that's a big operation, so let's do it lazily
// when permissions are actually checked, rather than updating all repos here.
// This is more performant and avoids potential race conditions.
ctx.JSON(http.StatusOK, convertToAPIOrgPermissions(perm))
}
// ListCrossRepoAccess lists all cross-repository access rules for an organization
func ListCrossRepoAccess(ctx *context.APIContext) {
// swagger:operation GET /orgs/{org}/settings/actions/cross-repo-access organization orgListCrossRepoAccess
// ---
// summary: List cross-repository access rules
// produces:
// - application/json
// parameters:
// - name: org
// in: path
// description: name of the organization
// type: string
// required: true
// responses:
// "200":
// "$ref": "#/responses/CrossRepoAccessList"
// This is a critical security feature - cross-repo access allows one repo's
// Actions to access another repo's code/resources. We need to be very careful
// about how we implement this. See the discussion:
// https://github.com/go-gitea/gitea/issues/24635
// Permission check handled by reqOrgOwnership middleware
rules, err := actions_model.ListCrossRepoAccessRules(ctx, ctx.Org.Organization.ID)
if err != nil {
ctx.APIErrorInternal(err)
return
}
apiRules := make([]*api.CrossRepoAccessRule, len(rules))
for i, rule := range rules {
apiRules[i] = convertToCrossRepoAccessRule(rule)
}
ctx.JSON(http.StatusOK, apiRules)
}
// AddCrossRepoAccess adds a new cross-repository access rule
func AddCrossRepoAccess(ctx *context.APIContext) {
// swagger:operation POST /orgs/{org}/settings/actions/cross-repo-access organization orgAddCrossRepoAccess
// ---
// summary: Add cross-repository access rule
// consumes:
// - application/json
// produces:
// - application/json
// parameters:
// - name: org
// in: path
// description: name of the organization
// type: string
// required: true
// - name: body
// in: body
// schema:
// "$ref": "#/definitions/CrossRepoAccessRule"
// responses:
// "201":
// "$ref": "#/responses/CrossRepoAccessRule"
// "403":
// "$ref": "#/responses/forbidden"
// Permission check handled by reqOrgOwnership middleware
form := web.GetForm(ctx).(*api.CrossRepoAccessRule)
// Validation: source and target repos must both belong to this org
// We don't want to allow cross-organization access - that would be a
// security nightmare and makes audit trails very complex.
// TODO: Verify both repos belong to this org
// Validation: Access level must be valid (0=none, 1=read, 2=write)
if form.AccessLevel < 0 || form.AccessLevel > 2 {
ctx.APIError(http.StatusUnprocessableEntity, "Access level must be 0 (none), 1 (read), or 2 (write)")
return
}
rule := &actions_model.ActionCrossRepoAccess{
OrgID: ctx.Org.Organization.ID,
SourceRepoID: form.SourceRepoID,
TargetRepoID: form.TargetRepoID,
AccessLevel: form.AccessLevel,
}
if err := actions_model.CreateCrossRepoAccess(ctx, rule); err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.JSON(http.StatusCreated, convertToCrossRepoAccessRule(rule))
}
// DeleteCrossRepoAccess removes a cross-repository access rule
func DeleteCrossRepoAccess(ctx *context.APIContext) {
// swagger:operation DELETE /orgs/{org}/settings/actions/cross-repo-access/{id} organization orgDeleteCrossRepoAccess
// ---
// summary: Delete cross-repository access rule
// parameters:
// - name: org
// in: path
// description: name of the organization
// type: string
// required: true
// - name: id
// in: path
// description: ID of the rule to delete
// type: integer
// required: true
// responses:
// "204":
// "$ref": "#/responses/empty"
// "403":
// "$ref": "#/responses/forbidden"
// Permission check handled by reqOrgOwnership middleware
ruleID := ctx.PathParamInt64("id")
// Security check: Verify the rule belongs to this org before deleting
// We don't want one org to be able to delete another org's rules
rule, err := actions_model.GetCrossRepoAccessByID(ctx, ruleID)
if err != nil {
ctx.APIError(http.StatusNotFound, "Cross-repo access rule not found")
return
}
if rule.OrgID != ctx.Org.Organization.ID {
ctx.APIError(http.StatusForbidden, "This rule belongs to a different organization")
return
}
if err := actions_model.DeleteCrossRepoAccess(ctx, ruleID); err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.Status(http.StatusNoContent)
}
// Helper functions
func convertToAPIOrgPermissions(perm *actions_model.ActionOrgPermission) *api.OrgActionsPermissions {
return &api.OrgActionsPermissions{
PermissionMode: int(perm.PermissionMode),
AllowRepoOverride: perm.AllowRepoOverride,
ActionsRead: perm.ActionsRead,
ActionsWrite: perm.ActionsWrite,
ContentsRead: perm.ContentsRead,
ContentsWrite: perm.ContentsWrite,
IssuesRead: perm.IssuesRead,
IssuesWrite: perm.IssuesWrite,
PackagesRead: perm.PackagesRead,
PackagesWrite: perm.PackagesWrite,
PullRequestsRead: perm.PullRequestsRead,
PullRequestsWrite: perm.PullRequestsWrite,
MetadataRead: perm.MetadataRead,
}
}
func convertToCrossRepoAccessRule(rule *actions_model.ActionCrossRepoAccess) *api.CrossRepoAccessRule {
return &api.CrossRepoAccessRule{
ID: rule.ID,
OrgID: rule.OrgID,
SourceRepoID: rule.SourceRepoID,
TargetRepoID: rule.TargetRepoID,
AccessLevel: rule.AccessLevel,
}
}

@ -0,0 +1,201 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package repo
import (
"net/http"
actions_model "code.gitea.io/gitea/models/actions"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/services/context"
)
// swagger:operation GET /repos/{owner}/{repo}/settings/actions/permissions repository repoGetActionsPermissions
// ---
// summary: Get repository Actions token permissions
// produces:
// - application/json
// parameters:
// - name: owner
// in: path
// description: owner of the repo
// type: string
// required: true
// - name: repo
// in: path
// description: name of the repo
// type: string
// required: true
// responses:
// "200":
// "$ref": "#/responses/ActionsPermissionsResponse"
// "404":
// "$ref": "#/responses/notFound"
// GetActionsPermissions returns the Actions token permissions for a repository
func GetActionsPermissions(ctx *context.APIContext) {
// Check if user has admin access to this repo
// NOTE: Only repo admins and owners should be able to view/modify permission settings
// This is important for security - we don't want regular contributors
// to be able to grant themselves elevated permissions via Actions
// This is enforced by the reqAdmin middleware.
perms, err := actions_model.GetRepoActionPermissions(ctx, ctx.Repo.Repository.ID)
if err != nil {
ctx.APIErrorInternal(err)
return
}
// If no custom permissions are set, return the default (restricted mode)
// This is intentional - we want a secure default that requires explicit opt-in
// to more permissive settings. See: https://github.com/go-gitea/gitea/issues/24635
if perms == nil {
perms = &actions_model.ActionTokenPermission{
RepoID: ctx.Repo.Repository.ID,
PermissionMode: actions_model.PermissionModeRestricted,
// Default restricted permissions - only read contents and metadata
ContentsRead: true,
MetadataRead: true,
}
}
ctx.JSON(http.StatusOK, convertToAPIPermissions(perms))
}
// swagger:operation PUT /repos/{owner}/{repo}/settings/actions/permissions repository repoUpdateActionsPermissions
// ---
// summary: Update repository Actions token permissions
// consumes:
// - application/json
// produces:
// - application/json
// parameters:
// - name: owner
// in: path
// description: owner of the repo
// type: string
// required: true
// - name: repo
// in: path
// description: name of the repo
// type: string
// required: true
// - name: body
// in: body
// schema:
// "$ref": "#/definitions/ActionsPermissions"
// responses:
// "200":
// "$ref": "#/responses/ActionsPermissionsResponse"
// "403":
// "$ref": "#/responses/forbidden"
// "422":
// "$ref": "#/responses/validationError"
// UpdateActionsPermissions updates the Actions token permissions for a repository
func UpdateActionsPermissions(ctx *context.APIContext) {
// Only repo admins and owners should be able to modify these settings.
// This is enforced by the reqAdmin middleware.
form := web.GetForm(ctx).(*api.ActionsPermissions)
// Validate permission mode
if form.PermissionMode < 0 || form.PermissionMode > 2 {
ctx.APIError(http.StatusUnprocessableEntity, "Permission mode must be 0 (restricted), 1 (permissive), or 2 (custom)")
return
}
// TODO: Check if org-level permissions exist and validate against them
// For now, we'll implement basic validation, but we should enhance this
// to ensure repo settings don't exceed org caps. This is important for
// multi-repository organizations where admins want centralized control.
// See wolfogre's comment: https://github.com/go-gitea/gitea/pull/24554#issuecomment-1537040811
perm := &actions_model.ActionTokenPermission{
RepoID: ctx.Repo.Repository.ID,
PermissionMode: actions_model.PermissionMode(form.PermissionMode),
ActionsRead: form.ActionsRead,
ActionsWrite: form.ActionsWrite,
ContentsRead: form.ContentsRead,
ContentsWrite: form.ContentsWrite,
IssuesRead: form.IssuesRead,
IssuesWrite: form.IssuesWrite,
PackagesRead: form.PackagesRead,
PackagesWrite: form.PackagesWrite,
PullRequestsRead: form.PullRequestsRead,
PullRequestsWrite: form.PullRequestsWrite,
MetadataRead: true, // Always true - needed for basic operations
}
if err := actions_model.CreateOrUpdateRepoPermissions(ctx, perm); err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.JSON(http.StatusOK, convertToAPIPermissions(perm))
}
// ResetActionsPermissions resets permissions to default (restricted mode)
func ResetActionsPermissions(ctx *context.APIContext) {
// swagger:operation DELETE /repos/{owner}/{repo}/settings/actions/permissions repository repoResetActionsPermissions
// ---
// summary: Reset repository Actions permissions to default
// produces:
// - application/json
// parameters:
// - name: owner
// in: path
// description: owner of the repo
// type: string
// required: true
// - name: repo
// in: path
// description: name of the repo
// type: string
// required: true
// responses:
// "204":
// "$ref": "#/responses/empty"
// "403":
// "$ref": "#/responses/forbidden"
// Only repo admins and owners should be able to modify these settings.
// This is enforced by the reqAdmin middleware.
// Create default restricted permissions
// This is a "safe reset" - puts the repo back to secure defaults
defaultPerm := &actions_model.ActionTokenPermission{
RepoID: ctx.Repo.Repository.ID,
PermissionMode: actions_model.PermissionModeRestricted,
ContentsRead: true,
MetadataRead: true,
}
if err := actions_model.CreateOrUpdateRepoPermissions(ctx, defaultPerm); err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.Status(http.StatusNoContent)
}
// convertToAPIPermissions converts model to API response format
// This helper keeps our internal model separate from the API contract
func convertToAPIPermissions(perm *actions_model.ActionTokenPermission) *api.ActionsPermissions {
return &api.ActionsPermissions{
PermissionMode: int(perm.PermissionMode),
ActionsRead: perm.ActionsRead,
ActionsWrite: perm.ActionsWrite,
ContentsRead: perm.ContentsRead,
ContentsWrite: perm.ContentsWrite,
IssuesRead: perm.IssuesRead,
IssuesWrite: perm.IssuesWrite,
PackagesRead: perm.PackagesRead,
PackagesWrite: perm.PackagesWrite,
PullRequestsRead: perm.PullRequestsRead,
PullRequestsWrite: perm.PullRequestsWrite,
MetadataRead: perm.MetadataRead,
}
}

@ -0,0 +1,242 @@
{{template "base/head" .}}
<div class="page-content repository settings options">
{{template "repo/header" .}}
<div class="ui container">
<div class="ui grid">
{{template "repo/settings/navbar" .}}
<div class="twelve wide column content">
{{template "base/alert" .}}
<h4 class="ui top attached header">
{{.locale.Tr "repo.settings.actions.permissions.title"}}
</h4>
<div class="ui attached segment">
<p class="help">
{{.locale.Tr "repo.settings.actions.permissions.desc"}}
<!-- TODO: Add link to documentation once it's written -->
<!-- Need to explain this feature clearly for users -->
</p>
<form class="ui form" method="post" action="{{.Link}}">
{{.CsrfTokenHtml}}
<!-- Permission Mode Selector -->
<div class="field">
<label>{{.locale.Tr "repo.settings.actions.permissions.mode"}}</label>
<div class="ui selection dropdown">
<input type="hidden" name="permission_mode" value="{{.PermissionMode}}">
<i class="dropdown icon"></i>
<div class="default text">Select permission mode</div>
<div class="menu">
<!-- Restricted mode - recommended for most users -->
<div class="item" data-value="0" data-text="Restricted (Recommended)">
<div class="header">🔒 Restricted (Recommended)</div>
<div class="description">
Minimal permissions. Actions can only read code. Secure default.
</div>
</div>
<!-- Permissive mode - for trusted repos -->
<div class="item" data-value="1" data-text="Permissive">
<div class="header">🔓 Permissive</div>
<div class="description">
Broad permissions. Actions can read/write most resources. For trusted environments only.
</div>
</div>
<!-- Custom mode - for advanced users -->
<div class="item" data-value="2" data-text="Custom">
<div class="header">⚙️ Custom</div>
<div class="description">
Fine-grained control. Configure each permission individually.
</div>
</div>
</div>
</div>
</div>
<!-- Custom permissions - only shown when mode is Custom -->
<!-- Note: We could use Vue.js here for reactivity, but keeping it simple with vanilla JS -->
<!-- If this gets more complex, consider refactoring to use Vue component -->
<div id="custom-permissions" class="{{if ne .PermissionMode 2}}hide{{end}}">
<div class="ui divider"></div>
<h5>Individual Permissions</h5>
{{/* Actions Permission */}}
<div class="field">
<div class="ui toggle checkbox">
<input type="checkbox" name="actions_read" id="actions_read" {{if .ActionsRead}}checked{{end}}>
<label for="actions_read">
<strong>Actions (Read)</strong> - View workflow runs and logs
</label>
</div>
</div>
<div class="field">
<div class="ui toggle checkbox">
<input type="checkbox" name="actions_write" id="actions_write" {{if .ActionsWrite}}checked{{end}}>
<label for="actions_write">
<strong>Actions (Write)</strong> - Cancel or re-run workflows
</label>
</div>
</div>
{{/* Contents Permission */}}
<div class="ui divider"></div>
<div class="field">
<div class="ui toggle checkbox">
<input type="checkbox" name="contents_read" id="contents_read" {{if .ContentsRead}}checked{{end}}>
<label for="contents_read">
<strong>Contents (Read)</strong> - Clone and read repository code
<span class="text grey">(Recommended: Keep enabled)</span>
</label>
</div>
</div>
<div class="field">
<div class="ui toggle checkbox">
<input type="checkbox" name="contents_write" id="contents_write" {{if .ContentsWrite}}checked{{end}}>
<label for="contents_write">
<strong>Contents (Write)</strong> - Push commits and create branches
<span class="text red">(Warning: High risk for fork PRs)</span>
</label>
</div>
</div>
{{/* Packages Permission */}}
<div class="ui divider"></div>
<div class="field">
<div class="ui toggle checkbox">
<input type="checkbox" name="packages_read" id="packages_read" {{if .PackagesRead}}checked{{end}}>
<label for="packages_read">
<strong>Packages (Read)</strong> - Pull packages from registry
</label>
</div>
</div>
<div class="field">
<div class="ui toggle checkbox">
<input type="checkbox" name="packages_write" id="packages_write" {{if .PackagesWrite}}checked{{end}}>
<label for="packages_write">
<strong>Packages (Write)</strong> - Publish and update packages
<!-- Note: Requires package-repository linking (see org settings) -->
</label>
</div>
</div>
{{/* Issues Permission */}}
<div class="ui divider"></div>
<div class="field">
<div class="ui toggle checkbox">
<input type="checkbox" name="issues_read" id="issues_read" {{if .IssuesRead}}checked{{end}}>
<label for="issues_read">
<strong>Issues (Read)</strong> - View issues
</label>
</div>
</div>
<div class="field">
<div class="ui toggle checkbox">
<input type="checkbox" name="issues_write" id="issues_write" {{if .IssuesWrite}}checked{{end}}>
<label for="issues_write">
<strong>Issues (Write)</strong> - Create, comment, and close issues
</label>
</div>
</div>
{{/* Pull Requests Permission */}}
<div class="ui divider"></div>
<div class="field">
<div class="ui toggle checkbox">
<input type="checkbox" name="pull_requests_read" id="pull_requests_read" {{if .PullRequestsRead}}checked{{end}}>
<label for="pull_requests_read">
<strong>Pull Requests (Read)</strong> - View pull requests
</label>
</div>
</div>
<div class="field">
<div class="ui toggle checkbox">
<input type="checkbox" name="pull_requests_write" id="pull_requests_write" {{if .PullRequestsWrite}}checked{{end}}>
<label for="pull_requests_write">
<strong>Pull Requests (Write)</strong> - Create and merge pull requests
</label>
</div>
</div>
</div>
<!-- Warning Message for fork PRs -->
<!-- This is important - users need to understand that fork PRs are always restricted -->
<div class="ui warning message">
<div class="header">
<i class="shield icon"></i>
Security Notice: Fork Pull Requests
</div>
<p>
For security reasons, workflows triggered by pull requests from forked repositories
are <strong>always restricted</strong> to read-only access, regardless of the settings above.
This prevents malicious forks from accessing secrets or modifying your repository.
</p>
<!-- Reference the security discussion that led to this decision -->
<!-- https://github.com/go-gitea/gitea/pull/24554#issuecomment-1537040811 -->
</div>
<!-- Organization Cap Notice (if applicable) -->
{{if .OrgID}}
{{if .OrgHasRestrictions}}
<div class="ui info message">
<div class="header">
<i class="building icon"></i>
Organization Restrictions Apply
</div>
<p>
This repository belongs to an organization with permission restrictions.
The settings above cannot exceed the organization's maximum permissions.
Contact your organization admin to grant additional permissions.
</p>
</div>
{{end}}
{{end}}
<!-- Submit Buttons -->
<div class="field">
<button class="ui green button" type="submit">
{{.locale.Tr "repo.settings.actions.permissions.save"}}
</button>
<a class="ui button" href="{{.Link}}">
{{.locale.Tr "repo.settings.cancel"}}
</a>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<!-- JavaScript for UI interactions -->
<script>
// Show/hide custom permissions based on mode selection
// TODO: Could move this to a separate JS file if it gets more complex
$(document).ready(function() {
// Drop down initialization
$('.ui.dropdown').dropdown({
onChange: function(value) {
// Show custom options only when Custom mode is selected
if (value === '2') {
$('#custom-permissions').removeClass('hide');
} else {
$('#custom-permissions').addClass('hide');
}
}
});
// Warning when enabling write permissions
// Helps prevent accidental security issues
$('#contents_write, #packages_write').change(function() {
if ($(this).is(':checked')) {
// Maybe add a confirmation dialog here?
// For now, just the inline warning text is probably enough
console.log('Write permission enabled - ensure this is intentional');
}
});
});
</script>
{{template "base/footer" .}}

@ -3583,6 +3583,168 @@
}
}
},
"/orgs/{org}/settings/actions/cross-repo-access": {
"get": {
"produces": [
"application/json"
],
"tags": [
"organization"
],
"summary": "List cross-repository access rules",
"operationId": "orgListCrossRepoAccess",
"parameters": [
{
"type": "string",
"description": "name of the organization",
"name": "org",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"$ref": "#/responses/CrossRepoAccessList"
}
}
},
"post": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"organization"
],
"summary": "Add cross-repository access rule",
"operationId": "orgAddCrossRepoAccess",
"parameters": [
{
"type": "string",
"description": "name of the organization",
"name": "org",
"in": "path",
"required": true
},
{
"name": "body",
"in": "body",
"schema": {
"$ref": "#/definitions/CrossRepoAccessRule"
}
}
],
"responses": {
"201": {
"$ref": "#/responses/CrossRepoAccessRule"
},
"403": {
"$ref": "#/responses/forbidden"
}
}
}
},
"/orgs/{org}/settings/actions/cross-repo-access/{id}": {
"delete": {
"tags": [
"organization"
],
"summary": "Delete cross-repository access rule",
"operationId": "orgDeleteCrossRepoAccess",
"parameters": [
{
"type": "string",
"description": "name of the organization",
"name": "org",
"in": "path",
"required": true
},
{
"type": "integer",
"description": "ID of the rule to delete",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"$ref": "#/responses/empty"
},
"403": {
"$ref": "#/responses/forbidden"
}
}
}
},
"/orgs/{org}/settings/actions/permissions": {
"get": {
"produces": [
"application/json"
],
"tags": [
"organization"
],
"summary": "Get organization Actions token permissions",
"operationId": "orgGetActionsPermissions",
"parameters": [
{
"type": "string",
"description": "name of the organization",
"name": "org",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"$ref": "#/responses/OrgActionsPermissionsResponse"
},
"404": {
"$ref": "#/responses/notFound"
}
}
},
"put": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"organization"
],
"summary": "Update organization Actions token permissions",
"operationId": "orgUpdateActionsPermissions",
"parameters": [
{
"type": "string",
"description": "name of the organization",
"name": "org",
"in": "path",
"required": true
},
{
"name": "body",
"in": "body",
"schema": {
"$ref": "#/definitions/OrgActionsPermissions"
}
}
],
"responses": {
"200": {
"$ref": "#/responses/OrgActionsPermissionsResponse"
},
"403": {
"$ref": "#/responses/forbidden"
}
}
}
},
"/orgs/{org}/teams": {
"get": {
"produces": [
@ -15787,6 +15949,123 @@
}
}
},
"/repos/{owner}/{repo}/settings/actions/permissions": {
"get": {
"produces": [
"application/json"
],
"tags": [
"repository"
],
"summary": "Get repository Actions token permissions",
"operationId": "repoGetActionsPermissions",
"parameters": [
{
"type": "string",
"description": "owner of the repo",
"name": "owner",
"in": "path",
"required": true
},
{
"type": "string",
"description": "name of the repo",
"name": "repo",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"$ref": "#/responses/ActionsPermissionsResponse"
},
"404": {
"$ref": "#/responses/notFound"
}
}
},
"put": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"repository"
],
"summary": "Update repository Actions token permissions",
"operationId": "repoUpdateActionsPermissions",
"parameters": [
{
"type": "string",
"description": "owner of the repo",
"name": "owner",
"in": "path",
"required": true
},
{
"type": "string",
"description": "name of the repo",
"name": "repo",
"in": "path",
"required": true
},
{
"name": "body",
"in": "body",
"schema": {
"$ref": "#/definitions/ActionsPermissions"
}
}
],
"responses": {
"200": {
"$ref": "#/responses/ActionsPermissionsResponse"
},
"403": {
"$ref": "#/responses/forbidden"
},
"422": {
"$ref": "#/responses/validationError"
}
}
},
"delete": {
"produces": [
"application/json"
],
"tags": [
"repository"
],
"summary": "Reset repository Actions permissions to default",
"operationId": "repoResetActionsPermissions",
"parameters": [
{
"type": "string",
"description": "owner of the repo",
"name": "owner",
"in": "path",
"required": true
},
{
"type": "string",
"description": "name of the repo",
"name": "repo",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"$ref": "#/responses/empty"
},
"403": {
"$ref": "#/responses/forbidden"
}
}
}
},
"/repos/{owner}/{repo}/signing-key.gpg": {
"get": {
"produces": [