Skip to content

Commit

Permalink
Add tag protection (#15629)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
3 people authored Jun 25, 2021
1 parent 7a0ed9a commit 44b8b07
Show file tree
Hide file tree
Showing 27 changed files with 1,220 additions and 182 deletions.
8 changes: 4 additions & 4 deletions cmd/hook.go
Original file line number Diff line number Diff line change
Expand Up @@ -221,16 +221,16 @@ Gitea or set your environment appropriately.`, "")
total++
lastline++

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

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

hookOptions.OldCommitIDs = oldCommitIDs
hookOptions.NewCommitIDs = newCommitIDs
Expand Down Expand Up @@ -261,7 +261,7 @@ Gitea or set your environment appropriately.`, "")
hookOptions.NewCommitIDs = newCommitIDs[:count]
hookOptions.RefFullNames = refFullNames[:count]

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

statusCode, msg := private.HookPreReceive(username, reponame, hookOptions)
switch statusCode {
Expand Down
57 changes: 57 additions & 0 deletions docs/content/doc/advanced/protected-tags.en-us.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
---
date: "2021-05-14T00:00:00-00:00"
title: "Protected tags"
slug: "protected-tags"
weight: 45
toc: false
draft: false
menu:
sidebar:
parent: "advanced"
name: "Protected tags"
weight: 45
identifier: "protected-tags"
---

# Protected tags

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.

**Table of Contents**

{{< toc >}}

## Setting up protected tags

To protect a tag, you need to follow these steps:

1. Go to the repository’s **Settings** > **Tags** page.
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.
1. Choose the allowed users and/or teams. If you leave these fields empty noone is allowed to create or modify this tag.
1. Select **Save** to save the configuration.

## Pattern protected tags

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.

Examples:

| Type | Pattern Protected Tag | Possible Matching Tags |
| ----- | ------------------------ | --------------------------------------- |
| Glob | `v*` | `v`, `v-1`, `version2` |
| Glob | `v[0-9]` | `v0`, `v1` up to `v9` |
| Glob | `*-release` | `2.1-release`, `final-release` |
| Glob | `gitea` | only `gitea` |
| Glob | `*gitea*` | `gitea`, `2.1-gitea`, `1_gitea-release` |
| Glob | `{v,rel}-*` | `v-`, `v-1`, `v-final`, `rel-`, `rel-x` |
| Glob | `*` | matches all possible tag names |
| Regex | `/\Av/` | `v`, `v-1`, `version2` |
| Regex | `/\Av[0-9]\z/` | `v0`, `v1` up to `v9` |
| Regex | `/\Av\d+\.\d+\.\d+\z/` | `v1.0.17`, `v2.1.0` |
| Regex | `/\Av\d+(\.\d+){0,2}\z/` | `v1`, `v2.1`, `v1.2.34` |
| Regex | `/-release\z/` | `2.1-release`, `final-release` |
| Regex | `/gitea/` | `gitea`, `2.1-gitea`, `1_gitea-release` |
| Regex | `/\Agitea\z/` | only `gitea` |
| Regex | `/^gitea$/` | only `gitea` |
| Regex | `/\A(v\|rel)-/` | `v-`, `v-1`, `v-final`, `rel-`, `rel-x` |
| Regex | `/.+/` | matches all possible tag names |
2 changes: 2 additions & 0 deletions integrations/mirror_pull_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,9 @@ func TestMirrorPull(t *testing.T) {

assert.NoError(t, release_service.CreateRelease(gitRepo, &models.Release{
RepoID: repo.ID,
Repo: repo,
PublisherID: user.ID,
Publisher: user,
TagName: "v0.2",
Target: "master",
Title: "v0.2 is released",
Expand Down
74 changes: 74 additions & 0 deletions integrations/repo_tag_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// 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 integrations

import (
"io/ioutil"
"net/url"
"testing"

"code.gitea.io/gitea/models"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/services/release"

"github.com/stretchr/testify/assert"
)

func TestCreateNewTagProtected(t *testing.T) {
defer prepareTestEnv(t)()

repo := models.AssertExistsAndLoadBean(t, &models.Repository{ID: 1}).(*models.Repository)
owner := models.AssertExistsAndLoadBean(t, &models.User{ID: repo.OwnerID}).(*models.User)

t.Run("API", func(t *testing.T) {
defer PrintCurrentTest(t)()

err := release.CreateNewTag(owner, repo, "master", "v-1", "first tag")
assert.NoError(t, err)

err = models.InsertProtectedTag(&models.ProtectedTag{
RepoID: repo.ID,
NamePattern: "v-*",
})
assert.NoError(t, err)
err = models.InsertProtectedTag(&models.ProtectedTag{
RepoID: repo.ID,
NamePattern: "v-1.1",
AllowlistUserIDs: []int64{repo.OwnerID},
})
assert.NoError(t, err)

err = release.CreateNewTag(owner, repo, "master", "v-2", "second tag")
assert.Error(t, err)
assert.True(t, models.IsErrProtectedTagName(err))

err = release.CreateNewTag(owner, repo, "master", "v-1.1", "third tag")
assert.NoError(t, err)
})

t.Run("Git", func(t *testing.T) {
onGiteaRun(t, func(t *testing.T, u *url.URL) {
username := "user2"
httpContext := NewAPITestContext(t, username, "repo1")

dstPath, err := ioutil.TempDir("", httpContext.Reponame)
assert.NoError(t, err)
defer util.RemoveAll(dstPath)

u.Path = httpContext.GitPath()
u.User = url.UserPassword(username, userPassword)

doGitClone(dstPath, u)(t)

_, err = git.NewCommand("tag", "v-2").RunInDir(dstPath)
assert.NoError(t, err)

_, err = git.NewCommand("push", "--tags").RunInDir(dstPath)
assert.Error(t, err)
assert.Contains(t, err.Error(), "Tag v-2 is protected")
})
})
}
15 changes: 15 additions & 0 deletions models/error.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions models/migrations/migrations.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions models/migrations/v186.go
Original file line number Diff line number Diff line change
@@ -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))
}
1 change: 1 addition & 0 deletions models/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ func init() {
new(IssueIndex),
new(PushMirror),
new(RepoArchiver),
new(ProtectedTag),
)

gonicNames := []string{"SSL", "UID"}
Expand Down
131 changes: 131 additions & 0 deletions models/protected_tag.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading

0 comments on commit 44b8b07

Please sign in to comment.