Implements explicit column migration logic to handle schema upgrades that xorm.Sync() doesn't reliably perform. Adds encryption_mode column migration for vault_secret table to support lockbox (E2E) encryption. Includes database-agnostic column existence checks and ALTER TABLE statement generation for PostgreSQL, MySQL, SQLite, and MSSQL.
263 lines
8.0 KiB
Go
263 lines
8.0 KiB
Go
// Copyright 2026 MarketAlly. All rights reserved.
|
|
// Business Source License 1.1 - See LICENSE file for details.
|
|
|
|
package vault
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
|
|
"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/routes"
|
|
|
|
"code.gitcaddy.com/server/v3/modules/log"
|
|
"code.gitcaddy.com/server/v3/modules/plugins"
|
|
|
|
"xorm.io/xorm"
|
|
)
|
|
|
|
const PluginName = "vault"
|
|
|
|
// Version can be set at build time via ldflags:
|
|
// -ldflags "-X git.marketally.com/gitcaddy/gitcaddy-vault.Version=v1.0.30"
|
|
var Version = "dev"
|
|
|
|
// init automatically registers the vault when this package is imported
|
|
func init() {
|
|
// Set version in routes package
|
|
routes.Version = Version
|
|
Register()
|
|
}
|
|
|
|
// VaultPlugin is the main entry point for the GitCaddy Vault plugin
|
|
type VaultPlugin struct {
|
|
license *license.Manager
|
|
}
|
|
|
|
// New creates a new VaultPlugin instance
|
|
func New() *VaultPlugin {
|
|
return &VaultPlugin{
|
|
license: license.NewManager(),
|
|
}
|
|
}
|
|
|
|
// Name returns the plugin name
|
|
func (p *VaultPlugin) Name() string {
|
|
return PluginName
|
|
}
|
|
|
|
// Version returns the plugin version
|
|
func (p *VaultPlugin) Version() string {
|
|
return Version
|
|
}
|
|
|
|
// Description returns the plugin description
|
|
func (p *VaultPlugin) Description() string {
|
|
return "Secure secrets management for GitCaddy repositories"
|
|
}
|
|
|
|
// Init initializes the plugin
|
|
func (p *VaultPlugin) Init(ctx context.Context) error {
|
|
log.Info("Initializing GitCaddy Vault plugin v%s", Version)
|
|
|
|
// Load master encryption key
|
|
if err := crypto.LoadMasterKey(); err != nil {
|
|
log.Warn("Vault master key not configured: %v", err)
|
|
log.Warn("Vault encryption will not work. Set MASTER_KEY in [vault] section of app.ini or GITCADDY_VAULT_KEY environment variable.")
|
|
}
|
|
|
|
// Load and validate license
|
|
if err := p.license.Load(); err != nil {
|
|
log.Warn("Vault license not found or invalid: %v", err)
|
|
log.Warn("Vault features will be disabled. Visit https://gitcaddy.com/vault to purchase a license.")
|
|
} else {
|
|
info := p.license.Info()
|
|
log.Info("Vault licensed: tier=%s, expires=%v", info.Tier, info.ExpiresAt)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Shutdown cleans up the plugin
|
|
func (p *VaultPlugin) Shutdown(ctx context.Context) error {
|
|
log.Info("Shutting down GitCaddy Vault plugin")
|
|
return nil
|
|
}
|
|
|
|
// RegisterModels returns the database models for this plugin
|
|
func (p *VaultPlugin) RegisterModels() []any {
|
|
return []any{
|
|
new(models.VaultSecret),
|
|
new(models.VaultSecretVersion),
|
|
new(models.VaultAuditEntry),
|
|
new(models.VaultToken),
|
|
new(models.VaultRepoKey),
|
|
}
|
|
}
|
|
|
|
// Migrate runs database migrations for this plugin
|
|
func (p *VaultPlugin) Migrate(ctx context.Context, x *xorm.Engine) error {
|
|
// First, sync tables (works for fresh installations)
|
|
if err := x.Sync(p.RegisterModels()...); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Then run explicit column migrations for upgrades
|
|
// xorm.Sync() doesn't reliably add columns to existing tables
|
|
return runColumnMigrations(ctx, x)
|
|
}
|
|
|
|
// runColumnMigrations adds any missing columns to existing tables.
|
|
// This handles the case where xorm.Sync() fails to add new columns.
|
|
func runColumnMigrations(ctx context.Context, x *xorm.Engine) error {
|
|
migrations := []struct {
|
|
table string
|
|
column string
|
|
columnType string
|
|
defaultVal string
|
|
}{
|
|
// v1.0.31: Add encryption_mode column for lockbox (E2E) secrets
|
|
{"vault_secret", "encryption_mode", "VARCHAR(20)", "'standard'"},
|
|
}
|
|
|
|
for _, m := range migrations {
|
|
exists, err := columnExists(x, m.table, m.column)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !exists {
|
|
log.Info("Vault migration: adding column %s.%s", m.table, m.column)
|
|
sql := buildAddColumnSQL(x, m.table, m.column, m.columnType, m.defaultVal)
|
|
if _, err := x.Exec(sql); err != nil {
|
|
return fmt.Errorf("failed to add column %s.%s: %w", m.table, m.column, err)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// columnExists checks if a column exists in a table
|
|
func columnExists(x *xorm.Engine, table, column string) (bool, error) {
|
|
dialect := x.Dialect().URI().DBType
|
|
var sql string
|
|
|
|
switch dialect {
|
|
case "postgres":
|
|
sql = fmt.Sprintf(`SELECT 1 FROM information_schema.columns WHERE table_name = '%s' AND column_name = '%s'`, table, column)
|
|
case "mysql":
|
|
sql = fmt.Sprintf(`SELECT 1 FROM information_schema.columns WHERE table_name = '%s' AND column_name = '%s'`, table, column)
|
|
case "sqlite3":
|
|
// SQLite uses PRAGMA
|
|
results, err := x.Query(fmt.Sprintf("PRAGMA table_info(%s)", table))
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
for _, row := range results {
|
|
if string(row["name"]) == column {
|
|
return true, nil
|
|
}
|
|
}
|
|
return false, nil
|
|
case "mssql":
|
|
sql = fmt.Sprintf(`SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID('%s') AND name = '%s'`, table, column)
|
|
default:
|
|
// Fallback: try postgres-style
|
|
sql = fmt.Sprintf(`SELECT 1 FROM information_schema.columns WHERE table_name = '%s' AND column_name = '%s'`, table, column)
|
|
}
|
|
|
|
results, err := x.Query(sql)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return len(results) > 0, nil
|
|
}
|
|
|
|
// buildAddColumnSQL builds the appropriate ALTER TABLE statement for the database type
|
|
func buildAddColumnSQL(x *xorm.Engine, table, column, columnType, defaultVal string) string {
|
|
dialect := x.Dialect().URI().DBType
|
|
|
|
switch dialect {
|
|
case "postgres":
|
|
return fmt.Sprintf(`ALTER TABLE "%s" ADD COLUMN IF NOT EXISTS "%s" %s NOT NULL DEFAULT %s`, table, column, columnType, defaultVal)
|
|
case "mysql":
|
|
// MySQL doesn't have IF NOT EXISTS for columns, but we already checked
|
|
return fmt.Sprintf("ALTER TABLE `%s` ADD COLUMN `%s` %s NOT NULL DEFAULT %s", table, column, columnType, defaultVal)
|
|
case "sqlite3":
|
|
return fmt.Sprintf(`ALTER TABLE "%s" ADD COLUMN "%s" %s NOT NULL DEFAULT %s`, table, column, columnType, defaultVal)
|
|
case "mssql":
|
|
return fmt.Sprintf(`ALTER TABLE [%s] ADD [%s] %s NOT NULL DEFAULT %s`, table, column, columnType, defaultVal)
|
|
default:
|
|
// Fallback to standard SQL
|
|
return fmt.Sprintf(`ALTER TABLE "%s" ADD COLUMN "%s" %s NOT NULL DEFAULT %s`, table, column, columnType, defaultVal)
|
|
}
|
|
}
|
|
|
|
// RegisterRepoWebRoutes adds vault routes under /{owner}/{repo}/vault
|
|
func (p *VaultPlugin) RegisterRepoWebRoutes(r plugins.PluginRouter) {
|
|
routes.RegisterRepoWebRoutes(r, p.license)
|
|
}
|
|
|
|
// RegisterRepoAPIRoutes adds vault API routes under /api/v1/repos/{owner}/{repo}/vault
|
|
func (p *VaultPlugin) RegisterRepoAPIRoutes(r plugins.PluginRouter) {
|
|
routes.RegisterRepoAPIRoutes(r, p.license)
|
|
}
|
|
|
|
// ValidateLicense validates the plugin license
|
|
func (p *VaultPlugin) ValidateLicense(ctx context.Context) error {
|
|
return p.license.Validate()
|
|
}
|
|
|
|
// LicenseInfo returns current license information
|
|
func (p *VaultPlugin) LicenseInfo() *plugins.LicenseInfo {
|
|
info := p.license.Info()
|
|
if info == nil {
|
|
return nil
|
|
}
|
|
return &plugins.LicenseInfo{
|
|
Valid: info.Valid,
|
|
Tier: info.Tier,
|
|
CustomerID: info.CustomerEmail,
|
|
ExpiresAt: info.ExpiresAt,
|
|
GracePeriod: info.GracePeriod,
|
|
}
|
|
}
|
|
|
|
// IsConfigured returns true if the vault has a master key configured
|
|
func (p *VaultPlugin) IsConfigured() bool {
|
|
return crypto.HasMasterKey()
|
|
}
|
|
|
|
// ConfigurationError returns the configuration error message if vault is not properly configured
|
|
func (p *VaultPlugin) ConfigurationError() string {
|
|
if !crypto.HasMasterKey() {
|
|
return "no master key configured - add MASTER_KEY to [vault] section in app.ini"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// IsUsingFallbackKey returns true if the vault is using Gitea's SECRET_KEY
|
|
// as the encryption key instead of an explicit vault-specific key.
|
|
func (p *VaultPlugin) IsUsingFallbackKey() bool {
|
|
return crypto.IsUsingFallbackKey()
|
|
}
|
|
|
|
// KeySource returns a human-readable description of where the master key was loaded from.
|
|
func (p *VaultPlugin) KeySource() string {
|
|
return crypto.KeySource()
|
|
}
|
|
|
|
// Ensure VaultPlugin implements all required interfaces
|
|
var (
|
|
_ plugins.Plugin = (*VaultPlugin)(nil)
|
|
_ plugins.DatabasePlugin = (*VaultPlugin)(nil)
|
|
_ plugins.RepoRoutesPlugin = (*VaultPlugin)(nil)
|
|
_ plugins.LicensedPlugin = (*VaultPlugin)(nil)
|
|
)
|
|
|
|
// Register registers the vault plugin with GitCaddy
|
|
func Register() {
|
|
plugins.Register(New())
|
|
}
|