mirror of
https://github.com/go-gitea/gitea.git
synced 2026-03-23 10:06:19 +01:00
## Overview This PR introduces granular permission controls for Gitea Actions tokens (`GITEA_TOKEN`), aligning Gitea's security model with GitHub Actions standards while maintaining compatibility with Gitea's unique repository unit system. It addresses the need for finer access control by allowing administrators and repository owners to define default token permissions, set maximum permission ceilings, and control cross-repository access within organizations. ## Key Features ### 1. Granular Token Permissions - **Standard Keyword Support**: Implements support for the `permissions:` keyword in workflow and job YAML files (e.g., `contents: read`, `issues: write`). - **Permission Modes**: - **Permissive**: Default write access for most units (backwards compatible). - **Restricted**: Default read-only access for `contents` and `packages`, with no access to other units. - ~~**Custom**: Allows defining specific default levels for each unit type (Code, Issues, PRs, Packages, etc.).~~**EDIT removed UI was confusing** - **Clamping Logic**: Workflow-defined permissions are automatically "clamped" by repository or organization-level maximum settings. Workflows cannot escalate their own permissions beyond these limits. ### 2. Organization & Repository Settings - **Settings UI**: Added new settings pages at both Organization and Repository levels to manage Actions token defaults and maximums. - **Inheritance**: Repositories can be configured to "Follow organization-level configuration," simplifying management across large organizations. - **Cross-Repository Access**: Added a policy to control whether Actions workflows can access other repositories or packages within the same organization. This can be set to "None," "All," or restricted to a "Selected" list of repositories. ### 3. Security Hardening - **Fork Pull Request Protection**: Tokens for workflows triggered by pull requests from forks are strictly enforced as read-only, regardless of repository settings. - ~~**Package Access**: Actions tokens can now only access packages explicitly linked to a repository, with cross-repo access governed by the organization's security policy.~~ **EDIT removed https://github.com/go-gitea/gitea/pull/36173#issuecomment-3873675346** - **Git Hook Integration**: Propagates Actions Task IDs to git hooks to ensure that pushes performed by Actions tokens respect the specific permissions granted at runtime. ### 4. Technical Implementation - **Permission Persistence**: Parsed permissions are calculated at job creation and stored in the `action_run_job` table. This ensures the token's authority is deterministic throughout the job's lifecycle. - **Parsing Priority**: Implemented a priority system in the YAML parser where the broad `contents` scope is applied first, allowing granular scopes like `code` or `releases` to override it for precise control. - **Re-runs**: Permissions are re-evaluated during a job re-run to incorporate any changes made to repository settings in the interim. ### How to Test 1. **Unit Tests**: Run `go test ./services/actions/...` and `go test ./models/repo/...` to verify parsing logic and permission clamping. 2. **Integration Tests**: Comprehensive tests have been added to `tests/integration/actions_job_token_test.go` covering: - Permissive vs. Restricted mode behavior. - YAML `permissions:` keyword evaluation. - Organization cross-repo access policies. - Resource access (Git, API, and Packages) under various permission configs. 3. **Manual Verification**: - Navigate to **Site/Org/Repo Settings -> Actions -> General**. - Change "Default Token Permissions" and verify that newly triggered workflows reflect these changes in their `GITEA_TOKEN` capabilities. - Attempt a cross-repo API call from an Action and verify the Org policy is enforced. ## Documentation Added a PR in gitea's docs for this : https://gitea.com/gitea/docs/pulls/318 ## UI: <img width="1366" height="619" alt="Screenshot 2026-01-24 174112" src="https://github.com/user-attachments/assets/bfa29c9a-4ea5-4346-9410-16d491ef3d44" /> <img width="1360" height="621" alt="Screenshot 2026-01-24 174048" src="https://github.com/user-attachments/assets/d5ec46c8-9a13-4874-a6a4-fb379936cef5" /> /fixes #24635 /claim #24635 --------- Signed-off-by: Excellencedev <ademiluyisuccessandexcellence@gmail.com> Signed-off-by: ChristopherHX <christopher.homberger@web.de> Signed-off-by: silverwind <me@silverwind.io> Signed-off-by: wxiaoguang <wxiaoguang@gmail.com> Co-authored-by: ChristopherHX <christopher.homberger@web.de> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: silverwind <me@silverwind.io> Co-authored-by: Zettat123 <zettat123@gmail.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
266 lines
6.3 KiB
Go
266 lines
6.3 KiB
Go
// Copyright 2017 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package util
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/rand"
|
|
"fmt"
|
|
"math/big"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"golang.org/x/text/cases"
|
|
"golang.org/x/text/language"
|
|
)
|
|
|
|
// IsEmptyString checks if the provided string is empty
|
|
func IsEmptyString(s string) bool {
|
|
return len(strings.TrimSpace(s)) == 0
|
|
}
|
|
|
|
// NormalizeEOL will convert Windows (CRLF) and Mac (CR) EOLs to UNIX (LF)
|
|
func NormalizeEOL(input []byte) []byte {
|
|
var right, left, pos int
|
|
if right = bytes.IndexByte(input, '\r'); right == -1 {
|
|
return input
|
|
}
|
|
length := len(input)
|
|
tmp := make([]byte, length)
|
|
|
|
// We know that left < length because otherwise right would be -1 from IndexByte.
|
|
copy(tmp[pos:pos+right], input[left:left+right])
|
|
pos += right
|
|
tmp[pos] = '\n'
|
|
left += right + 1
|
|
pos++
|
|
|
|
for left < length {
|
|
if input[left] == '\n' {
|
|
left++
|
|
}
|
|
|
|
right = bytes.IndexByte(input[left:], '\r')
|
|
if right == -1 {
|
|
copy(tmp[pos:], input[left:])
|
|
pos += length - left
|
|
break
|
|
}
|
|
copy(tmp[pos:pos+right], input[left:left+right])
|
|
pos += right
|
|
tmp[pos] = '\n'
|
|
left += right + 1
|
|
pos++
|
|
}
|
|
return tmp[:pos]
|
|
}
|
|
|
|
// CryptoRandomInt returns a crypto random integer between 0 and limit, inclusive
|
|
func CryptoRandomInt(limit int64) (int64, error) {
|
|
rInt, err := rand.Int(rand.Reader, big.NewInt(limit))
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return rInt.Int64(), nil
|
|
}
|
|
|
|
const alphanumericalChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
|
|
|
// CryptoRandomString generates a crypto random alphanumerical string, each byte is generated by [0,61] range
|
|
func CryptoRandomString(length int64) (string, error) {
|
|
buf := make([]byte, length)
|
|
limit := int64(len(alphanumericalChars))
|
|
for i := range buf {
|
|
num, err := CryptoRandomInt(limit)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
buf[i] = alphanumericalChars[num]
|
|
}
|
|
return string(buf), nil
|
|
}
|
|
|
|
// CryptoRandomBytes generates `length` crypto bytes
|
|
// This differs from CryptoRandomString, as each byte in CryptoRandomString is generated by [0,61] range
|
|
// This function generates totally random bytes, each byte is generated by [0,255] range
|
|
func CryptoRandomBytes(length int64) ([]byte, error) {
|
|
buf := make([]byte, length)
|
|
_, err := rand.Read(buf)
|
|
return buf, err
|
|
}
|
|
|
|
// ToLowerASCII returns s with all ASCII letters mapped to their lower case.
|
|
func ToLowerASCII(s string) string {
|
|
b := []byte(s)
|
|
for i, c := range b {
|
|
if 'A' <= c && c <= 'Z' {
|
|
b[i] += 'a' - 'A'
|
|
}
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
// ToTitleCase returns s with all english words capitalized
|
|
func ToTitleCase(s string) string {
|
|
// `cases.Title` is not thread-safe, do not use global shared variable for it
|
|
return cases.Title(language.English).String(s)
|
|
}
|
|
|
|
// ToTitleCaseNoLower returns s with all english words capitalized without lower-casing
|
|
func ToTitleCaseNoLower(s string) string {
|
|
// `cases.Title` is not thread-safe, do not use global shared variable for it
|
|
return cases.Title(language.English, cases.NoLower).String(s)
|
|
}
|
|
|
|
// ToInt64 transform a given int into int64.
|
|
func ToInt64(number any) (int64, error) {
|
|
var value int64
|
|
switch v := number.(type) {
|
|
case int:
|
|
value = int64(v)
|
|
case int8:
|
|
value = int64(v)
|
|
case int16:
|
|
value = int64(v)
|
|
case int32:
|
|
value = int64(v)
|
|
case int64:
|
|
value = v
|
|
|
|
case uint:
|
|
value = int64(v)
|
|
case uint8:
|
|
value = int64(v)
|
|
case uint16:
|
|
value = int64(v)
|
|
case uint32:
|
|
value = int64(v)
|
|
case uint64:
|
|
value = int64(v)
|
|
|
|
case float32:
|
|
value = int64(v)
|
|
case float64:
|
|
value = int64(v)
|
|
|
|
case string:
|
|
var err error
|
|
if value, err = strconv.ParseInt(v, 10, 64); err != nil {
|
|
return 0, err
|
|
}
|
|
default:
|
|
return 0, fmt.Errorf("unable to convert %v to int64", number)
|
|
}
|
|
return value, nil
|
|
}
|
|
|
|
// ToFloat64 transform a given int into float64.
|
|
func ToFloat64(number any) (float64, error) {
|
|
var value float64
|
|
switch v := number.(type) {
|
|
case int:
|
|
value = float64(v)
|
|
case int8:
|
|
value = float64(v)
|
|
case int16:
|
|
value = float64(v)
|
|
case int32:
|
|
value = float64(v)
|
|
case int64:
|
|
value = float64(v)
|
|
|
|
case uint:
|
|
value = float64(v)
|
|
case uint8:
|
|
value = float64(v)
|
|
case uint16:
|
|
value = float64(v)
|
|
case uint32:
|
|
value = float64(v)
|
|
case uint64:
|
|
value = float64(v)
|
|
|
|
case float32:
|
|
value = float64(v)
|
|
case float64:
|
|
value = v
|
|
|
|
case string:
|
|
var err error
|
|
if value, err = strconv.ParseFloat(v, 64); err != nil {
|
|
return 0, err
|
|
}
|
|
default:
|
|
return 0, fmt.Errorf("unable to convert %v to float64", number)
|
|
}
|
|
return value, nil
|
|
}
|
|
|
|
// Iif is an "inline-if", it returns "trueVal" if "condition" is true, otherwise "falseVal"
|
|
func Iif[T any](condition bool, trueVal, falseVal T) T {
|
|
if condition {
|
|
return trueVal
|
|
}
|
|
return falseVal
|
|
}
|
|
|
|
// IfZero returns "def" if "v" is a zero value, otherwise "v"
|
|
func IfZero[T comparable](v, def T) T {
|
|
var zero T
|
|
if v == zero {
|
|
return def
|
|
}
|
|
return v
|
|
}
|
|
|
|
func IfEmpty[T any](v, def []T) []T {
|
|
if len(v) == 0 {
|
|
return def
|
|
}
|
|
return v
|
|
}
|
|
|
|
// OptionalArg helps the "optional argument" in Golang:
|
|
//
|
|
// func foo(optArg ...int) { return OptionalArg(optArg) }
|
|
// calling `foo()` gets zero value 0, calling `foo(100)` gets 100
|
|
// func bar(optArg ...int) { return OptionalArg(optArg, 42) }
|
|
// calling `bar()` gets default value 42, calling `bar(100)` gets 100
|
|
//
|
|
// Passing more than 1 item to `optArg` or `defaultValue` is undefined behavior.
|
|
// At the moment only the first item is used.
|
|
func OptionalArg[T any](optArg []T, defaultValue ...T) (ret T) {
|
|
if len(optArg) >= 1 {
|
|
return optArg[0]
|
|
}
|
|
if len(defaultValue) >= 1 {
|
|
return defaultValue[0]
|
|
}
|
|
return ret
|
|
}
|
|
|
|
type EnumConst[T comparable] interface {
|
|
EnumValues() []T
|
|
}
|
|
|
|
// EnumValue returns the value if it's in the enum const's values,
|
|
// otherwise returns the first item of enums as default value.
|
|
func EnumValue[T comparable](val EnumConst[T]) (ret T, valid bool) {
|
|
enums := val.EnumValues()
|
|
if slices.Contains(enums, val.(T)) {
|
|
return val.(T), true
|
|
}
|
|
return enums[0], false
|
|
}
|
|
|
|
func ReserveLineBreakForTextarea(input string) string {
|
|
// Since the content is from a form which is a textarea, the line endings are \r\n.
|
|
// It's a standard behavior of HTML.
|
|
// But we want to store them as \n like what GitHub does.
|
|
// And users are unlikely to really need to keep the \r.
|
|
// Other than this, we should respect the original content, even leading or trailing spaces.
|
|
return strings.ReplaceAll(input, "\r\n", "\n")
|
|
}
|