Files
oikos/internal/secrets/infisical.go
dtoro e3449b24c1
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
feat: wire Infisical secret store into API server and MCP tools
- Wire secretsManager in NewHandler() — instantiate InfisicalBackend
  when OIKOS_INFISICAL_SITE_URL is set (previously always nil)
- Add get_secret, list_secrets, set_secret MCP tools with nil-backend
  graceful degradation
- Add oikos secret get|set|list CLI subcommands for Infisical
- Fix Set() bug: create-before-update so new keys are created;
  add Type: "shared" to Update so it finds the right secret;
  disable SDK cache so Get returns fresh data after Set
- Clean enrollment response: remove fake infisical_client_id/
  infisical_client_secret stubs, store age key in Infisical for real
2026-08-05 23:03:27 +02:00

154 lines
3.8 KiB
Go

package secrets
import (
"context"
"fmt"
"os"
"strings"
"sync"
infisical "github.com/infisical/go-sdk"
)
// InfisicalBackend reads secrets from an Infisical instance via machine identity auth.
type InfisicalBackend struct {
client infisical.InfisicalClientInterface
cfg InfisicalConfig
mu sync.Mutex
connected bool
}
// InfisicalConfig holds connection parameters for an Infisical instance.
type InfisicalConfig struct {
SiteURL string // Infisical server URL (cloud or self-hosted)
ClientID string // Machine identity client ID
ClientSecret string // Machine identity client secret
ProjectID string // Infisical project/workspace ID
SecretPath string // Path prefix for secrets (e.g., "/")
Env string // Environment slug (e.g., "dev", "prod")
}
// NewInfisicalBackend creates an Infisical backend. Connects lazily on first Get.
func NewInfisicalBackend(cfg InfisicalConfig) *InfisicalBackend {
autoRefresh := true
return &InfisicalBackend{
cfg: cfg,
client: infisical.NewInfisicalClient(context.Background(), infisical.Config{
SiteUrl: cfg.SiteURL,
AutoTokenRefresh: &autoRefresh,
CacheExpiryInSeconds: 0, // no caching — live reads over localhost
}),
}
}
func (b *InfisicalBackend) Name() string { return "infisical" }
func (b *InfisicalBackend) connect() error {
b.mu.Lock()
defer b.mu.Unlock()
if b.connected {
return nil
}
clientID := b.cfg.ClientID
clientSecret := b.cfg.ClientSecret
if clientID == "" {
clientID = os.Getenv("INFISICAL_CLIENT_ID")
}
if clientSecret == "" {
clientSecret = os.Getenv("INFISICAL_CLIENT_SECRET")
}
if clientID == "" || clientSecret == "" {
return fmt.Errorf("%w: INFISICAL_CLIENT_ID and INFISICAL_CLIENT_SECRET not set", ErrBackendUnavailable)
}
_, err := b.client.Auth().UniversalAuthLogin(clientID, clientSecret)
if err != nil {
return fmt.Errorf("%w: infisical auth: %w", ErrBackendUnavailable, err)
}
b.connected = true
return nil
}
func (b *InfisicalBackend) Get(ctx context.Context, key string) (string, error) {
if err := b.connect(); err != nil {
return "", err
}
opts := infisical.ListSecretsOptions{
ProjectID: b.cfg.ProjectID,
Environment: b.cfg.Env,
SecretPath: b.cfg.SecretPath,
}
secrets, err := b.client.Secrets().List(opts)
if err != nil {
return "", fmt.Errorf("infisical list: %w", err)
}
// Strip path prefix for matching
lookupKey := strings.TrimPrefix(key, b.cfg.SecretPath)
for _, s := range secrets {
if s.SecretKey == lookupKey {
return s.SecretValue, nil
}
}
return "", ErrNotFound
}
func (b *InfisicalBackend) List(ctx context.Context) ([]string, error) {
if err := b.connect(); err != nil {
return nil, err
}
opts := infisical.ListSecretsOptions{
ProjectID: b.cfg.ProjectID,
Environment: b.cfg.Env,
SecretPath: b.cfg.SecretPath,
}
secrets, err := b.client.Secrets().List(opts)
if err != nil {
return nil, fmt.Errorf("infisical list: %w", err)
}
keys := make([]string, len(secrets))
for i, s := range secrets {
keys[i] = s.SecretKey
}
return keys, nil
}
func (b *InfisicalBackend) Set(ctx context.Context, key string, value string) error {
if err := b.connect(); err != nil {
return err
}
// Try create first (idempotent — upserts); fall back to update on conflict.
_, err := b.client.Secrets().Create(infisical.CreateSecretOptions{
SecretKey: key,
SecretValue: value,
Environment: b.cfg.Env,
SecretPath: b.cfg.SecretPath,
ProjectID: b.cfg.ProjectID,
Type: "shared",
})
if err != nil {
// Create failed (key may already exist) — update instead.
_, err = b.client.Secrets().Update(infisical.UpdateSecretOptions{
SecretKey: key,
NewSecretValue: value,
Environment: b.cfg.Env,
SecretPath: b.cfg.SecretPath,
ProjectID: b.cfg.ProjectID,
Type: "shared",
})
}
return err
}