feat(secrets): file-backed SecretStore for Stage-0
Implements ports.SecretStore over a 0600 JSON file as a stand-in for op/ESO so the demo runs without live op. Put persists atomically (temp + rename) and merges; Get returns ErrNotFound for unknown refs so a missing token fails loud. Behind the port, so swapping to op/ESO later is wiring, not code (ADR-002). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
// Package secrets provides a local file-backed implementation of the
|
||||
// ports.SecretStore port. It is a Stage-0 stand-in for op/ESO: secret material
|
||||
// (the YouTube OAuth refresh token) is kept in a 0600 JSON file rather than the
|
||||
// vault, so the demo runs without live op. Because every consumer depends on
|
||||
// the SecretStore port, swapping this for an op/ESO-backed store later is a
|
||||
// wiring change, not a code change (ADR-002, docs/homelab-integration.md).
|
||||
//
|
||||
// Secret values are never logged. Get returns an error for an unknown ref so a
|
||||
// missing token surfaces loudly rather than as an empty string.
|
||||
package secrets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"gitea.d-ma.be/mathias/tapir/internal/ports"
|
||||
)
|
||||
|
||||
// FileStore is a SecretStore backed by a single 0600 JSON file mapping opaque
|
||||
// refs to secret values. Safe for concurrent use within one process.
|
||||
type FileStore struct {
|
||||
path string
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// Static check: FileStore satisfies the read side of the port.
|
||||
var _ ports.SecretStore = (*FileStore)(nil)
|
||||
|
||||
// ErrNotFound is returned by Get when no secret is stored under the ref.
|
||||
var ErrNotFound = errors.New("secrets: ref not found")
|
||||
|
||||
// NewFileStore returns a store backed by path. The file need not exist yet; it
|
||||
// is created on the first Put.
|
||||
func NewFileStore(path string) *FileStore {
|
||||
return &FileStore{path: path}
|
||||
}
|
||||
|
||||
// Get resolves a ref to its secret value. It returns ErrNotFound if the file or
|
||||
// the ref is absent.
|
||||
func (s *FileStore) Get(_ context.Context, ref string) (string, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
m, err := s.load()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
v, ok := m[ref]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("%w: %q", ErrNotFound, ref)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// Put stores value under ref, persisting the file with 0600 permissions. It
|
||||
// merges into any existing entries and writes atomically (temp file + rename).
|
||||
func (s *FileStore) Put(ref, value string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
m, err := s.load()
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
if m == nil {
|
||||
m = make(map[string]string)
|
||||
}
|
||||
m[ref] = value
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil {
|
||||
return fmt.Errorf("secrets: create dir: %w", err)
|
||||
}
|
||||
b, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return fmt.Errorf("secrets: marshal: %w", err)
|
||||
}
|
||||
tmp := s.path + ".tmp"
|
||||
if err := os.WriteFile(tmp, b, 0o600); err != nil {
|
||||
return fmt.Errorf("secrets: write temp: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmp, s.path); err != nil {
|
||||
return fmt.Errorf("secrets: rename: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// load reads the backing file. A missing file yields an empty map (not an
|
||||
// error) for Get's caller, except Put distinguishes os.ErrNotExist.
|
||||
func (s *FileStore) load() (map[string]string, error) {
|
||||
b, err := os.ReadFile(s.path)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return map[string]string{}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("secrets: read %s: %w", s.path, err)
|
||||
}
|
||||
var m map[string]string
|
||||
if err := json.Unmarshal(b, &m); err != nil {
|
||||
return nil, fmt.Errorf("secrets: parse %s: %w", s.path, err)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package secrets_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/secrets"
|
||||
)
|
||||
|
||||
func TestPutThenGet(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "nested", "secrets.json")
|
||||
s := secrets.NewFileStore(path)
|
||||
|
||||
if err := s.Put("youtube/refresh_token", "rt-123"); err != nil {
|
||||
t.Fatalf("Put: %v", err)
|
||||
}
|
||||
got, err := s.Get(context.Background(), "youtube/refresh_token")
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
if got != "rt-123" {
|
||||
t.Errorf("Get = %q, want %q", got, "rt-123")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUnknownRef(t *testing.T) {
|
||||
s := secrets.NewFileStore(filepath.Join(t.TempDir(), "secrets.json"))
|
||||
_, err := s.Get(context.Background(), "missing")
|
||||
if !errors.Is(err, secrets.ErrNotFound) {
|
||||
t.Errorf("err = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutIsOwnerOnly(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "secrets.json")
|
||||
s := secrets.NewFileStore(path)
|
||||
if err := s.Put("k", "v"); err != nil {
|
||||
t.Fatalf("Put: %v", err)
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Stat: %v", err)
|
||||
}
|
||||
if perm := info.Mode().Perm(); perm != 0o600 {
|
||||
t.Errorf("file perm = %o, want 0600 (token must not be world-readable)", perm)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutMergesEntries(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "secrets.json")
|
||||
s := secrets.NewFileStore(path)
|
||||
if err := s.Put("a", "1"); err != nil {
|
||||
t.Fatalf("Put a: %v", err)
|
||||
}
|
||||
if err := s.Put("b", "2"); err != nil {
|
||||
t.Fatalf("Put b: %v", err)
|
||||
}
|
||||
// Re-open from disk to prove persistence, not in-memory state.
|
||||
s2 := secrets.NewFileStore(path)
|
||||
for k, want := range map[string]string{"a": "1", "b": "2"} {
|
||||
got, err := s2.Get(context.Background(), k)
|
||||
if err != nil {
|
||||
t.Fatalf("Get %q: %v", k, err)
|
||||
}
|
||||
if got != want {
|
||||
t.Errorf("Get %q = %q, want %q", k, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user