feat: allow renaming and replacing secrets (#11732)

So far, Forgejo's UI only allowed to create Forgejo Actions secrets. But renaming or replacing their value wasn't possible. With this change, users can do both. The existing secret value is never revealed for security reasons.

Additionally, a confusing behaviour is removed. If a user created a new secret whose name matched an existing secret, the existing secret was silently updated. That does no longer happen. The new secret is rejected instead.

Resolves https://codeberg.org/forgejo/forgejo/issues/5707.

## Checklist

The [contributor guide](https://forgejo.org/docs/next/contributor/) contains information that will be helpful to first time contributors. There also are a few [conditions for merging Pull Requests in Forgejo repositories](https://codeberg.org/forgejo/governance/src/branch/main/PullRequestsAgreement.md). You are also welcome to join the [Forgejo development chatroom](https://matrix.to/#/#forgejo-development:matrix.org).

### Tests for Go changes

(can be removed for JavaScript changes)

- I added test coverage for Go changes...
  - [x] in their respective `*_test.go` for unit tests.
  - [x] in the `tests/integration` directory if it involves interactions with a live Forgejo server.
- I ran...
  - [x] `make pr-go` before pushing

### Tests for JavaScript changes

(can be removed for Go changes)

- I added test coverage for JavaScript changes...
  - [ ] in `web_src/js/*.test.js` if it can be unit tested.
  - [ ] in `tests/e2e/*.test.e2e.js` if it requires interactions with a live Forgejo server (see also the [developer guide for JavaScript testing](https://codeberg.org/forgejo/forgejo/src/branch/forgejo/tests/e2e/README.md#end-to-end-tests)).

### Documentation

- [ ] I created a pull request [to the documentation](https://codeberg.org/forgejo/docs) to explain to Forgejo users how to use this change.
- [x] I did not document these changes and I do not expect someone else to do it.

### Release notes

- [x] This change will be noticed by a Forgejo user or admin (feature, bug fix, performance, etc.). I suggest to include a release note for this change.
- [ ] This change is not visible to a Forgejo user or admin (refactor, dependency upgrade, etc.). I think there is no need to add a release note for this change.

*The decision if the pull request will be shown in the release notes is up to the mergers / release team.*

The content of the `release-notes/<pull request number>.md` file will serve as the basis for the release notes. If the file does not exist, the title of the pull request will be used instead.

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/11732
Reviewed-by: Mathieu Fenniak <mfenniak@noreply.codeberg.org>
Co-authored-by: Andreas Ahlenstorf <andreas@ahlenstorf.ch>
Co-committed-by: Andreas Ahlenstorf <andreas@ahlenstorf.ch>
This commit is contained in:
Andreas Ahlenstorf 2026-03-23 03:30:02 +01:00 committed by Mathieu Fenniak
parent ce1c0dc2cc
commit bdbd0b5622
18 changed files with 776 additions and 251 deletions

View file

@ -0,0 +1,23 @@
- id: 637340
name: TEST_SECRET
owner_id: 3
repo_id: 0
# very secret
data: 0x0e17d357077de0c1f72e9d87e1899c8026a207fcbbc0f1a2a79d0cb305da39fa3e9fe201b085e963fa1f9eeb59b4ff9ee6d748
created_unix: 1773692671
- id: 637341
name: ANOTHER_SECRET
owner_id: 0
repo_id: 62 # user2/test_workflows
# also very secret
data: 0xb697cd4a66b02bc36d0afddcb6f158d7602f6cf0b0d31a0c96e178469e8b66babccb30b95e01c84824add04c5bfe91b9b773a21a78088a3e
created_unix: 1773692672
- id: 637342
name: TEST_SECRET
owner_id: 1
repo_id: 0
# super secret
data: 0xd97d8cb662c07ca94953a388bc93209f713281a8b0d25499359cbd03a1fbe565a2a0ba183b8290fc110ee6b1c6437c569451e0c3
created_unix: 1773692673

View file

@ -6,6 +6,7 @@ package secret
import (
"context"
"fmt"
"regexp"
"strings"
"forgejo.org/models/db"
@ -17,6 +18,13 @@ import (
"xorm.io/builder"
)
var (
namePattern = regexp.MustCompile("(?i)^[A-Z_][A-Z0-9_]*$")
forbiddenPrefixPattern = regexp.MustCompile("(?i)^FORGEJO_|GITEA_|GITHUB_")
ErrInvalidName = util.NewInvalidArgumentErrorf("invalid secret name")
)
// Secret represents a secret
//
// It can be:
@ -63,6 +71,9 @@ func InsertEncryptedSecret(ctx context.Context, ownerID, repoID int64, name, dat
if ownerID == 0 && repoID == 0 {
return nil, fmt.Errorf("%w: ownerID and repoID cannot be both zero, global secrets are not supported", util.ErrInvalidArgument)
}
if err := ValidateName(name); err != nil {
return nil, err
}
secret := &Secret{
OwnerID: ownerID,
@ -129,6 +140,46 @@ func (s *Secret) GetDecryptedData() (string, error) {
return string(v), nil
}
func GetSecretByID(ctx context.Context, ownerID, repoID, id int64) (*Secret, error) {
query := db.GetEngine(ctx).Where("id=?", id)
if repoID > 0 {
query = query.And(builder.Eq{"repo_id": repoID})
} else if ownerID > 0 {
query = query.And(builder.Eq{"owner_id": ownerID})
} else {
return nil, fmt.Errorf("ownerID and repoID cannot be simultaneously 0")
}
var secret Secret
has, err := query.Get(&secret)
if err != nil {
return nil, err
} else if !has {
return nil, fmt.Errorf("secret with ID %d: %w", id, util.ErrNotExist)
}
return &secret, nil
}
func UpdateSecret(ctx context.Context, secret *Secret, columns ...string) error {
e := db.GetEngine(ctx)
if err := ValidateName(secret.Name); err != nil {
return err
}
secret.Name = strings.ToUpper(secret.Name)
var err error
if len(columns) == 0 {
_, err = e.ID(secret.ID).AllCols().Update(secret)
} else {
_, err = e.ID(secret.ID).Cols(columns...).Update(secret)
}
return err
}
func FetchActionSecrets(ctx context.Context, ownerID, repoID int64) (map[string]string, error) {
secrets := map[string]string{}
@ -154,3 +205,10 @@ func FetchActionSecrets(ctx context.Context, ownerID, repoID int64) (map[string]
return secrets, nil
}
func ValidateName(name string) error {
if !namePattern.MatchString(name) || forbiddenPrefixPattern.MatchString(name) {
return ErrInvalidName
}
return nil
}

View file

@ -4,6 +4,7 @@
package secret
import (
"strings"
"testing"
"forgejo.org/models/unittest"
@ -83,6 +84,11 @@ func TestInsertEncryptedSecret(t *testing.T) {
})
})
t.Run("Rejects invalid name", func(t *testing.T) {
_, err := InsertEncryptedSecret(t.Context(), 2, 0, "invalid name", "some secret")
require.ErrorContains(t, err, "invalid secret name")
})
t.Run("FetchActionSecrets", func(t *testing.T) {
secrets, err := FetchActionSecrets(t.Context(), 2, 1)
require.NoError(t, err)
@ -123,3 +129,169 @@ func TestSecretGetDecryptedData(t *testing.T) {
assert.ErrorContains(t, err, "unable to decrypt secret[id=495,name=\"A_SECRET\"]")
})
}
func TestSecretGetSecretByID(t *testing.T) {
defer unittest.OverrideFixtures("models/secret/TestSecretGetSecretByID")()
require.NoError(t, unittest.PrepareTestDatabase())
testCases := []struct {
name string
ownerID int64
repoID int64
id int64
expectedName string
expectedData string
expectedError string
}{
{
name: "Organization secret",
ownerID: 3,
repoID: 0,
id: 637340,
expectedName: "TEST_SECRET",
expectedData: "very secret",
},
{
name: "Owner mismatch",
ownerID: 4,
repoID: 0,
id: 637340,
expectedError: "secret with ID 637340: resource does not exist",
},
{
name: "Repository mismatch",
ownerID: 0,
repoID: 1,
id: 637340,
expectedError: "secret with ID 637340: resource does not exist",
},
{
name: "Repository secret",
ownerID: 0,
repoID: 62,
id: 637341,
expectedName: "ANOTHER_SECRET",
expectedData: "also very secret",
},
{
name: "Unsupported instance secret",
ownerID: 0,
repoID: 0,
id: 637341,
expectedError: "ownerID and repoID cannot be simultaneously 0",
},
{
name: "User secret",
ownerID: 1,
repoID: 0,
id: 637342,
expectedName: "TEST_SECRET",
expectedData: "super secret",
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
secret, err := GetSecretByID(t.Context(), testCase.ownerID, testCase.repoID, testCase.id)
if testCase.expectedError != "" {
assert.ErrorContains(t, err, testCase.expectedError)
} else {
require.NoError(t, err)
assert.Equal(t, testCase.id, secret.ID)
assert.Equal(t, testCase.ownerID, secret.OwnerID)
assert.Equal(t, testCase.repoID, secret.RepoID)
assert.Equal(t, testCase.expectedName, secret.Name)
data, err := secret.GetDecryptedData()
require.NoError(t, err)
assert.Equal(t, testCase.expectedData, data)
}
})
}
}
func TestSecretUpdateSecret(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
secret, err := InsertEncryptedSecret(t.Context(), 2, 0, "a_secret", "very secret")
require.NoError(t, err)
secret.Name = "new_name"
secret.SetData("also very secret")
err = UpdateSecret(t.Context(), secret)
require.NoError(t, err)
updatedSecret := unittest.AssertExistsAndLoadBean(t, &Secret{ID: secret.ID})
decryptedData, err := updatedSecret.GetDecryptedData()
require.NoError(t, err)
assert.Equal(t, "NEW_NAME", updatedSecret.Name)
assert.Equal(t, "also very secret", decryptedData)
}
func TestSecretUpdateSecret_RejectsInvalidName(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
secret, err := InsertEncryptedSecret(t.Context(), 2, 0, "a_secret", "very secret")
require.NoError(t, err)
secret.Name = "GITHUB_IS_REJECTED" // Because it starts with `GITHUB_`.
secret.SetData("also very secret")
err = UpdateSecret(t.Context(), secret)
require.ErrorContains(t, err, "invalid secret name")
updatedSecret := unittest.AssertExistsAndLoadBean(t, &Secret{ID: secret.ID})
decryptedData, err := updatedSecret.GetDecryptedData()
require.NoError(t, err)
assert.Equal(t, "A_SECRET", updatedSecret.Name)
assert.Equal(t, "very secret", decryptedData)
}
func TestSecretValidateName(t *testing.T) {
testCases := []struct {
name string
valid bool
}{
{"FORGEJO_", false},
{"FORGEJO_123", false},
{"FORGEJO_ABC", false},
{"GITEA_", false},
{"GITEA_123", false},
{"GITEA_ABC", false},
{"GITHUB_", false},
{"GITHUB_123", false},
{"GITHUB_ABC", false},
{"123_TEST", false},
{"CI", true},
{"_CI", true},
{"CI_", true},
{"CI123", true},
{"CIABC", true},
{"FORGEJO", true},
{"FORGEJO123", true},
{"FORGEJOABC", true},
{"GITEA", true},
{"GITEA123", true},
{"GITEAABC", true},
{"GITHUB", true},
{"GITHUB123", true},
{"GITHUBABC", true},
{"_123_TEST", true},
}
for _, tC := range testCases {
t.Run(tC.name, func(t *testing.T) {
t.Helper()
if tC.valid {
assert.NoError(t, ValidateName(tC.name))
assert.NoError(t, ValidateName(strings.ToLower(tC.name)))
} else {
require.ErrorIs(t, ValidateName(tC.name), ErrInvalidName)
require.ErrorIs(t, ValidateName(strings.ToLower(tC.name)), ErrInvalidName)
}
})
}
}