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:
@@ -222,6 +222,50 @@ func GetSecretsOfTask(ctx context.Context, task *actions_model.ActionTask) (map[
|
|||||||
return secrets, nil
|
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) {
|
func CountWrongRepoLevelSecrets(ctx context.Context) (int64, error) {
|
||||||
var result int64
|
var result int64
|
||||||
_, err := db.GetEngine(ctx).SQL("SELECT count(`id`) FROM `secret` WHERE `repo_id` > 0 AND `owner_id` > 0").Get(&result)
|
_, err := db.GetEngine(ctx).SQL("SELECT count(`id`) FROM `secret` WHERE `repo_id` > 0 AND `owner_id` > 0").Get(&result)
|
||||||
|
|||||||
@@ -3812,6 +3812,15 @@
|
|||||||
"secrets.deletion.description": "Removing a secret is permanent and cannot be undone. Continue?",
|
"secrets.deletion.description": "Removing a secret is permanent and cannot be undone. Continue?",
|
||||||
"secrets.deletion.success": "The secret has been removed.",
|
"secrets.deletion.success": "The secret has been removed.",
|
||||||
"secrets.deletion.failed": "Failed to remove secret.",
|
"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.management": "Secrets Management",
|
||||||
"secrets.global_secrets": "Global Secrets",
|
"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.",
|
"secrets.global_secrets.description": "These secrets are configured by system administrators and are available to all workflows. They cannot be modified here.",
|
||||||
|
|||||||
@@ -100,6 +100,15 @@ func Secrets(ctx *context.Context) {
|
|||||||
ctx.Data["DisableSSH"] = setting.SSH.Disabled
|
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)
|
shared.SetSecretsContextWithGlobal(ctx, sCtx.OwnerID, sCtx.RepoID, sCtx.IsGlobal)
|
||||||
if ctx.Written() {
|
if ctx.Written() {
|
||||||
return
|
return
|
||||||
@@ -142,3 +151,42 @@ func SecretsDelete(ctx *context.Context) {
|
|||||||
sCtx.RedirectLink,
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
package secrets
|
package secrets
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
"code.gitcaddy.com/server/v3/models/db"
|
"code.gitcaddy.com/server/v3/models/db"
|
||||||
secret_model "code.gitcaddy.com/server/v3/models/secret"
|
secret_model "code.gitcaddy.com/server/v3/models/secret"
|
||||||
"code.gitcaddy.com/server/v3/modules/log"
|
"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)
|
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) {
|
func PerformSecretsDeleteWithGlobal(ctx *context.Context, ownerID, repoID int64, global bool, redirectURL string) {
|
||||||
id := ctx.FormInt64("id")
|
id := ctx.FormInt64("id")
|
||||||
|
|
||||||
|
|||||||
@@ -502,6 +502,7 @@ func registerWebRoutes(m *web.Router) {
|
|||||||
m.Get("", repo_setting.Secrets)
|
m.Get("", repo_setting.Secrets)
|
||||||
m.Post("", web.Bind(forms.AddSecretForm{}), repo_setting.SecretsPost)
|
m.Post("", web.Bind(forms.AddSecretForm{}), repo_setting.SecretsPost)
|
||||||
m.Post("/delete", repo_setting.SecretsDelete)
|
m.Post("/delete", repo_setting.SecretsDelete)
|
||||||
|
m.Post("/promote", repo_setting.SecretsPromote)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -130,6 +130,11 @@ func DeleteSecretByName(ctx context.Context, ownerID, repoID int64, name string)
|
|||||||
return deleteSecret(ctx, s[0])
|
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 {
|
func deleteSecret(ctx context.Context, s *secret_model.Secret) error {
|
||||||
if _, err := db.DeleteByID[secret_model.Secret](ctx, s.ID); err != nil {
|
if _, err := db.DeleteByID[secret_model.Secret](ctx, s.ID); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
<div class="blog-featured-meta">
|
<div class="blog-featured-meta">
|
||||||
{{if .FeaturedPost.Author}}
|
{{if .FeaturedPost.Author}}
|
||||||
<img class="blog-avatar" src="{{.FeaturedPost.Author.AvatarLink ctx}}" alt="{{.FeaturedPost.Author.Name}}">
|
<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">·</span>
|
<span class="blog-meta-sep">·</span>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{if .FeaturedPost.Repo}}
|
{{if .FeaturedPost.Repo}}
|
||||||
@@ -53,7 +53,7 @@
|
|||||||
<div class="blog-tile-meta">
|
<div class="blog-tile-meta">
|
||||||
{{if .Author}}
|
{{if .Author}}
|
||||||
<img class="blog-avatar-sm" src="{{.Author.AvatarLink ctx}}" alt="{{.Author.Name}}">
|
<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">·</span>
|
<span class="blog-meta-sep">·</span>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{if .Repo}}
|
{{if .Repo}}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
<div class="blog-featured-meta">
|
<div class="blog-featured-meta">
|
||||||
{{if .FeaturedPost.Author}}
|
{{if .FeaturedPost.Author}}
|
||||||
<img class="blog-avatar" src="{{.FeaturedPost.Author.AvatarLink ctx}}" alt="{{.FeaturedPost.Author.Name}}">
|
<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">·</span>
|
<span class="blog-meta-sep">·</span>
|
||||||
{{end}}
|
{{end}}
|
||||||
<span class="blog-date">{{DateUtils.TimeSince .FeaturedPost.CreatedUnix}}</span>
|
<span class="blog-date">{{DateUtils.TimeSince .FeaturedPost.CreatedUnix}}</span>
|
||||||
@@ -61,7 +61,7 @@
|
|||||||
<div class="blog-post-tile-meta">
|
<div class="blog-post-tile-meta">
|
||||||
{{if .Author}}
|
{{if .Author}}
|
||||||
<img class="blog-avatar-sm" src="{{.Author.AvatarLink ctx}}" alt="{{.Author.Name}}">
|
<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">·</span>
|
<span class="blog-meta-sep">·</span>
|
||||||
{{end}}
|
{{end}}
|
||||||
<span>{{DateUtils.TimeSince .CreatedUnix}}</span>
|
<span>{{DateUtils.TimeSince .CreatedUnix}}</span>
|
||||||
|
|||||||
@@ -21,7 +21,11 @@
|
|||||||
<div class="blog-view-meta">
|
<div class="blog-view-meta">
|
||||||
{{if .BlogPost.Author}}
|
{{if .BlogPost.Author}}
|
||||||
<img class="blog-avatar" src="{{.BlogPost.Author.AvatarLink ctx}}" alt="{{.BlogPost.Author.Name}}">
|
<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">·</span>
|
<span class="blog-meta-sep">·</span>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{if .BlogPost.PublishedUnix}}
|
{{if .BlogPost.PublishedUnix}}
|
||||||
|
|||||||
@@ -91,6 +91,25 @@
|
|||||||
>
|
>
|
||||||
{{svg "octicon-trash"}}
|
{{svg "octicon-trash"}}
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
Reference in New Issue
Block a user