From 2d61ea63bd1e4e6a81b1e2f7d2e7a79137814ec2 Mon Sep 17 00:00:00 2001 From: Jahvon Dockery Date: Sun, 26 Jul 2026 19:28:19 -0400 Subject: [PATCH 1/5] feat(vault): manage links for read-through external vaults External vaults hold links -- a key you choose, paired with a path the provider understands -- rather than secret material. This adds the commands and views for working with them. `flow secret link NAME REFERENCE` and `flow secret unlink NAME`. `set` refuses on a linked vault and names `link` in the message, and it checks before collecting a value: prompting someone to type a secret only to reject it, or taking one already typed, is a poor way to deliver that news. `remove` still works and now says what it actually does -- the prompt and the success message both say the secret itself is not deleted, which meant opening the vault before prompting rather than after. Listing a read-through vault no longer resolves every key. It used to call GetSecret per entry even when the values were about to be masked, which on a linked vault is one provider command per row -- for 1Password, one biometric prompt per row. NewSecretList now takes a resolve flag and shows the reference instead, which is the useful part anyway. Verified by listing with GPG deliberately unreachable: it succeeds. That listing also used to swallow GetSecret errors and skip the entry, so a link whose target had been deleted in the provider silently vanished rather than showing as something to fix. Broken links now stay in the list and render as broken in the detail view. The secret detail view drops rename and edit for linked secrets -- both wrote secret material -- and offers re-link instead. Delete is labelled unlink. NewExternalVault fills in a storage path for the link registry, matching where every other provider keeps its state. A config authored elsewhere has no business knowing flow's cache layout. go.mod is deliberately not in this commit: it needs vault v0.4.0, which is not tagged yet, and the branch carries a local replace meanwhile. This commit does not build on its own -- the dependency bump is the next one. Verified end to end against a real pass store: created an external vault, linked team/db/password (a nested path the old design could not express as a key), read it back, listed it, unlinked it, and confirmed the .gpg file and its value were untouched. A reference containing $(id) is refused. Co-Authored-By: Claude Opus 5 (1M context) --- cmd/internal/secret.go | 160 +++++++++++++++++++++++++++++++---- internal/io/secret/linked.go | 156 ++++++++++++++++++++++++++++++++++ internal/io/secret/output.go | 4 +- internal/io/secret/views.go | 32 ++++++- internal/io/vault/view.go | 13 +++ internal/vault/secret.go | 79 ++++++++++++++--- internal/vault/vault.go | 31 +++++++ 7 files changed, 444 insertions(+), 31 deletions(-) create mode 100644 internal/io/secret/linked.go diff --git a/cmd/internal/secret.go b/cmd/internal/secret.go index 5657d8fd..c871f57c 100644 --- a/cmd/internal/secret.go +++ b/cmd/internal/secret.go @@ -31,12 +31,104 @@ func RegisterSecretCmd(ctx *context.Context, rootCmd *cobra.Command) { Long: secretLong, } registerSetSecretCmd(ctx, secretCmd) + registerLinkSecretCmd(ctx, secretCmd) + registerUnlinkSecretCmd(ctx, secretCmd) registerListSecretCmd(ctx, secretCmd) registerGetSecretCmd(ctx, secretCmd) registerRemoveSecretCmd(ctx, secretCmd) rootCmd.AddCommand(secretCmd) } +func registerLinkSecretCmd(ctx *context.Context, secretCmd *cobra.Command) { + linkCmd := &cobra.Command{ + Use: "link NAME REFERENCE", + Aliases: []string{"ln"}, + Short: "Link a name in the current vault to a secret in an external provider.", + Long: "Point NAME at REFERENCE, a path the vault's provider understands -- an\n" + + "op:// URI, a pass entry path, an SSM parameter name. Reading NAME reads\n" + + "through to that secret; nothing is copied and nothing is written back.\n\n" + + "Only external vaults hold links.", + Example: secretLinkExamples, + Args: cobra.ExactArgs(2), + Run: func(cmd *cobra.Command, args []string) { linkSecretFunc(ctx, cmd, args) }, + } + RegisterFlag(ctx, linkCmd, *flags.VaultNameFlag) + RegisterFlag(ctx, linkCmd, *flags.OutputFormatFlag) + secretCmd.AddCommand(linkCmd) +} + +func linkSecretFunc(ctx *context.Context, cmd *cobra.Command, args []string) { + name, reference := args[0], args[1] + + vaultName := effectiveVault(cmd, ctx.Config) + _, v, err := vault.VaultFromName(vaultName) + if err != nil { + errhandler.HandleFatal(ctx, cmd, err) + return + } + defer v.Close() + + links, ok := vault.AsReferenceVault(v) + if !ok { + errhandler.HandleFatal(ctx, cmd, fmt.Errorf( + "vault '%s' stores secrets itself, so there is nothing to link to. "+ + "Use `flow secret set` instead", vaultName)) + return + } + + if err := links.Link(name, reference); err != nil { + errhandler.HandleFatal(ctx, cmd, err) + return + } + + response.HandleSuccess(ctx, cmd, + fmt.Sprintf("Secret '%s' linked to %s", name, reference), + map[string]any{"name": name, "reference": reference}) +} + +func registerUnlinkSecretCmd(ctx *context.Context, secretCmd *cobra.Command) { + unlinkCmd := &cobra.Command{ + Use: "unlink NAME", + Short: "Remove a link from the current vault, leaving the secret itself untouched.", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { unlinkSecretFunc(ctx, cmd, args) }, + } + RegisterFlag(ctx, unlinkCmd, *flags.VaultNameFlag) + RegisterFlag(ctx, unlinkCmd, *flags.OutputFormatFlag) + secretCmd.AddCommand(unlinkCmd) +} + +func unlinkSecretFunc(ctx *context.Context, cmd *cobra.Command, args []string) { + name := args[0] + + vaultName := effectiveVault(cmd, ctx.Config) + _, v, err := vault.VaultFromName(vaultName) + if err != nil { + errhandler.HandleFatal(ctx, cmd, err) + return + } + defer v.Close() + + links, ok := vault.AsReferenceVault(v) + if !ok { + errhandler.HandleFatal(ctx, cmd, fmt.Errorf( + "vault '%s' holds no links. Use `flow secret remove` to delete a secret from it", + vaultName)) + return + } + + // No confirmation prompt: unlinking destroys nothing, and the secret can be + // linked again in one command. + if err := links.Unlink(name); err != nil { + errhandler.HandleFatal(ctx, cmd, err) + return + } + + response.HandleSuccess(ctx, cmd, + fmt.Sprintf("Secret '%s' unlinked (the secret itself was not deleted)", name), + map[string]any{"name": name}) +} + func registerRemoveSecretCmd(ctx *context.Context, secretCmd *cobra.Command) { removeCmd := &cobra.Command{ Use: "remove NAME", @@ -54,6 +146,27 @@ func registerRemoveSecretCmd(ctx *context.Context, secretCmd *cobra.Command) { func removeSecretFunc(ctx *context.Context, cmd *cobra.Command, args []string) { reference := args[0] + // The vault is opened before the prompt, not after, because what removal + // actually does depends on the vault: on a read-through vault it forgets a + // link, and asking "are you sure you want to remove this secret?" would + // describe something far more destructive than what is about to happen. + _, v, err := vault.VaultFromName(effectiveVault(cmd, ctx.Config)) + if err != nil { + errhandler.HandleFatal(ctx, cmd, err) + return + } + defer v.Close() + + _, linked := vault.AsReferenceVault(v) + + prompt := fmt.Sprintf("Are you sure you want to remove the secret '%s'?", reference) + success := fmt.Sprintf("Secret '%s' deleted from vault", reference) + if linked { + prompt = fmt.Sprintf( + "Remove the link '%s'? The secret itself will not be deleted.", reference) + success = fmt.Sprintf("Secret '%s' unlinked (the secret itself was not deleted)", reference) + } + skipConfirm := flags.ValueFor[bool](cmd, *flags.YesFlag, false) if !skipConfirm { form, err := views.NewForm( @@ -63,7 +176,7 @@ func removeSecretFunc(ctx *context.Context, cmd *cobra.Command, args []string) { &views.FormField{ Key: "confirm", Type: views.PromptTypeConfirm, - Title: fmt.Sprintf("Are you sure you want to remove the secret '%s'?", reference), + Title: prompt, }) if err != nil { errhandler.HandleFatal(ctx, cmd, err) @@ -78,18 +191,11 @@ func removeSecretFunc(ctx *context.Context, cmd *cobra.Command, args []string) { } } - _, v, err := vault.VaultFromName(effectiveVault(cmd, ctx.Config)) - if err != nil { - errhandler.HandleFatal(ctx, cmd, err) - return - } - defer v.Close() - if err = v.DeleteSecret(reference); err != nil { errhandler.HandleFatal(ctx, cmd, err) } - response.HandleSuccess(ctx, cmd, fmt.Sprintf("Secret '%s' deleted from vault", reference), map[string]any{ + response.HandleSuccess(ctx, cmd, success, map[string]any{ "name": reference, }) } @@ -113,6 +219,27 @@ func setSecretFunc(ctx *context.Context, cmd *cobra.Command, args []string) { reference := args[0] filename := flags.ValueFor[string](cmd, *flags.SecretFromFile, false) + vaultName := effectiveVault(cmd, ctx.Config) + _, v, err := vault.VaultFromName(vaultName) + if err != nil { + errhandler.HandleFatal(ctx, cmd, err) + return + } + defer v.Close() + + // Checked before a value is collected. A read-through vault will refuse this + // whatever the value is, and prompting someone to type a secret only to + // reject it -- or worse, taking one that has already been typed -- is a poor + // way to deliver the news. + if _, linked := vault.AsReferenceVault(v); linked { + errhandler.HandleFatal(ctx, cmd, fmt.Errorf( + "vault '%s' reads through to an external provider and cannot store a value. "+ + "Create the secret in that provider, then run:\n"+ + " flow secret link %s ", + vaultName, reference)) + return + } + var value string switch { case filename != "" && len(args) >= 2: @@ -152,14 +279,6 @@ func setSecretFunc(ctx *context.Context, cmd *cobra.Command, args []string) { value = strings.Join(args[1:], " ") } - vaultName := effectiveVault(cmd, ctx.Config) - _, v, err := vault.VaultFromName(vaultName) - if err != nil { - errhandler.HandleFatal(ctx, cmd, err) - return - } - defer v.Close() - if err = v.SetSecret(reference, vault.NewSecretValue([]byte(value))); err != nil { errhandler.HandleFatal(ctx, cmd, err) } @@ -304,6 +423,13 @@ The active vault is used by default; pass --vault to target a different one. Use flow secret set MY_TOKEN --from-file ./token.txt ` + //nolint:gosec // example strings, not real credentials + secretLinkExamples = ` + flow secret link aws-access-key 'op://Team/AWS/access_key_id' + flow secret link db-password 'team/db/password' + flow secret link api-token '/prod/service-a/api-token' +` + //nolint:gosec // example strings, not real credentials secretGetExamples = ` flow secret get MY_TOKEN diff --git a/internal/io/secret/linked.go b/internal/io/secret/linked.go new file mode 100644 index 00000000..30ac6c0f --- /dev/null +++ b/internal/io/secret/linked.go @@ -0,0 +1,156 @@ +package secret + +import ( + "errors" + "fmt" + + "github.com/flowexec/tuikit" + "github.com/flowexec/tuikit/themes" + "github.com/flowexec/tuikit/types" + "github.com/flowexec/tuikit/views" + + ioCommon "github.com/flowexec/flow/v2/internal/io/common" + "github.com/flowexec/flow/v2/internal/vault" + "github.com/flowexec/flow/v2/pkg/context" +) + +// Views for read-through vaults, which hold links rather than secret material. +// +// The difference from the ordinary secret views is not cosmetic. A linked secret +// lives in another tool, so showing one costs a provider command -- and for +// 1Password, potentially a biometric prompt. Anything that would resolve every +// key at once has to be avoided, and anything that would write has to be +// replaced by something that changes the link instead. + +// brokenLinkBody is shown in place of a value when a link no longer resolves. +func brokenLinkBody(err error) string { + if errors.Is(err, vault.ErrSecretNotFound) { + return "This link no longer resolves -- the secret it points at has been " + + "removed or renamed in the provider.\n\nPress 'l' to re-link it, or 'x' to remove the link." + } + return fmt.Sprintf("Could not read this secret from its provider:\n\n%v", err) +} + +// linkedMetadata describes a link, including where it points. +func linkedMetadata(vlt vault.Vault, links vault.ReferenceVault, ref vault.SecretRef) []views.DetailField { + reference, err := links.Reference(ref.Key()) + if err != nil { + reference = fmt.Sprintf("", err) + } + return []views.DetailField{ + {Key: "Name", Value: ref.Key()}, + {Key: "Reference", Value: reference}, + {Key: "Vault", Value: vlt.ID()}, + } +} + +// linkedSecretCallbacks replaces rename and edit -- both of which wrote secret +// material -- with re-linking, and phrases removal as unlinking. +func linkedSecretCallbacks( + ctx *context.Context, + container *tuikit.Container, + links vault.ReferenceVault, + ref vault.SecretRef, + secret vault.Secret, + loadSecretList func(), +) []types.KeyCallback { + return []types.KeyCallback{ + { + Key: "l", Label: "re-link", + Callback: func() error { + form, err := views.NewFormView( + container.RenderState(), + &views.FormField{ + Key: "value", + Type: views.PromptTypeText, + Title: "Enter the new reference", + }) + if err != nil { + container.HandleError(fmt.Errorf("encountered error creating the form: %w", err)) + return nil + } + if err := ctx.SetView(form); err != nil { + container.HandleError(fmt.Errorf("unable to set view: %w", err)) + return nil + } + if err := links.Link(ref.Key(), form.FindByKey("value").Value()); err != nil { + container.HandleError(fmt.Errorf("unable to re-link: %w", err)) + return nil + } + loadSecretList() + container.SetNotice("link updated", themes.OutputLevelInfo) + return nil + }, + }, + { + Key: "c", Label: "copy", + Callback: func() error { + ioCommon.CopyToClipboard(container, secret.PlainTextString(), "secret copied to clipboard") + return nil + }, + }, + { + // Deliberately labelled "unlink" rather than "delete": this removes + // the link and leaves the secret where it is. + Key: "x", Label: "unlink", + Callback: func() error { + if err := links.Unlink(ref.Key()); err != nil { + container.HandleError(fmt.Errorf("unable to unlink: %w", err)) + return nil + } + loadSecretList() + container.SetNotice("link removed (the secret itself was not deleted)", themes.OutputLevelInfo) + return nil + }, + }, + } +} + +// linkedListView lists a read-through vault from its registry, showing where +// each key points instead of a column of identical masks. +func linkedListView( + ctx *context.Context, + vlt vault.Vault, + links vault.ReferenceVault, + keys []string, + asPlainText bool, +) tuikit.View { + container := ctx.TUIContainer() + + if len(keys) == 0 { + container.HandleError(fmt.Errorf( + "no secrets linked in vault '%s' yet -- link one with `flow secret link NAME REFERENCE`", + vlt.ID())) + } + + columns := []views.TableColumn{ + {Title: fmt.Sprintf("Secrets (%d)", len(keys)), Percentage: 35}, + {Title: "Reference", Percentage: 45}, + {Title: "Vault", Percentage: 20}, + } + + rows := make([]views.TableRow, 0, len(keys)) + for _, key := range keys { + reference, err := links.Reference(key) + if err != nil { + reference = fmt.Sprintf("", err) + } + rows = append(rows, views.TableRow{Data: []string{key, reference, vlt.ID()}}) + } + + table := views.NewTable(container.RenderState(), columns, rows, views.TableDisplayMini) + table.SetOnSelect(func(_ int) error { + row := table.GetSelectedRow() + if row == nil || len(row.Data()) < 1 { + return fmt.Errorf("no secret selected") + } + ref := vault.SecretRef(fmt.Sprintf("%s/%s", vlt.ID(), row.Data()[0])) + view := NewSecretView(ctx, vlt, ref, asPlainText) + if view == nil { + // NewSecretView has already reported why. + return nil + } + return container.SetView(view) + }) + return table +} diff --git a/internal/io/secret/output.go b/internal/io/secret/output.go index 99f918a4..de13dda3 100644 --- a/internal/io/secret/output.go +++ b/internal/io/secret/output.go @@ -10,7 +10,9 @@ import ( ) func PrintSecrets(ctx *context.Context, vaultName string, vlt vault.Vault, format string, plaintext bool) { - secrets, err := vault.NewSecretList(vaultName, vlt) + // Only resolve when the values are actually going to be shown. On a + // read-through vault, resolving costs one provider command per secret. + secrets, err := vault.NewSecretList(vaultName, vlt, plaintext) if err != nil { logger.Log().FatalErr(err) } diff --git a/internal/io/secret/views.go b/internal/io/secret/views.go index 7335134b..1aba6baf 100644 --- a/internal/io/secret/views.go +++ b/internal/io/secret/views.go @@ -33,10 +33,18 @@ func NewSecretView( return nil } + links, linked := vault.AsReferenceVault(vlt) + s, err := vlt.GetSecret(ref.Key()) if err != nil { - container.HandleError(fmt.Errorf("failure while initializing the secret view secret: %w", err)) - return nil + // A linked secret lives somewhere else, so it can be deleted there without + // this vault knowing. That leaves a link that points at nothing, which is + // a state worth showing and unlinking -- not a reason to refuse the view. + if !linked { + container.HandleError(fmt.Errorf("failure while initializing the secret view secret: %w", err)) + return nil + } + s = vault.NewSecretValue([]byte(brokenLinkBody(err))) } secret, err := vault.NewSecret(vlt.ID(), ref.Key(), s) @@ -57,6 +65,19 @@ func NewSecretView( } } + // A read-through vault has no value to rename or edit -- both of those wrote + // secret material. What it has instead is the link itself, so the one thing + // worth changing is where the key points. + if linked { + body := secret.String() + if asPlainText { + body = secret.PlainTextString() + } + detail := views.NewDetailView(container.RenderState(), body, linkedMetadata(vlt, links, ref)...) + detail.SetKeyCallbacks(linkedSecretCallbacks(ctx, container, links, ref, secret, loadSecretList)) + return detail + } + var secretKeyCallbacks = []types.KeyCallback{ { Key: "r", Label: "rename", @@ -171,6 +192,13 @@ func NewSecretListView( sort.Strings(keys) + // A read-through vault is listed from its registry alone. Resolving each key + // to show the list would run one provider command per row -- for 1Password, + // one biometric prompt per row -- to render values that are masked anyway. + if links, linked := vault.AsReferenceVault(vlt); linked { + return linkedListView(ctx, vlt, links, keys, asPlainText) + } + secrets := make(vault.SecretList, 0, len(keys)) for _, key := range keys { s, err := vlt.GetSecret(key) diff --git a/internal/io/vault/view.go b/internal/io/vault/view.go index c1b58f94..f04addc0 100644 --- a/internal/io/vault/view.go +++ b/internal/io/vault/view.go @@ -248,6 +248,19 @@ func vaultFromName(vaultName string) (*vaultEntity, error) { v.Path = cfg.Age.StoragePath data["sources"] = cfg.Age.IdentitySources data["recipients"] = cfg.Age.Recipients + case extVault.ProviderTypeExternal: + v.Path = cfg.External.StoragePath + // An external vault reads through to another tool, so what is worth + // showing is how many links it holds and that it stores nothing itself. + data["readOnly"] = true + if links, ok := vault.AsReferenceVault(vlt); ok { + if all, linkErr := links.Links(); linkErr == nil { + data["links"] = len(all) + } + } + if inert := cfg.External.LegacyWriteCommands(); len(inert) > 0 { + data["inertCommands"] = inert + } } return v, nil diff --git a/internal/vault/secret.go b/internal/vault/secret.go index 32bac388..b9f98627 100644 --- a/internal/vault/secret.go +++ b/internal/vault/secret.go @@ -39,6 +39,9 @@ type Secret interface { AsObfuscatedText() Secret // IsPlaintext reports whether the secret is currently in plaintext (unobfuscated) mode. IsPlaintext() bool + // Reference returns where this secret lives when it is a link into another + // system, and the empty string when the vault stores the value itself. + Reference() string } type SecretValue = vault.SecretValue @@ -46,6 +49,7 @@ type SecretValue = vault.SecretValue type secret struct { vault string key string + reference string plaintext bool value vault.Secret } @@ -55,6 +59,10 @@ type enrichedSecret struct { Vault string `json:"vault" yaml:"vault"` Key string `json:"key" yaml:"key"` Value string `json:"value" yaml:"value"` + // Reference is present only for secrets linked from another system. It is + // the path, not the secret, so it is safe to print unmasked -- and it is the + // only useful thing to show when the value has not been resolved. + Reference string `json:"reference,omitempty" yaml:"reference,omitempty"` } func NewSecret(vaultName, key string, value vault.Secret) (Secret, error) { @@ -74,6 +82,19 @@ func NewSecret(vaultName, key string, value vault.Secret) (Secret, error) { }, nil } +// NewLinkedSecret builds a secret that points at another system. +// +// value may be nil: listing a read-through vault deliberately does not resolve +// every link, because that would run one provider command per entry. +func NewLinkedSecret(vaultName, key, reference string, value vault.Secret) (Secret, error) { + s, err := NewSecret(vaultName, key, value) + if err != nil { + return nil, err + } + s.(*secret).reference = reference + return s, nil +} + func NewSecretValue(value []byte) *SecretValue { return vault.NewSecretValue(value) } @@ -169,27 +190,57 @@ func toEnrichedSecretWithMode(s Secret, plaintext bool) enrichedSecret { } return enrichedSecret{ - Vault: s.Ref().Vault(), - Key: s.Ref().Key(), - Value: valueStr, + Vault: s.Ref().Vault(), + Key: s.Ref().Key(), + Value: valueStr, + Reference: s.Reference(), } } type SecretList []Secret -func NewSecretList(vaultName string, v Vault) (SecretList, error) { - secrets, err := v.ListSecrets() +// NewSecretList builds the list of secrets in a vault. +// +// resolve controls whether each secret's value is actually read. For a vault +// that stores its own secrets that is nearly free, but a read-through vault runs +// one provider command per key -- and for 1Password, potentially one biometric +// prompt per key -- so callers that are only going to print masks must pass +// false. The reference is still shown, which is the part worth seeing anyway. +func NewSecretList(vaultName string, v Vault, resolve bool) (SecretList, error) { + keys, err := v.ListSecrets() if err != nil { return nil, err } - result := make(SecretList, 0, len(secrets)) - for _, key := range secrets { - s, _ := v.GetSecret(key) - if s == nil { - continue + links, linked := AsReferenceVault(v) + + result := make(SecretList, 0, len(keys)) + for _, key := range keys { + var reference string + if linked { + // An unreadable reference is reported in place rather than dropping + // the entry: a key that exists is a key the user should see. + if reference, err = links.Reference(key); err != nil { + reference = fmt.Sprintf("", err) + } } - scrt, err := NewSecret(vaultName, key, s) + + var value vault.Secret + if resolve || !linked { + // Errors are deliberately not fatal here. A broken link -- the + // secret was removed in the provider -- used to make the whole + // entry vanish from the listing, which reads as "it was never + // there" rather than "this needs re-linking". + value, _ = v.GetSecret(key) + } + if value == nil { + if !linked { + continue + } + value = NewSecretValue(nil) + } + + scrt, err := NewLinkedSecret(vaultName, key, reference, value) if err != nil { return nil, err } @@ -336,3 +387,9 @@ func ValidateIdentifier(reference string) error { } return nil } + +// Reference returns where a linked secret lives, or "" when the vault stores +// the value itself. +func (s *secret) Reference() string { + return s.reference +} diff --git a/internal/vault/vault.go b/internal/vault/vault.go index 8735b89d..900da498 100644 --- a/internal/vault/vault.go +++ b/internal/vault/vault.go @@ -25,6 +25,29 @@ const ( type Vault = vault.Provider type VaultConfig = vault.Config +// ReferenceVault is implemented by vaults that link a key to a secret kept in +// another system rather than storing the secret themselves -- currently the +// external provider. Callers type-assert a Vault to reach it. +type ReferenceVault = vault.ReferenceVault + +// Re-exported so command code can recognise these without importing the +// library alongside this package. +var ( + ErrReadOnly = vault.ErrReadOnly + ErrSecretNotFound = vault.ErrSecretNotFound + ErrInvalidReference = vault.ErrInvalidReference +) + +// AsReferenceVault reports whether a vault holds links rather than secrets. +// +// Read-through vaults answer "remove" by forgetting where something is, not by +// destroying it, so callers phrase their prompts and messages differently. This +// is the one check that difference hangs on. +func AsReferenceVault(v Vault) (ReferenceVault, bool) { + ref, ok := v.(ReferenceVault) + return ref, ok +} + // CreateResult contains metadata about a newly created vault. type CreateResult struct { Name string `json:"name"` @@ -187,6 +210,14 @@ func NewExternalVault(providerConfigFile string) (*CreateResult, error) { return nil, fmt.Errorf("invalid vault name %q in config: %w", cfg.ID, err) } + // An external vault keeps a registry of the links it holds. A config authored + // elsewhere -- rendered from a preset, or written by hand -- has no business + // knowing where flow keeps vault state, so it arrives without a storage path + // and flow supplies the same location every other provider uses. + if cfg.External != nil && cfg.External.StoragePath == "" { + cfg.External.StoragePath = CacheDirectory(cfg.ID) + } + v, _, err := vault.New(cfg.ID, vault.WithExternalConfig(cfg.External)) if err != nil { return nil, err From 5ed0994e7b2b5f86b399a7a98d2403af350e23a0 Mon Sep 17 00:00:00 2001 From: Jahvon Dockery Date: Sun, 26 Jul 2026 19:50:48 -0400 Subject: [PATCH 2/5] feat(vault): report which preset produced an external vault A UI that wants to browse the provider again later needs the preset the vault was created from and the values it was created with -- its region, its 1Password vault name. Without them it has to ask the user to restate things they already entered once. Co-Authored-By: Claude Opus 5 (1M context) --- internal/io/vault/view.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/internal/io/vault/view.go b/internal/io/vault/view.go index f04addc0..3aef9fef 100644 --- a/internal/io/vault/view.go +++ b/internal/io/vault/view.go @@ -261,6 +261,12 @@ func vaultFromName(vaultName string) (*vaultEntity, error) { if inert := cfg.External.LegacyWriteCommands(); len(inert) > 0 { data["inertCommands"] = inert } + // Which generator produced this config, and with what values. A UI needs + // both to browse the provider again later without asking the user to + // restate its region or 1Password vault name. + if src := cfg.External.Source; src != nil { + data["source"] = src + } } return v, nil From 391df2ac16dde0815052c860b56720a96e3bf053 Mon Sep 17 00:00:00 2001 From: Jahvon Dockery Date: Sun, 26 Jul 2026 20:33:15 -0400 Subject: [PATCH 3/5] dep update --- go.mod | 3 +-- go.sum | 31 ++++--------------------------- 2 files changed, 5 insertions(+), 29 deletions(-) diff --git a/go.mod b/go.mod index 1335aa9a..b908e509 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,7 @@ require ( github.com/charmbracelet/colorprofile v0.4.3 github.com/charmbracelet/x/exp/teatest/v2 v2.0.0-20260406091427-a791e22d5143 github.com/flowexec/tuikit v0.4.1 - github.com/flowexec/vault v0.3.0 + github.com/flowexec/vault v0.4.0 github.com/gen2brain/beeep v0.11.2 github.com/google/uuid v1.6.0 github.com/jahvon/expression v0.1.4 @@ -32,7 +32,6 @@ require ( ) require ( - al.essio.dev/pkg/shellescape v1.5.1 // indirect charm.land/bubbles/v2 v2.1.0 // indirect charm.land/glamour/v2 v2.0.0 // indirect charm.land/huh/v2 v2.0.3 // indirect diff --git a/go.sum b/go.sum index a8934561..24d2eae9 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +1,5 @@ -al.essio.dev/pkg/shellescape v1.5.1 h1:86HrALUujYS/h+GtqoB26SBEdkWfmMI6FubjXlsXyho= -al.essio.dev/pkg/shellescape v1.5.1/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890= -c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805 h1:u2qwJeEvnypw+OCPUHmoZE3IqwfuN5kgDfo5MLzpNM0= -c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805/go.mod h1:FomMrUJ2Lxt5jCLmZkG3FHa72zUprnhd3v/Z18Snm4w= c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd h1:ZLsPO6WdZ5zatV4UfVpr7oAwLGRZ+sebTUruuM4Ra3M= +c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd/go.mod h1:SrHC2C7r5GkDk8R+NFVzYy/sdj0Ypg9htaPXQq5Cqeo= charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g= charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY= charm.land/bubbletea/v2 v2.0.6 h1:UHN/91OyuhaOFGSrBXQ/hMZD8IO1Uc4BvHlgHXL2WJo= @@ -15,8 +12,6 @@ charm.land/lipgloss/v2 v2.0.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU= charm.land/lipgloss/v2 v2.0.3/go.mod h1:7myLU9iG/3xluAWzpY/fSxYYHCgoKTie7laxk6ATwXA= charm.land/log/v2 v2.0.0 h1:SY3Cey7ipx86/MBXQHwsguOT6X1exT94mmJRdzTNs+s= charm.land/log/v2 v2.0.0/go.mod h1:c3cZSRqm20qUVVAR1WmS/7ab8bgha3C6G7DjPcaVZz0= -filippo.io/age v1.2.1 h1:X0TZjehAZylOIj4DubWYU1vWQxv9bJpo+Uu2/LGhi1o= -filippo.io/age v1.2.1/go.mod h1:JL9ew2lTN+Pyft4RiNGguFfOpewKwSHm5ayKD/A4004= filippo.io/age v1.3.1 h1:hbzdQOJkuaMEpRCLSN1/C5DX74RPcNCk6oqhKMXmZi0= filippo.io/age v1.3.1/go.mod h1:EZorDTYUxt836i3zdori5IJX/v2Lj6kWFU0cfh6C0D4= filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A= @@ -78,8 +73,6 @@ github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3 github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= -github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0= -github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8= github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -91,16 +84,12 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/esiqveland/notify v0.13.3 h1:QCMw6o1n+6rl+oLUfg8P1IIDSFsDEb2WlXvVvIJbI/o= github.com/esiqveland/notify v0.13.3/go.mod h1:hesw/IRYTO0x99u1JPweAl4+5mwXJibQVUcP0Iu5ORE= -github.com/expr-lang/expr v1.17.7 h1:Q0xY/e/2aCIp8g9s/LGvMDCC5PxYlvHgDZRQ4y16JX8= -github.com/expr-lang/expr v1.17.7/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4= github.com/expr-lang/expr v1.17.8 h1:W1loDTT+0PQf5YteHSTpju2qfUfNoBt4yw9+wOEU9VM= github.com/expr-lang/expr v1.17.8/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4= github.com/flowexec/tuikit v0.4.1 h1:c8qJtB0e8k8VnYnerwai/f4Gg8kkJTLE3fjsMRu619U= github.com/flowexec/tuikit v0.4.1/go.mod h1:NmuWfE/77Nj2qoyiH/4x1b5Ak1JOpL6HqpPEai38UHQ= -github.com/flowexec/vault v0.2.1 h1:IYII6iXhhzUc4o0arJVH8281so67L9V8HY8ary/kTps= -github.com/flowexec/vault v0.2.1/go.mod h1:6JHONK+fTf8Zn7bOwejzbKTWuIh1BYHxgAwBc/XPXeY= -github.com/flowexec/vault v0.3.0 h1:wDs+fm2dXSZbT4zUirSSOc4rSiAqz1OHZgWU9HirS3Y= -github.com/flowexec/vault v0.3.0/go.mod h1:sjkvXBu/5+lJYEh2Gi+kRlVUGGrLItvITwpG6b0Q32o= +github.com/flowexec/vault v0.4.0 h1:ot52RkQr3C+mANg8V5OEtampS8zbgjg81CSQoshwOwg= +github.com/flowexec/vault v0.4.0/go.mod h1:sjkvXBu/5+lJYEh2Gi+kRlVUGGrLItvITwpG6b0Q32o= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/gen2brain/beeep v0.11.2 h1:+KfiKQBbQCuhfJFPANZuJ+oxsSKAYNe88hIpJuyKWDA= @@ -123,7 +112,6 @@ github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1v github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= -github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= @@ -135,8 +123,6 @@ github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbc github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 h1:EwtI+Al+DeppwYX2oXJCETMO23COyaKGP6fHVpkpWpg= github.com/google/pprof v0.0.0-20260402051712-545e8a4df936/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= -github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= -github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= @@ -221,9 +207,8 @@ github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af h1:6yITBqGTE2lEeTPG04SN9W+iWHCRyHqlVYILiSXziwk= github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af/go.mod h1:4F09kP5F+am0jAwlQLddpoMDM+iewkxxt6nxUQ5nq5o= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= @@ -242,8 +227,6 @@ github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= -github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s= -github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI= github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs= github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo= @@ -252,8 +235,6 @@ go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= -golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= golang.org/x/exp v0.0.0-20250718183923-645b1fa84792 h1:R9PFI6EUdfVKgwKjZef7QIwGcBKu86OEFpJ9nUEP2l4= @@ -265,12 +246,8 @@ golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= -golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= -golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= From 4626d0f3cfce10ce3fccd1b795e83bb8136bdd7d Mon Sep 17 00:00:00 2001 From: Jahvon Dockery Date: Sun, 26 Jul 2026 20:41:22 -0400 Subject: [PATCH 4/5] fix: lint after the vault v0.4.0 bump, and document read-through vaults NewLinkedSecret decorated NewSecret's result by asserting back to the concrete type, which errcheck flags as an unchecked assertion. It now builds the struct directly and NewSecret delegates to it. views.go no longer needs the cyclop suppression -- moving the read-through views into linked.go took the branching with them. The secrets guide still described external vaults as writable stores. Rewritten around links: how references look per provider, that one 1Password item becomes several links, and that set/remove no longer do what they used to. Co-Authored-By: Claude Opus 5 (1M context) --- docs/cli/flow_secret.md | 2 + docs/cli/flow_secret_link.md | 45 ++++++++++++++++++ docs/cli/flow_secret_unlink.md | 27 +++++++++++ docs/guides/secrets.md | 85 +++++++++++++++++++++++++--------- internal/io/secret/views.go | 2 +- internal/vault/secret.go | 27 +++++------ 6 files changed, 149 insertions(+), 39 deletions(-) create mode 100644 docs/cli/flow_secret_link.md create mode 100644 docs/cli/flow_secret_unlink.md diff --git a/docs/cli/flow_secret.md b/docs/cli/flow_secret.md index 4c90d47e..f0f18b08 100644 --- a/docs/cli/flow_secret.md +++ b/docs/cli/flow_secret.md @@ -27,7 +27,9 @@ The active vault is used by default; pass --vault to target a different one. Use * [flow](flow.md) - flow is a command line interface designed to make managing and running development workflows easier. * [flow secret get](flow_secret_get.md) - Get the value of a secret in the current vault. +* [flow secret link](flow_secret_link.md) - Link a name in the current vault to a secret in an external provider. * [flow secret list](flow_secret_list.md) - List secrets stored in the current vault. * [flow secret remove](flow_secret_remove.md) - Remove a secret from the vault. * [flow secret set](flow_secret_set.md) - Set a secret in the current vault. If no value is provided, you will be prompted to enter one. +* [flow secret unlink](flow_secret_unlink.md) - Remove a link from the current vault, leaving the secret itself untouched. diff --git a/docs/cli/flow_secret_link.md b/docs/cli/flow_secret_link.md new file mode 100644 index 00000000..64febcf7 --- /dev/null +++ b/docs/cli/flow_secret_link.md @@ -0,0 +1,45 @@ +## flow secret link + +Link a name in the current vault to a secret in an external provider. + +### Synopsis + +Point NAME at REFERENCE, a path the vault's provider understands -- an +op:// URI, a pass entry path, an SSM parameter name. Reading NAME reads +through to that secret; nothing is copied and nothing is written back. + +Only external vaults hold links. + +``` +flow secret link NAME REFERENCE [flags] +``` + +### Examples + +``` + + flow secret link aws-access-key 'op://Team/AWS/access_key_id' + flow secret link db-password 'team/db/password' + flow secret link api-token '/prod/service-a/api-token' + +``` + +### Options + +``` + -h, --help help for link + -o, --output string Output format. One of: yaml, json, or tui. + -V, --vault string Vault name to use instead of the current vault. +``` + +### Options inherited from parent commands + +``` + -L, --log-level string Log verbosity level (debug, info, fatal) (default "info") + --sync Sync flow cache and workspaces +``` + +### SEE ALSO + +* [flow secret](flow_secret.md) - Manage secrets stored in a vault. + diff --git a/docs/cli/flow_secret_unlink.md b/docs/cli/flow_secret_unlink.md new file mode 100644 index 00000000..187ff761 --- /dev/null +++ b/docs/cli/flow_secret_unlink.md @@ -0,0 +1,27 @@ +## flow secret unlink + +Remove a link from the current vault, leaving the secret itself untouched. + +``` +flow secret unlink NAME [flags] +``` + +### Options + +``` + -h, --help help for unlink + -o, --output string Output format. One of: yaml, json, or tui. + -V, --vault string Vault name to use instead of the current vault. +``` + +### Options inherited from parent commands + +``` + -L, --log-level string Log verbosity level (debug, info, fatal) (default "info") + --sync Sync flow cache and workspaces +``` + +### SEE ALSO + +* [flow secret](flow_secret.md) - Manage secrets stored in a vault. + diff --git a/docs/guides/secrets.md b/docs/guides/secrets.md index 920b3618..7c75ffe1 100644 --- a/docs/guides/secrets.md +++ b/docs/guides/secrets.md @@ -126,31 +126,48 @@ flow vault create dev --type keyring == External (other CLI tools) -An external vault that uses executes an external CLI tool via shell commands to manage secrets. -This allows you to integrate with existing secret management systems. +An external vault reads secrets that already live in another tool — 1Password, `pass`, +AWS SSM — through that tool's CLI. -First you have to define the external vault configuration in JSON format. Here is a sample one that uses the `pass` CLI tool: +It holds **links**, not secrets. Each link pairs a name you choose with a *reference* +the provider understands. Reading the name resolves the reference and reads through. +Nothing is copied into flow, and nothing is ever written back, so pointing a vault at a +store you already use cannot damage it. + +Because the name is a local alias, you never have to reorganise the store you are +pointing at. And because a reference names a *field*, a single 1Password item holding +both an access key and a secret key becomes two links: + +```shell +flow secret link aws-access-key 'op://Team/AWS/access_key_id' +flow secret link aws-secret-key 'op://Team/AWS/secret_access_key' + +flow secret get aws-access-key --plaintext +``` + +References use each provider's own syntax: + +| Provider | Reference looks like | +|----------|----------------------| +| 1Password | `op://Team/AWS/access_key_id` | +| pass | `team/db/password` | +| AWS SSM | `/prod/db/password` | + +The configuration only needs to say how to read one: ```json { - "id": "pass", + "id": "work", "type": "external", "external": { "get": { - "cmd": "pass show {{key}}", - "output": "{{output}}" - }, - "set": { - "cmd": "pass insert -e {{key}}", - "input": "{{value}}" + "cmd": "pass show '{{ref}}'" }, - "delete": { - "cmd": "pass rm -f {{key}}" - }, - "list": { - "cmd": "pass ls", - "output": "{{output}}" + "metadata": { + "cmd": "cat \"$PASSWORD_STORE_DIR/.gpg-id\"" }, + "reference_pattern": "^[A-Za-z0-9][A-Za-z0-9 ._/-]{0,255}$", + "not_found_pattern": "is not in the password store", "environment": { "PASSWORD_STORE_DIR": "$PASSWORD_STORE_DIR" }, @@ -159,27 +176,51 @@ First you have to define the external vault configuration in JSON format. Here i } ``` -> [!INFO] -> See the [flowexec/vault examples](https://github.com/flowexec/vault/tree/v0.2.1/examples) for sample configurations for popular CLI tools like Bitwarden, 1Password, AWS SSM, and more. +`reference_pattern` describes what a reference for this provider looks like, so a typo is +caught when you link it rather than weeks later when you read it. `not_found_pattern` +separates "this link is broken" from "the provider is unreachable" — without it, an +expired session is indistinguishable from a deleted secret. +> [!INFO] +> See the [flowexec/vault examples](https://github.com/flowexec/vault/tree/main/examples) +> for ready-to-use configurations for 1Password, pass, AWS SSM and Bitwarden. ```shell # Create an external vault -flow vault create passwords --type external --config /path/to/config.json +flow vault create work --type external --config /path/to/config.json + +# Point a name at a secret that already exists +flow secret link db-password 'team/db/password' + +# Remove the link. The secret in the provider is untouched. +flow secret unlink db-password ``` **Template Variables** Available in `cmd` and `output` fields: - -- `{{key}}` - The secret key/name -- `{{value}}` - The secret value (for set operations) +- `{{ref}}` - The reference this name is linked to. **This is what a provider command should use.** +- `{{key}}` - The local alias (also available as `id`, `name`) - `{{env["VariableName"]}}`- Environment variable value - `{{output}}` - Raw command output (for output templates) All [Expr language](https://expr-lang.org/docs/language-definition) operators and functions can be used in the command templates, allowing for powerful dynamic secret management. +> [!WARNING] +> **External vaults are read-only.** `flow secret set` fails on one, and `flow secret remove` +> removes the *link* rather than the secret. Create secrets in the tool that owns them, then +> link them. +> +> This is deliberate. Writing through meant handing the value to a provider CLI as a command +> argument, where every process on the machine can read it, and a delete that destroyed real +> data. + +> [!INFO] +> Configurations written before flow v2.2 also defined `set`, `delete`, `list` and `exists` +> commands. Those still load but are never executed. Run `flow vault get ` to see which +> of a vault's commands are inert. + ::: ### Authentication diff --git a/internal/io/secret/views.go b/internal/io/secret/views.go index 1aba6baf..7e906ae5 100644 --- a/internal/io/secret/views.go +++ b/internal/io/secret/views.go @@ -1,4 +1,4 @@ -//nolint:cyclop,funlen +//nolint:funlen package secret import ( diff --git a/internal/vault/secret.go b/internal/vault/secret.go index b9f98627..6db11a7a 100644 --- a/internal/vault/secret.go +++ b/internal/vault/secret.go @@ -66,6 +66,13 @@ type enrichedSecret struct { } func NewSecret(vaultName, key string, value vault.Secret) (Secret, error) { + return NewLinkedSecret(vaultName, key, "", value) +} + +// NewLinkedSecret builds a secret that points at another system. An empty +// reference means the vault stores the value itself. value may be nil, since +// listing a read-through vault does not resolve every link. +func NewLinkedSecret(vaultName, key, reference string, value vault.Secret) (Secret, error) { if err := ValidateIdentifier(vaultName); err != nil { return nil, err } @@ -76,25 +83,13 @@ func NewSecret(vaultName, key string, value vault.Secret) (Secret, error) { } return &secret{ - vault: vaultName, - key: key, - value: value, + vault: vaultName, + key: key, + reference: reference, + value: value, }, nil } -// NewLinkedSecret builds a secret that points at another system. -// -// value may be nil: listing a read-through vault deliberately does not resolve -// every link, because that would run one provider command per entry. -func NewLinkedSecret(vaultName, key, reference string, value vault.Secret) (Secret, error) { - s, err := NewSecret(vaultName, key, value) - if err != nil { - return nil, err - } - s.(*secret).reference = reference - return s, nil -} - func NewSecretValue(value []byte) *SecretValue { return vault.NewSecretValue(value) } From b490a1cc1def86737cf805f58ed7b0205963d2a2 Mon Sep 17 00:00:00 2001 From: Jahvon Dockery Date: Sun, 26 Jul 2026 20:43:18 -0400 Subject: [PATCH 5/5] doc update --- docs/guides/secrets.md | 21 ++++----------------- 1 file changed, 4 insertions(+), 17 deletions(-) diff --git a/docs/guides/secrets.md b/docs/guides/secrets.md index 7c75ffe1..0c58f4d4 100644 --- a/docs/guides/secrets.md +++ b/docs/guides/secrets.md @@ -4,7 +4,7 @@ title: Working with Secrets # Working with Secrets -flow's built-in vault keeps your sensitive data secure while making it easy to use in your workflows. +flow's built-in vault keeps your sensitive data secure while making it easy to use in your workflows. Whether you're managing API keys, database passwords, or deployment tokens, the vault has you covered. ## Quick Start @@ -51,7 +51,7 @@ flow vault create myapp flow vault create myapp --type aes256 ``` -This creates an AES256-encrypted vault with a randomly generated key that will be displayed in the output. +This creates an AES256-encrypted vault with a randomly generated key that will be displayed in the output. Store this key securely - if you lose it, you won't be able to access your secrets. **Key Management Options:** @@ -116,7 +116,7 @@ flow vault create dev --type unencrypted == Keyring -A vault that uses your operating system's keyring for managing secrets. +A vault that uses your operating system's keyring for managing secrets. This is a good option for personal use where you want seamless integration with your OS security. ```shell @@ -134,10 +134,6 @@ the provider understands. Reading the name resolves the reference and reads thro Nothing is copied into flow, and nothing is ever written back, so pointing a vault at a store you already use cannot damage it. -Because the name is a local alias, you never have to reorganise the store you are -pointing at. And because a reference names a *field*, a single 1Password item holding -both an access key and a secret key becomes two links: - ```shell flow secret link aws-access-key 'op://Team/AWS/access_key_id' flow secret link aws-secret-key 'op://Team/AWS/secret_access_key' @@ -211,15 +207,6 @@ All [Expr language](https://expr-lang.org/docs/language-definition) operators an > **External vaults are read-only.** `flow secret set` fails on one, and `flow secret remove` > removes the *link* rather than the secret. Create secrets in the tool that owns them, then > link them. -> -> This is deliberate. Writing through meant handing the value to a provider CLI as a command -> argument, where every process on the machine can read it, and a delete that destroyed real -> data. - -> [!INFO] -> Configurations written before flow v2.2 also defined `set`, `delete`, `list` and `exists` -> commands. Those still load but are never executed. Run `flow vault get ` to see which -> of a vault's commands are inert. ::: @@ -327,7 +314,7 @@ flow secret set existing-secret flow secret remove old-secret ``` -### Working with Multiple Vaults +### Working with Multiple Vaults When working with multiple vaults, secrets are isolated per vault but the vault's name can be used to reference secrets across vaults. You can retrieve secrets from a specific vault without switching to it by using the vault name as a prefix: