+ {{.locale.Tr "repo.settings.actions.permissions.title"}} +
+ ++ {{.locale.Tr "repo.settings.actions.permissions.desc"}} + + +
+ + +diff --git a/IMPLEMENTATION_NOTES.md b/IMPLEMENTATION_NOTES.md new file mode 100644 index 0000000000000..d811916de9b73 --- /dev/null +++ b/IMPLEMENTATION_NOTES.md @@ -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 diff --git a/models/actions/action_permissions.go b/models/actions/action_permissions.go new file mode 100644 index 0000000000000..66ee44295ecb9 --- /dev/null +++ b/models/actions/action_permissions.go @@ -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 +} diff --git a/models/actions/cross_repo_access.go b/models/actions/cross_repo_access.go new file mode 100644 index 0000000000000..5c1f1a91ad994 --- /dev/null +++ b/models/actions/cross_repo_access.go @@ -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 +} diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index e8ebb5df43ce1..f81130e7b2d84 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -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 } diff --git a/models/migrations/v1_27/v1_27.go b/models/migrations/v1_27/v1_27.go new file mode 100644 index 0000000000000..2fe1624c20b35 --- /dev/null +++ b/models/migrations/v1_27/v1_27.go @@ -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 +} diff --git a/modules/actions/permission_checker.go b/modules/actions/permission_checker.go new file mode 100644 index 0000000000000..96b8fd861c125 --- /dev/null +++ b/modules/actions/permission_checker.go @@ -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 +} diff --git a/modules/actions/permission_checker_test.go b/modules/actions/permission_checker_test.go new file mode 100644 index 0000000000000..3c18e73322273 --- /dev/null +++ b/modules/actions/permission_checker_test.go @@ -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}, + } +} diff --git a/modules/structs/actions_permissions.go b/modules/structs/actions_permissions.go new file mode 100644 index 0000000000000..8ce8898f76ed2 --- /dev/null +++ b/modules/structs/actions_permissions.go @@ -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"` +} diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 9bce98ac025b5..c2f9fe068aeb9 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -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) diff --git a/routers/api/v1/org/org_actions_permissions.go b/routers/api/v1/org/org_actions_permissions.go new file mode 100644 index 0000000000000..84d1759443c73 --- /dev/null +++ b/routers/api/v1/org/org_actions_permissions.go @@ -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, + } +} diff --git a/routers/api/v1/repo/repo_actions_permissions.go b/routers/api/v1/repo/repo_actions_permissions.go new file mode 100644 index 0000000000000..0d0f59cae8b52 --- /dev/null +++ b/routers/api/v1/repo/repo_actions_permissions.go @@ -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, + } +} diff --git a/templates/repo/settings/actions_permissions.tmpl b/templates/repo/settings/actions_permissions.tmpl new file mode 100644 index 0000000000000..a74fe40e6fa42 --- /dev/null +++ b/templates/repo/settings/actions_permissions.tmpl @@ -0,0 +1,242 @@ +{{template "base/head" .}} +
+ + + + +{{template "base/footer" .}} diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index b37937dcee982..5226e7e4570c4 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -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": [