Skip to content

Commit 72bc9b8

Browse files
KN4CK3Rzeripathlunny
authored andcommitted
Add tag protection (go-gitea#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 <[email protected]> Co-authored-by: Lunny Xiao <[email protected]>
1 parent 5f5639a commit 72bc9b8

File tree

27 files changed

+1220
-182
lines changed

27 files changed

+1220
-182
lines changed

cmd/hook.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -221,16 +221,16 @@ Gitea or set your environment appropriately.`, "")
221221
total++
222222
lastline++
223223

224-
// If the ref is a branch, check if it's protected
225-
if strings.HasPrefix(refFullName, git.BranchPrefix) {
224+
// If the ref is a branch or tag, check if it's protected
225+
if strings.HasPrefix(refFullName, git.BranchPrefix) || strings.HasPrefix(refFullName, git.TagPrefix) {
226226
oldCommitIDs[count] = oldCommitID
227227
newCommitIDs[count] = newCommitID
228228
refFullNames[count] = refFullName
229229
count++
230230
fmt.Fprintf(out, "*")
231231

232232
if count >= hookBatchSize {
233-
fmt.Fprintf(out, " Checking %d branches\n", count)
233+
fmt.Fprintf(out, " Checking %d references\n", count)
234234

235235
hookOptions.OldCommitIDs = oldCommitIDs
236236
hookOptions.NewCommitIDs = newCommitIDs
@@ -261,7 +261,7 @@ Gitea or set your environment appropriately.`, "")
261261
hookOptions.NewCommitIDs = newCommitIDs[:count]
262262
hookOptions.RefFullNames = refFullNames[:count]
263263

264-
fmt.Fprintf(out, " Checking %d branches\n", count)
264+
fmt.Fprintf(out, " Checking %d references\n", count)
265265

266266
statusCode, msg := private.HookPreReceive(username, reponame, hookOptions)
267267
switch statusCode {
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
---
2+
date: "2021-05-14T00:00:00-00:00"
3+
title: "Protected tags"
4+
slug: "protected-tags"
5+
weight: 45
6+
toc: false
7+
draft: false
8+
menu:
9+
sidebar:
10+
parent: "advanced"
11+
name: "Protected tags"
12+
weight: 45
13+
identifier: "protected-tags"
14+
---
15+
16+
# Protected tags
17+
18+
Protected tags allow control over who has permission to create or update git tags. Each rule allows you to match either an individual tag name, or use an appropriate pattern to control multiple tags at once.
19+
20+
**Table of Contents**
21+
22+
{{< toc >}}
23+
24+
## Setting up protected tags
25+
26+
To protect a tag, you need to follow these steps:
27+
28+
1. Go to the repository’s **Settings** > **Tags** page.
29+
1. Type a pattern to match a name. You can use a single name, a [glob pattern](https://pkg.go.dev/github.com/gobwas/glob#Compile) or a regular expression.
30+
1. Choose the allowed users and/or teams. If you leave these fields empty noone is allowed to create or modify this tag.
31+
1. Select **Save** to save the configuration.
32+
33+
## Pattern protected tags
34+
35+
The pattern uses [glob](https://pkg.go.dev/github.com/gobwas/glob#Compile) or regular expressions to match a tag name. For regular expressions you need to enclose the pattern in slashes.
36+
37+
Examples:
38+
39+
| Type | Pattern Protected Tag | Possible Matching Tags |
40+
| ----- | ------------------------ | --------------------------------------- |
41+
| Glob | `v*` | `v`, `v-1`, `version2` |
42+
| Glob | `v[0-9]` | `v0`, `v1` up to `v9` |
43+
| Glob | `*-release` | `2.1-release`, `final-release` |
44+
| Glob | `gitea` | only `gitea` |
45+
| Glob | `*gitea*` | `gitea`, `2.1-gitea`, `1_gitea-release` |
46+
| Glob | `{v,rel}-*` | `v-`, `v-1`, `v-final`, `rel-`, `rel-x` |
47+
| Glob | `*` | matches all possible tag names |
48+
| Regex | `/\Av/` | `v`, `v-1`, `version2` |
49+
| Regex | `/\Av[0-9]\z/` | `v0`, `v1` up to `v9` |
50+
| Regex | `/\Av\d+\.\d+\.\d+\z/` | `v1.0.17`, `v2.1.0` |
51+
| Regex | `/\Av\d+(\.\d+){0,2}\z/` | `v1`, `v2.1`, `v1.2.34` |
52+
| Regex | `/-release\z/` | `2.1-release`, `final-release` |
53+
| Regex | `/gitea/` | `gitea`, `2.1-gitea`, `1_gitea-release` |
54+
| Regex | `/\Agitea\z/` | only `gitea` |
55+
| Regex | `/^gitea$/` | only `gitea` |
56+
| Regex | `/\A(v\|rel)-/` | `v-`, `v-1`, `v-final`, `rel-`, `rel-x` |
57+
| Regex | `/.+/` | matches all possible tag names |

integrations/mirror_pull_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,9 @@ func TestMirrorPull(t *testing.T) {
5959

6060
assert.NoError(t, release_service.CreateRelease(gitRepo, &models.Release{
6161
RepoID: repo.ID,
62+
Repo: repo,
6263
PublisherID: user.ID,
64+
Publisher: user,
6365
TagName: "v0.2",
6466
Target: "master",
6567
Title: "v0.2 is released",

integrations/repo_tag_test.go

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
// Copyright 2021 The Gitea Authors. All rights reserved.
2+
// Use of this source code is governed by a MIT-style
3+
// license that can be found in the LICENSE file.
4+
5+
package integrations
6+
7+
import (
8+
"io/ioutil"
9+
"net/url"
10+
"testing"
11+
12+
"code.gitea.io/gitea/models"
13+
"code.gitea.io/gitea/modules/git"
14+
"code.gitea.io/gitea/modules/util"
15+
"code.gitea.io/gitea/services/release"
16+
17+
"github.com/stretchr/testify/assert"
18+
)
19+
20+
func TestCreateNewTagProtected(t *testing.T) {
21+
defer prepareTestEnv(t)()
22+
23+
repo := models.AssertExistsAndLoadBean(t, &models.Repository{ID: 1}).(*models.Repository)
24+
owner := models.AssertExistsAndLoadBean(t, &models.User{ID: repo.OwnerID}).(*models.User)
25+
26+
t.Run("API", func(t *testing.T) {
27+
defer PrintCurrentTest(t)()
28+
29+
err := release.CreateNewTag(owner, repo, "master", "v-1", "first tag")
30+
assert.NoError(t, err)
31+
32+
err = models.InsertProtectedTag(&models.ProtectedTag{
33+
RepoID: repo.ID,
34+
NamePattern: "v-*",
35+
})
36+
assert.NoError(t, err)
37+
err = models.InsertProtectedTag(&models.ProtectedTag{
38+
RepoID: repo.ID,
39+
NamePattern: "v-1.1",
40+
AllowlistUserIDs: []int64{repo.OwnerID},
41+
})
42+
assert.NoError(t, err)
43+
44+
err = release.CreateNewTag(owner, repo, "master", "v-2", "second tag")
45+
assert.Error(t, err)
46+
assert.True(t, models.IsErrProtectedTagName(err))
47+
48+
err = release.CreateNewTag(owner, repo, "master", "v-1.1", "third tag")
49+
assert.NoError(t, err)
50+
})
51+
52+
t.Run("Git", func(t *testing.T) {
53+
onGiteaRun(t, func(t *testing.T, u *url.URL) {
54+
username := "user2"
55+
httpContext := NewAPITestContext(t, username, "repo1")
56+
57+
dstPath, err := ioutil.TempDir("", httpContext.Reponame)
58+
assert.NoError(t, err)
59+
defer util.RemoveAll(dstPath)
60+
61+
u.Path = httpContext.GitPath()
62+
u.User = url.UserPassword(username, userPassword)
63+
64+
doGitClone(dstPath, u)(t)
65+
66+
_, err = git.NewCommand("tag", "v-2").RunInDir(dstPath)
67+
assert.NoError(t, err)
68+
69+
_, err = git.NewCommand("push", "--tags").RunInDir(dstPath)
70+
assert.Error(t, err)
71+
assert.Contains(t, err.Error(), "Tag v-2 is protected")
72+
})
73+
})
74+
}

models/error.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -985,6 +985,21 @@ func (err ErrInvalidTagName) Error() string {
985985
return fmt.Sprintf("release tag name is not valid [tag_name: %s]", err.TagName)
986986
}
987987

988+
// ErrProtectedTagName represents a "ProtectedTagName" kind of error.
989+
type ErrProtectedTagName struct {
990+
TagName string
991+
}
992+
993+
// IsErrProtectedTagName checks if an error is a ErrProtectedTagName.
994+
func IsErrProtectedTagName(err error) bool {
995+
_, ok := err.(ErrProtectedTagName)
996+
return ok
997+
}
998+
999+
func (err ErrProtectedTagName) Error() string {
1000+
return fmt.Sprintf("release tag name is protected [tag_name: %s]", err.TagName)
1001+
}
1002+
9881003
// ErrRepoFileAlreadyExists represents a "RepoFileAlreadyExist" kind of error.
9891004
type ErrRepoFileAlreadyExists struct {
9901005
Path string

models/migrations/migrations.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,8 @@ var migrations = []Migration{
321321
NewMigration("Rename Task errors to message", renameTaskErrorsToMessage),
322322
// v185 -> v186
323323
NewMigration("Add new table repo_archiver", addRepoArchiver),
324+
// v186 -> v187
325+
NewMigration("Create protected tag table", createProtectedTagTable),
324326
}
325327

326328
// GetCurrentDBVersion returns the current db version

models/migrations/v186.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
// Copyright 2021 The Gitea Authors. All rights reserved.
2+
// Use of this source code is governed by a MIT-style
3+
// license that can be found in the LICENSE file.
4+
5+
package migrations
6+
7+
import (
8+
"code.gitea.io/gitea/modules/timeutil"
9+
10+
"xorm.io/xorm"
11+
)
12+
13+
func createProtectedTagTable(x *xorm.Engine) error {
14+
type ProtectedTag struct {
15+
ID int64 `xorm:"pk autoincr"`
16+
RepoID int64
17+
NamePattern string
18+
AllowlistUserIDs []int64 `xorm:"JSON TEXT"`
19+
AllowlistTeamIDs []int64 `xorm:"JSON TEXT"`
20+
21+
CreatedUnix timeutil.TimeStamp `xorm:"created"`
22+
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
23+
}
24+
25+
return x.Sync2(new(ProtectedTag))
26+
}

models/models.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ func init() {
137137
new(IssueIndex),
138138
new(PushMirror),
139139
new(RepoArchiver),
140+
new(ProtectedTag),
140141
)
141142

142143
gonicNames := []string{"SSL", "UID"}

models/protected_tag.go

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
// Copyright 2021 The Gitea Authors. All rights reserved.
2+
// Use of this source code is governed by a MIT-style
3+
// license that can be found in the LICENSE file.
4+
5+
package models
6+
7+
import (
8+
"regexp"
9+
"strings"
10+
11+
"code.gitea.io/gitea/modules/base"
12+
"code.gitea.io/gitea/modules/timeutil"
13+
14+
"github.com/gobwas/glob"
15+
)
16+
17+
// ProtectedTag struct
18+
type ProtectedTag struct {
19+
ID int64 `xorm:"pk autoincr"`
20+
RepoID int64
21+
NamePattern string
22+
RegexPattern *regexp.Regexp `xorm:"-"`
23+
GlobPattern glob.Glob `xorm:"-"`
24+
AllowlistUserIDs []int64 `xorm:"JSON TEXT"`
25+
AllowlistTeamIDs []int64 `xorm:"JSON TEXT"`
26+
27+
CreatedUnix timeutil.TimeStamp `xorm:"created"`
28+
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
29+
}
30+
31+
// InsertProtectedTag inserts a protected tag to database
32+
func InsertProtectedTag(pt *ProtectedTag) error {
33+
_, err := x.Insert(pt)
34+
return err
35+
}
36+
37+
// UpdateProtectedTag updates the protected tag
38+
func UpdateProtectedTag(pt *ProtectedTag) error {
39+
_, err := x.ID(pt.ID).AllCols().Update(pt)
40+
return err
41+
}
42+
43+
// DeleteProtectedTag deletes a protected tag by ID
44+
func DeleteProtectedTag(pt *ProtectedTag) error {
45+
_, err := x.ID(pt.ID).Delete(&ProtectedTag{})
46+
return err
47+
}
48+
49+
// EnsureCompiledPattern ensures the glob pattern is compiled
50+
func (pt *ProtectedTag) EnsureCompiledPattern() error {
51+
if pt.RegexPattern != nil || pt.GlobPattern != nil {
52+
return nil
53+
}
54+
55+
var err error
56+
if len(pt.NamePattern) >= 2 && strings.HasPrefix(pt.NamePattern, "/") && strings.HasSuffix(pt.NamePattern, "/") {
57+
pt.RegexPattern, err = regexp.Compile(pt.NamePattern[1 : len(pt.NamePattern)-1])
58+
} else {
59+
pt.GlobPattern, err = glob.Compile(pt.NamePattern)
60+
}
61+
return err
62+
}
63+
64+
// IsUserAllowed returns true if the user is allowed to modify the tag
65+
func (pt *ProtectedTag) IsUserAllowed(userID int64) (bool, error) {
66+
if base.Int64sContains(pt.AllowlistUserIDs, userID) {
67+
return true, nil
68+
}
69+
70+
if len(pt.AllowlistTeamIDs) == 0 {
71+
return false, nil
72+
}
73+
74+
in, err := IsUserInTeams(userID, pt.AllowlistTeamIDs)
75+
if err != nil {
76+
return false, err
77+
}
78+
return in, nil
79+
}
80+
81+
// GetProtectedTags gets all protected tags of the repository
82+
func (repo *Repository) GetProtectedTags() ([]*ProtectedTag, error) {
83+
tags := make([]*ProtectedTag, 0)
84+
return tags, x.Find(&tags, &ProtectedTag{RepoID: repo.ID})
85+
}
86+
87+
// GetProtectedTagByID gets the protected tag with the specific id
88+
func GetProtectedTagByID(id int64) (*ProtectedTag, error) {
89+
tag := new(ProtectedTag)
90+
has, err := x.ID(id).Get(tag)
91+
if err != nil {
92+
return nil, err
93+
}
94+
if !has {
95+
return nil, nil
96+
}
97+
return tag, nil
98+
}
99+
100+
// IsUserAllowedToControlTag checks if a user can control the specific tag.
101+
// It returns true if the tag name is not protected or the user is allowed to control it.
102+
func IsUserAllowedToControlTag(tags []*ProtectedTag, tagName string, userID int64) (bool, error) {
103+
isAllowed := true
104+
for _, tag := range tags {
105+
err := tag.EnsureCompiledPattern()
106+
if err != nil {
107+
return false, err
108+
}
109+
110+
if !tag.matchString(tagName) {
111+
continue
112+
}
113+
114+
isAllowed, err = tag.IsUserAllowed(userID)
115+
if err != nil {
116+
return false, err
117+
}
118+
if isAllowed {
119+
break
120+
}
121+
}
122+
123+
return isAllowed, nil
124+
}
125+
126+
func (pt *ProtectedTag) matchString(name string) bool {
127+
if pt.RegexPattern != nil {
128+
return pt.RegexPattern.MatchString(name)
129+
}
130+
return pt.GlobPattern.Match(name)
131+
}

0 commit comments

Comments
 (0)