Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 73 additions & 1 deletion cmd/cmd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -175,13 +175,85 @@ func TestVaultCredentialStoreSubcommandsRegistered(t *testing.T) {
if setCmd == nil {
t.Fatal("set command not found under credential-store")
}
for _, flag := range []string{"kind", "infisical-project-id", "infisical-environment", "infisical-path", "poll-interval-seconds", "yes"} {
for _, flag := range []string{"kind", "infisical-project-id", "infisical-environment", "infisical-path", "infisical-recursive", "poll-interval-seconds", "yes"} {
if setCmd.Flags().Lookup(flag) == nil {
t.Errorf("expected credential-store set to define --%s flag", flag)
}
}
}

func TestVaultCreateInfisicalFlags(t *testing.T) {
vCmd := findSubcommand(rootCmd, "vault")
if vCmd == nil {
t.Fatal("vault command not found")
}
createCmd := findSubcommand(vCmd, "create")
if createCmd == nil {
t.Fatal("create command not found under vault")
}
for _, flag := range []string{"credential-store", "infisical-project-id", "infisical-environment", "infisical-path", "infisical-recursive", "poll-interval-seconds"} {
if createCmd.Flags().Lookup(flag) == nil {
t.Errorf("expected vault create to define --%s flag", flag)
}
}
}

// newInfisicalFlagCommand mirrors the Infisical flag set shared by
// `vault create` and `vault credential-store set` without mutating the
// package-level commands.
func newInfisicalFlagCommand() *cobra.Command {
c := &cobra.Command{}
c.Flags().String("infisical-project-id", "", "")
c.Flags().String("infisical-environment", "", "")
c.Flags().String("infisical-path", "/", "")
c.Flags().Bool("infisical-recursive", false, "")
c.Flags().Int("poll-interval-seconds", 60, "")
return c
}

func TestInfisicalStorePayloadFromFlags_Recursive(t *testing.T) {
c := newInfisicalFlagCommand()
mustSet := func(name, val string) {
t.Helper()
if err := c.Flags().Set(name, val); err != nil {
t.Fatalf("set --%s: %v", name, err)
}
}
mustSet("infisical-project-id", "p")
mustSet("infisical-environment", "dev")
mustSet("infisical-recursive", "true")

payload, err := infisicalStorePayloadFromFlags(c)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
cfg, ok := payload["config"].(map[string]interface{})
if !ok {
t.Fatalf("payload has no config map: %+v", payload)
}
if cfg["recursive"] != true {
t.Errorf("config.recursive: want true, got %v", cfg["recursive"])
}
}

func TestInfisicalStorePayloadFromFlags_RecursiveDefaultsFalse(t *testing.T) {
c := newInfisicalFlagCommand()
if err := c.Flags().Set("infisical-project-id", "p"); err != nil {
t.Fatal(err)
}
if err := c.Flags().Set("infisical-environment", "dev"); err != nil {
t.Fatal(err)
}
payload, err := infisicalStorePayloadFromFlags(c)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
cfg := payload["config"].(map[string]interface{})
if cfg["recursive"] != false {
t.Errorf("config.recursive: want false by default, got %v", cfg["recursive"])
}
}

func TestVaultCredentialStoreSetRequiresKind(t *testing.T) {
// Missing --kind should fail before any network call.
_, err := executeCommand("vault", "credential-store", "set", "my-app", "--yes")
Expand Down
8 changes: 8 additions & 0 deletions cmd/vaults.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ func infisicalStorePayloadFromFlags(cmd *cobra.Command) (map[string]interface{},
projectID, _ := cmd.Flags().GetString("infisical-project-id")
environment, _ := cmd.Flags().GetString("infisical-environment")
secretPath, _ := cmd.Flags().GetString("infisical-path")
recursive, _ := cmd.Flags().GetBool("infisical-recursive")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Omitted Flag Disables Recursion

When vault credential-store set updates an existing recursive store without repeating --infisical-recursive, GetBool returns the flag default and the payload sends recursive: false. Existing scripts that only change another setting therefore silently disable recursive sync and drop subfolder secrets from the next snapshot.

pollSecs, _ := cmd.Flags().GetInt("poll-interval-seconds")

if projectID == "" || environment == "" {
Expand All @@ -105,6 +106,7 @@ func infisicalStorePayloadFromFlags(cmd *cobra.Command) (map[string]interface{},
"project_id": projectID,
"environment": environment,
"secret_path": secretPath,
"recursive": recursive,
},
"poll_interval_seconds": pollSecs,
}, nil
Expand Down Expand Up @@ -254,6 +256,10 @@ func printCredentialStore(out io.Writer, cs map[string]interface{}) {
fmt.Fprintf(out, " Project: %v\n", cfg["project_id"])
fmt.Fprintf(out, " Environment: %v\n", cfg["environment"])
fmt.Fprintf(out, " Path: %v\n", cfg["secret_path"])
// Guarded: servers predating the recursive option omit the field.
if v, ok := cfg["recursive"]; ok {
fmt.Fprintf(out, " Recursive: %v\n", v)
}
}
if v, ok := cs["poll_interval_seconds"]; ok {
fmt.Fprintf(out, " Poll: %vs\n", v)
Expand Down Expand Up @@ -584,6 +590,7 @@ func init() {
vaultCreateCmd.Flags().String("infisical-project-id", "", "Infisical project ID (required when --credential-store=infisical)")
vaultCreateCmd.Flags().String("infisical-environment", "", "Infisical environment slug, e.g. dev/prod")
vaultCreateCmd.Flags().String("infisical-path", "/", "Infisical secret path (default /)")
vaultCreateCmd.Flags().Bool("infisical-recursive", false, "Sync secrets from all subfolders of --infisical-path (secret keys must be unique across the whole folder tree)")
vaultCreateCmd.Flags().Int("poll-interval-seconds", 60, "Sync cadence floor for the external store (min 10; server wakes every 10s and refreshes vaults past their interval)")

vaultCmd.AddCommand(vaultCreateCmd)
Expand All @@ -597,6 +604,7 @@ func init() {
vaultCredentialStoreSetCmd.Flags().String("infisical-project-id", "", "Infisical project ID (required when --kind=infisical)")
vaultCredentialStoreSetCmd.Flags().String("infisical-environment", "", "Infisical environment slug, e.g. dev/prod")
vaultCredentialStoreSetCmd.Flags().String("infisical-path", "/", "Infisical secret path (default /)")
vaultCredentialStoreSetCmd.Flags().Bool("infisical-recursive", false, "Sync secrets from all subfolders of --infisical-path (secret keys must be unique across the whole folder tree)")
vaultCredentialStoreSetCmd.Flags().Int("poll-interval-seconds", 60, "Sync cadence floor for the external store (min 10)")
vaultCredentialStoreSetCmd.Flags().Bool("yes", false, "Skip confirmation prompt")

Expand Down
14 changes: 14 additions & 0 deletions docs/learn/credential-stores.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ agent-vault vault create my-app \
--poll-interval-seconds=60
```

Add `--infisical-recursive` to also sync secrets from all subfolders of the secret path (see [Recursive sync](#recursive-sync)).

The create handler probes Infisical first. If the project, environment, or path doesn't resolve, or if the machine identity can't read it, the request fails with a clear error and the vault is **not** created. On success, the vault row, the credential-store config, the initial encrypted secret snapshot, and your admin grant are all committed in a single SQL transaction.

To inspect a vault's credential store later:
Expand Down Expand Up @@ -67,6 +69,18 @@ In the browser, open the vault's **Settings → Edit settings** and change the *

**HTTP**: `PATCH {AGENT_VAULT_ADDR}/v1/vaults/{name}/credential-store` with `{"kind":"builtin"}` or `{"kind":"infisical","config":{...},"poll_interval_seconds":N}`. Disconnecting (`builtin`) needs vault admin or instance owner; connecting (`infisical`) needs instance owner.

## Recursive sync

By default, only secrets directly at the configured secret path are synced — subfolders are ignored. Enable recursive sync to pull secrets from the entire folder tree beneath the path:

- **CLI**: pass `--infisical-recursive` to `vault create` or `vault credential-store set`.
- **Web UI**: enable the **Recursive sync** toggle in the vault form's Infisical section.
- **HTTP**: set `"recursive": true` inside the `config` object.

Synced credential keys stay flat — a secret's folder is not encoded into its key. That means secret names must be **unique across the whole folder tree**: if the same key exists in two folders (e.g. `/stripe/TOKEN` and `/github/TOKEN`), the entire sync fails with `external_store_duplicate_key` and the vault keeps serving its previous snapshot until one secret is renamed upstream. Like the key-naming rule below, this is all-or-nothing — there is no partial sync.

Recursive sync applies to static secrets only; [dynamic secrets](#dynamic-secrets) remain scoped to the configured path and are never discovered recursively.

## Upstream key naming

Agent Vault requires credential keys to match `^[A-Z][A-Z0-9_]*$` (UPPER_SNAKE_CASE). The same rule applies to keys fetched from Infisical: any non-conforming key (e.g., `database-url`, `stripeKey`) fails the whole sync with `external_store_invalid_key` and the vault keeps serving its previous snapshot until the key is renamed upstream. There is no partial sync. Rename the secret in Infisical, then retry.
Expand Down
2 changes: 2 additions & 0 deletions docs/reference/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,7 @@ description: "Complete reference for all Agent Vault CLI commands."
| `--infisical-project-id` | | Infisical project ID. Required when `--credential-store=infisical`. |
| `--infisical-environment` | | Infisical environment slug (e.g., `dev`, `prod`). Required when `--credential-store=infisical`. |
| `--infisical-path` | `/` | Infisical secret path (must start with `/`). |
| `--infisical-recursive` | `false` | Sync secrets from all subfolders of `--infisical-path` recursively. Secret keys must be unique across the whole folder tree; a duplicate fails the sync. |
| `--poll-interval-seconds` | `60` | Refresh cadence floor for the cached secrets. Minimum 10s. The server wakes every 10s and refreshes any vault past its interval, so actual cadence rounds up to the next 10s boundary. |
</Accordion>

Expand Down Expand Up @@ -319,6 +320,7 @@ description: "Complete reference for all Agent Vault CLI commands."
| `--infisical-project-id` | | Infisical project ID. Required when `--kind=infisical`. |
| `--infisical-environment` | | Infisical environment slug (e.g., `dev`, `prod`). Required when `--kind=infisical`. |
| `--infisical-path` | `/` | Infisical secret path (must start with `/`). |
| `--infisical-recursive` | `false` | Sync secrets from all subfolders of `--infisical-path` recursively. Secret keys must be unique across the whole folder tree; a duplicate fails the sync. |
| `--poll-interval-seconds` | `60` | Refresh cadence floor for the cached secrets. Minimum 10s. |
| `--yes` | `false` | Skip confirmation prompt |
</Accordion>
Expand Down
72 changes: 60 additions & 12 deletions internal/infisical/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,20 @@ package infisical
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"sort"
"strconv"
"strings"
"sync"
"time"

sdk "github.com/infisical/go-sdk"
"github.com/infisical/go-sdk/packages/models"
)

// SecretsFetcher is the slice of the SDK the syncer actually uses; tests
Expand Down Expand Up @@ -119,28 +122,73 @@ func NewClient(ctx context.Context, logger *slog.Logger) (*Client, error) {
// AuthMethod returns the detected machine-identity flow this client uses.
func (c *Client) AuthMethod() AuthMethod { return c.method }

// ErrDuplicateKey marks a recursive-sync failure: the same secret key exists
// in more than one folder. Surfaced so the operator can rename upstream.
var ErrDuplicateKey = errors.New("infisical: duplicate secret key across folders")

// FetchSecrets honors ctx.Done() via runSDK; on cancel the orphaned SDK call
// runs to completion (up to the SDK's internal timeout) and is discarded.
func (c *Client) FetchSecrets(ctx context.Context, cfg VaultConfig) ([]Secret, error) {
return runSDK(ctx, func() ([]Secret, error) {
res, err := c.sdk.Secrets().ListSecrets(sdk.ListSecretsOptions{
ProjectID: cfg.ProjectID,
Environment: cfg.Environment,
SecretPath: cfg.SecretPath,
ExpandSecretReferences: true,
AttachToProcessEnv: false,
})
res, err := c.sdk.Secrets().ListSecrets(listSecretsOptions(cfg))
if err != nil {
return nil, err
}
out := make([]Secret, len(res.Secrets))
for i, s := range res.Secrets {
out[i] = Secret{Key: s.SecretKey, Value: s.SecretValue}
}
return out, nil
return secretsFromList(res.Secrets, cfg.Recursive)
})
}

// listSecretsOptions maps a VaultConfig onto the SDK request. Recursive mode
// also sets SkipUniqueValidation: without it the SDK collapses cross-folder
// duplicates to one arbitrary winner before we can reject them.
func listSecretsOptions(cfg VaultConfig) sdk.ListSecretsOptions {
return sdk.ListSecretsOptions{
ProjectID: cfg.ProjectID,
Environment: cfg.Environment,
SecretPath: cfg.SecretPath,
ExpandSecretReferences: true,
AttachToProcessEnv: false,
Recursive: cfg.Recursive,
SkipUniqueValidation: cfg.Recursive,
}
}

// secretsFromList maps SDK results to broker secrets. In recursive mode the
// flat credential keyspace can collide across folders; any key found at more
// than one path fails the whole fetch (all-or-nothing, like ErrInvalidKey).
// Sorted before formatting: the SDK refills its result slice from a map, so
// input order is nondeterministic.
func secretsFromList(raw []models.Secret, recursive bool) ([]Secret, error) {
if recursive {
pathsByKey := make(map[string][]string, len(raw))
for _, s := range raw {
pathsByKey[s.SecretKey] = append(pathsByKey[s.SecretKey], s.SecretPath)
}
var dupKeys []string
for key, paths := range pathsByKey {
if len(paths) > 1 {
dupKeys = append(dupKeys, key)
}
}
if len(dupKeys) > 0 {
sort.Strings(dupKeys)
paths := pathsByKey[dupKeys[0]]
sort.Strings(paths)
more := ""
if len(dupKeys) > 1 {
more = fmt.Sprintf(" (and %d more duplicate keys)", len(dupKeys)-1)
}
return nil, fmt.Errorf("%w: %q found at %s (secret keys must be unique across all folders when recursive sync is enabled; rename the secret upstream)%s",
ErrDuplicateKey, dupKeys[0], strings.Join(paths, ", "), more)
}
}
out := make([]Secret, len(raw))
for i, s := range raw {
out[i] = Secret{Key: s.SecretKey, Value: s.SecretValue}
}
return out, nil
}

// runSDK runs a context-unaware SDK call in a goroutine so ctx.Done() is
// honored; on cancel the orphaned call runs to completion and is discarded.
func runSDK[T any](ctx context.Context, fn func() (T, error)) (T, error) {
Expand Down
90 changes: 90 additions & 0 deletions internal/infisical/client_recursive_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package infisical

import (
"errors"
"strings"
"testing"

"github.com/infisical/go-sdk/packages/models"
)

func TestListSecretsOptions_NonRecursiveUnchanged(t *testing.T) {
opts := listSecretsOptions(VaultConfig{ProjectID: "p", Environment: "dev", SecretPath: "/app"})
if opts.ProjectID != "p" || opts.Environment != "dev" || opts.SecretPath != "/app" {
t.Fatalf("config fields not passed through: %+v", opts)
}
if !opts.ExpandSecretReferences {
t.Fatalf("ExpandSecretReferences must stay enabled")
}
if opts.Recursive || opts.SkipUniqueValidation {
t.Fatalf("non-recursive config must not set Recursive/SkipUniqueValidation: %+v", opts)
}
}

// TestListSecretsOptions_RecursiveSetsSkipUniqueValidation: SkipUniqueValidation
// must accompany Recursive — without it the SDK dedups cross-folder duplicates
// by key with an arbitrary winner before we can detect and reject them.
func TestListSecretsOptions_RecursiveSetsSkipUniqueValidation(t *testing.T) {
opts := listSecretsOptions(VaultConfig{ProjectID: "p", Environment: "dev", SecretPath: "/", Recursive: true})
if !opts.Recursive {
t.Fatalf("Recursive not set")
}
if !opts.SkipUniqueValidation {
t.Fatalf("SkipUniqueValidation must be set when Recursive is")
}
}

func TestSecretsFromList_MapsKeysAndValues(t *testing.T) {
raw := []models.Secret{
{SecretKey: "ALPHA", SecretValue: "a", SecretPath: "/"},
{SecretKey: "BETA", SecretValue: "b", SecretPath: "/sub"},
}
secs, err := secretsFromList(raw, true)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(secs) != 2 || secs[0] != (Secret{Key: "ALPHA", Value: "a"}) || secs[1] != (Secret{Key: "BETA", Value: "b"}) {
t.Fatalf("bad mapping: %+v", secs)
}
}

func TestSecretsFromList_DuplicateAcrossFolders(t *testing.T) {
raw := []models.Secret{
{SecretKey: "TOKEN", SecretValue: "1", SecretPath: "/stripe"},
{SecretKey: "TOKEN", SecretValue: "2", SecretPath: "/github"},
{SecretKey: "OTHER", SecretValue: "3", SecretPath: "/"},
}
_, err := secretsFromList(raw, true)
if !errors.Is(err, ErrDuplicateKey) {
t.Fatalf("expected ErrDuplicateKey, got %v", err)
}
for _, want := range []string{`"TOKEN"`, "/github", "/stripe"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("error %q missing %q", err, want)
}
}

// The SDK refills its result slice from a map, so input order is
// nondeterministic — the formatted error must not depend on it.
reversed := []models.Secret{raw[2], raw[1], raw[0]}
_, err2 := secretsFromList(reversed, true)
if err2 == nil || err2.Error() != err.Error() {
t.Fatalf("error text must be deterministic:\n%v\n%v", err, err2)
}
}

// Defensive: in non-recursive mode the SDK already dedups by key, so the
// collision check must not run (and must not reject) there.
func TestSecretsFromList_NonRecursiveSkipsDuplicateCheck(t *testing.T) {
raw := []models.Secret{
{SecretKey: "TOKEN", SecretValue: "1"},
{SecretKey: "TOKEN", SecretValue: "2"},
}
secs, err := secretsFromList(raw, false)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(secs) != 2 {
t.Fatalf("expected passthrough of 2 secrets, got %d", len(secs))
}
}
3 changes: 3 additions & 0 deletions internal/infisical/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ type VaultConfig struct {
ProjectID string `json:"project_id"`
Environment string `json:"environment"`
SecretPath string `json:"secret_path"`
// Recursive syncs secrets from all subfolders of SecretPath. Keys must
// then be unique across the whole folder tree (see ErrDuplicateKey).
Recursive bool `json:"recursive"`
}

// Validate enforces the structural invariants the SDK and the broker both
Expand Down
Loading