2
0

feat(secrets): add secret promotion between scopes

Adds ability to promote secrets from repository to organization scope, or from repository/organization to global scope. Includes conflict detection to prevent duplicate names at target scope, permission checks (org owner for repo→org, system admin for →global), and UI buttons with confirmation dialogs. Implements MoveSecret model method and PerformSecretsPromote handler.
This commit is contained in:
2026-02-01 21:39:45 -05:00
parent 8182b1be81
commit 7b34e295eb
10 changed files with 157 additions and 5 deletions

View File

@@ -222,6 +222,50 @@ func GetSecretsOfTask(ctx context.Context, task *actions_model.ActionTask) (map[
return secrets, nil
}
// ErrSecretConflict represents a name conflict when moving a secret.
type ErrSecretConflict struct {
Name string
}
func (err ErrSecretConflict) Error() string {
return fmt.Sprintf("a secret named '%s' already exists at the target scope", err.Name)
}
// MoveSecret moves a secret to a new scope by updating its OwnerID and RepoID.
// It checks for name conflicts at the target scope first.
func MoveSecret(ctx context.Context, secretID, newOwnerID, newRepoID int64) error {
// Load the secret
s, err := db.Find[Secret](ctx, FindSecretsOptions{SecretID: secretID})
if err != nil {
return err
}
if len(s) == 0 {
return ErrSecretNotFound{}
}
secret := s[0]
// Check for name conflict at target scope
var conflictOpts FindSecretsOptions
if newOwnerID == 0 && newRepoID == 0 {
conflictOpts = FindSecretsOptions{Global: true, Name: secret.Name}
} else {
conflictOpts = FindSecretsOptions{OwnerID: newOwnerID, RepoID: newRepoID, Name: secret.Name}
}
existing, err := db.Find[Secret](ctx, conflictOpts)
if err != nil {
return err
}
if len(existing) > 0 {
return ErrSecretConflict{Name: secret.Name}
}
// Update scope
secret.OwnerID = newOwnerID
secret.RepoID = newRepoID
_, err = db.GetEngine(ctx).ID(secretID).Cols("owner_id", "repo_id").Update(secret)
return err
}
func CountWrongRepoLevelSecrets(ctx context.Context) (int64, error) {
var result int64
_, err := db.GetEngine(ctx).SQL("SELECT count(`id`) FROM `secret` WHERE `repo_id` > 0 AND `owner_id` > 0").Get(&result)

View File

@@ -3812,6 +3812,15 @@
"secrets.deletion.description": "Removing a secret is permanent and cannot be undone. Continue?",
"secrets.deletion.success": "The secret has been removed.",
"secrets.deletion.failed": "Failed to remove secret.",
"secrets.promote": "Promote",
"secrets.promote.to_org": "Move to Organization",
"secrets.promote.to_global": "Move to Global",
"secrets.promote.confirm_org": "Move secret '%s' to organization scope? It will be removed from this repository.",
"secrets.promote.confirm_global": "Move secret '%s' to global scope? It will be removed from its current scope.",
"secrets.promote.success": "Secret has been moved successfully.",
"secrets.promote.conflict": "A secret named '%s' already exists at the target scope.",
"secrets.promote.forbidden": "You do not have permission to promote this secret.",
"secrets.promote.failed": "Failed to promote secret.",
"secrets.management": "Secrets Management",
"secrets.global_secrets": "Global Secrets",
"secrets.global_secrets.description": "These secrets are configured by system administrators and are available to all workflows. They cannot be modified here.",

View File

@@ -100,6 +100,15 @@ func Secrets(ctx *context.Context) {
ctx.Data["DisableSSH"] = setting.SSH.Disabled
}
// Set context flags for promote buttons
ctx.Data["IsSystemAdmin"] = ctx.Doer != nil && ctx.Doer.IsAdmin
ctx.Data["IsOrgContext"] = sCtx.IsOrg
ctx.Data["IsRepoContext"] = sCtx.IsRepo
if sCtx.IsRepo && ctx.Repo.Repository != nil && ctx.Repo.Repository.Owner != nil {
ctx.Data["RepoOwnerIsOrg"] = ctx.Repo.Repository.Owner.IsOrganization()
ctx.Data["RepoOwnerID"] = ctx.Repo.Repository.OwnerID
}
shared.SetSecretsContextWithGlobal(ctx, sCtx.OwnerID, sCtx.RepoID, sCtx.IsGlobal)
if ctx.Written() {
return
@@ -142,3 +151,42 @@ func SecretsDelete(ctx *context.Context) {
sCtx.RedirectLink,
)
}
func SecretsPromote(ctx *context.Context) {
sCtx, err := getSecretsCtx(ctx)
if err != nil {
ctx.ServerError("getSecretsCtx", err)
return
}
target := ctx.FormString("target")
var newOwnerID, newRepoID int64
switch target {
case "org":
// Repo → Org: must be a repo context with an org owner
if !sCtx.IsRepo {
ctx.JSONError(ctx.Tr("secrets.promote.forbidden"))
return
}
if ctx.Repo.Repository == nil || ctx.Repo.Repository.Owner == nil || !ctx.Repo.Repository.Owner.IsOrganization() {
ctx.JSONError(ctx.Tr("secrets.promote.forbidden"))
return
}
newOwnerID = ctx.Repo.Repository.OwnerID
newRepoID = 0
case "global":
// Repo/Org → Global: must be system admin
if ctx.Doer == nil || !ctx.Doer.IsAdmin {
ctx.JSONError(ctx.Tr("secrets.promote.forbidden"))
return
}
newOwnerID = 0
newRepoID = 0
default:
ctx.JSONError(ctx.Tr("secrets.promote.forbidden"))
return
}
shared.PerformSecretsPromote(ctx, newOwnerID, newRepoID, sCtx.RedirectLink)
}

View File

@@ -4,6 +4,8 @@
package secrets
import (
"errors"
"code.gitcaddy.com/server/v3/models/db"
secret_model "code.gitcaddy.com/server/v3/models/secret"
"code.gitcaddy.com/server/v3/modules/log"
@@ -70,6 +72,26 @@ func PerformSecretsDelete(ctx *context.Context, ownerID, repoID int64, redirectU
PerformSecretsDeleteWithGlobal(ctx, ownerID, repoID, false, redirectURL)
}
func PerformSecretsPromote(ctx *context.Context, newOwnerID, newRepoID int64, redirectURL string) {
id := ctx.FormInt64("id")
err := secret_service.PromoteSecret(ctx, id, newOwnerID, newRepoID)
if err != nil {
var conflictErr secret_model.ErrSecretConflict
if errors.As(err, &conflictErr) {
ctx.Flash.Error(ctx.Tr("secrets.promote.conflict", conflictErr.Name))
ctx.JSONRedirect(redirectURL)
return
}
log.Error("PromoteSecret(%d) failed: %v", id, err)
ctx.JSONError(ctx.Tr("secrets.promote.failed"))
return
}
ctx.Flash.Success(ctx.Tr("secrets.promote.success"))
ctx.JSONRedirect(redirectURL)
}
func PerformSecretsDeleteWithGlobal(ctx *context.Context, ownerID, repoID int64, global bool, redirectURL string) {
id := ctx.FormInt64("id")

View File

@@ -502,6 +502,7 @@ func registerWebRoutes(m *web.Router) {
m.Get("", repo_setting.Secrets)
m.Post("", web.Bind(forms.AddSecretForm{}), repo_setting.SecretsPost)
m.Post("/delete", repo_setting.SecretsDelete)
m.Post("/promote", repo_setting.SecretsPromote)
})
}

View File

@@ -130,6 +130,11 @@ func DeleteSecretByName(ctx context.Context, ownerID, repoID int64, name string)
return deleteSecret(ctx, s[0])
}
// PromoteSecret moves a secret to a new scope (org or global).
func PromoteSecret(ctx context.Context, secretID, newOwnerID, newRepoID int64) error {
return secret_model.MoveSecret(ctx, secretID, newOwnerID, newRepoID)
}
func deleteSecret(ctx context.Context, s *secret_model.Secret) error {
if _, err := db.DeleteByID[secret_model.Secret](ctx, s.ID); err != nil {
return err

View File

@@ -18,7 +18,7 @@
<div class="blog-featured-meta">
{{if .FeaturedPost.Author}}
<img class="blog-avatar" src="{{.FeaturedPost.Author.AvatarLink ctx}}" alt="{{.FeaturedPost.Author.Name}}">
<span class="blog-author">{{.FeaturedPost.Author.GetDisplayName}}</span>
<span class="blog-author">{{.FeaturedPost.Author.DisplayName}}</span>
<span class="blog-meta-sep">&middot;</span>
{{end}}
{{if .FeaturedPost.Repo}}
@@ -53,7 +53,7 @@
<div class="blog-tile-meta">
{{if .Author}}
<img class="blog-avatar-sm" src="{{.Author.AvatarLink ctx}}" alt="{{.Author.Name}}">
<span>{{.Author.GetDisplayName}}</span>
<span>{{.Author.DisplayName}}</span>
<span class="blog-meta-sep">&middot;</span>
{{end}}
{{if .Repo}}

View File

@@ -23,7 +23,7 @@
<div class="blog-featured-meta">
{{if .FeaturedPost.Author}}
<img class="blog-avatar" src="{{.FeaturedPost.Author.AvatarLink ctx}}" alt="{{.FeaturedPost.Author.Name}}">
<span class="blog-author">{{.FeaturedPost.Author.GetDisplayName}}</span>
<span class="blog-author">{{.FeaturedPost.Author.DisplayName}}</span>
<span class="blog-meta-sep">&middot;</span>
{{end}}
<span class="blog-date">{{DateUtils.TimeSince .FeaturedPost.CreatedUnix}}</span>
@@ -61,7 +61,7 @@
<div class="blog-post-tile-meta">
{{if .Author}}
<img class="blog-avatar-sm" src="{{.Author.AvatarLink ctx}}" alt="{{.Author.Name}}">
<span>{{.Author.GetDisplayName}}</span>
<span>{{.Author.DisplayName}}</span>
<span class="blog-meta-sep">&middot;</span>
{{end}}
<span>{{DateUtils.TimeSince .CreatedUnix}}</span>

View File

@@ -21,7 +21,11 @@
<div class="blog-view-meta">
{{if .BlogPost.Author}}
<img class="blog-avatar" src="{{.BlogPost.Author.AvatarLink ctx}}" alt="{{.BlogPost.Author.Name}}">
<a href="{{.BlogPost.Author.HomeLink}}" class="blog-author-link">{{.BlogPost.Author.GetDisplayName}}</a>
{{if not .BlogPost.Author.KeepEmailPrivate}}
<a href="mailto:{{.BlogPost.Author.Email}}" class="blog-author-link">{{.BlogPost.Author.DisplayName}}</a>
{{else}}
<a href="{{.BlogPost.Author.HomeLink}}" class="blog-author-link">{{.BlogPost.Author.DisplayName}}</a>
{{end}}
<span class="blog-meta-sep">&middot;</span>
{{end}}
{{if .BlogPost.PublishedUnix}}

View File

@@ -91,6 +91,25 @@
>
{{svg "octicon-trash"}}
</button>
{{if or (and $.IsRepoContext $.RepoOwnerIsOrg) (and $.IsOrgContext $.IsSystemAdmin) (and $.IsRepoContext $.IsSystemAdmin)}}
<div class="ui small dropdown button tw-p-2" style="min-width:auto;" data-tooltip-content="{{ctx.Locale.Tr "secrets.promote"}}">
{{svg "octicon-arrow-up"}}
<div class="menu">
{{if and $.IsRepoContext $.RepoOwnerIsOrg}}
<button class="item link-action"
data-url="{{$.Link}}/promote?id={{.ID}}&target=org"
data-modal-confirm="{{ctx.Locale.Tr "secrets.promote.confirm_org" .Name}}"
>{{svg "octicon-organization" 14}} {{ctx.Locale.Tr "secrets.promote.to_org"}}</button>
{{end}}
{{if $.IsSystemAdmin}}
<button class="item link-action"
data-url="{{$.Link}}/promote?id={{.ID}}&target=global"
data-modal-confirm="{{ctx.Locale.Tr "secrets.promote.confirm_global" .Name}}"
>{{svg "octicon-globe" 14}} {{ctx.Locale.Tr "secrets.promote.to_global"}}</button>
{{end}}
</div>
</div>
{{end}}
</div>
</div>
{{end}}