mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2026-05-12 22:10:25 +00:00
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:
parent
ce1c0dc2cc
commit
bdbd0b5622
18 changed files with 776 additions and 251 deletions
23
models/secret/TestSecretGetSecretByID/secret.yml
Normal file
23
models/secret/TestSecretGetSecretByID/secret.yml
Normal 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
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue