feat: support structured secrets and backend policies

This commit is contained in:
thibaud-leclere 2026-04-13 16:47:22 +02:00
parent ad463d971e
commit 246e63ec2b
3 changed files with 474 additions and 11 deletions

View file

@ -115,7 +115,14 @@ Notes :
## Secrets
Le package `secretstore` s'appuie sur le wallet natif du système :
Le package `secretstore` supporte plusieurs politiques de backend :
- `auto` : comportement par défaut, utilise un backend keyring disponible et peut retomber sur l'environnement si `LookupEnv` est fourni
- `kwallet-only` : impose KWallet et retourne une erreur explicite si KWallet n'est pas disponible
- `keyring-any` : impose l'utilisation d'un backend keyring disponible
- `env-only` : lecture seule depuis les variables d'environnement
Backends keyring typiques :
- macOS : Keychain
- Linux : Secret Service ou KWallet selon l'environnement
@ -125,7 +132,8 @@ Exemple :
```go
store, err := secretstore.Open(secretstore.Options{
ServiceName: "my-mcp",
ServiceName: "my-mcp",
BackendPolicy: secretstore.BackendAuto,
})
if err != nil {
return err
@ -146,6 +154,42 @@ default:
}
```
Pour imposer KWallet sur Linux :
```go
store, err := secretstore.Open(secretstore.Options{
ServiceName: "email-mcp",
BackendPolicy: secretstore.BackendKWalletOnly,
})
```
Pour stocker un secret structuré en JSON :
```go
type Credentials struct {
Host string `json:"host"`
Username string `json:"username"`
Password string `json:"password"`
}
err = secretstore.SetJSON(store, "imap-credentials", "IMAP credentials", Credentials{
Host: "imap.example.com",
Username: "alice",
Password: token,
})
if err != nil {
return err
}
creds, err := secretstore.GetJSON[Credentials](store, "imap-credentials")
if err != nil {
return err
}
```
En mode `env-only`, `GetSecret("API_TOKEN")` lit la variable d'environnement `API_TOKEN`.
Les opérations d'écriture et de suppression retournent `secretstore.ErrReadOnly`.
## Helpers CLI
`cli` fournit des helpers simples pour les assistants interactifs :

View file

@ -1,18 +1,36 @@
package secretstore
import (
"encoding/json"
"errors"
"fmt"
"os"
"runtime"
"slices"
"strings"
"github.com/99designs/keyring"
)
var ErrNotFound = errors.New("secret not found")
var ErrBackendUnavailable = errors.New("secret backend unavailable")
var ErrReadOnly = errors.New("secret backend is read-only")
type BackendPolicy string
const (
BackendAuto BackendPolicy = "auto"
BackendKWalletOnly BackendPolicy = "kwallet-only"
BackendKeyringAny BackendPolicy = "keyring-any"
BackendEnvOnly BackendPolicy = "env-only"
)
type Options struct {
ServiceName string
ServiceName string
BackendPolicy BackendPolicy
LookupEnv func(string) (string, bool)
KWalletAppID string
KWalletFolder string
}
type Store interface {
@ -21,28 +39,91 @@ type Store interface {
DeleteSecret(name string) error
}
type BackendUnavailableError struct {
Policy BackendPolicy
Required string
Available []string
}
func (e *BackendUnavailableError) Error() string {
if len(e.Available) == 0 {
return fmt.Sprintf("secret backend policy %q requires %s, but no compatible backend is available", e.Policy, e.Required)
}
return fmt.Sprintf(
"secret backend policy %q requires %s, but only [%s] are available",
e.Policy,
e.Required,
strings.Join(e.Available, ", "),
)
}
func (e *BackendUnavailableError) Unwrap() error {
return ErrBackendUnavailable
}
type keyringStore struct {
ring keyring.Keyring
serviceName string
}
type envStore struct {
lookupEnv func(string) (string, bool)
}
var (
openKeyring = keyring.Open
availableKeyringPolicy = keyring.AvailableBackends
)
func Open(options Options) (Store, error) {
policy := options.BackendPolicy
if policy == "" {
policy = BackendAuto
}
switch policy {
case BackendAuto, BackendKWalletOnly, BackendKeyringAny, BackendEnvOnly:
default:
return nil, fmt.Errorf("invalid secret backend policy %q", policy)
}
if policy == BackendEnvOnly {
return newEnvStore(options), nil
}
serviceName := strings.TrimSpace(options.ServiceName)
if serviceName == "" {
return nil, errors.New("service name must not be empty")
}
ring, err := keyring.Open(keyring.Config{
ServiceName: serviceName,
})
available := availableKeyringPolicy()
allowed, err := allowedBackends(policy, available)
if err != nil {
return nil, fmt.Errorf("open OS wallet backend %q for service %q: %w", BackendName(), serviceName, err)
if errors.Is(err, ErrBackendUnavailable) && policy == BackendAuto && options.LookupEnv != nil {
return newEnvStore(options), nil
}
return nil, err
}
return &keyringStore{
ring: ring,
serviceName: serviceName,
}, nil
ring, err := openKeyring(keyring.Config{
ServiceName: serviceName,
AllowedBackends: allowed,
KWalletAppID: strings.TrimSpace(options.KWalletAppID),
KWalletFolder: strings.TrimSpace(options.KWalletFolder),
})
if err == nil {
return &keyringStore{
ring: ring,
serviceName: serviceName,
}, nil
}
if policy == BackendAuto && options.LookupEnv != nil {
return newEnvStore(options), nil
}
return nil, fmt.Errorf("open secret backend for service %q with policy %q: %w", serviceName, policy, err)
}
func BackendName() string {
@ -58,6 +139,38 @@ func BackendName() string {
}
}
func SetJSON[T any](store Store, name, label string, value T) error {
data, err := json.Marshal(value)
if err != nil {
return fmt.Errorf("encode structured secret %q as JSON: %w", name, err)
}
return store.SetSecret(name, label, string(data))
}
func GetJSON[T any](store Store, name string) (T, error) {
var value T
if err := GetJSONInto(store, name, &value); err != nil {
var zero T
return zero, err
}
return value, nil
}
func GetJSONInto(store Store, name string, target any) error {
secret, err := store.GetSecret(name)
if err != nil {
return err
}
if err := json.Unmarshal([]byte(secret), target); err != nil {
return fmt.Errorf("decode structured secret %q from JSON: %w", name, err)
}
return nil
}
func (s *keyringStore) SetSecret(name, label, secret string) error {
if err := s.ring.Set(keyring.Item{
Key: name,
@ -88,3 +201,62 @@ func (s *keyringStore) DeleteSecret(name string) error {
}
return nil
}
func (s *envStore) SetSecret(name, label, secret string) error {
return fmt.Errorf("save secret %q in environment backend: %w", name, ErrReadOnly)
}
func (s *envStore) GetSecret(name string) (string, error) {
value, ok := s.lookupEnv(name)
if !ok {
return "", ErrNotFound
}
return value, nil
}
func (s *envStore) DeleteSecret(name string) error {
return fmt.Errorf("delete secret %q from environment backend: %w", name, ErrReadOnly)
}
func newEnvStore(options Options) Store {
lookupEnv := options.LookupEnv
if lookupEnv == nil {
lookupEnv = os.LookupEnv
}
return &envStore{lookupEnv: lookupEnv}
}
func allowedBackends(policy BackendPolicy, available []keyring.BackendType) ([]keyring.BackendType, error) {
switch policy {
case BackendAuto, BackendKeyringAny:
if len(available) == 0 {
return nil, &BackendUnavailableError{
Policy: policy,
Required: "any keyring backend",
Available: nil,
}
}
return slices.Clone(available), nil
case BackendKWalletOnly:
if !slices.Contains(available, keyring.KWalletBackend) {
return nil, &BackendUnavailableError{
Policy: policy,
Required: string(keyring.KWalletBackend),
Available: backendNames(available),
}
}
return []keyring.BackendType{keyring.KWalletBackend}, nil
default:
return nil, fmt.Errorf("invalid secret backend policy %q", policy)
}
}
func backendNames(backends []keyring.BackendType) []string {
names := make([]string, 0, len(backends))
for _, backend := range backends {
names = append(names, string(backend))
}
return names
}

247
secretstore/store_test.go Normal file
View file

@ -0,0 +1,247 @@
package secretstore
import (
"errors"
"testing"
"github.com/99designs/keyring"
)
type stubKeyring struct {
items map[string]keyring.Item
}
func (s *stubKeyring) Get(key string) (keyring.Item, error) {
item, ok := s.items[key]
if !ok {
return keyring.Item{}, keyring.ErrKeyNotFound
}
return item, nil
}
func (s *stubKeyring) GetMetadata(key string) (keyring.Metadata, error) {
item, err := s.Get(key)
if err != nil {
return keyring.Metadata{}, err
}
return keyring.Metadata{Item: &item}, nil
}
func (s *stubKeyring) Set(item keyring.Item) error {
if s.items == nil {
s.items = map[string]keyring.Item{}
}
s.items[item.Key] = item
return nil
}
func (s *stubKeyring) Remove(key string) error {
delete(s.items, key)
return nil
}
func (s *stubKeyring) Keys() ([]string, error) {
keys := make([]string, 0, len(s.items))
for key := range s.items {
keys = append(keys, key)
}
return keys, nil
}
func withKeyringHooks(
t *testing.T,
available []keyring.BackendType,
opener func(cfg keyring.Config) (keyring.Keyring, error),
) {
t.Helper()
prevAvailable := availableKeyringPolicy
prevOpen := openKeyring
availableKeyringPolicy = func() []keyring.BackendType {
return available
}
openKeyring = opener
t.Cleanup(func() {
availableKeyringPolicy = prevAvailable
openKeyring = prevOpen
})
}
func TestOpenRejectsInvalidPolicy(t *testing.T) {
_, err := Open(Options{
ServiceName: "mcp-framework-test",
BackendPolicy: BackendPolicy("invalid"),
})
if err == nil {
t.Fatal("expected error")
}
}
func TestOpenAutoUsesAvailableKeyringBackends(t *testing.T) {
var gotAllowed []keyring.BackendType
ring := &stubKeyring{}
withKeyringHooks(t, []keyring.BackendType{
keyring.SecretServiceBackend,
keyring.KWalletBackend,
}, func(cfg keyring.Config) (keyring.Keyring, error) {
gotAllowed = append([]keyring.BackendType(nil), cfg.AllowedBackends...)
return ring, nil
})
store, err := Open(Options{
ServiceName: "mcp-framework-test",
BackendPolicy: BackendAuto,
})
if err != nil {
t.Fatalf("Open returned error: %v", err)
}
if err := store.SetSecret("token", "API token", "secret-value"); err != nil {
t.Fatalf("SetSecret returned error: %v", err)
}
value, err := store.GetSecret("token")
if err != nil {
t.Fatalf("GetSecret returned error: %v", err)
}
if value != "secret-value" {
t.Fatalf("GetSecret = %q, want secret-value", value)
}
if len(gotAllowed) != 2 {
t.Fatalf("allowed backends = %v, want two entries", gotAllowed)
}
}
func TestOpenAutoFallsBackToEnvironmentWhenNoKeyringBackendIsAvailable(t *testing.T) {
withKeyringHooks(t, nil, func(cfg keyring.Config) (keyring.Keyring, error) {
t.Fatal("unexpected keyring open call")
return nil, nil
})
store, err := Open(Options{
ServiceName: "mcp-framework-test",
BackendPolicy: BackendAuto,
LookupEnv: func(name string) (string, bool) {
if name == "EMAIL_CREDENTIALS" {
return `{"username":"alice"}`, true
}
return "", false
},
})
if err != nil {
t.Fatalf("Open returned error: %v", err)
}
value, err := store.GetSecret("EMAIL_CREDENTIALS")
if err != nil {
t.Fatalf("GetSecret returned error: %v", err)
}
if value != `{"username":"alice"}` {
t.Fatalf("GetSecret = %q", value)
}
}
func TestOpenKeyringAnyReturnsExplicitUnavailableError(t *testing.T) {
withKeyringHooks(t, []keyring.BackendType{keyring.SecretServiceBackend}, func(cfg keyring.Config) (keyring.Keyring, error) {
t.Fatal("unexpected keyring open call")
return nil, nil
})
_, err := Open(Options{
ServiceName: "mcp-framework-test",
BackendPolicy: BackendKWalletOnly,
})
if err == nil {
t.Fatal("expected error")
}
var backendErr *BackendUnavailableError
if !errors.As(err, &backendErr) {
t.Fatalf("error = %v, want BackendUnavailableError", err)
}
if !errors.Is(err, ErrBackendUnavailable) {
t.Fatalf("error = %v, want ErrBackendUnavailable", err)
}
if backendErr.Required != "kwallet" {
t.Fatalf("required backend = %q, want kwallet", backendErr.Required)
}
}
func TestOpenEnvOnlyIsReadOnlyAndUsesLookupEnv(t *testing.T) {
store, err := Open(Options{
BackendPolicy: BackendEnvOnly,
LookupEnv: func(name string) (string, bool) {
if name == "API_TOKEN" {
return "from-env", true
}
return "", false
},
})
if err != nil {
t.Fatalf("Open returned error: %v", err)
}
value, err := store.GetSecret("API_TOKEN")
if err != nil {
t.Fatalf("GetSecret returned error: %v", err)
}
if value != "from-env" {
t.Fatalf("GetSecret = %q, want from-env", value)
}
err = store.SetSecret("API_TOKEN", "API token", "new-value")
if !errors.Is(err, ErrReadOnly) {
t.Fatalf("SetSecret error = %v, want ErrReadOnly", err)
}
err = store.DeleteSecret("API_TOKEN")
if !errors.Is(err, ErrReadOnly) {
t.Fatalf("DeleteSecret error = %v, want ErrReadOnly", err)
}
}
func TestJSONHelpersRoundTrip(t *testing.T) {
ring := &stubKeyring{}
withKeyringHooks(t, []keyring.BackendType{keyring.SecretServiceBackend}, func(cfg keyring.Config) (keyring.Keyring, error) {
return ring, nil
})
store, err := Open(Options{
ServiceName: "mcp-framework-test",
})
if err != nil {
t.Fatalf("Open returned error: %v", err)
}
type credentials struct {
Host string `json:"host"`
Username string `json:"username"`
Password string `json:"password"`
}
input := credentials{
Host: "imap.example.com",
Username: "alice",
Password: "s3cr3t",
}
if err := SetJSON(store, "imap-credentials", "IMAP credentials", input); err != nil {
t.Fatalf("SetJSON returned error: %v", err)
}
output, err := GetJSON[credentials](store, "imap-credentials")
if err != nil {
t.Fatalf("GetJSON returned error: %v", err)
}
if output != input {
t.Fatalf("GetJSON = %#v, want %#v", output, input)
}
}