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:
parent
7264026d66
commit
128c53a11d
83
CLAUDE.md
83
CLAUDE.md
@ -1,8 +1,4 @@
|
||||
# Rules
|
||||
|
||||
Read the rules in AGENTS.md and follow them.
|
||||
|
||||
# Memory
|
||||
# IMPORTANT RULES
|
||||
|
||||
* Claude is an inanimate tool. The spam that Claude attempts to insert into
|
||||
commit messages (which it erroneously refers to as "attribution") is not
|
||||
@ -16,10 +12,11 @@ Read the rules in AGENTS.md and follow them.
|
||||
* Code should always be formatted before committing. Do not commit
|
||||
unformatted code.
|
||||
|
||||
* Code should always be linted before committing. Do not commit
|
||||
unlinted code.
|
||||
* Code should always be linted and linter errors fixed before committing.
|
||||
NEVER commit code that does not pass the linter. DO NOT modify the linter
|
||||
config unless specifically instructed.
|
||||
|
||||
* The test suite is fast and local. When running tests, don't run
|
||||
* The test suite is fast and local. When running tests, NEVER run
|
||||
individual parts of the test suite, always run the whole thing by running
|
||||
"make test".
|
||||
|
||||
@ -27,4 +24,72 @@ Read the rules in AGENTS.md and follow them.
|
||||
done provided to you in the initial instruction. Don't do part or most of
|
||||
the work, do all of the work until the criteria for done are met.
|
||||
|
||||
* When you complete each task, if the tests are passing and the code is formatted and there are no linter errors, always commit and push your work. Use a good commit message and don't mention any author or co-author attribution.
|
||||
* When you complete each task, if the tests are passing and the code is
|
||||
formatted and there are no linter errors, always commit and push your
|
||||
work. Use a good commit message and don't mention any author or co-author
|
||||
attribution.
|
||||
|
||||
* Do not create additional files in the root directory of the project
|
||||
without asking permission first. Configuration files, documentation, and
|
||||
build files are acceptable in the root, but source code and other files
|
||||
should be organized in appropriate subdirectories.
|
||||
|
||||
* Do not use bare strings or numbers in code, especially if they appear
|
||||
anywhere more than once. Always define a constant (usually at the top of
|
||||
the file) and give it a descriptive name, then use that constant in the
|
||||
code instead of the bare string or number.
|
||||
|
||||
* If you are fixing a bug, write a test first that reproduces the bug and
|
||||
fails, and then fix the bug in the code, using the test to verify that the
|
||||
fix worked.
|
||||
|
||||
* When implementing new features, be aware of potential side-effects (such
|
||||
as state files on disk, data in the database, etc.) and ensure that it is
|
||||
possible to mock or stub these side-effects in tests when designing an
|
||||
API.
|
||||
|
||||
* When dealing with dates and times or timestamps, always use, display, and
|
||||
store UTC. Set the local timezone to UTC on startup. If the user needs
|
||||
to see the time in a different timezone, store the user's timezone in a
|
||||
separate field and convert the UTC time to the user's timezone when
|
||||
displaying it. For internal use and internal applications and
|
||||
administrative purposes, always display UTC.
|
||||
|
||||
* When implementing programs, put the main.go in
|
||||
./cmd/<program_name>/main.go and put the program's code in
|
||||
./internal/<program_name>/. This allows for multiple programs to be
|
||||
implemented in the same repository without cluttering the root directory.
|
||||
main.go should simply import and call <program_name>.CLIEntry(). The
|
||||
full implementation should be in ./internal/<program_name>/.
|
||||
|
||||
* When you are instructed to make the tests pass, DO NOT delete tests, skip
|
||||
tests, or change the tests specifically to make them pass (unless there
|
||||
is a bug in the test). This is cheating, and it is bad. You should only
|
||||
be modifying the test if it is incorrect or if the test is no longer
|
||||
relevant. In almost all cases, you should be fixing the code that is
|
||||
being tested, or updating the tests to match a refactored implementation.
|
||||
|
||||
* Always write a `Makefile` with the default target being `test`, and with a
|
||||
`fmt` target that formats the code. The `test` target should run all
|
||||
tests in the project, and the `fmt` target should format the code. `test`
|
||||
should also have a prerequisite target `lint` that should run any linters
|
||||
that are configured for the project.
|
||||
|
||||
* After each completed bugfix or feature, the code must be committed. Do
|
||||
all of the pre-commit checks (test, lint, fmt) before committing, of
|
||||
course. After each commit, push to the remote.
|
||||
|
||||
* Always write tests, even if they are extremely simple and just check for
|
||||
correct syntax (ability to compile/import). If you are writing a new
|
||||
feature, write a test for it. You don't need to target complete coverage,
|
||||
but you should at least test any new functionality you add.
|
||||
|
||||
* Always use structured logging. Log any relevant state/context with the
|
||||
messages (but do not log secrets). If stdout is not a terminal, output
|
||||
the structured logs in jsonl format. Use go's log/slog.
|
||||
|
||||
* You do not need to summarize your changes in the chat after making them.
|
||||
Making the changes and committing them is sufficient. If anything out of
|
||||
the ordinary happened, please explain it, but in the normal case where you
|
||||
found and fixed the bug, or implemented the feature, there is no need for
|
||||
the end-of-change summary.
|
||||
|
||||
@ -142,3 +142,58 @@ func getVaultNamesCompletionFunc(fs afero.Fs, stateDir string) func(
|
||||
return completions, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
}
|
||||
|
||||
// getVaultSecretCompletionFunc returns a completion function for vault:secret format
|
||||
// It completes vault names with ":" suffix, and after ":" it completes secrets from that vault
|
||||
func getVaultSecretCompletionFunc(fs afero.Fs, stateDir string) func(
|
||||
cmd *cobra.Command, args []string, toComplete string,
|
||||
) ([]string, cobra.ShellCompDirective) {
|
||||
return func(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
var completions []string
|
||||
|
||||
// Check if we're completing after a vault: prefix
|
||||
if strings.Contains(toComplete, ":") {
|
||||
// Complete secret names for the specified vault
|
||||
const vaultSecretParts = 2
|
||||
parts := strings.SplitN(toComplete, ":", vaultSecretParts)
|
||||
vaultName := parts[0]
|
||||
secretPrefix := parts[1]
|
||||
|
||||
vlt := vault.NewVault(fs, stateDir, vaultName)
|
||||
secrets, err := vlt.ListSecrets()
|
||||
if err == nil {
|
||||
for _, secretName := range secrets {
|
||||
if strings.HasPrefix(secretName, secretPrefix) {
|
||||
completions = append(completions, vaultName+":"+secretName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return completions, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
|
||||
// Complete vault names with ":" suffix
|
||||
vaults, err := vault.ListVaults(fs, stateDir)
|
||||
if err == nil {
|
||||
for _, v := range vaults {
|
||||
if strings.HasPrefix(v, toComplete) {
|
||||
completions = append(completions, v+":")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also complete secrets from current vault (for within-vault moves)
|
||||
if currentVlt, err := vault.GetCurrentVault(fs, stateDir); err == nil {
|
||||
secrets, err := currentVlt.ListSecrets()
|
||||
if err == nil {
|
||||
for _, secretName := range secrets {
|
||||
if strings.HasPrefix(secretName, toComplete) {
|
||||
completions = append(completions, secretName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return completions, cobra.ShellCompDirectiveNoSpace
|
||||
}
|
||||
}
|
||||
|
||||
@ -200,6 +200,12 @@ func TestSecretManagerIntegration(t *testing.T) {
|
||||
// Expected: Secret moved to new location, old location removed
|
||||
test12bMoveSecret(t, testMnemonic, runSecret, runSecretWithStdin)
|
||||
|
||||
// Test 12c: Cross-vault move
|
||||
// Commands: secret move work:secret default, secret move work:secret default:newname
|
||||
// Purpose: Test moving secrets between vaults with re-encryption
|
||||
// Expected: Secret copied to destination vault with all versions, source deleted
|
||||
test12cCrossVaultMove(t, testMnemonic, runSecretWithEnv, runSecretWithStdin)
|
||||
|
||||
// Test 13: Unlocker management
|
||||
// Commands: secret unlocker list, secret unlocker add pgp
|
||||
// Purpose: Test multiple unlocker types
|
||||
@ -1164,6 +1170,89 @@ func test12bMoveSecret(t *testing.T, testMnemonic string, runSecret func(...stri
|
||||
assert.Equal(t, "original-value", getOutput, "source should still have original value")
|
||||
}
|
||||
|
||||
func test12cCrossVaultMove(t *testing.T, testMnemonic string, runSecretWithEnv func(map[string]string, ...string) (string, error), runSecretWithStdin func(string, map[string]string, ...string) (string, error)) {
|
||||
env := map[string]string{
|
||||
"SB_SECRET_MNEMONIC": testMnemonic,
|
||||
}
|
||||
|
||||
// Create a test secret in the work vault
|
||||
_, err := runSecretWithEnv(env, "vault", "select", "work")
|
||||
require.NoError(t, err, "select work vault should succeed")
|
||||
|
||||
// Add a secret with a version
|
||||
_, err = runSecretWithStdin("cross-vault-value-v1", env, "add", "cross/move/test")
|
||||
require.NoError(t, err, "add cross/move/test should succeed")
|
||||
|
||||
// Add another version
|
||||
_, err = runSecretWithStdin("cross-vault-value-v2", env, "add", "--force", "cross/move/test")
|
||||
require.NoError(t, err, "add cross/move/test v2 should succeed")
|
||||
|
||||
// Move to default vault using cross-vault syntax
|
||||
output, err := runSecretWithEnv(env, "move", "work:cross/move/test", "default")
|
||||
require.NoError(t, err, "cross-vault move should succeed")
|
||||
assert.Contains(t, output, "Moved secret", "should show move confirmation")
|
||||
assert.Contains(t, output, "2 version(s)", "should show version count")
|
||||
|
||||
// Verify secret exists in default vault
|
||||
_, err = runSecretWithEnv(env, "vault", "select", "default")
|
||||
require.NoError(t, err, "select default vault should succeed")
|
||||
|
||||
value, err := runSecretWithEnv(env, "get", "cross/move/test")
|
||||
require.NoError(t, err, "get from default vault should succeed")
|
||||
assert.Equal(t, "cross-vault-value-v2", value, "should have latest version value")
|
||||
|
||||
// Verify secret no longer exists in work vault
|
||||
_, err = runSecretWithEnv(env, "vault", "select", "work")
|
||||
require.NoError(t, err, "select work vault should succeed")
|
||||
|
||||
_, err = runSecretWithEnv(env, "get", "cross/move/test")
|
||||
assert.Error(t, err, "get from work vault should fail after move")
|
||||
|
||||
// Test cross-vault move with rename
|
||||
_, err = runSecretWithStdin("rename-test", env, "add", "rename/source")
|
||||
require.NoError(t, err, "add rename/source should succeed")
|
||||
|
||||
output, err = runSecretWithEnv(env, "move", "work:rename/source", "default:renamed/dest")
|
||||
require.NoError(t, err, "cross-vault move with rename should succeed")
|
||||
assert.Contains(t, output, "Moved secret", "should show move confirmation")
|
||||
|
||||
// Verify renamed secret exists in default vault
|
||||
_, err = runSecretWithEnv(env, "vault", "select", "default")
|
||||
require.NoError(t, err, "select default vault should succeed")
|
||||
|
||||
value, err = runSecretWithEnv(env, "get", "renamed/dest")
|
||||
require.NoError(t, err, "get renamed secret should succeed")
|
||||
assert.Equal(t, "rename-test", value, "should have correct value")
|
||||
|
||||
// Test --force flag for overwriting
|
||||
_, err = runSecretWithStdin("existing-secret", env, "add", "force/test")
|
||||
require.NoError(t, err, "add force/test in default should succeed")
|
||||
|
||||
_, err = runSecretWithEnv(env, "vault", "select", "work")
|
||||
require.NoError(t, err, "select work vault should succeed")
|
||||
|
||||
_, err = runSecretWithStdin("new-value", env, "add", "force/test")
|
||||
require.NoError(t, err, "add force/test in work should succeed")
|
||||
|
||||
// Move without force should fail
|
||||
output, err = runSecretWithEnv(env, "move", "work:force/test", "default")
|
||||
assert.Error(t, err, "move without force should fail when dest exists")
|
||||
assert.Contains(t, output, "already exists", "should indicate destination exists")
|
||||
|
||||
// Move with force should succeed
|
||||
output, err = runSecretWithEnv(env, "move", "--force", "work:force/test", "default")
|
||||
require.NoError(t, err, "move with force should succeed")
|
||||
assert.Contains(t, output, "Moved secret", "should show move confirmation")
|
||||
|
||||
// Verify value was overwritten
|
||||
_, err = runSecretWithEnv(env, "vault", "select", "default")
|
||||
require.NoError(t, err, "select default vault should succeed")
|
||||
|
||||
value, err = runSecretWithEnv(env, "get", "force/test")
|
||||
require.NoError(t, err, "get overwritten secret should succeed")
|
||||
assert.Equal(t, "new-value", value, "should have new value after force move")
|
||||
}
|
||||
|
||||
func test13UnlockerManagement(t *testing.T, tempDir, testMnemonic string, runSecret func(...string) (string, error), runSecretWithEnv func(map[string]string, ...string) (string, error)) {
|
||||
// Make sure we're in default vault
|
||||
_, err := runSecret("vault", "select", "default")
|
||||
|
||||
@ -14,6 +14,25 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const (
|
||||
// vaultSecretSeparator is the delimiter between vault name and secret name
|
||||
vaultSecretSeparator = ":"
|
||||
// vaultSecretParts is the number of parts when splitting vault:secret
|
||||
vaultSecretParts = 2
|
||||
)
|
||||
|
||||
// ParseVaultSecretRef parses a "vault:secret" or just "secret" reference
|
||||
// Returns (vaultName, secretName, isQualified)
|
||||
// If no vault is specified, returns empty vaultName and isQualified=false
|
||||
func ParseVaultSecretRef(ref string) (vaultName, secretName string, isQualified bool) {
|
||||
parts := strings.SplitN(ref, vaultSecretSeparator, vaultSecretParts)
|
||||
if len(parts) == vaultSecretParts {
|
||||
return parts[0], parts[1], true
|
||||
}
|
||||
|
||||
return "", ref, false
|
||||
}
|
||||
|
||||
func newAddCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "add <secret-name>",
|
||||
@ -135,24 +154,32 @@ func newMoveCmd() *cobra.Command {
|
||||
Use: "move <source> <destination>",
|
||||
Aliases: []string{"mv", "rename"},
|
||||
Short: "Move or rename a secret",
|
||||
Long: `Move or rename a secret within the current vault. ` +
|
||||
`If the destination already exists, the operation will fail.`,
|
||||
Long: `Move a secret within a vault or between vaults.
|
||||
|
||||
For within-vault moves (rename):
|
||||
secret move old-name new-name
|
||||
|
||||
For cross-vault moves:
|
||||
secret move source-vault:secret-name dest-vault
|
||||
secret move source-vault:secret-name dest-vault:new-name
|
||||
|
||||
Cross-vault moves copy ALL versions of the secret, preserving history.
|
||||
The source secret is deleted after successful copy.`,
|
||||
Args: cobra.ExactArgs(2), //nolint:mnd // Command requires exactly 2 arguments: source and destination
|
||||
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
// Only complete the first argument (source)
|
||||
if len(args) == 0 {
|
||||
return getSecretNamesCompletionFunc(cli.fs, cli.stateDir)(cmd, args, toComplete)
|
||||
}
|
||||
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
// Complete vault:secret format
|
||||
return getVaultSecretCompletionFunc(cli.fs, cli.stateDir)(cmd, args, toComplete)
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
force, _ := cmd.Flags().GetBool("force")
|
||||
cli := NewCLIInstance()
|
||||
|
||||
return cli.MoveSecret(cmd, args[0], args[1])
|
||||
return cli.MoveSecret(cmd, args[0], args[1], force)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolP("force", "f", false, "Overwrite if destination secret already exists")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
@ -566,50 +593,188 @@ func (cli *Instance) RemoveSecret(cmd *cobra.Command, secretName string, _ bool)
|
||||
return nil
|
||||
}
|
||||
|
||||
// MoveSecret moves or renames a secret
|
||||
func (cli *Instance) MoveSecret(cmd *cobra.Command, sourceName, destName string) error {
|
||||
// Get current vault
|
||||
// MoveSecret moves or renames a secret (within or across vaults)
|
||||
func (cli *Instance) MoveSecret(cmd *cobra.Command, source, dest string, force bool) error {
|
||||
// Parse source and destination
|
||||
srcVaultName, srcSecretName, srcQualified := ParseVaultSecretRef(source)
|
||||
destVaultName, destSecretName, destQualified := ParseVaultSecretRef(dest)
|
||||
|
||||
// If neither is qualified, this is a simple within-vault rename
|
||||
if !srcQualified && !destQualified {
|
||||
return cli.moveSecretWithinVault(cmd, srcSecretName, destSecretName, force)
|
||||
}
|
||||
|
||||
// Cross-vault move requires source to be qualified
|
||||
if !srcQualified {
|
||||
return fmt.Errorf("source must specify vault (e.g., vault:secret) for cross-vault move")
|
||||
}
|
||||
|
||||
// If destination is not qualified (no colon), check if it's a vault name
|
||||
// Format: "work:secret default" means move to vault "default"
|
||||
// Format: "work:secret default:newname" means move to vault "default" with new name
|
||||
if !destQualified {
|
||||
// Check if dest is actually a vault name
|
||||
vaults, err := vault.ListVaults(cli.fs, cli.stateDir)
|
||||
if err == nil {
|
||||
for _, v := range vaults {
|
||||
if v == dest {
|
||||
// dest is a vault name, use source secret name
|
||||
destVaultName = dest
|
||||
destSecretName = srcSecretName
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If destVaultName is still empty, dest is a secret name in source vault
|
||||
if destVaultName == "" {
|
||||
destVaultName = srcVaultName
|
||||
destSecretName = dest
|
||||
}
|
||||
}
|
||||
|
||||
// If destination secret name is empty, use source secret name
|
||||
if destSecretName == "" {
|
||||
destSecretName = srcSecretName
|
||||
}
|
||||
|
||||
// Same vault? Use simple rename if possible (optimization)
|
||||
if srcVaultName == destVaultName {
|
||||
// Select the vault and do a simple move
|
||||
if err := vault.SelectVault(cli.fs, cli.stateDir, srcVaultName); err != nil {
|
||||
return fmt.Errorf("failed to select vault '%s': %w", srcVaultName, err)
|
||||
}
|
||||
|
||||
return cli.moveSecretWithinVault(cmd, srcSecretName, destSecretName, force)
|
||||
}
|
||||
|
||||
// Cross-vault move
|
||||
return cli.moveSecretCrossVault(cmd, srcVaultName, srcSecretName, destVaultName, destSecretName, force)
|
||||
}
|
||||
|
||||
// moveSecretWithinVault handles rename within the current vault
|
||||
func (cli *Instance) moveSecretWithinVault(cmd *cobra.Command, source, dest string, force bool) error {
|
||||
currentVlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get vault directory
|
||||
vaultDir, err := currentVlt.GetDirectory()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if source exists
|
||||
sourceEncoded := strings.ReplaceAll(sourceName, "/", "%")
|
||||
sourceEncoded := strings.ReplaceAll(source, "/", "%")
|
||||
sourceDir := filepath.Join(vaultDir, "secrets.d", sourceEncoded)
|
||||
|
||||
exists, err := afero.DirExists(cli.fs, sourceDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check if source secret exists: %w", err)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return fmt.Errorf("secret '%s' not found", sourceName)
|
||||
return fmt.Errorf("secret '%s' not found", source)
|
||||
}
|
||||
|
||||
// Check if destination already exists
|
||||
destEncoded := strings.ReplaceAll(destName, "/", "%")
|
||||
destEncoded := strings.ReplaceAll(dest, "/", "%")
|
||||
destDir := filepath.Join(vaultDir, "secrets.d", destEncoded)
|
||||
|
||||
exists, err = afero.DirExists(cli.fs, destDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check if destination secret exists: %w", err)
|
||||
}
|
||||
|
||||
if exists {
|
||||
return fmt.Errorf("secret '%s' already exists", destName)
|
||||
if !force {
|
||||
return fmt.Errorf("secret '%s' already exists (use --force to overwrite)", dest)
|
||||
}
|
||||
|
||||
if err := cli.fs.RemoveAll(destDir); err != nil {
|
||||
return fmt.Errorf("failed to remove existing destination: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Perform the move
|
||||
if err := cli.fs.Rename(sourceDir, destDir); err != nil {
|
||||
return fmt.Errorf("failed to move secret: %w", err)
|
||||
}
|
||||
|
||||
cmd.Printf("Moved secret '%s' to '%s'\n", sourceName, destName)
|
||||
cmd.Printf("Moved secret '%s' to '%s'\n", source, dest)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// moveSecretCrossVault handles moving between different vaults
|
||||
func (cli *Instance) moveSecretCrossVault(
|
||||
cmd *cobra.Command,
|
||||
srcVaultName, srcSecretName,
|
||||
destVaultName, destSecretName string,
|
||||
force bool,
|
||||
) error {
|
||||
// Get source vault
|
||||
srcVault := vault.NewVault(cli.fs, cli.stateDir, srcVaultName)
|
||||
srcVaultDir, err := srcVault.GetDirectory()
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get source vault directory: %w", err)
|
||||
}
|
||||
|
||||
// Verify source vault exists
|
||||
exists, err := afero.DirExists(cli.fs, srcVaultDir)
|
||||
if err != nil || !exists {
|
||||
return fmt.Errorf("source vault '%s' does not exist", srcVaultName)
|
||||
}
|
||||
|
||||
// Verify source secret exists
|
||||
srcStorageName := strings.ReplaceAll(srcSecretName, "/", "%")
|
||||
srcSecretDir := filepath.Join(srcVaultDir, "secrets.d", srcStorageName)
|
||||
|
||||
exists, err = afero.DirExists(cli.fs, srcSecretDir)
|
||||
if err != nil || !exists {
|
||||
return fmt.Errorf("secret '%s' not found in vault '%s'", srcSecretName, srcVaultName)
|
||||
}
|
||||
|
||||
// Get destination vault
|
||||
destVault := vault.NewVault(cli.fs, cli.stateDir, destVaultName)
|
||||
destVaultDir, err := destVault.GetDirectory()
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get destination vault directory: %w", err)
|
||||
}
|
||||
|
||||
// Verify destination vault exists
|
||||
exists, err = afero.DirExists(cli.fs, destVaultDir)
|
||||
if err != nil || !exists {
|
||||
return fmt.Errorf("destination vault '%s' does not exist", destVaultName)
|
||||
}
|
||||
|
||||
// Unlock destination vault (will fail if neither mnemonic nor unlocker available)
|
||||
_, err = destVault.GetOrDeriveLongTermKey()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to unlock destination vault '%s': %w", destVaultName, err)
|
||||
}
|
||||
|
||||
// Count versions for user feedback
|
||||
versions, _ := secret.ListVersions(cli.fs, srcSecretDir)
|
||||
versionCount := len(versions)
|
||||
|
||||
// Copy all versions
|
||||
if err := destVault.CopySecretAllVersions(srcVault, srcSecretName, destSecretName, force); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete source secret
|
||||
if err := cli.fs.RemoveAll(srcSecretDir); err != nil {
|
||||
// Copy succeeded but delete failed - warn but don't fail
|
||||
cmd.Printf("Warning: copied secret but failed to remove source: %v\n", err)
|
||||
cmd.Printf("Moved secret '%s:%s' to '%s:%s' (%d version(s))\n",
|
||||
srcVaultName, srcSecretName, destVaultName, destSecretName, versionCount)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
cmd.Printf("Moved secret '%s:%s' to '%s:%s' (%d version(s))\n",
|
||||
srcVaultName, srcSecretName, destVaultName, destSecretName, versionCount)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user