2
0

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:
2026-01-21 00:39:21 -05:00
parent a8d39d6aa5
commit 8aed522586
6 changed files with 285 additions and 24 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
return s
}
func webRollbackSecret(lic *license.Manager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireLicense(lic, w) {

View 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" .}}

View File

@@ -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>

View File

@@ -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}}