Add cross-vault move command for secrets

Implement syntax: secret move/mv <vault>:<secret> <vault>[:<secret>]
- Copies all versions to destination vault with re-encryption
- Deletes source after successful copy (true move)
- Add --force flag to overwrite existing destination
- Support both within-vault rename and cross-vault move
- Add shell completion for vault:secret syntax
- Include integration tests for cross-vault move
This commit is contained in:
2025-12-23 15:24:13 +07:00
parent 7264026d66
commit 128c53a11d
5 changed files with 559 additions and 30 deletions

View File

@@ -483,3 +483,158 @@ func (v *Vault) GetSecretObject(name string) (*secret.Secret, error) {
return secretObj, nil
}
// CopySecretVersion copies a single version from source to this vault
// It decrypts the value using srcIdentity and re-encrypts for this vault
func (v *Vault) CopySecretVersion(
srcVersion *secret.Version,
srcIdentity *age.X25519Identity,
destSecretName string,
destVersionName string,
) error {
secret.DebugWith("Copying secret version to vault",
slog.String("src_secret", srcVersion.SecretName),
slog.String("src_version", srcVersion.Version),
slog.String("dest_vault", v.Name),
slog.String("dest_secret", destSecretName),
slog.String("dest_version", destVersionName),
)
// Get the decrypted value from source
valueBuffer, err := srcVersion.GetValue(srcIdentity)
if err != nil {
return fmt.Errorf("failed to decrypt source version: %w", err)
}
defer valueBuffer.Destroy()
// Load source metadata
if err := srcVersion.LoadMetadata(srcIdentity); err != nil {
return fmt.Errorf("failed to load source metadata: %w", err)
}
// Create destination version with same name
destVersion := secret.NewVersion(v, destSecretName, destVersionName)
// Copy metadata (preserve original timestamps)
destVersion.Metadata = srcVersion.Metadata
// Save the version (encrypts to this vault's LT key)
if err := destVersion.Save(valueBuffer); err != nil {
return fmt.Errorf("failed to save destination version: %w", err)
}
secret.Debug("Successfully copied secret version",
"src_version", srcVersion.Version,
"dest_version", destVersionName,
"dest_vault", v.Name)
return nil
}
// CopySecretAllVersions copies all versions of a secret from source vault to this vault
// It re-encrypts each version with this vault's long-term key
func (v *Vault) CopySecretAllVersions(
srcVault *Vault,
srcSecretName string,
destSecretName string,
force bool,
) error {
secret.DebugWith("Copying all secret versions between vaults",
slog.String("src_vault", srcVault.Name),
slog.String("src_secret", srcSecretName),
slog.String("dest_vault", v.Name),
slog.String("dest_secret", destSecretName),
slog.Bool("force", force),
)
// Get destination vault directory
destVaultDir, err := v.GetDirectory()
if err != nil {
return fmt.Errorf("failed to get destination vault directory: %w", err)
}
// Check if destination secret already exists
destStorageName := strings.ReplaceAll(destSecretName, "/", "%")
destSecretDir := filepath.Join(destVaultDir, "secrets.d", destStorageName)
exists, err := afero.DirExists(v.fs, destSecretDir)
if err != nil {
return fmt.Errorf("failed to check destination: %w", err)
}
if exists && !force {
return fmt.Errorf("secret '%s' already exists in vault '%s' (use --force to overwrite)",
destSecretName, v.Name)
}
if exists && force {
// Remove existing secret
secret.Debug("Removing existing destination secret", "path", destSecretDir)
if err := v.fs.RemoveAll(destSecretDir); err != nil {
return fmt.Errorf("failed to remove existing destination secret: %w", err)
}
}
// Get source vault's long-term key
srcIdentity, err := srcVault.GetOrDeriveLongTermKey()
if err != nil {
return fmt.Errorf("failed to unlock source vault '%s': %w", srcVault.Name, err)
}
// Get source secret directory
srcVaultDir, err := srcVault.GetDirectory()
if err != nil {
return fmt.Errorf("failed to get source vault directory: %w", err)
}
srcStorageName := strings.ReplaceAll(srcSecretName, "/", "%")
srcSecretDir := filepath.Join(srcVaultDir, "secrets.d", srcStorageName)
// List all versions
versions, err := secret.ListVersions(srcVault.fs, srcSecretDir)
if err != nil {
return fmt.Errorf("failed to list source versions: %w", err)
}
if len(versions) == 0 {
return fmt.Errorf("source secret '%s' has no versions", srcSecretName)
}
// Get current version name
currentVersion, err := secret.GetCurrentVersion(srcVault.fs, srcSecretDir)
if err != nil {
return fmt.Errorf("failed to get current version: %w", err)
}
// Create destination secret directory
if err := v.fs.MkdirAll(destSecretDir, secret.DirPerms); err != nil {
return fmt.Errorf("failed to create destination secret directory: %w", err)
}
// Copy each version
for _, versionName := range versions {
srcVersion := secret.NewVersion(srcVault, srcSecretName, versionName)
if err := v.CopySecretVersion(srcVersion, srcIdentity, destSecretName, versionName); err != nil {
// Rollback: remove partial copy
secret.Debug("Rolling back partial copy due to error", "error", err)
_ = v.fs.RemoveAll(destSecretDir)
return fmt.Errorf("failed to copy version %s: %w", versionName, err)
}
}
// Set current version
if err := secret.SetCurrentVersion(v.fs, destSecretDir, currentVersion); err != nil {
_ = v.fs.RemoveAll(destSecretDir)
return fmt.Errorf("failed to set current version: %w", err)
}
secret.DebugWith("Successfully copied all secret versions",
slog.String("src_vault", srcVault.Name),
slog.String("dest_vault", v.Name),
slog.Int("version_count", len(versions)),
)
return nil
}