diff --git a/CHANGELOG.md b/CHANGELOG.md index 0be02342ea..3fc9252291 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added - Org branch protection: repositories now show the inherited organization rules read-only in their Branch Protection settings, with an expandable detail (direct push, force-push, branch deletion, merge restrictions, required approvals, status checks, protected files, and whitelisted teams) — like GitHub surfaces org rulesets in a repo (#727) - Org branch protection: org-level rules can now also protect against branch deletion (`enable_delete` + delete allowlist teams), mirroring the per-repo delete allowlist (#727) +- Org-level tag protection: protect tag patterns org-wide (e.g. `v*`) with a team allowlist, layered on top of each repo's own protected tags — a tag is controllable only if allowed at both levels (fail-closed). API at `/orgs/{org}/tag_protections`; enforced at the git push/delete hook and the release create/delete paths; shown read-only in the repo Tag settings (#727) - Code security scanner: pattern-based detection of SQL injection, XSS, command injection, path traversal, insecure deserialization, hardcoded credentials, and weak cryptography across Go/PHP/Python/JS/TS (#552) - Cascade merge: auto-create PRs to downstream branches after merge with configurable rules per repo (#460) - Issue status presets: 4 built-in templates (default, software-development, support-tickets, bug-tracking) with API + web UI (#507) diff --git a/models/git/org_protected_tag.go b/models/git/org_protected_tag.go new file mode 100644 index 0000000000..0ba6c23526 --- /dev/null +++ b/models/git/org_protected_tag.go @@ -0,0 +1,133 @@ +// Copyright 2026 Moko Consulting +// SPDX-License-Identifier: GPL-3.0-or-later + +package git + +import ( + "context" + "fmt" + + "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db" + repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo" + user_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/user" + "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil" + + "xorm.io/builder" +) + +// OrgProtectedTag represents an org-level tag protection rule. It cascades to all +// repositories in the organization and layers on top of each repo's own protected +// tags (a tag is controllable only if allowed at both levels). Org rules reference +// teams only (like OrgProtectedBranch). See issue #727. +type OrgProtectedTag struct { + ID int64 `xorm:"pk autoincr"` + OrgID int64 `xorm:"UNIQUE(s) index"` + NamePattern string `xorm:"UNIQUE(s)"` + AllowlistTeamIDs []int64 `xorm:"JSON TEXT"` + CreatedUnix timeutil.TimeStamp `xorm:"created"` + UpdatedUnix timeutil.TimeStamp `xorm:"updated"` +} + +func init() { + db.RegisterModel(new(OrgProtectedTag)) +} + +// ToProtectedTag converts an org-level tag rule into a repo-scoped ProtectedTag so +// the standard name-matching and allowlist logic can be reused. Org rules are +// team-only, so the user allowlist is left empty. +func (o *OrgProtectedTag) ToProtectedTag() *ProtectedTag { + return &ProtectedTag{ + NamePattern: o.NamePattern, + AllowlistTeamIDs: o.AllowlistTeamIDs, + } +} + +// GetOrgProtectedTagByID retrieves a single org tag rule by org ID and rule ID. +func GetOrgProtectedTagByID(ctx context.Context, orgID, id int64) (*OrgProtectedTag, error) { + rule, exist, err := db.Get[OrgProtectedTag](ctx, builder.Eq{"org_id": orgID, "id": id}) + if err != nil { + return nil, err + } else if !exist { + return nil, nil //nolint:nilnil + } + return rule, nil +} + +// GetOrgProtectedTagByNamePattern retrieves a single org tag rule by its pattern. +func GetOrgProtectedTagByNamePattern(ctx context.Context, orgID int64, pattern string) (*OrgProtectedTag, error) { + rule, exist, err := db.Get[OrgProtectedTag](ctx, builder.Eq{"org_id": orgID, "name_pattern": pattern}) + if err != nil { + return nil, err + } else if !exist { + return nil, nil //nolint:nilnil + } + return rule, nil +} + +// FindOrgProtectedTags loads all org-level tag protection rules for an organization. +func FindOrgProtectedTags(ctx context.Context, orgID int64) ([]*OrgProtectedTag, error) { + var rules []*OrgProtectedTag + err := db.GetEngine(ctx).Where("org_id = ?", orgID).Asc("created_unix").Find(&rules) + return rules, err +} + +// CreateOrgProtectedTag creates a new org-level tag protection rule. +func CreateOrgProtectedTag(ctx context.Context, rule *OrgProtectedTag) error { + if _, err := db.GetEngine(ctx).Insert(rule); err != nil { + return fmt.Errorf("Insert OrgProtectedTag: %v", err) + } + return nil +} + +// UpdateOrgProtectedTag updates an existing org-level tag protection rule. +func UpdateOrgProtectedTag(ctx context.Context, rule *OrgProtectedTag) error { + if _, err := db.GetEngine(ctx).ID(rule.ID).AllCols().Update(rule); err != nil { + return fmt.Errorf("Update OrgProtectedTag: %v", err) + } + return nil +} + +// DeleteOrgProtectedTag deletes an org-level tag protection rule. +func DeleteOrgProtectedTag(ctx context.Context, orgID, id int64) error { + affected, err := db.GetEngine(ctx).Where("org_id = ? AND id = ?", orgID, id).Delete(new(OrgProtectedTag)) + if err != nil { + return err + } + if affected == 0 { + return fmt.Errorf("org tag protection rule ID(%d) not found", id) + } + return nil +} + +// IsUserAllowedToControlTagInRepo layers org-level tag rules on top of a repo's own +// protected tags: the user must be allowed by BOTH levels (most-restrictive). The +// repo decision is evaluated first (using the already-loaded repoTags); if it +// allows and the owner is an organization, the org-level rules must also allow. +func IsUserAllowedToControlTagInRepo(ctx context.Context, repoTags []*ProtectedTag, repo *repo_model.Repository, tagName string, userID int64) (bool, error) { + allowed, err := IsUserAllowedToControlTag(ctx, repoTags, tagName, userID) + if err != nil || !allowed { + return allowed, err + } + + owner, err := user_model.GetUserByID(ctx, repo.OwnerID) + if err != nil { + return false, err + } + if !owner.IsOrganization() { + return true, nil + } + + orgRules, err := FindOrgProtectedTags(ctx, owner.ID) + if err != nil { + return false, err + } + if len(orgRules) == 0 { + return true, nil + } + + orgTags := make([]*ProtectedTag, len(orgRules)) + for i, r := range orgRules { + orgTags[i] = r.ToProtectedTag() + } + return IsUserAllowedToControlTag(ctx, orgTags, tagName, userID) +} diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index 2476df640d..b9ab6e5cb2 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -440,6 +440,7 @@ func prepareMigrationTasks() []*migration { newMigration(360, "Add delete allowlist to protected branch", v1_27.AddDeleteAllowlistToProtectedBranch), newMigration(361, "Add cascade merge rule table", v1_27.AddCascadeMergeRuleTable), newMigration(362, "Add delete allowlist to org protected branch", v1_27.AddDeleteAllowlistToOrgProtectedBranch), + newMigration(363, "Add org protected tag table", v1_27.AddOrgProtectedTagTable), } return preparedMigrations } diff --git a/models/migrations/v1_27/v364.go b/models/migrations/v1_27/v364.go new file mode 100644 index 0000000000..62f1923713 --- /dev/null +++ b/models/migrations/v1_27/v364.go @@ -0,0 +1,25 @@ +// Copyright 2026 Moko Consulting +// SPDX-License-Identifier: GPL-3.0-or-later + +package v1_27 + +import ( + "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil" + + "xorm.io/xorm" +) + +// AddOrgProtectedTagTable creates the org-level tag protection table. Org tag rules +// cascade to all repositories in the organization and layer on top of each repo's +// own protected tags. See issue #727. +func AddOrgProtectedTagTable(x *xorm.Engine) error { + type OrgProtectedTag struct { + ID int64 `xorm:"pk autoincr"` + OrgID int64 `xorm:"UNIQUE(s) index"` + NamePattern string `xorm:"UNIQUE(s)"` + AllowlistTeamIDs []int64 `xorm:"JSON TEXT"` + CreatedUnix timeutil.TimeStamp `xorm:"created"` + UpdatedUnix timeutil.TimeStamp `xorm:"updated"` + } + return x.Sync(new(OrgProtectedTag)) +} diff --git a/modules/structs/org_tag.go b/modules/structs/org_tag.go new file mode 100644 index 0000000000..626c1176c9 --- /dev/null +++ b/modules/structs/org_tag.go @@ -0,0 +1,30 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package structs + +import "time" + +// OrgTagProtection represents an org-level tag protection rule +type OrgTagProtection struct { + ID int64 `json:"id"` + OrgID int64 `json:"org_id"` + NamePattern string `json:"name_pattern"` + WhitelistTeams []string `json:"whitelist_teams"` + // swagger:strfmt date-time + Created time.Time `json:"created_at"` + // swagger:strfmt date-time + Updated time.Time `json:"updated_at"` +} + +// CreateOrgTagProtectionOption options for creating an org-level tag protection +type CreateOrgTagProtectionOption struct { + NamePattern string `json:"name_pattern" binding:"Required"` + WhitelistTeams []string `json:"whitelist_teams"` +} + +// EditOrgTagProtectionOption options for editing an org-level tag protection +type EditOrgTagProtectionOption struct { + NamePattern *string `json:"name_pattern"` + WhitelistTeams []string `json:"whitelist_teams"` +} diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 4b56cde6a6..d3b81085e5 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -2433,6 +2433,9 @@ "repo.settings.org_protected_branch.block_outdated": "Block on outdated branch", "repo.settings.org_protected_branch.block_rejected": "Block on rejected reviews", "repo.settings.org_protected_branch.block_admin": "Block admin merge override", + "repo.settings.org_protected_tag": "Organization Tag Protection", + "repo.settings.org_protected_tag_desc": "These tag protection rules are defined by the organization and are enforced on top of this repository's own rules. They cannot be edited here.", + "repo.settings.org_protected_tag.read_only": "Read-only", "repo.settings.protected_branch_can_push": "Allow push?", "repo.settings.protected_branch_can_push_yes": "You can push", "repo.settings.protected_branch_can_push_no": "You cannot push", diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 6395a6cf97..a23aac2d37 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1823,6 +1823,16 @@ func Routes() *web.Router { }) }, reqToken(), reqOrgOwnership()) + m.Group("/tag_protections", func() { + m.Combo("").Get(org.ListOrgTagProtections). + Post(bind(api.CreateOrgTagProtectionOption{}), org.CreateOrgTagProtection) + m.Group("/{id}", func() { + m.Get("", org.GetOrgTagProtection) + m.Patch("", bind(api.EditOrgTagProtectionOption{}), org.EditOrgTagProtection) + m.Delete("", org.DeleteOrgTagProtection) + }) + }, reqToken(), reqOrgOwnership()) + m.Group("/blocks", func() { m.Get("", org.ListBlocks) m.Group("/{username}", func() { diff --git a/routers/api/v1/org/tag_protection.go b/routers/api/v1/org/tag_protection.go new file mode 100644 index 0000000000..9288ac0804 --- /dev/null +++ b/routers/api/v1/org/tag_protection.go @@ -0,0 +1,152 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package org + +import ( + "net/http" + + git_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/git" + "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/organization" + api "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/structs" + "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/web" + "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context" +) + +// toAPIOrgTagProtection converts an org tag rule to its API representation. +func toAPIOrgTagProtection(ctx *context.APIContext, rule *git_model.OrgProtectedTag) *api.OrgTagProtection { + teams, err := organization.FindOrgTeams(ctx, rule.OrgID) + if err != nil { + teams = nil + } + teamNamesByID := make(map[int64]string, len(teams)) + for _, t := range teams { + teamNamesByID[t.ID] = t.Name + } + names := make([]string, 0, len(rule.AllowlistTeamIDs)) + for _, id := range rule.AllowlistTeamIDs { + if name, ok := teamNamesByID[id]; ok { + names = append(names, name) + } + } + return &api.OrgTagProtection{ + ID: rule.ID, + OrgID: rule.OrgID, + NamePattern: rule.NamePattern, + WhitelistTeams: names, + Created: rule.CreatedUnix.AsTime(), + Updated: rule.UpdatedUnix.AsTime(), + } +} + +// ListOrgTagProtections list org-level tag protection rules +func ListOrgTagProtections(ctx *context.APIContext) { + rules, err := git_model.FindOrgProtectedTags(ctx, ctx.Org.Organization.ID) + if err != nil { + ctx.APIErrorInternal(err) + return + } + apiRules := make([]*api.OrgTagProtection, len(rules)) + for i, rule := range rules { + apiRules[i] = toAPIOrgTagProtection(ctx, rule) + } + ctx.JSON(http.StatusOK, apiRules) +} + +// GetOrgTagProtection get a specific org-level tag protection rule +func GetOrgTagProtection(ctx *context.APIContext) { + rule, err := git_model.GetOrgProtectedTagByID(ctx, ctx.Org.Organization.ID, ctx.PathParamInt64("id")) + if err != nil { + ctx.APIErrorInternal(err) + return + } + if rule == nil { + ctx.APIErrorNotFound() + return + } + ctx.JSON(http.StatusOK, toAPIOrgTagProtection(ctx, rule)) +} + +// CreateOrgTagProtection create an org-level tag protection rule +func CreateOrgTagProtection(ctx *context.APIContext) { + form := web.GetForm(ctx).(*api.CreateOrgTagProtectionOption) + orgID := ctx.Org.Organization.ID + + existing, err := git_model.GetOrgProtectedTagByNamePattern(ctx, orgID, form.NamePattern) + if err != nil { + ctx.APIErrorInternal(err) + return + } + if existing != nil { + ctx.APIError(http.StatusForbidden, "org tag protection rule already exists for this pattern") + return + } + + teams, ok := resolveTeamIDs(ctx, orgID, form.WhitelistTeams) + if !ok { + return + } + + rule := &git_model.OrgProtectedTag{ + OrgID: orgID, + NamePattern: form.NamePattern, + AllowlistTeamIDs: teams, + } + if err := git_model.CreateOrgProtectedTag(ctx, rule); err != nil { + ctx.APIErrorInternal(err) + return + } + ctx.JSON(http.StatusCreated, toAPIOrgTagProtection(ctx, rule)) +} + +// EditOrgTagProtection edit an org-level tag protection rule +func EditOrgTagProtection(ctx *context.APIContext) { + form := web.GetForm(ctx).(*api.EditOrgTagProtectionOption) + orgID := ctx.Org.Organization.ID + + rule, err := git_model.GetOrgProtectedTagByID(ctx, orgID, ctx.PathParamInt64("id")) + if err != nil { + ctx.APIErrorInternal(err) + return + } + if rule == nil { + ctx.APIErrorNotFound() + return + } + + if form.NamePattern != nil { + rule.NamePattern = *form.NamePattern + } + if form.WhitelistTeams != nil { + ids, ok := resolveTeamIDs(ctx, orgID, form.WhitelistTeams) + if !ok { + return + } + rule.AllowlistTeamIDs = ids + } + + if err := git_model.UpdateOrgProtectedTag(ctx, rule); err != nil { + ctx.APIErrorInternal(err) + return + } + ctx.JSON(http.StatusOK, toAPIOrgTagProtection(ctx, rule)) +} + +// DeleteOrgTagProtection delete an org-level tag protection rule +func DeleteOrgTagProtection(ctx *context.APIContext) { + orgID := ctx.Org.Organization.ID + rule, err := git_model.GetOrgProtectedTagByID(ctx, orgID, ctx.PathParamInt64("id")) + if err != nil { + ctx.APIErrorInternal(err) + return + } + if rule == nil { + ctx.APIErrorNotFound() + return + } + if err := git_model.DeleteOrgProtectedTag(ctx, orgID, rule.ID); err != nil { + ctx.APIErrorInternal(err) + return + } + ctx.Status(http.StatusNoContent) +} diff --git a/routers/private/hook_pre_receive.go b/routers/private/hook_pre_receive.go index a4d9763ff2..0ec8bd731c 100644 --- a/routers/private/hook_pre_receive.go +++ b/routers/private/hook_pre_receive.go @@ -468,7 +468,7 @@ func preReceiveTag(ctx *preReceiveContext, refFullName git.RefName) { ctx.gotProtectedTags = true } - isAllowed, err := git_model.IsUserAllowedToControlTag(ctx, ctx.protectedTags, tagName, ctx.opts.UserID) + isAllowed, err := git_model.IsUserAllowedToControlTagInRepo(ctx, ctx.protectedTags, ctx.Repo.Repository, tagName, ctx.opts.UserID) if err != nil { ctx.JSON(http.StatusInternalServerError, private.Response{ Err: err.Error(), diff --git a/routers/web/repo/setting/protected_tag.go b/routers/web/repo/setting/protected_tag.go index bb2956bfe2..28cf018fac 100644 --- a/routers/web/repo/setting/protected_tag.go +++ b/routers/web/repo/setting/protected_tag.go @@ -138,6 +138,13 @@ func DeleteProtectedTagPost(ctx *context.Context) { ctx.Redirect(ctx.Repo.Repository.Link() + "/settings/tags") } +// orgProtectedTagView is a read-only presentation of an org-level tag rule for the +// repo settings page, with allowlist team IDs resolved to names. +type orgProtectedTagView struct { + Rule *git_model.OrgProtectedTag + Teams string +} + func setTagsContext(ctx *context.Context) error { ctx.Data["Title"] = ctx.Tr("repo.settings.tags") ctx.Data["PageIsSettingsTags"] = true @@ -163,6 +170,36 @@ func setTagsContext(ctx *context.Context) error { return err } ctx.Data["Teams"] = teams + + // Surface the organization's tag protection rules read-only, so admins can see + // the org "floor" layered on top of this repo's own protected tags (#727). + orgRules, err := git_model.FindOrgProtectedTags(ctx, ctx.Repo.Owner.ID) + if err != nil { + ctx.ServerError("FindOrgProtectedTags", err) + return err + } + if len(orgRules) > 0 { + allTeams, err := organization.FindOrgTeams(ctx, ctx.Repo.Owner.ID) + if err != nil { + ctx.ServerError("FindOrgTeams", err) + return err + } + teamNames := make(map[int64]string, len(allTeams)) + for _, t := range allTeams { + teamNames[t.ID] = t.Name + } + views := make([]*orgProtectedTagView, len(orgRules)) + for i, r := range orgRules { + names := make([]string, 0, len(r.AllowlistTeamIDs)) + for _, id := range r.AllowlistTeamIDs { + if n, ok := teamNames[id]; ok { + names = append(names, n) + } + } + views[i] = &orgProtectedTagView{Rule: r, Teams: strings.Join(names, ", ")} + } + ctx.Data["OrgProtectedTags"] = views + } } return nil diff --git a/services/release/release.go b/services/release/release.go index 04cf233c5b..ee679d900a 100644 --- a/services/release/release.go +++ b/services/release/release.go @@ -92,7 +92,7 @@ func createTag(ctx context.Context, gitRepo *git.Repository, rel *repo_model.Rel // Trim '--' prefix to prevent command line argument vulnerability. rel.TagName = strings.TrimPrefix(rel.TagName, "--") - isAllowed, err := git_model.IsUserAllowedToControlTag(ctx, protectedTags, rel.TagName, rel.PublisherID) + isAllowed, err := git_model.IsUserAllowedToControlTagInRepo(ctx, protectedTags, rel.Repo, rel.TagName, rel.PublisherID) if err != nil { return false, err } @@ -439,7 +439,7 @@ func DeleteReleaseByID(ctx context.Context, repo *repo_model.Repository, rel *re if err != nil { return fmt.Errorf("GetProtectedTags: %w", err) } - isAllowed, err := git_model.IsUserAllowedToControlTag(ctx, protectedTags, rel.TagName, doer.ID) + isAllowed, err := git_model.IsUserAllowedToControlTagInRepo(ctx, protectedTags, repo, rel.TagName, doer.ID) if err != nil { return err } diff --git a/templates/repo/settings/tags.tmpl b/templates/repo/settings/tags.tmpl index b5494d9447..ca2bd7dd88 100644 --- a/templates/repo/settings/tags.tmpl +++ b/templates/repo/settings/tags.tmpl @@ -116,6 +116,30 @@ {{end}} + {{if .OrgProtectedTags}} +
+ {{svg "octicon-organization" 14}} {{ctx.Locale.Tr "repo.settings.org_protected_tag"}} +
+
+

{{ctx.Locale.Tr "repo.settings.org_protected_tag_desc"}}

+ + + + + + + + {{range .OrgProtectedTags}} + + + + + + {{end}} + +
{{ctx.Locale.Tr "repo.settings.tags.protection.pattern"}}{{ctx.Locale.Tr "repo.settings.tags.protection.allowed"}}{{ctx.Locale.Tr "repo.settings.org_protected_tag.read_only"}}
{{.Rule.NamePattern}}
{{if .Teams}}{{.Teams}}{{else}}{{ctx.Locale.Tr "repo.settings.tags.protection.allowed.noone"}}{{end}}{{svg "octicon-lock" 14}}
+
+ {{end}}