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
|
# IMPORTANT RULES
|
||||||
|
|
||||||
Read the rules in AGENTS.md and follow them.
|
|
||||||
|
|
||||||
# Memory
|
|
||||||
|
|
||||||
* Claude is an inanimate tool. The spam that Claude attempts to insert into
|
* Claude is an inanimate tool. The spam that Claude attempts to insert into
|
||||||
commit messages (which it erroneously refers to as "attribution") is not
|
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
|
* Code should always be formatted before committing. Do not commit
|
||||||
unformatted code.
|
unformatted code.
|
||||||
|
|
||||||
* Code should always be linted before committing. Do not commit
|
* Code should always be linted and linter errors fixed before committing.
|
||||||
unlinted code.
|
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
|
individual parts of the test suite, always run the whole thing by running
|
||||||
"make test".
|
"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
|
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.
|
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
|
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
|
// Expected: Secret moved to new location, old location removed
|
||||||
test12bMoveSecret(t, testMnemonic, runSecret, runSecretWithStdin)
|
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
|
// Test 13: Unlocker management
|
||||||
// Commands: secret unlocker list, secret unlocker add pgp
|
// Commands: secret unlocker list, secret unlocker add pgp
|
||||||
// Purpose: Test multiple unlocker types
|
// 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")
|
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)) {
|
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
|
// Make sure we're in default vault
|
||||||
_, err := runSecret("vault", "select", "default")
|
_, err := runSecret("vault", "select", "default")
|
||||||
|
|||||||
@ -14,6 +14,25 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"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 {
|
func newAddCmd() *cobra.Command {
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "add <secret-name>",
|
Use: "add <secret-name>",
|
||||||
@ -135,24 +154,32 @@ func newMoveCmd() *cobra.Command {
|
|||||||
Use: "move <source> <destination>",
|
Use: "move <source> <destination>",
|
||||||
Aliases: []string{"mv", "rename"},
|
Aliases: []string{"mv", "rename"},
|
||||||
Short: "Move or rename a secret",
|
Short: "Move or rename a secret",
|
||||||
Long: `Move or rename a secret within the current vault. ` +
|
Long: `Move a secret within a vault or between vaults.
|
||||||
`If the destination already exists, the operation will fail.`,
|
|
||||||
|
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
|
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) {
|
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||||
// Only complete the first argument (source)
|
// Complete vault:secret format
|
||||||
if len(args) == 0 {
|
return getVaultSecretCompletionFunc(cli.fs, cli.stateDir)(cmd, args, toComplete)
|
||||||
return getSecretNamesCompletionFunc(cli.fs, cli.stateDir)(cmd, args, toComplete)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
|
||||||
},
|
},
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
force, _ := cmd.Flags().GetBool("force")
|
||||||
cli := NewCLIInstance()
|
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
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -566,50 +593,188 @@ func (cli *Instance) RemoveSecret(cmd *cobra.Command, secretName string, _ bool)
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// MoveSecret moves or renames a secret
|
// MoveSecret moves or renames a secret (within or across vaults)
|
||||||
func (cli *Instance) MoveSecret(cmd *cobra.Command, sourceName, destName string) error {
|
func (cli *Instance) MoveSecret(cmd *cobra.Command, source, dest string, force bool) error {
|
||||||
// Get current vault
|
// 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)
|
currentVlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get vault directory
|
|
||||||
vaultDir, err := currentVlt.GetDirectory()
|
vaultDir, err := currentVlt.GetDirectory()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if source exists
|
sourceEncoded := strings.ReplaceAll(source, "/", "%")
|
||||||
sourceEncoded := strings.ReplaceAll(sourceName, "/", "%")
|
|
||||||
sourceDir := filepath.Join(vaultDir, "secrets.d", sourceEncoded)
|
sourceDir := filepath.Join(vaultDir, "secrets.d", sourceEncoded)
|
||||||
|
|
||||||
exists, err := afero.DirExists(cli.fs, sourceDir)
|
exists, err := afero.DirExists(cli.fs, sourceDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to check if source secret exists: %w", err)
|
return fmt.Errorf("failed to check if source secret exists: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !exists {
|
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(dest, "/", "%")
|
||||||
destEncoded := strings.ReplaceAll(destName, "/", "%")
|
|
||||||
destDir := filepath.Join(vaultDir, "secrets.d", destEncoded)
|
destDir := filepath.Join(vaultDir, "secrets.d", destEncoded)
|
||||||
|
|
||||||
exists, err = afero.DirExists(cli.fs, destDir)
|
exists, err = afero.DirExists(cli.fs, destDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to check if destination secret exists: %w", err)
|
return fmt.Errorf("failed to check if destination secret exists: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if exists {
|
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 {
|
if err := cli.fs.Rename(sourceDir, destDir); err != nil {
|
||||||
return fmt.Errorf("failed to move secret: %w", err)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -483,3 +483,158 @@ func (v *Vault) GetSecretObject(name string) (*secret.Secret, error) {
|
|||||||
|
|
||||||
return secretObj, nil
|
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