2
0
Files
gitcaddy-vault/routes/routes.go
logikonline 6790c1ea7c feat(license): enforce tier limits for secrets and tokens
Add license limit enforcement when creating secrets and tokens. Pass license limits to service layer and return appropriate errors when tier limits are exceeded. Handle limit errors in both API and web routes with proper error messages prompting users to upgrade.
2026-01-21 15:55:29 -05:00

1697 lines
44 KiB
Go

// Copyright 2026 MarketAlly. All rights reserved.
// Proprietary and confidential.
package routes
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"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/plugins"
"code.gitcaddy.com/server/v3/modules/templates"
"code.gitcaddy.com/server/v3/modules/timeutil"
"code.gitcaddy.com/server/v3/services/context"
"git.marketally.com/gitcaddy/gitcaddy-vault/crypto"
"git.marketally.com/gitcaddy/gitcaddy-vault/license"
"git.marketally.com/gitcaddy/gitcaddy-vault/models"
"git.marketally.com/gitcaddy/gitcaddy-vault/services"
"github.com/go-chi/chi/v5"
)
// Version is set by the main vault package at init time
var Version = "dev"
// Template names for vault pages
const (
tplVaultList templates.TplName = "repo/vault/list"
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"
)
// API Response types
// SecretResponse represents a secret in API responses (no encrypted values)
type SecretResponse struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Type string `json:"type"`
CurrentVersion int `json:"current_version"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
IsDeleted bool `json:"is_deleted,omitempty"`
}
// SecretValueResponse represents a secret with its decrypted value
type SecretValueResponse struct {
SecretResponse
Value string `json:"value"`
}
// SecretVersionResponse represents a version of a secret
type SecretVersionResponse struct {
Version int `json:"version"`
Comment string `json:"comment,omitempty"`
CreatedBy int64 `json:"created_by"`
CreatedAt int64 `json:"created_at"`
}
// TokenResponse represents a token in API responses
type TokenResponse struct {
ID int64 `json:"id"`
Description string `json:"description"`
Scope string `json:"scope"`
CreatedAt int64 `json:"created_at"`
ExpiresAt int64 `json:"expires_at"`
LastUsedAt int64 `json:"last_used_at,omitempty"`
UsedCount int64 `json:"used_count"`
IsRevoked bool `json:"is_revoked"`
}
// TokenCreateResponse includes the raw token (only shown once)
type TokenCreateResponse struct {
TokenResponse
Token string `json:"token"`
}
// AuditEntryResponse represents an audit log entry
type AuditEntryResponse struct {
ID int64 `json:"id"`
SecretID int64 `json:"secret_id,omitempty"`
SecretName string `json:"secret_name,omitempty"`
Action string `json:"action"`
UserID int64 `json:"user_id"`
Username string `json:"username,omitempty"`
IPAddress string `json:"ip_address,omitempty"`
Success bool `json:"success"`
Message string `json:"message,omitempty"`
Timestamp int64 `json:"timestamp"`
}
// API Request types
// CreateSecretRequest is the request body for creating a secret
type CreateSecretRequest struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" binding:"required"`
}
// UpdateSecretRequest is the request body for updating a secret
type UpdateSecretRequest struct {
Value string `json:"value" binding:"required"`
Type string `json:"type"`
Comment string `json:"comment"`
}
// CreateTokenRequest is the request body for creating a token
type CreateTokenRequest struct {
Description string `json:"description" binding:"required"`
Scope string `json:"scope"` // read, write, read:secret_name, etc.
TTL string `json:"ttl"` // e.g., "24h", "7d", "30d"
}
// RollbackRequest is the request body for rolling back a secret
type RollbackRequest struct {
Version int `json:"version" binding:"required"`
}
// RegisterRepoWebRoutes registers vault web routes under /{owner}/{repo}/vault
func RegisterRepoWebRoutes(r plugins.PluginRouter, lic *license.Manager) {
r.Group("/vault", func(r plugins.PluginRouter) {
// Web UI routes - render HTML pages
// IMPORTANT: More specific routes must come before less specific ones
r.Get("/", webListSecrets(lic))
r.Get("/audit", webViewAudit(lic))
r.Get("/tokens", webListTokens(lic))
r.Post("/tokens", webCreateToken(lic))
r.Post("/tokens/{id}/revoke", webRevokeToken(lic))
// Static secret routes first
r.Get("/secrets/new", webNewSecretForm(lic))
r.Post("/secrets/new", webCreateSecret(lic))
// Parameterized routes with suffixes before bare parameter routes
r.Get("/secrets/{name}/versions", webListVersions(lic))
r.Get("/secrets/{name}/compare", webCompareVersions(lic))
r.Post("/secrets/{name}/delete", webDeleteSecret(lic))
r.Post("/secrets/{name}/restore", webRestoreSecret(lic))
r.Post("/secrets/{name}/rollback", webRollbackSecret(lic))
// Bare parameter routes last (least specific)
r.Get("/secrets/{name}", webViewSecret(lic))
r.Post("/secrets/{name}", webUpdateSecret(lic))
})
}
// RegisterRepoAPIRoutes registers vault API routes under /api/v1/repos/{owner}/{repo}/vault
func RegisterRepoAPIRoutes(r plugins.PluginRouter, lic *license.Manager) {
r.Group("/vault", func(r plugins.PluginRouter) {
// Secrets CRUD
r.Get("/secrets", apiListSecrets(lic))
r.Get("/secrets/{name}", apiGetSecret(lic))
r.Put("/secrets/{name}", apiPutSecret(lic))
r.Delete("/secrets/{name}", apiDeleteSecret(lic))
// Restore
r.Post("/secrets/{name}/restore", apiRestoreSecret(lic))
// Versions
r.Get("/secrets/{name}/versions", apiListVersions(lic))
// Rollback
r.Post("/secrets/{name}/rollback", apiRollbackSecret(lic))
// Audit
r.Get("/audit", apiGetAudit(lic))
// Tokens
r.Get("/tokens", apiListTokens(lic))
r.Post("/tokens", apiCreateToken(lic))
r.Delete("/tokens/{id}", apiRevokeToken(lic))
// Key rotation (enterprise)
r.Post("/rotate-key", apiRotateKey(lic))
})
}
// Helper functions
// requireWebLicense checks license for web routes and renders HTML error page
func requireWebLicense(lic *license.Manager, r *http.Request) bool {
if err := lic.Validate(); err != nil {
ctx := getWebContext(r)
if ctx != nil {
ctx.Data["Title"] = "License Required"
ctx.HTML(http.StatusPaymentRequired, "repo/vault/not_installed")
return false
}
return false
}
return true
}
func getRepoContext(r *http.Request) *context.APIContext {
return context.GetAPIContext(r)
}
func getWebContext(r *http.Request) *context.Context {
return context.GetWebContext(r.Context())
}
func requireRepoAdmin(ctx *context.APIContext) bool {
if !ctx.Repo.IsAdmin() {
ctx.JSON(http.StatusForbidden, map[string]any{
"error": "forbidden",
"message": "Repository admin access required",
})
return false
}
return true
}
func requireRepoWrite(ctx *context.APIContext) bool {
if !ctx.Repo.CanWrite(unit.TypeCode) {
ctx.JSON(http.StatusForbidden, map[string]any{
"error": "forbidden",
"message": "Repository write access required",
})
return false
}
return true
}
func secretToResponse(s *models.VaultSecret) SecretResponse {
return SecretResponse{
Name: s.Name,
Description: s.Description,
Type: string(s.Type),
CurrentVersion: s.CurrentVersion,
CreatedAt: int64(s.CreatedUnix),
UpdatedAt: int64(s.UpdatedUnix),
IsDeleted: s.DeletedUnix > 0,
}
}
func tokenToResponse(t *models.VaultToken) TokenResponse {
return TokenResponse{
ID: t.ID,
Description: t.Description,
Scope: string(t.Scope),
CreatedAt: int64(t.CreatedUnix),
ExpiresAt: int64(t.ExpiresUnix),
LastUsedAt: int64(t.LastUsedUnix),
UsedCount: t.UsedCount,
IsRevoked: t.RevokedUnix > 0,
}
}
// ============================================================================
// API Handlers
// ============================================================================
func apiListSecrets(lic *license.Manager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireWebLicense(lic, r) {
return
}
ctx := getRepoContext(r)
if ctx == nil || ctx.Repo.Repository == nil {
http.Error(w, "Repository context not found", http.StatusInternalServerError)
return
}
includeDeleted := r.URL.Query().Get("include_deleted") == "true"
secrets, err := services.ListSecrets(ctx, ctx.Repo.Repository.ID, includeDeleted)
if err != nil {
ctx.JSON(http.StatusInternalServerError, map[string]any{
"error": "internal_error",
"message": err.Error(),
})
return
}
// Log the access
_ = services.CreateAuditEntry(ctx, &models.VaultAuditEntry{
RepoID: ctx.Repo.Repository.ID,
Action: models.AuditActionList,
UserID: ctx.Doer.ID,
IPAddress: ctx.RemoteAddr(),
Success: true,
Timestamp: timeutil.TimeStampNow(),
})
response := make([]SecretResponse, len(secrets))
for i, s := range secrets {
response[i] = secretToResponse(s)
}
ctx.JSON(http.StatusOK, response)
}
}
func apiGetSecret(lic *license.Manager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireWebLicense(lic, r) {
return
}
ctx := getRepoContext(r)
if ctx == nil || ctx.Repo.Repository == nil {
http.Error(w, "Repository context not found", http.StatusInternalServerError)
return
}
name := chi.URLParam(r, "name")
versionStr := r.URL.Query().Get("version")
version := 0
if versionStr != "" {
var err error
version, err = strconv.Atoi(versionStr)
if err != nil {
ctx.JSON(http.StatusBadRequest, map[string]any{
"error": "invalid_version",
"message": "Version must be a number",
})
return
}
}
// Get the secret metadata
secret, err := services.GetSecret(ctx, ctx.Repo.Repository.ID, name)
if err != nil {
if err == services.ErrSecretNotFound {
ctx.JSON(http.StatusNotFound, map[string]any{
"error": "not_found",
"message": "Secret not found",
})
} else {
ctx.JSON(http.StatusInternalServerError, map[string]any{
"error": "internal_error",
"message": err.Error(),
})
}
return
}
// Get the decrypted value
value, err := services.GetSecretValue(ctx, ctx.Repo.Repository.ID, name, version)
if err != nil {
log.Error("Failed to decrypt secret %s: %v", name, err)
ctx.JSON(http.StatusInternalServerError, map[string]any{
"error": "decryption_failed",
"message": "Failed to decrypt secret",
})
return
}
// Log the access
_ = services.CreateAuditEntry(ctx, &models.VaultAuditEntry{
RepoID: ctx.Repo.Repository.ID,
SecretID: secret.ID,
Action: models.AuditActionRead,
UserID: ctx.Doer.ID,
IPAddress: ctx.RemoteAddr(),
Success: true,
Timestamp: timeutil.TimeStampNow(),
})
response := SecretValueResponse{
SecretResponse: secretToResponse(secret),
Value: value,
}
ctx.JSON(http.StatusOK, response)
}
}
func apiPutSecret(lic *license.Manager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireWebLicense(lic, r) {
return
}
ctx := getRepoContext(r)
if ctx == nil || ctx.Repo.Repository == nil {
http.Error(w, "Repository context not found", http.StatusInternalServerError)
return
}
if !requireRepoWrite(ctx) {
return
}
name := chi.URLParam(r, "name")
var req CreateSecretRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
ctx.JSON(http.StatusBadRequest, map[string]any{
"error": "invalid_request",
"message": "Invalid JSON body",
})
return
}
// Check if secret exists
existing, err := services.GetSecret(ctx, ctx.Repo.Repository.ID, name)
if err == services.ErrSecretNotFound {
// Create new secret
if req.Name == "" {
req.Name = name
}
var limits *license.Limits
if info := lic.Info(); info != nil {
limits = &info.Limits
}
secret, err := services.CreateSecret(ctx, ctx.Repo.Repository.ID, services.CreateSecretOptions{
Name: req.Name,
Description: req.Description,
Type: req.Type,
Value: req.Value,
CreatorID: ctx.Doer.ID,
}, limits)
if err != nil {
if err == services.ErrSecretExists {
ctx.JSON(http.StatusConflict, map[string]any{
"error": "already_exists",
"message": "Secret already exists",
})
} else if err == services.ErrSecretLimitReached {
ctx.JSON(http.StatusPaymentRequired, map[string]any{
"error": "limit_reached",
"message": "Secret limit reached for this tier. Upgrade your license for more secrets.",
})
} else {
ctx.JSON(http.StatusInternalServerError, map[string]any{
"error": "internal_error",
"message": err.Error(),
})
}
return
}
ctx.JSON(http.StatusCreated, secretToResponse(secret))
return
} else if err != nil {
ctx.JSON(http.StatusInternalServerError, map[string]any{
"error": "internal_error",
"message": err.Error(),
})
return
}
// Update existing secret
secretType := req.Type
if secretType == "" {
secretType = string(existing.Type)
}
secret, err := services.UpdateSecret(ctx, ctx.Repo.Repository.ID, name, services.UpdateSecretOptions{
Type: secretType,
Value: req.Value,
Comment: req.Description,
UpdaterID: ctx.Doer.ID,
})
if err != nil {
ctx.JSON(http.StatusInternalServerError, map[string]any{
"error": "internal_error",
"message": err.Error(),
})
return
}
ctx.JSON(http.StatusOK, secretToResponse(secret))
}
}
func apiDeleteSecret(lic *license.Manager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireWebLicense(lic, r) {
return
}
ctx := getRepoContext(r)
if ctx == nil || ctx.Repo.Repository == nil {
http.Error(w, "Repository context not found", http.StatusInternalServerError)
return
}
if !requireRepoWrite(ctx) {
return
}
name := chi.URLParam(r, "name")
if err := services.DeleteSecret(ctx, ctx.Repo.Repository.ID, name, ctx.Doer.ID); err != nil {
if err == services.ErrSecretNotFound {
ctx.JSON(http.StatusNotFound, map[string]any{
"error": "not_found",
"message": "Secret not found",
})
} else {
ctx.JSON(http.StatusInternalServerError, map[string]any{
"error": "internal_error",
"message": err.Error(),
})
}
return
}
ctx.JSON(http.StatusOK, map[string]any{
"message": "Secret deleted",
})
}
}
func apiRestoreSecret(lic *license.Manager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireWebLicense(lic, r) {
return
}
ctx := getRepoContext(r)
if ctx == nil || ctx.Repo.Repository == nil {
http.Error(w, "Repository context not found", http.StatusInternalServerError)
return
}
if !requireRepoWrite(ctx) {
return
}
name := chi.URLParam(r, "name")
if err := services.RestoreSecret(ctx, ctx.Repo.Repository.ID, name); err != nil {
if err == services.ErrSecretNotFound {
ctx.JSON(http.StatusNotFound, map[string]any{
"error": "not_found",
"message": "Secret not found",
})
} else {
ctx.JSON(http.StatusInternalServerError, map[string]any{
"error": "internal_error",
"message": err.Error(),
})
}
return
}
ctx.JSON(http.StatusOK, map[string]any{
"message": "Secret restored",
})
}
}
func apiListVersions(lic *license.Manager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireWebLicense(lic, r) {
return
}
ctx := getRepoContext(r)
if ctx == nil || ctx.Repo.Repository == nil {
http.Error(w, "Repository context not found", http.StatusInternalServerError)
return
}
name := chi.URLParam(r, "name")
versions, err := services.ListVersions(ctx, ctx.Repo.Repository.ID, name)
if err != nil {
if err == services.ErrSecretNotFound {
ctx.JSON(http.StatusNotFound, map[string]any{
"error": "not_found",
"message": "Secret not found",
})
} else {
ctx.JSON(http.StatusInternalServerError, map[string]any{
"error": "internal_error",
"message": err.Error(),
})
}
return
}
response := make([]SecretVersionResponse, len(versions))
for i, v := range versions {
response[i] = SecretVersionResponse{
Version: v.Version,
Comment: v.Comment,
CreatedBy: v.CreatedBy,
CreatedAt: int64(v.CreatedUnix),
}
}
ctx.JSON(http.StatusOK, response)
}
}
func apiRollbackSecret(lic *license.Manager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireWebLicense(lic, r) {
return
}
ctx := getRepoContext(r)
if ctx == nil || ctx.Repo.Repository == nil {
http.Error(w, "Repository context not found", http.StatusInternalServerError)
return
}
if !requireRepoWrite(ctx) {
return
}
name := chi.URLParam(r, "name")
var req RollbackRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
ctx.JSON(http.StatusBadRequest, map[string]any{
"error": "invalid_request",
"message": "Invalid JSON body",
})
return
}
if req.Version <= 0 {
ctx.JSON(http.StatusBadRequest, map[string]any{
"error": "invalid_version",
"message": "Version must be a positive number",
})
return
}
if err := services.RollbackSecret(ctx, ctx.Repo.Repository.ID, name, req.Version, ctx.Doer.ID); err != nil {
switch err {
case services.ErrSecretNotFound:
ctx.JSON(http.StatusNotFound, map[string]any{
"error": "not_found",
"message": "Secret not found",
})
case services.ErrVersionNotFound:
ctx.JSON(http.StatusNotFound, map[string]any{
"error": "version_not_found",
"message": "Version not found",
})
default:
ctx.JSON(http.StatusInternalServerError, map[string]any{
"error": "internal_error",
"message": err.Error(),
})
}
return
}
ctx.JSON(http.StatusOK, map[string]any{
"message": "Secret rolled back to version " + strconv.Itoa(req.Version),
})
}
}
func apiGetAudit(lic *license.Manager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireWebLicense(lic, r) {
return
}
ctx := getRepoContext(r)
if ctx == nil || ctx.Repo.Repository == nil {
http.Error(w, "Repository context not found", http.StatusInternalServerError)
return
}
if !requireRepoAdmin(ctx) {
return
}
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
pageSize, _ := strconv.Atoi(r.URL.Query().Get("page_size"))
if page <= 0 {
page = 1
}
if pageSize <= 0 || pageSize > 100 {
pageSize = 50
}
entries, total, err := services.ListAuditEntries(ctx, ctx.Repo.Repository.ID, page, pageSize)
if err != nil {
ctx.JSON(http.StatusInternalServerError, map[string]any{
"error": "internal_error",
"message": err.Error(),
})
return
}
response := make([]AuditEntryResponse, len(entries))
for i, e := range entries {
response[i] = AuditEntryResponse{
ID: e.ID,
SecretID: e.SecretID,
Action: string(e.Action),
UserID: e.UserID,
IPAddress: e.IPAddress,
Success: e.Success,
Message: e.FailReason,
Timestamp: int64(e.Timestamp),
}
}
ctx.JSON(http.StatusOK, map[string]any{
"entries": response,
"total": total,
"page": page,
"pages": (total + int64(pageSize) - 1) / int64(pageSize),
})
}
}
func apiListTokens(lic *license.Manager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireWebLicense(lic, r) {
return
}
ctx := getRepoContext(r)
if ctx == nil || ctx.Repo.Repository == nil {
http.Error(w, "Repository context not found", http.StatusInternalServerError)
return
}
if !requireRepoAdmin(ctx) {
return
}
tokens, err := services.ListTokens(ctx, ctx.Repo.Repository.ID)
if err != nil {
ctx.JSON(http.StatusInternalServerError, map[string]any{
"error": "internal_error",
"message": err.Error(),
})
return
}
response := make([]TokenResponse, len(tokens))
for i, t := range tokens {
response[i] = tokenToResponse(t)
}
ctx.JSON(http.StatusOK, response)
}
}
func apiCreateToken(lic *license.Manager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireWebLicense(lic, r) {
return
}
ctx := getRepoContext(r)
if ctx == nil || ctx.Repo.Repository == nil {
http.Error(w, "Repository context not found", http.StatusInternalServerError)
return
}
if !requireRepoAdmin(ctx) {
return
}
var req CreateTokenRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
ctx.JSON(http.StatusBadRequest, map[string]any{
"error": "invalid_request",
"message": "Invalid JSON body",
})
return
}
if req.Description == "" {
ctx.JSON(http.StatusBadRequest, map[string]any{
"error": "invalid_request",
"message": "Description is required",
})
return
}
// Default values
if req.Scope == "" {
req.Scope = "read"
}
if req.TTL == "" {
req.TTL = "30d"
}
// Parse TTL string (support days)
ttl := req.TTL
if len(ttl) > 0 && ttl[len(ttl)-1] == 'd' {
days, err := strconv.Atoi(ttl[:len(ttl)-1])
if err == nil {
ttl = strconv.Itoa(days*24) + "h"
}
}
var limits *license.Limits
if info := lic.Info(); info != nil {
limits = &info.Limits
}
token, rawToken, err := services.CreateToken(ctx, ctx.Repo.Repository.ID, services.CreateTokenOptions{
Description: req.Description,
Scope: req.Scope,
TTL: ttl,
CreatorID: ctx.Doer.ID,
}, limits)
if err != nil {
if err == services.ErrTokenLimitReached {
ctx.JSON(http.StatusPaymentRequired, map[string]any{
"error": "limit_reached",
"message": "Token limit reached for this tier. Upgrade your license for more tokens.",
})
return
}
ctx.JSON(http.StatusInternalServerError, map[string]any{
"error": "internal_error",
"message": err.Error(),
})
return
}
response := TokenCreateResponse{
TokenResponse: tokenToResponse(token),
Token: rawToken,
}
ctx.JSON(http.StatusCreated, response)
}
}
func apiRevokeToken(lic *license.Manager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireWebLicense(lic, r) {
return
}
ctx := getRepoContext(r)
if ctx == nil || ctx.Repo.Repository == nil {
http.Error(w, "Repository context not found", http.StatusInternalServerError)
return
}
if !requireRepoAdmin(ctx) {
return
}
idStr := chi.URLParam(r, "id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
ctx.JSON(http.StatusBadRequest, map[string]any{
"error": "invalid_id",
"message": "Invalid token ID",
})
return
}
if err := services.RevokeToken(ctx, ctx.Repo.Repository.ID, id); err != nil {
if err == services.ErrTokenNotFound {
ctx.JSON(http.StatusNotFound, map[string]any{
"error": "not_found",
"message": "Token not found",
})
} else {
ctx.JSON(http.StatusInternalServerError, map[string]any{
"error": "internal_error",
"message": err.Error(),
})
}
return
}
ctx.JSON(http.StatusOK, map[string]any{
"message": "Token revoked",
})
}
}
func apiRotateKey(lic *license.Manager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireWebLicense(lic, r) {
return
}
info := lic.Info()
if info == nil || info.Tier != "enterprise" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusPaymentRequired)
_ = json.NewEncoder(w).Encode(map[string]any{
"error": "enterprise_required",
"message": "Key rotation requires an Enterprise license",
})
return
}
ctx := getRepoContext(r)
if ctx == nil || ctx.Repo.Repository == nil {
http.Error(w, "Repository context not found", http.StatusInternalServerError)
return
}
if !requireRepoAdmin(ctx) {
return
}
// TODO: Implement key rotation
// This involves:
// 1. Generate new DEK
// 2. Re-encrypt all secret versions with new DEK
// 3. Update the repo key
// This should be done in a transaction
ctx.JSON(http.StatusNotImplemented, map[string]any{
"error": "not_implemented",
"message": "Key rotation coming soon",
})
}
}
// ============================================================================
// Web Handlers (HTML rendering)
// ============================================================================
func webListSecrets(lic *license.Manager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireWebLicense(lic, r) {
return
}
ctx := getWebContext(r)
if ctx == nil || ctx.Repo.Repository == nil {
http.Error(w, "Context not found", http.StatusInternalServerError)
return
}
ctx.Data["Title"] = ctx.Tr("vault.secrets")
ctx.Data["PageIsVaultSecrets"] = true
includeDeleted := r.URL.Query().Get("include_deleted") == "true"
secrets, err := services.ListSecrets(ctx, ctx.Repo.Repository.ID, includeDeleted)
if err != nil {
ctx.ServerError("ListSecrets", err)
return
}
ctx.Data["Secrets"] = secrets
ctx.Data["IncludeDeleted"] = includeDeleted
// Group secrets by type for template
secretsByType := make(map[string][]*models.VaultSecret)
for _, s := range secrets {
secretsByType[string(s.Type)] = append(secretsByType[string(s.Type)], s)
}
ctx.Data["SecretsByType"] = secretsByType
// Check permissions - use multiple fallbacks for team-based access
isOwner := ctx.Repo.Repository.OwnerID == ctx.Doer.ID
hasWriteAccess := ctx.Repo.CanWrite(unit.TypeCode)
isAdmin := ctx.Repo.IsAdmin()
hasAccess := ctx.Repo.AccessMode >= perm.AccessModeWrite
ctx.Data["ShowDeleted"] = isAdmin || isOwner || hasAccess
ctx.Data["CanWrite"] = hasWriteAccess || isOwner || hasAccess
ctx.Data["IsRepoAdmin"] = isAdmin || isOwner || hasAccess
// License info for display
licInfo := lic.Info()
if licInfo != nil {
ctx.Data["LicenseTier"] = licInfo.Tier
ctx.Data["LicenseValid"] = licInfo.Valid
}
ctx.Data["VaultVersion"] = Version
// Check vault configuration
ctx.Data["VaultConfigured"] = crypto.HasMasterKey()
if !crypto.HasMasterKey() {
ctx.Data["VaultConfigError"] = "no master key configured"
}
ctx.HTML(http.StatusOK, tplVaultList)
}
}
func webViewSecret(lic *license.Manager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireWebLicense(lic, r) {
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
}
value, err := services.GetSecretValue(ctx, ctx.Repo.Repository.ID, name, 0)
if err != nil {
log.Error("Failed to decrypt secret %s: %v", name, err)
ctx.ServerError("GetSecretValue", err)
return
}
versions, err := services.ListVersions(ctx, ctx.Repo.Repository.ID, name)
if err != nil {
log.Warn("Failed to list versions for %s: %v", name, err)
}
// Log access
_ = services.CreateAuditEntry(ctx, &models.VaultAuditEntry{
RepoID: ctx.Repo.Repository.ID,
SecretID: secret.ID,
Action: models.AuditActionRead,
UserID: ctx.Doer.ID,
IPAddress: ctx.RemoteAddr(),
Success: true,
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
// 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)
}
}
func webUpdateSecret(lic *license.Manager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireWebLicense(lic, r) {
return
}
ctx := getWebContext(r)
if ctx == nil || ctx.Repo.Repository == nil {
http.Error(w, "Context not found", http.StatusInternalServerError)
return
}
if !ctx.Repo.CanWrite(unit.TypeCode) {
ctx.NotFound(nil)
return
}
name := chi.URLParam(r, "name")
value := r.FormValue("value")
secretType := r.FormValue("type")
comment := r.FormValue("comment")
if value == "" {
ctx.Flash.Error(ctx.Tr("vault.error_value_required"))
ctx.Redirect(ctx.Repo.RepoLink + "/vault/secrets/" + name)
return
}
_, err := services.UpdateSecret(ctx, ctx.Repo.Repository.ID, name, services.UpdateSecretOptions{
Type: secretType,
Value: value,
Comment: comment,
UpdaterID: ctx.Doer.ID,
})
if err != nil {
ctx.Flash.Error(ctx.Tr("vault.error_update_failed"))
ctx.Redirect(ctx.Repo.RepoLink + "/vault/secrets/" + name)
return
}
ctx.Flash.Success(ctx.Tr("vault.secret_updated"))
ctx.Redirect(ctx.Repo.RepoLink + "/vault/secrets/" + name)
}
}
func webNewSecretForm(lic *license.Manager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireWebLicense(lic, r) {
return
}
ctx := getWebContext(r)
if ctx == nil || ctx.Repo.Repository == nil {
http.Error(w, "Context not found", http.StatusInternalServerError)
return
}
if !ctx.Repo.CanWrite(unit.TypeCode) {
ctx.NotFound(nil)
return
}
ctx.Data["Title"] = ctx.Tr("vault.new_secret")
ctx.Data["PageIsVaultSecrets"] = true
ctx.Data["CanWrite"] = true
ctx.Data["IsRepoAdmin"] = ctx.Repo.IsAdmin()
ctx.HTML(http.StatusOK, tplVaultNew)
}
}
func webCreateSecret(lic *license.Manager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireWebLicense(lic, r) {
return
}
ctx := getWebContext(r)
if ctx == nil || ctx.Repo.Repository == nil {
http.Error(w, "Context not found", http.StatusInternalServerError)
return
}
if !ctx.Repo.CanWrite(unit.TypeCode) {
ctx.NotFound(nil)
return
}
name := r.FormValue("name")
description := r.FormValue("description")
secretType := r.FormValue("type")
value := r.FormValue("value")
if name == "" || value == "" {
ctx.Data["Title"] = ctx.Tr("vault.new_secret")
ctx.Data["PageIsVaultSecrets"] = true
ctx.Data["Err_Name"] = name == ""
ctx.Data["Err_Value"] = value == ""
ctx.Data["name"] = name
ctx.Data["description"] = description
ctx.Flash.Error(ctx.Tr("vault.error_required_fields"))
ctx.HTML(http.StatusOK, tplVaultNew)
return
}
var limits *license.Limits
if info := lic.Info(); info != nil {
limits = &info.Limits
}
_, err := services.CreateSecret(ctx, ctx.Repo.Repository.ID, services.CreateSecretOptions{
Name: name,
Description: description,
Type: secretType,
Value: value,
CreatorID: ctx.Doer.ID,
}, limits)
if err != nil {
if err == services.ErrSecretExists {
ctx.Data["Title"] = ctx.Tr("vault.new_secret")
ctx.Data["PageIsVaultSecrets"] = true
ctx.Data["Err_Name"] = true
ctx.Data["name"] = name
ctx.Data["description"] = description
ctx.Flash.Error(ctx.Tr("vault.error_secret_exists"))
ctx.HTML(http.StatusOK, tplVaultNew)
return
} else if err == services.ErrSecretLimitReached {
ctx.Data["Title"] = ctx.Tr("vault.new_secret")
ctx.Data["PageIsVaultSecrets"] = true
ctx.Data["name"] = name
ctx.Data["description"] = description
ctx.Flash.Error(ctx.Tr("vault.error_secret_limit"))
ctx.HTML(http.StatusOK, tplVaultNew)
return
}
ctx.ServerError("CreateSecret", err)
return
}
ctx.Flash.Success(ctx.Tr("vault.secret_created"))
ctx.Redirect(ctx.Repo.RepoLink + "/vault/secrets/" + name)
}
}
func webDeleteSecret(lic *license.Manager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireWebLicense(lic, r) {
return
}
ctx := getWebContext(r)
if ctx == nil || ctx.Repo.Repository == nil {
http.Error(w, "Context not found", http.StatusInternalServerError)
return
}
if !ctx.Repo.CanWrite(unit.TypeCode) {
ctx.NotFound(nil)
return
}
name := chi.URLParam(r, "name")
if err := services.DeleteSecret(ctx, ctx.Repo.Repository.ID, name, ctx.Doer.ID); err != nil {
if err == services.ErrSecretNotFound {
ctx.NotFound(nil)
} else {
ctx.ServerError("DeleteSecret", err)
}
return
}
ctx.Flash.Success(ctx.Tr("vault.secret_deleted"))
ctx.Redirect(ctx.Repo.RepoLink + "/vault")
}
}
func webRestoreSecret(lic *license.Manager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireWebLicense(lic, r) {
return
}
ctx := getWebContext(r)
if ctx == nil || ctx.Repo.Repository == nil {
http.Error(w, "Context not found", http.StatusInternalServerError)
return
}
if !ctx.Repo.CanWrite(unit.TypeCode) {
ctx.NotFound(nil)
return
}
name := chi.URLParam(r, "name")
if err := services.RestoreSecret(ctx, ctx.Repo.Repository.ID, name); err != nil {
if err == services.ErrSecretNotFound {
ctx.NotFound(nil)
} else {
ctx.ServerError("RestoreSecret", err)
}
return
}
ctx.Flash.Success(ctx.Tr("vault.secret_restored"))
ctx.Redirect(ctx.Repo.RepoLink + "/vault/secrets/" + name)
}
}
func webListVersions(lic *license.Manager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireWebLicense(lic, r) {
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 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
// 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 !requireWebLicense(lic, r) {
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 !requireWebLicense(lic, r) {
return
}
ctx := getWebContext(r)
if ctx == nil || ctx.Repo.Repository == nil {
http.Error(w, "Context not found", http.StatusInternalServerError)
return
}
if !ctx.Repo.CanWrite(unit.TypeCode) {
ctx.NotFound(nil)
return
}
name := chi.URLParam(r, "name")
versionStr := r.FormValue("version")
version, err := strconv.Atoi(versionStr)
if err != nil || version <= 0 {
ctx.Flash.Error(ctx.Tr("vault.error_invalid_version"))
ctx.Redirect(ctx.Repo.RepoLink + "/vault/secrets/" + name)
return
}
if err := services.RollbackSecret(ctx, ctx.Repo.Repository.ID, name, version, ctx.Doer.ID); err != nil {
switch err {
case services.ErrSecretNotFound:
ctx.NotFound(nil)
case services.ErrVersionNotFound:
ctx.Flash.Error(ctx.Tr("vault.error_version_not_found"))
ctx.Redirect(ctx.Repo.RepoLink + "/vault/secrets/" + name)
default:
ctx.ServerError("RollbackSecret", err)
}
return
}
ctx.Flash.Success(ctx.Tr("vault.secret_rolled_back", version))
ctx.Redirect(ctx.Repo.RepoLink + "/vault/secrets/" + name)
}
}
func webViewAudit(lic *license.Manager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireWebLicense(lic, r) {
return
}
ctx := getWebContext(r)
if ctx == nil || ctx.Repo.Repository == nil {
http.Error(w, "Context not found", http.StatusInternalServerError)
return
}
if !ctx.Repo.IsAdmin() {
ctx.NotFound(nil)
return
}
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
if page <= 0 {
page = 1
}
pageSize := 50
entries, total, err := services.ListAuditEntries(ctx, ctx.Repo.Repository.ID, page, pageSize)
if err != nil {
ctx.ServerError("ListAuditEntries", err)
return
}
totalPages := (int(total) + pageSize - 1) / pageSize
// Generate page numbers for pagination
var pages []int
for i := 1; i <= totalPages; i++ {
pages = append(pages, i)
}
ctx.Data["Title"] = ctx.Tr("vault.audit")
ctx.Data["PageIsVaultAudit"] = true
ctx.Data["AuditEntries"] = entries
ctx.Data["Page"] = page
ctx.Data["TotalPages"] = totalPages
ctx.Data["Pages"] = pages
ctx.Data["IsRepoAdmin"] = true
ctx.HTML(http.StatusOK, tplVaultAudit)
}
}
func webListTokens(lic *license.Manager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireWebLicense(lic, r) {
return
}
ctx := getWebContext(r)
if ctx == nil || ctx.Repo.Repository == nil {
http.Error(w, "Context not found", http.StatusInternalServerError)
return
}
if !ctx.Repo.IsAdmin() {
ctx.NotFound(nil)
return
}
tokens, err := services.ListTokens(ctx, ctx.Repo.Repository.ID)
if err != nil {
ctx.ServerError("ListTokens", err)
return
}
ctx.Data["Title"] = ctx.Tr("vault.tokens")
ctx.Data["PageIsVaultTokens"] = true
ctx.Data["Tokens"] = tokens
ctx.Data["IsRepoAdmin"] = true
ctx.HTML(http.StatusOK, tplVaultTokens)
}
}
func webCreateToken(lic *license.Manager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireWebLicense(lic, r) {
return
}
ctx := getWebContext(r)
if ctx == nil || ctx.Repo.Repository == nil {
http.Error(w, "Context not found", http.StatusInternalServerError)
return
}
if !ctx.Repo.IsAdmin() {
ctx.NotFound(nil)
return
}
description := r.FormValue("description")
scope := r.FormValue("scope")
ttl := r.FormValue("ttl")
if description == "" {
ctx.Flash.Error(ctx.Tr("vault.error_description_required"))
ctx.Redirect(ctx.Repo.RepoLink + "/vault/tokens")
return
}
if scope == "" {
scope = "read"
}
if ttl == "" {
ttl = "30d"
}
// Convert days to hours for the service
if len(ttl) > 0 && ttl[len(ttl)-1] == 'd' {
days, err := strconv.Atoi(ttl[:len(ttl)-1])
if err == nil {
ttl = strconv.Itoa(days*24) + "h"
}
}
var limits *license.Limits
if info := lic.Info(); info != nil {
limits = &info.Limits
}
token, rawToken, err := services.CreateToken(ctx, ctx.Repo.Repository.ID, services.CreateTokenOptions{
Description: description,
Scope: scope,
TTL: ttl,
CreatorID: ctx.Doer.ID,
}, limits)
if err != nil {
if err == services.ErrTokenLimitReached {
ctx.Flash.Error(ctx.Tr("vault.error_token_limit"))
ctx.Redirect(ctx.Repo.RepoLink + "/vault/tokens")
return
}
ctx.Flash.Error(ctx.Tr("vault.error_create_token_failed"))
ctx.Redirect(ctx.Repo.RepoLink + "/vault/tokens")
return
}
// Get all tokens for display
tokens, _ := services.ListTokens(ctx, ctx.Repo.Repository.ID)
ctx.Data["Title"] = ctx.Tr("vault.tokens")
ctx.Data["PageIsVaultTokens"] = true
ctx.Data["Tokens"] = tokens
ctx.Data["NewToken"] = rawToken
ctx.Data["CreatedToken"] = token
ctx.Data["IsRepoAdmin"] = true
ctx.HTML(http.StatusOK, tplVaultTokens)
}
}
func webRevokeToken(lic *license.Manager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireWebLicense(lic, r) {
return
}
ctx := getWebContext(r)
if ctx == nil || ctx.Repo.Repository == nil {
http.Error(w, "Context not found", http.StatusInternalServerError)
return
}
if !ctx.Repo.IsAdmin() {
ctx.NotFound(nil)
return
}
idStr := chi.URLParam(r, "id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
ctx.Flash.Error(ctx.Tr("vault.error_invalid_token_id"))
ctx.Redirect(ctx.Repo.RepoLink + "/vault/tokens")
return
}
if err := services.RevokeToken(ctx, ctx.Repo.Repository.ID, id); err != nil {
if err == services.ErrTokenNotFound {
ctx.Flash.Error(ctx.Tr("vault.error_token_not_found"))
} else {
ctx.Flash.Error(ctx.Tr("vault.error_revoke_failed"))
}
ctx.Redirect(ctx.Repo.RepoLink + "/vault/tokens")
return
}
ctx.Flash.Success(ctx.Tr("vault.token_revoked"))
ctx.Redirect(ctx.Repo.RepoLink + "/vault/tokens")
}
}