feat(vault): add version comparison feature for secrets
- Add new compare endpoint and template for viewing diffs between secret versions - Display creator information (name and avatar) for each version - Add locale strings for comparison UI, type filters, and view modes - Enhance permission checks to include owner and access mode validation - Add non-database fields to SecretVersion model for UI display
This commit is contained in:
@@ -148,3 +148,28 @@ error_create_token_failed = Failed to create token
|
||||
error_invalid_token_id = Invalid token ID
|
||||
error_token_not_found = Token not found
|
||||
error_revoke_failed = Failed to revoke token
|
||||
; Compare versions
|
||||
compare = Compare
|
||||
compare_version = Compare this version
|
||||
compare_versions = Compare Versions
|
||||
compare_from = From Version
|
||||
compare_to = To Version
|
||||
run_compare = Compare
|
||||
unified_diff = Unified Diff
|
||||
back_to_versions = Back to Versions
|
||||
compare_same_version = Cannot compare a version with itself
|
||||
|
||||
; Type filter
|
||||
all_types = All Types
|
||||
|
||||
; View modes
|
||||
hidden = Hidden
|
||||
raw = Raw
|
||||
copy = Copy
|
||||
copied = Copied!
|
||||
save = Save
|
||||
edit_secret = Edit Secret
|
||||
edit_hint = Modifying this value will create a new version
|
||||
view_hidden = View hidden
|
||||
view_raw = View raw value
|
||||
value = Value
|
||||
|
||||
@@ -20,6 +20,10 @@ type VaultSecretVersion struct {
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"created NOT NULL"`
|
||||
CreatedBy int64 `xorm:"NOT NULL"`
|
||||
Comment string `xorm:"TEXT"` // "Initial", "Rotated key", "Rolled back to v3"
|
||||
|
||||
// Non-database fields for UI display
|
||||
CreatorName string `xorm:"-"`
|
||||
Creator any `xorm:"-"` // *user_model.User for avatar display
|
||||
}
|
||||
|
||||
// TableName returns the table name for xorm
|
||||
|
||||
171
routes/routes.go
171
routes/routes.go
@@ -5,8 +5,10 @@ package routes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.marketally.com/gitcaddy/gitcaddy-vault/license"
|
||||
"git.marketally.com/gitcaddy/gitcaddy-vault/models"
|
||||
@@ -14,6 +16,7 @@ import (
|
||||
|
||||
"code.gitcaddy.com/server/v3/models/perm"
|
||||
"code.gitcaddy.com/server/v3/models/unit"
|
||||
user_model "code.gitcaddy.com/server/v3/models/user"
|
||||
"code.gitcaddy.com/server/v3/modules/log"
|
||||
"code.gitcaddy.com/server/v3/modules/templates"
|
||||
"code.gitcaddy.com/server/v3/modules/timeutil"
|
||||
@@ -31,6 +34,7 @@ const (
|
||||
tplVaultView templates.TplName = "repo/vault/view"
|
||||
tplVaultNew templates.TplName = "repo/vault/new"
|
||||
tplVaultVersions templates.TplName = "repo/vault/versions"
|
||||
tplVaultCompare templates.TplName = "repo/vault/compare"
|
||||
tplVaultAudit templates.TplName = "repo/vault/audit"
|
||||
tplVaultTokens templates.TplName = "repo/vault/tokens"
|
||||
)
|
||||
@@ -135,6 +139,7 @@ func RegisterRepoWebRoutes(m chi.Router, lic *license.Manager) {
|
||||
r.Post("/secrets/{name}/delete", webDeleteSecret(lic))
|
||||
r.Post("/secrets/{name}/restore", webRestoreSecret(lic))
|
||||
r.Get("/secrets/{name}/versions", webListVersions(lic))
|
||||
r.Get("/secrets/{name}/compare", webCompareVersions(lic))
|
||||
r.Post("/secrets/{name}/rollback", webRollbackSecret(lic))
|
||||
r.Get("/audit", webViewAudit(lic))
|
||||
r.Get("/tokens", webListTokens(lic))
|
||||
@@ -989,13 +994,29 @@ func webViewSecret(lic *license.Manager) http.HandlerFunc {
|
||||
Timestamp: timeutil.TimeStampNow(),
|
||||
})
|
||||
|
||||
// Populate creator info for versions
|
||||
for _, v := range versions {
|
||||
if v.CreatedBy > 0 {
|
||||
user, err := user_model.GetUserByID(ctx, v.CreatedBy)
|
||||
if err == nil && user != nil {
|
||||
v.CreatorName = user.Name
|
||||
v.Creator = user
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.Data["Title"] = name + " - " + ctx.Locale.TrString("vault.secrets")
|
||||
ctx.Data["PageIsVaultSecrets"] = true
|
||||
ctx.Data["Secret"] = secret
|
||||
ctx.Data["SecretValue"] = value
|
||||
ctx.Data["Versions"] = versions
|
||||
ctx.Data["CanWrite"] = ctx.Repo.CanWrite(unit.TypeCode)
|
||||
ctx.Data["IsRepoAdmin"] = ctx.Repo.IsAdmin()
|
||||
|
||||
// Permission checks - match webListSecrets for consistency
|
||||
isOwner := ctx.Repo.Repository.OwnerID == ctx.Doer.ID
|
||||
hasWriteAccess := ctx.Repo.CanWrite(unit.TypeCode)
|
||||
hasAccess := ctx.Repo.AccessMode >= perm.AccessModeWrite
|
||||
ctx.Data["CanWrite"] = hasWriteAccess || isOwner || hasAccess
|
||||
ctx.Data["IsRepoAdmin"] = ctx.Repo.IsAdmin() || isOwner || hasAccess
|
||||
|
||||
ctx.HTML(http.StatusOK, tplVaultView)
|
||||
}
|
||||
@@ -1229,17 +1250,159 @@ func webListVersions(lic *license.Manager) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
// Populate creator info for each version
|
||||
for _, v := range versions {
|
||||
if v.CreatedBy > 0 {
|
||||
user, err := user_model.GetUserByID(ctx, v.CreatedBy)
|
||||
if err == nil && user != nil {
|
||||
v.CreatorName = user.Name
|
||||
v.Creator = user
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.Data["Title"] = name + " - " + ctx.Locale.TrString("vault.version_history")
|
||||
ctx.Data["PageIsVaultSecrets"] = true
|
||||
ctx.Data["Secret"] = secret
|
||||
ctx.Data["Versions"] = versions
|
||||
ctx.Data["CanWrite"] = ctx.Repo.CanWrite(unit.TypeCode)
|
||||
ctx.Data["IsRepoAdmin"] = ctx.Repo.IsAdmin()
|
||||
|
||||
// Permission checks - match webListSecrets for consistency
|
||||
isOwner := ctx.Repo.Repository.OwnerID == ctx.Doer.ID
|
||||
hasWriteAccess := ctx.Repo.CanWrite(unit.TypeCode)
|
||||
hasAccess := ctx.Repo.AccessMode >= perm.AccessModeWrite
|
||||
ctx.Data["CanWrite"] = hasWriteAccess || isOwner || hasAccess
|
||||
ctx.Data["IsRepoAdmin"] = ctx.Repo.IsAdmin() || isOwner || hasAccess
|
||||
|
||||
ctx.HTML(http.StatusOK, tplVaultVersions)
|
||||
}
|
||||
}
|
||||
|
||||
func webCompareVersions(lic *license.Manager) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireLicense(lic, w) {
|
||||
return
|
||||
}
|
||||
|
||||
ctx := getWebContext(r)
|
||||
if ctx == nil || ctx.Repo.Repository == nil {
|
||||
http.Error(w, "Context not found", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
name := chi.URLParam(r, "name")
|
||||
|
||||
secret, err := services.GetSecret(ctx, ctx.Repo.Repository.ID, name)
|
||||
if err != nil {
|
||||
if err == services.ErrSecretNotFound {
|
||||
ctx.NotFound(nil)
|
||||
} else {
|
||||
ctx.ServerError("GetSecret", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
versions, err := services.ListVersions(ctx, ctx.Repo.Repository.ID, name)
|
||||
if err != nil {
|
||||
ctx.ServerError("ListVersions", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Populate creator info for versions
|
||||
for _, v := range versions {
|
||||
if v.CreatedBy > 0 {
|
||||
user, err := user_model.GetUserByID(ctx, v.CreatedBy)
|
||||
if err == nil && user != nil {
|
||||
v.CreatorName = user.Name
|
||||
v.Creator = user
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fromStr := r.URL.Query().Get("from")
|
||||
toStr := r.URL.Query().Get("to")
|
||||
|
||||
fromVersion := 0
|
||||
toVersion := secret.CurrentVersion
|
||||
if fromStr != "" {
|
||||
fromVersion, _ = strconv.Atoi(fromStr)
|
||||
}
|
||||
if toStr != "" {
|
||||
toVersion, _ = strconv.Atoi(toStr)
|
||||
}
|
||||
|
||||
// Default from to first non-current version if not specified
|
||||
if fromVersion == 0 && len(versions) > 0 {
|
||||
for _, v := range versions {
|
||||
if v.Version != secret.CurrentVersion {
|
||||
fromVersion = v.Version
|
||||
break
|
||||
}
|
||||
}
|
||||
if fromVersion == 0 {
|
||||
fromVersion = secret.CurrentVersion
|
||||
}
|
||||
}
|
||||
|
||||
ctx.Data["Title"] = name + " - " + ctx.Locale.TrString("vault.compare_versions")
|
||||
ctx.Data["PageIsVaultSecrets"] = true
|
||||
ctx.Data["Secret"] = secret
|
||||
ctx.Data["Versions"] = versions
|
||||
ctx.Data["FromVersion"] = fromVersion
|
||||
ctx.Data["ToVersion"] = toVersion
|
||||
|
||||
// If both versions specified and different, show the diff
|
||||
if fromVersion > 0 && toVersion > 0 && fromVersion != toVersion {
|
||||
fromValue, err1 := services.GetSecretValue(ctx, ctx.Repo.Repository.ID, name, fromVersion)
|
||||
toValue, err2 := services.GetSecretValue(ctx, ctx.Repo.Repository.ID, name, toVersion)
|
||||
|
||||
if err1 == nil && err2 == nil {
|
||||
ctx.Data["ShowDiff"] = true
|
||||
ctx.Data["FromValue"] = fromValue
|
||||
ctx.Data["ToValue"] = toValue
|
||||
ctx.Data["DiffHTML"] = computeHTMLDiff(fromValue, toValue, fromVersion, toVersion)
|
||||
}
|
||||
}
|
||||
|
||||
ctx.HTML(http.StatusOK, tplVaultCompare)
|
||||
}
|
||||
}
|
||||
|
||||
func computeHTMLDiff(text1, text2 string, v1, v2 int) string {
|
||||
lines1 := strings.Split(text1, "\n")
|
||||
lines2 := strings.Split(text2, "\n")
|
||||
var result strings.Builder
|
||||
|
||||
result.WriteString(fmt.Sprintf("<span class=\"diff-header\">@@ v%d -> v%d @@</span>\n", v1, v2))
|
||||
|
||||
i, j := 0, 0
|
||||
for i < len(lines1) || j < len(lines2) {
|
||||
if i >= len(lines1) {
|
||||
result.WriteString(fmt.Sprintf("<span class=\"diff-add\">+ %s</span>\n", escapeHTML(lines2[j])))
|
||||
j++
|
||||
} else if j >= len(lines2) {
|
||||
result.WriteString(fmt.Sprintf("<span class=\"diff-del\">- %s</span>\n", escapeHTML(lines1[i])))
|
||||
i++
|
||||
} else if lines1[i] == lines2[j] {
|
||||
result.WriteString(fmt.Sprintf(" %s\n", escapeHTML(lines1[i])))
|
||||
i++
|
||||
j++
|
||||
} else {
|
||||
result.WriteString(fmt.Sprintf("<span class=\"diff-del\">- %s</span>\n", escapeHTML(lines1[i])))
|
||||
result.WriteString(fmt.Sprintf("<span class=\"diff-add\">+ %s</span>\n", escapeHTML(lines2[j])))
|
||||
i++
|
||||
j++
|
||||
}
|
||||
}
|
||||
|
||||
return result.String()
|
||||
}
|
||||
func escapeHTML(s string) string {
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
return s
|
||||
}
|
||||
|
||||
func webRollbackSecret(lic *license.Manager) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireLicense(lic, w) {
|
||||
|
||||
64
templates/repo/vault/compare.tmpl
Normal file
64
templates/repo/vault/compare.tmpl
Normal file
@@ -0,0 +1,64 @@
|
||||
{{template "repo/vault/layout_head" (dict "ctxData" . "pageClass" "repository vault compare")}}
|
||||
<div class="ui segment">
|
||||
<div class="tw-flex tw-items-start tw-justify-between tw-gap-4">
|
||||
<div>
|
||||
<h4 class="ui header tw-mb-0">
|
||||
{{svg "octicon-diff" 20}} {{ctx.Locale.Tr "vault.compare_versions"}}
|
||||
<div class="sub header">{{.Secret.Name}}</div>
|
||||
</h4>
|
||||
</div>
|
||||
<div class="tw-flex-shrink-0">
|
||||
<a class="ui button" href="{{.RepoLink}}/vault/secrets/{{.Secret.Name}}/versions">
|
||||
{{svg "octicon-arrow-left" 16}} {{ctx.Locale.Tr "vault.back_to_versions"}}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ui segment">
|
||||
<form class="ui form" method="get" action="{{.RepoLink}}/vault/secrets/{{.Secret.Name}}/compare">
|
||||
<div class="tw-flex tw-gap-4 tw-items-end">
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "vault.compare_from"}}</label>
|
||||
<select name="from" class="ui dropdown">
|
||||
{{range .Versions}}
|
||||
<option value="{{.Version}}" {{if eq .Version $.FromVersion}}selected{{end}}>v{{.Version}}{{if eq .Version $.Secret.CurrentVersion}} ({{ctx.Locale.Tr "vault.current"}}){{end}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "vault.compare_to"}}</label>
|
||||
<select name="to" class="ui dropdown">
|
||||
{{range .Versions}}
|
||||
<option value="{{.Version}}" {{if eq .Version $.ToVersion}}selected{{end}}>v{{.Version}}{{if eq .Version $.Secret.CurrentVersion}} ({{ctx.Locale.Tr "vault.current"}}){{end}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
<button class="ui primary button" type="submit">
|
||||
{{svg "octicon-diff" 14}} {{ctx.Locale.Tr "vault.run_compare"}}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{{if .ShowDiff}}
|
||||
<div class="ui segment">
|
||||
<div class="ui two column stackable grid">
|
||||
<div class="column">
|
||||
<h5 class="ui header">{{ctx.Locale.Tr "vault.version"}} v{{.FromVersion}}</h5>
|
||||
<pre class="ui segment secondary tw-font-mono tw-overflow-auto" style="max-height: 400px; white-space: pre-wrap; word-break: break-all;">{{.FromValue}}</pre>
|
||||
</div>
|
||||
<div class="column">
|
||||
<h5 class="ui header">{{ctx.Locale.Tr "vault.version"}} v{{.ToVersion}}</h5>
|
||||
<pre class="ui segment secondary tw-font-mono tw-overflow-auto" style="max-height: 400px; white-space: pre-wrap; word-break: break-all;">{{.ToValue}}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ui segment">
|
||||
<h5 class="ui header">{{ctx.Locale.Tr "vault.unified_diff"}}</h5>
|
||||
<pre class="tw-font-mono" style="max-height: 400px; overflow: auto; white-space: pre-wrap; word-break: break-all;">{{.DiffHTML}}</pre>
|
||||
</div>
|
||||
<style>.diff-add { background-color: #e6ffec; color: #1a7f37; } .diff-del { background-color: #ffebe9; color: #cf222e; } .diff-header { color: #6e7781; font-weight: bold; }</style>
|
||||
{{end}}
|
||||
{{template "repo/vault/layout_footer" .}}
|
||||
@@ -52,9 +52,9 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="tw-w-full">{{ctx.Locale.Tr "vault.secret_name"}}</th>
|
||||
<th class="tw-whitespace-nowrap">{{ctx.Locale.Tr "vault.version"}}</th>
|
||||
<th class="tw-whitespace-nowrap">{{ctx.Locale.Tr "vault.updated"}}</th>
|
||||
<th class="right aligned tw-whitespace-nowrap">{{ctx.Locale.Tr "actions"}}</th>
|
||||
<th class="tw-text-center tw-whitespace-nowrap" style="width: 70px;">{{ctx.Locale.Tr "vault.version"}}</th>
|
||||
<th class="tw-text-center tw-whitespace-nowrap" style="width: 120px;">{{ctx.Locale.Tr "vault.updated"}}</th>
|
||||
<th class="tw-text-center tw-whitespace-nowrap" style="width: 120px;">{{ctx.Locale.Tr "actions"}}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -71,9 +71,9 @@
|
||||
<span class="ui red label">{{ctx.Locale.Tr "vault.deleted"}}</span>
|
||||
{{end}}
|
||||
</td>
|
||||
<td>v{{.CurrentVersion}}</td>
|
||||
<td>{{DateUtils.TimeSince .UpdatedUnix}}</td>
|
||||
<td class="right aligned tw-whitespace-nowrap">
|
||||
<td class="tw-text-center tw-whitespace-nowrap">v{{.CurrentVersion}}</td>
|
||||
<td class="tw-text-center tw-whitespace-nowrap">{{DateUtils.TimeSince .UpdatedUnix}}</td>
|
||||
<td class="tw-text-center tw-whitespace-nowrap">
|
||||
<a class="ui tiny button" href="{{$.RepoLink}}/vault/secrets/{{.Name}}">
|
||||
{{svg "octicon-eye" 14}} {{ctx.Locale.Tr "view"}}
|
||||
</a>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
{{template "repo/vault/layout_head" (dict "ctxData" . "pageClass" "repository vault versions")}}
|
||||
<div class="ui segment">
|
||||
<div class="ui two column stackable grid">
|
||||
<div class="column">
|
||||
<h4 class="ui header">
|
||||
<div class="tw-flex tw-items-start tw-justify-between tw-gap-4">
|
||||
<div>
|
||||
<h4 class="ui header tw-mb-0">
|
||||
{{svg "octicon-history" 20}} {{ctx.Locale.Tr "vault.version_history"}}
|
||||
<div class="sub header">{{.Secret.Name}}</div>
|
||||
</h4>
|
||||
</div>
|
||||
<div class="column right aligned">
|
||||
<div class="tw-flex-shrink-0">
|
||||
<a class="ui button" href="{{.RepoLink}}/vault/secrets/{{.Secret.Name}}">
|
||||
{{svg "octicon-arrow-left" 16}} {{ctx.Locale.Tr "vault.back_to_secret"}}
|
||||
</a>
|
||||
@@ -46,16 +46,21 @@
|
||||
{{end}}
|
||||
</td>
|
||||
<td>{{DateUtils.TimeSince .CreatedUnix}}</td>
|
||||
<td class="right aligned">
|
||||
{{if and $.CanWrite (ne .Version $.Secret.CurrentVersion)}}
|
||||
<form class="ui inline" action="{{$.RepoLink}}/vault/secrets/{{$.Secret.Name}}/rollback" method="post">
|
||||
{{$.CsrfTokenHtml}}
|
||||
<input type="hidden" name="version" value="{{.Version}}">
|
||||
<button class="ui tiny button" type="submit" onclick="return confirm('{{ctx.Locale.Tr "vault.confirm_rollback" .Version}}');">
|
||||
{{svg "octicon-history" 14}} {{ctx.Locale.Tr "vault.rollback_to_this"}}
|
||||
</button>
|
||||
</form>
|
||||
{{end}}
|
||||
<td class="right aligned tw-whitespace-nowrap">
|
||||
<div class="ui tiny buttons">
|
||||
<a class="ui button" href="{{$.RepoLink}}/vault/secrets/{{$.Secret.Name}}/compare?from={{.Version}}">
|
||||
{{svg "octicon-diff" 14}} {{ctx.Locale.Tr "vault.compare"}}
|
||||
</a>
|
||||
{{if and $.CanWrite (ne .Version $.Secret.CurrentVersion)}}
|
||||
<form class="ui inline tw-inline" action="{{$.RepoLink}}/vault/secrets/{{$.Secret.Name}}/rollback" method="post">
|
||||
{{$.CsrfTokenHtml}}
|
||||
<input type="hidden" name="version" value="{{.Version}}">
|
||||
<button class="ui button" type="submit" onclick="return confirm('{{ctx.Locale.Tr "vault.confirm_rollback" .Version}}');">
|
||||
{{svg "octicon-history" 14}} {{ctx.Locale.Tr "vault.rollback"}}
|
||||
</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
|
||||
Reference in New Issue
Block a user