diff --git a/models/secret/secret.go b/models/secret/secret.go index 20235dc115..c33318a5a1 100644 --- a/models/secret/secret.go +++ b/models/secret/secret.go @@ -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) diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 3152f765d3..d749d133f4 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -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.", diff --git a/routers/web/repo/setting/secrets.go b/routers/web/repo/setting/secrets.go index 326480e339..b17ca2c282 100644 --- a/routers/web/repo/setting/secrets.go +++ b/routers/web/repo/setting/secrets.go @@ -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) +} diff --git a/routers/web/shared/secrets/secrets.go b/routers/web/shared/secrets/secrets.go index dff24ef8f9..e3693278f7 100644 --- a/routers/web/shared/secrets/secrets.go +++ b/routers/web/shared/secrets/secrets.go @@ -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") diff --git a/routers/web/web.go b/routers/web/web.go index 0ed7d9edc8..28d8a03d71 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -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) }) } diff --git a/services/secrets/secrets.go b/services/secrets/secrets.go index 9ac8d265a2..31b3df6286 100644 --- a/services/secrets/secrets.go +++ b/services/secrets/secrets.go @@ -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 diff --git a/templates/explore/blogs.tmpl b/templates/explore/blogs.tmpl index 57ba17a984..1b9a448632 100644 --- a/templates/explore/blogs.tmpl +++ b/templates/explore/blogs.tmpl @@ -18,7 +18,7 @@