feat: support structured secrets and backend policies
This commit is contained in:
parent
ad463d971e
commit
246e63ec2b
3 changed files with 474 additions and 11 deletions
48
README.md
48
README.md
|
|
@ -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 :
|
||||
|
|
|
|||
|
|
@ -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
247
secretstore/store_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue