Replace x.Dialect().URI().DBType with x.DriverName() for more reliable database driver detection. Add support for 'pgx' and 'sqlite' driver variants alongside existing 'postgres' and 'sqlite3'. Improve logging with driver information and error messages for better migration debugging.
270 lines
8.6 KiB
Go
270 lines
8.6 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) {
|
|
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())
|
|
}
|