diff --git a/internal/secrets/infisical.go b/internal/secrets/infisical.go new file mode 100644 index 0000000..e72e8c2 --- /dev/null +++ b/internal/secrets/infisical.go @@ -0,0 +1,114 @@ +package secrets + +import ( + "context" + "encoding/json" + "fmt" +) + +// This file is the Infisical provider (S4). Like SOPS and AWS it shells out to +// the vendor CLI (`infisical`) rather than the Go SDK — same anti-bloat reasoning +// (DECISIONS) — and inherits the user's existing auth (`infisical login` or +// INFISICAL_TOKEN), so no credential passes through devstack. The whole +// environment is exported ONCE (a batch) and each ref's key is extracted. +// +// Reference shape: +// secret:/// (the secret's key in the configured env) +// secret:///# (walk into a JSON-valued secret) +// +// Provider config: ProjectID (workspace/project id), Env (environment slug), +// Opts["path"] (secrets folder, default "/"). + +// InfisicalKind selects this factory. +const InfisicalKind = "infisical" + +// InfisicalProvider exports an Infisical environment via the `infisical` CLI. +type InfisicalProvider struct { + name string + projectID string + env string + path string // folder path; "" lets the CLI default ("/") + runner CmdRunner +} + +// InfisicalFactory builds the provider from its config. +func InfisicalFactory(cfg ProviderConfig) (Provider, error) { + return &InfisicalProvider{ + name: cfg.Name, + projectID: cfg.ProjectID, + env: cfg.Env, + path: cfg.Opts["path"], + }, nil +} + +func (p *InfisicalProvider) Name() string { return p.name } + +// Resolve exports the environment once and extracts each ref's secret. +func (p *InfisicalProvider) Resolve(ctx context.Context, refs []Ref) (map[string]string, error) { + if p.runner == nil { + p.runner = execCmdRunner{} + } + if _, err := p.runner.LookPath("infisical"); err != nil { + return nil, fmt.Errorf("infisical CLI not found on PATH — install it (https://infisical.com/docs/cli) and authenticate (`infisical login` or INFISICAL_TOKEN) for the %q provider", p.name) + } + + args := []string{"export", "--format=json"} + if p.projectID != "" { + args = append(args, "--projectId", p.projectID) + } + if p.env != "" { + args = append(args, "--env", p.env) + } + if p.path != "" { + args = append(args, "--path", p.path) + } + raw, err := p.runner.Output(ctx, nil, "infisical", args...) + if err != nil { + return nil, fmt.Errorf("infisical export (%q): %w", p.name, err) + } + secrets, err := parseInfisicalExport(raw) + if err != nil { + return nil, fmt.Errorf("infisical %q: %w", p.name, err) + } + + out := map[string]string{} + for _, r := range refs { + v, ok := secrets[r.Path] + if !ok { + return nil, fmt.Errorf("infisical: secret %q not found in env %q", r.Path, p.env) + } + if r.Key != "" { + sub, ok := lookupJSONString(v, r.Key) + if !ok { + return nil, fmt.Errorf("infisical: key %q not found in secret %q (is its value JSON?)", r.Key, r.Path) + } + v = sub + } + out[r.Raw] = v + } + return out, nil +} + +// parseInfisicalExport tolerates the two shapes `infisical export --format=json` +// has emitted across versions: a flat {KEY: VALUE} object, or a list of +// {key/secretKey, value/secretValue} records. +func parseInfisicalExport(raw []byte) (map[string]string, error) { + var flat map[string]string + if err := json.Unmarshal(raw, &flat); err == nil && flat != nil { + return flat, nil + } + var list []struct { + Key string `json:"key"` + SecretKey string `json:"secretKey"` + Value string `json:"value"` + SecretValue string `json:"secretValue"` + } + if err := json.Unmarshal(raw, &list); err != nil { + return nil, fmt.Errorf("parse export output (neither object nor list of secrets): %w", err) + } + out := make(map[string]string, len(list)) + for _, s := range list { + out[firstNonEmpty(s.Key, s.SecretKey)] = firstNonEmpty(s.Value, s.SecretValue) + } + return out, nil +} diff --git a/internal/secrets/infisical_test.go b/internal/secrets/infisical_test.go new file mode 100644 index 0000000..5a3bb3e --- /dev/null +++ b/internal/secrets/infisical_test.go @@ -0,0 +1,89 @@ +package secrets + +import ( + "context" + "errors" + "testing" +) + +// infisicalFakeRunner serves canned `infisical export` output. +type infisicalFakeRunner struct { + out []byte + missing bool + calls [][]string +} + +func (f *infisicalFakeRunner) LookPath(string) (string, error) { + if f.missing { + return "", errors.New("infisical: not found") + } + return "/usr/bin/infisical", nil +} +func (f *infisicalFakeRunner) Output(_ context.Context, _ []string, _ string, args ...string) ([]byte, error) { + f.calls = append(f.calls, args) + return f.out, nil +} + +func TestInfisicalFlatObjectFormat(t *testing.T) { + fr := &infisicalFakeRunner{out: []byte(`{"DB_URL":"postgres://x","API_KEY":"k"}`)} + p := &InfisicalProvider{name: "inf", projectID: "proj", env: "dev", runner: fr} + + got, err := p.Resolve(context.Background(), mustRefs(t, "secret://inf/DB_URL", "secret://inf/API_KEY")) + if err != nil { + t.Fatal(err) + } + if got["secret://inf/DB_URL"] != "postgres://x" || got["secret://inf/API_KEY"] != "k" { + t.Errorf("values wrong: %v", got) + } + // Single batched export carrying projectId + env. + if len(fr.calls) != 1 { + t.Fatalf("made %d calls, want 1 batched export", len(fr.calls)) + } + if argAfter(fr.calls[0], "--projectId") != "proj" || argAfter(fr.calls[0], "--env") != "dev" { + t.Errorf("export args missing projectId/env: %v", fr.calls[0]) + } +} + +func TestInfisicalListFormatAndJSONKey(t *testing.T) { + // The other shape: a list of {secretKey, secretValue}; one value is JSON. + fr := &infisicalFakeRunner{out: []byte(`[ + {"secretKey":"PLAIN","secretValue":"v"}, + {"secretKey":"BLOB","secretValue":"{\"nested\":\"deep\"}"} + ]`)} + p := &InfisicalProvider{name: "inf", runner: fr} + + got, err := p.Resolve(context.Background(), mustRefs(t, "secret://inf/PLAIN", "secret://inf/BLOB#nested")) + if err != nil { + t.Fatal(err) + } + if got["secret://inf/PLAIN"] != "v" { + t.Errorf("PLAIN = %q, want v", got["secret://inf/PLAIN"]) + } + if got["secret://inf/BLOB#nested"] != "deep" { + t.Errorf("BLOB#nested = %q, want deep", got["secret://inf/BLOB#nested"]) + } +} + +func TestInfisicalMissingSecret(t *testing.T) { + fr := &infisicalFakeRunner{out: []byte(`{"A":"1"}`)} + p := &InfisicalProvider{name: "inf", runner: fr} + if _, err := p.Resolve(context.Background(), mustRefs(t, "secret://inf/NOPE")); err == nil { + t.Fatal("a missing secret must error") + } +} + +func TestInfisicalMissingCLI(t *testing.T) { + p := &InfisicalProvider{name: "inf", runner: &infisicalFakeRunner{missing: true}} + if _, err := p.Resolve(context.Background(), mustRefs(t, "secret://inf/A")); err == nil { + t.Fatal("missing infisical CLI must error") + } +} + +func TestInfisicalRegisteredAsBuiltin(t *testing.T) { + reg := NewRegistry() + RegisterBuiltins(reg) + reg.Configure(ProviderConfig{Name: "inf", Kind: InfisicalKind}) + if _, err := reg.Provider("inf"); err != nil { + t.Errorf("infisical not built from builtins: %v", err) + } +} diff --git a/internal/secrets/sops.go b/internal/secrets/sops.go index d6c0a02..8c4b4e3 100644 --- a/internal/secrets/sops.go +++ b/internal/secrets/sops.go @@ -54,6 +54,7 @@ func RegisterBuiltins(reg *Registry) { reg.RegisterFactory(SopsKind, SopsFactory) reg.RegisterFactory(AWSSecretsManagerKind, AWSFactory) reg.RegisterFactory(AWSSSMKind, AWSFactory) + reg.RegisterFactory(InfisicalKind, InfisicalFactory) } func (p *SopsProvider) Name() string { return p.name }