// 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) { driverName := x.DriverName() log.Info("Vault migration: checking column %s.%s (driver: %s)", table, column, driverName) var sql string switch driverName { case "postgres", "pgx": 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": // 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 information_schema (works for postgres/mysql) log.Warn("Vault migration: unknown driver '%s', using information_schema fallback", driverName) 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 { log.Error("Vault migration: column check failed: %v", err) return false, err } exists := len(results) > 0 log.Info("Vault migration: column %s.%s exists: %v", table, column, exists) return exists, nil } // buildAddColumnSQL builds the appropriate ALTER TABLE statement for the database type func buildAddColumnSQL(x *xorm.Engine, table, column, columnType, defaultVal string) string { driverName := x.DriverName() log.Info("Vault migration: building ALTER TABLE for driver: %s", driverName) switch driverName { case "postgres", "pgx": 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", "sqlite": 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 postgres-style (most compatible) log.Warn("Vault migration: unknown driver '%s', using postgres-style ALTER TABLE", driverName) return fmt.Sprintf(`ALTER TABLE "%s" ADD COLUMN IF NOT EXISTS "%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()) }