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.
1697 lines
44 KiB
Go
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, "&", "&")
|
|
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 !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")
|
|
}
|
|
}
|