Skip to content
Merged
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
114 changes: 114 additions & 0 deletions internal/secrets/infisical.go
Original file line number Diff line number Diff line change
@@ -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://<provider>/<SECRET_NAME> (the secret's key in the configured env)
// secret://<provider>/<SECRET_NAME>#<json.key> (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
}
89 changes: 89 additions & 0 deletions internal/secrets/infisical_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
1 change: 1 addition & 0 deletions internal/secrets/sops.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
Loading