1
1
mirror of https://github.com/go-gitea/gitea synced 2025-07-23 18:58:38 +00:00

Add tag protection (#15629)

* Added tag protection in hook.

* Prevent UI tag creation if protected.

* Added settings page.

* Added tests.

* Added suggestions.

* Moved tests.

* Use individual errors.

* Removed unneeded methods.

* Switched delete selector.

* Changed method names.

* No reason to be unique.

* Allow editing of protected tags.

* Removed unique key from migration.

* Added docs page.

* Changed date.

* Respond with 404 to not found tags.

* Replaced glob with regex pattern.

* Added support for glob and regex pattern.

* Updated documentation.

* Changed white* to allow*.

* Fixed edit button link.

* Added cancel button.

Co-authored-by: zeripath <art27@cantab.net>
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
This commit is contained in:
KN4CK3R
2021-06-25 16:28:55 +02:00
committed by GitHub
parent 7a0ed9a046
commit 44b8b07631
27 changed files with 1227 additions and 189 deletions

View File

@@ -985,6 +985,21 @@ func (err ErrInvalidTagName) Error() string {
return fmt.Sprintf("release tag name is not valid [tag_name: %s]", err.TagName)
}
// ErrProtectedTagName represents a "ProtectedTagName" kind of error.
type ErrProtectedTagName struct {
TagName string
}
// IsErrProtectedTagName checks if an error is a ErrProtectedTagName.
func IsErrProtectedTagName(err error) bool {
_, ok := err.(ErrProtectedTagName)
return ok
}
func (err ErrProtectedTagName) Error() string {
return fmt.Sprintf("release tag name is protected [tag_name: %s]", err.TagName)
}
// ErrRepoFileAlreadyExists represents a "RepoFileAlreadyExist" kind of error.
type ErrRepoFileAlreadyExists struct {
Path string

View File

@@ -321,6 +321,8 @@ var migrations = []Migration{
NewMigration("Rename Task errors to message", renameTaskErrorsToMessage),
// v185 -> v186
NewMigration("Add new table repo_archiver", addRepoArchiver),
// v186 -> v187
NewMigration("Create protected tag table", createProtectedTagTable),
}
// GetCurrentDBVersion returns the current db version

26
models/migrations/v186.go Normal file
View File

@@ -0,0 +1,26 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package migrations
import (
"code.gitea.io/gitea/modules/timeutil"
"xorm.io/xorm"
)
func createProtectedTagTable(x *xorm.Engine) error {
type ProtectedTag struct {
ID int64 `xorm:"pk autoincr"`
RepoID int64
NamePattern string
AllowlistUserIDs []int64 `xorm:"JSON TEXT"`
AllowlistTeamIDs []int64 `xorm:"JSON TEXT"`
CreatedUnix timeutil.TimeStamp `xorm:"created"`
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
}
return x.Sync2(new(ProtectedTag))
}

View File

@@ -137,6 +137,7 @@ func init() {
new(IssueIndex),
new(PushMirror),
new(RepoArchiver),
new(ProtectedTag),
)
gonicNames := []string{"SSL", "UID"}

131
models/protected_tag.go Normal file
View File

@@ -0,0 +1,131 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package models
import (
"regexp"
"strings"
"code.gitea.io/gitea/modules/base"
"code.gitea.io/gitea/modules/timeutil"
"github.com/gobwas/glob"
)
// ProtectedTag struct
type ProtectedTag struct {
ID int64 `xorm:"pk autoincr"`
RepoID int64
NamePattern string
RegexPattern *regexp.Regexp `xorm:"-"`
GlobPattern glob.Glob `xorm:"-"`
AllowlistUserIDs []int64 `xorm:"JSON TEXT"`
AllowlistTeamIDs []int64 `xorm:"JSON TEXT"`
CreatedUnix timeutil.TimeStamp `xorm:"created"`
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
}
// InsertProtectedTag inserts a protected tag to database
func InsertProtectedTag(pt *ProtectedTag) error {
_, err := x.Insert(pt)
return err
}
// UpdateProtectedTag updates the protected tag
func UpdateProtectedTag(pt *ProtectedTag) error {
_, err := x.ID(pt.ID).AllCols().Update(pt)
return err
}
// DeleteProtectedTag deletes a protected tag by ID
func DeleteProtectedTag(pt *ProtectedTag) error {
_, err := x.ID(pt.ID).Delete(&ProtectedTag{})
return err
}
// EnsureCompiledPattern ensures the glob pattern is compiled
func (pt *ProtectedTag) EnsureCompiledPattern() error {
if pt.RegexPattern != nil || pt.GlobPattern != nil {
return nil
}
var err error
if len(pt.NamePattern) >= 2 && strings.HasPrefix(pt.NamePattern, "/") && strings.HasSuffix(pt.NamePattern, "/") {
pt.RegexPattern, err = regexp.Compile(pt.NamePattern[1 : len(pt.NamePattern)-1])
} else {
pt.GlobPattern, err = glob.Compile(pt.NamePattern)
}
return err
}
// IsUserAllowed returns true if the user is allowed to modify the tag
func (pt *ProtectedTag) IsUserAllowed(userID int64) (bool, error) {
if base.Int64sContains(pt.AllowlistUserIDs, userID) {
return true, nil
}
if len(pt.AllowlistTeamIDs) == 0 {
return false, nil
}
in, err := IsUserInTeams(userID, pt.AllowlistTeamIDs)
if err != nil {
return false, err
}
return in, nil
}
// GetProtectedTags gets all protected tags of the repository
func (repo *Repository) GetProtectedTags() ([]*ProtectedTag, error) {
tags := make([]*ProtectedTag, 0)
return tags, x.Find(&tags, &ProtectedTag{RepoID: repo.ID})
}
// GetProtectedTagByID gets the protected tag with the specific id
func GetProtectedTagByID(id int64) (*ProtectedTag, error) {
tag := new(ProtectedTag)
has, err := x.ID(id).Get(tag)
if err != nil {
return nil, err
}
if !has {
return nil, nil
}
return tag, nil
}
// IsUserAllowedToControlTag checks if a user can control the specific tag.
// It returns true if the tag name is not protected or the user is allowed to control it.
func IsUserAllowedToControlTag(tags []*ProtectedTag, tagName string, userID int64) (bool, error) {
isAllowed := true
for _, tag := range tags {
err := tag.EnsureCompiledPattern()
if err != nil {
return false, err
}
if !tag.matchString(tagName) {
continue
}
isAllowed, err = tag.IsUserAllowed(userID)
if err != nil {
return false, err
}
if isAllowed {
break
}
}
return isAllowed, nil
}
func (pt *ProtectedTag) matchString(name string) bool {
if pt.RegexPattern != nil {
return pt.RegexPattern.MatchString(name)
}
return pt.GlobPattern.Match(name)
}

View File

@@ -0,0 +1,162 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package models
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestIsUserAllowed(t *testing.T) {
assert.NoError(t, PrepareTestDatabase())
pt := &ProtectedTag{}
allowed, err := pt.IsUserAllowed(1)
assert.NoError(t, err)
assert.False(t, allowed)
pt = &ProtectedTag{
AllowlistUserIDs: []int64{1},
}
allowed, err = pt.IsUserAllowed(1)
assert.NoError(t, err)
assert.True(t, allowed)
allowed, err = pt.IsUserAllowed(2)
assert.NoError(t, err)
assert.False(t, allowed)
pt = &ProtectedTag{
AllowlistTeamIDs: []int64{1},
}
allowed, err = pt.IsUserAllowed(1)
assert.NoError(t, err)
assert.False(t, allowed)
allowed, err = pt.IsUserAllowed(2)
assert.NoError(t, err)
assert.True(t, allowed)
pt = &ProtectedTag{
AllowlistUserIDs: []int64{1},
AllowlistTeamIDs: []int64{1},
}
allowed, err = pt.IsUserAllowed(1)
assert.NoError(t, err)
assert.True(t, allowed)
allowed, err = pt.IsUserAllowed(2)
assert.NoError(t, err)
assert.True(t, allowed)
}
func TestIsUserAllowedToControlTag(t *testing.T) {
cases := []struct {
name string
userid int64
allowed bool
}{
{
name: "test",
userid: 1,
allowed: true,
},
{
name: "test",
userid: 3,
allowed: true,
},
{
name: "gitea",
userid: 1,
allowed: true,
},
{
name: "gitea",
userid: 3,
allowed: false,
},
{
name: "test-gitea",
userid: 1,
allowed: true,
},
{
name: "test-gitea",
userid: 3,
allowed: false,
},
{
name: "gitea-test",
userid: 1,
allowed: true,
},
{
name: "gitea-test",
userid: 3,
allowed: true,
},
{
name: "v-1",
userid: 1,
allowed: false,
},
{
name: "v-1",
userid: 2,
allowed: true,
},
{
name: "release",
userid: 1,
allowed: false,
},
}
t.Run("Glob", func(t *testing.T) {
protectedTags := []*ProtectedTag{
{
NamePattern: `*gitea`,
AllowlistUserIDs: []int64{1},
},
{
NamePattern: `v-*`,
AllowlistUserIDs: []int64{2},
},
{
NamePattern: "release",
},
}
for n, c := range cases {
isAllowed, err := IsUserAllowedToControlTag(protectedTags, c.name, c.userid)
assert.NoError(t, err)
assert.Equal(t, c.allowed, isAllowed, "case %d: error should match", n)
}
})
t.Run("Regex", func(t *testing.T) {
protectedTags := []*ProtectedTag{
{
NamePattern: `/gitea\z/`,
AllowlistUserIDs: []int64{1},
},
{
NamePattern: `/\Av-/`,
AllowlistUserIDs: []int64{2},
},
{
NamePattern: "/release/",
},
}
for n, c := range cases {
isAllowed, err := IsUserAllowedToControlTag(protectedTags, c.name, c.userid)
assert.NoError(t, err)
assert.Equal(t, c.allowed, isAllowed, "case %d: error should match", n)
}
})
}

View File

@@ -1498,6 +1498,7 @@ func DeleteRepository(doer *User, uid, repoID int64) error {
&Mirror{RepoID: repoID},
&Notification{RepoID: repoID},
&ProtectedBranch{RepoID: repoID},
&ProtectedTag{RepoID: repoID},
&PullRequest{BaseRepoID: repoID},
&PushMirror{RepoID: repoID},
&Release{RepoID: repoID},