Add backend resolver interface with file and env backends plus phase-3 stubs
Introduce the single Backend interface every credential source implements.
v0 ships two working backends: file (0600 KEY=VALUE env files, parsed in
pure Go, never sourced; looser permission masks refused before read) and
env (process-environment indirection). Bitwarden Secrets Manager and
HashiCorp Vault ship as explicit not-implemented stubs behind the same
interface so the phase-3 connectors are drop-ins. All failures are typed
ResolveErrors carrying only the ref, backend, and a fixed reason enum —
never material.
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
// Package backend defines the resolver interface every credential source
|
||||
// implements. Backends receive opaque refs (mpk-<name>) and return key
|
||||
// material; material is held in memory only, never persisted, never
|
||||
// written to logs. Errors identify the ref, the backend, and a
|
||||
// machine-readable reason — never material.
|
||||
package backend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// Ref is one configured mpk-<name> entry from keyproxy.toml. Source and
|
||||
// Key name WHERE material lives; they are locations, not material.
|
||||
type Ref struct {
|
||||
Name string `json:"ref"` // consumer-facing opaque ref, e.g. "mpk-redmine"
|
||||
Backend string `json:"backend"` // file | env | bitwarden | vault
|
||||
Source string `json:"-"` // backend-specific source (file path, env var name, vault path)
|
||||
Key string `json:"-"` // key inside the source (env-file key, secret name)
|
||||
Mode string `json:"-"` // file backend: allowed permission mask, default "0600"
|
||||
}
|
||||
|
||||
// Reason is a machine-readable failure class. Reason strings are the only
|
||||
// failure detail that may cross the wire or reach logs: they are fixed
|
||||
// enums and can never embed source contents.
|
||||
type Reason string
|
||||
|
||||
const (
|
||||
ReasonUnknownRef Reason = "unknown_ref"
|
||||
ReasonNotImplemented Reason = "not_implemented"
|
||||
ReasonUnreadableSource Reason = "unreadable_source"
|
||||
ReasonInsecureMode Reason = "insecure_source_mode"
|
||||
ReasonMalformedSource Reason = "malformed_source"
|
||||
ReasonMissingKey Reason = "missing_key"
|
||||
ReasonEmptyValue Reason = "empty_value"
|
||||
)
|
||||
|
||||
// ResolveError names the ref, the backend and a reason. It never carries
|
||||
// material; its Error() output is safe to log.
|
||||
type ResolveError struct {
|
||||
Ref string
|
||||
Backend string
|
||||
Reason Reason
|
||||
}
|
||||
|
||||
func (e *ResolveError) Error() string {
|
||||
return fmt.Sprintf("ref %s: backend %s: %s", e.Ref, e.Backend, e.Reason)
|
||||
}
|
||||
|
||||
// Err wraps err as a ResolveError for ref/backend, deduplicating if it
|
||||
// already is one.
|
||||
func Err(ref Ref, backend string, reason Reason) error {
|
||||
return &ResolveError{Ref: ref.Name, Backend: backend, Reason: reason}
|
||||
}
|
||||
|
||||
// AsResolveError extracts a *ResolveError, or wraps an unexpected error
|
||||
// as a generic unreadable_source failure (the underlying text is dropped:
|
||||
// it is never safe to assume it is material-free).
|
||||
func AsResolveError(err error, ref Ref, backend string) *ResolveError {
|
||||
var re *ResolveError
|
||||
if err != nil && errors.As(err, &re) {
|
||||
return re
|
||||
}
|
||||
return &ResolveError{Ref: ref.Name, Backend: backend, Reason: ReasonUnreadableSource}
|
||||
}
|
||||
|
||||
// Backend resolves one Ref to its material. Implementations must not
|
||||
// persist material, cache it to disk, or write it to any log.
|
||||
type Backend interface {
|
||||
// Name is the backend's config-facing name (file, env, ...).
|
||||
Name() string
|
||||
// Resolve returns the material for ref. Errors must be (or wrap) a
|
||||
// *ResolveError; returned strings exist in memory only.
|
||||
Resolve(ctx context.Context, ref Ref) (string, error)
|
||||
}
|
||||
|
||||
// Registry maps backend names to implementations.
|
||||
type Registry struct {
|
||||
m map[string]Backend
|
||||
}
|
||||
|
||||
// NewRegistry builds a registry from the given backends.
|
||||
func NewRegistry(backends ...Backend) *Registry {
|
||||
r := &Registry{m: make(map[string]Backend, len(backends))}
|
||||
for _, b := range backends {
|
||||
r.m[b.Name()] = b
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// Get returns the backend registered under name.
|
||||
func (r *Registry) Get(name string) (Backend, bool) {
|
||||
b, ok := r.m[name]
|
||||
return b, ok
|
||||
}
|
||||
|
||||
// Names lists registered backend names, sorted.
|
||||
func (r *Registry) Names() []string {
|
||||
out := make([]string, 0, len(r.m))
|
||||
for n := range r.m {
|
||||
out = append(out, n)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package backend
|
||||
|
||||
import "context"
|
||||
|
||||
// Bitwarden is the phase-3 Bitwarden Secrets Manager backend (machine
|
||||
// accounts, plain REST via stdlib — the official SDK is source-available
|
||||
// and AGPL-incompatible). v0 ships the interface slot ONLY: it resolves
|
||||
// nothing and returns an explicit not-implemented error naming the ref
|
||||
// and backend, so the connector is a drop-in behind the same interface
|
||||
// later and misconfigured rollouts fail loudly today.
|
||||
type Bitwarden struct{}
|
||||
|
||||
// NewBitwarden returns the bitwarden stub.
|
||||
func NewBitwarden() *Bitwarden { return &Bitwarden{} }
|
||||
|
||||
// Name implements Backend.
|
||||
func (b *Bitwarden) Name() string { return "bitwarden" }
|
||||
|
||||
// Resolve implements Backend.
|
||||
func (b *Bitwarden) Resolve(ctx context.Context, ref Ref) (string, error) {
|
||||
return "", Err(ref, b.Name(), ReasonNotImplemented)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
)
|
||||
|
||||
// Env resolves refs through process-environment indirection: ref.Source
|
||||
// is the variable name. This is the crush exposure-minimization pattern —
|
||||
// configs carry env var NAMES, never literals. The value is read on
|
||||
// demand and kept in memory only.
|
||||
type Env struct{}
|
||||
|
||||
// NewEnv returns the env backend.
|
||||
func NewEnv() *Env { return &Env{} }
|
||||
|
||||
// Name implements Backend.
|
||||
func (e *Env) Name() string { return "env" }
|
||||
|
||||
// Resolve implements Backend.
|
||||
func (e *Env) Resolve(ctx context.Context, ref Ref) (string, error) {
|
||||
v, ok := os.LookupEnv(ref.Source)
|
||||
if !ok {
|
||||
return "", Err(ref, e.Name(), ReasonMissingKey)
|
||||
}
|
||||
if v == "" {
|
||||
return "", Err(ref, e.Name(), ReasonEmptyValue)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEnvResolve(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
set func(t *testing.T)
|
||||
ref Ref
|
||||
want string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "set variable resolves",
|
||||
set: func(t *testing.T) { t.Setenv("KEYPROXY_TEST_VAR", testMaterial) },
|
||||
ref: Ref{Name: "mpk-env", Backend: "env", Source: "KEYPROXY_TEST_VAR"},
|
||||
want: testMaterial,
|
||||
},
|
||||
{
|
||||
name: "unset variable",
|
||||
set: func(t *testing.T) {},
|
||||
ref: Ref{Name: "mpk-env", Backend: "env", Source: "KEYPROXY_TEST_UNSET"},
|
||||
wantErr: "missing_key",
|
||||
},
|
||||
{
|
||||
name: "empty variable",
|
||||
set: func(t *testing.T) { t.Setenv("KEYPROXY_TEST_EMPTY", "") },
|
||||
ref: Ref{Name: "mpk-env", Backend: "env", Source: "KEYPROXY_TEST_EMPTY"},
|
||||
wantErr: "empty_value",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tt.set(t)
|
||||
e := NewEnv()
|
||||
got, err := e.Resolve(context.Background(), tt.ref)
|
||||
if tt.wantErr != "" {
|
||||
if err == nil {
|
||||
t.Fatalf("want error %q, got value", tt.wantErr)
|
||||
}
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, tt.wantErr) {
|
||||
t.Fatalf("error %q does not contain %q", msg, tt.wantErr)
|
||||
}
|
||||
assertRedactedError(t, msg, tt.ref.Name, "env", testMaterial)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("got %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStubsNotImplemented(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
backend Backend
|
||||
bname string
|
||||
}{
|
||||
{"bitwarden", NewBitwarden(), "bitwarden"},
|
||||
{"vault", NewVault(), "vault"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ref := Ref{Name: "mpk-stub", Backend: tt.bname, Source: "sm://x", Key: "K"}
|
||||
got, err := tt.backend.Resolve(context.Background(), ref)
|
||||
if err == nil {
|
||||
t.Fatalf("stub resolved to %q; want explicit not-implemented error", got)
|
||||
}
|
||||
if got != "" {
|
||||
t.Fatalf("stub returned non-empty value %q", got)
|
||||
}
|
||||
msg := err.Error()
|
||||
for _, want := range []string{"mpk-stub", tt.bname, "not_implemented"} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Fatalf("stub error %q must contain %q", msg, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistry(t *testing.T) {
|
||||
r := NewRegistry(NewFile(), NewEnv(), NewBitwarden(), NewVault())
|
||||
if got := r.Names(); strings.Join(got, ",") != "bitwarden,env,file,vault" {
|
||||
t.Fatalf("names = %v", got)
|
||||
}
|
||||
if _, ok := r.Get("nope"); ok {
|
||||
t.Fatal("unknown backend must not resolve")
|
||||
}
|
||||
if b, ok := r.Get("file"); !ok || b.Name() != "file" {
|
||||
t.Fatal("file backend must resolve")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// parseEnvFile parses KEY=VALUE lines in pure Go. Env files are NEVER
|
||||
// sourced or eval'd (house rule: no exec of env files); there is no
|
||||
// shell expansion, no command substitution, no interpolation of any
|
||||
// kind. Accepted syntax per line:
|
||||
//
|
||||
// # comment skipped (also inline comments after whitespace)
|
||||
// (blank line) skipped
|
||||
// export KEY=VALUE optional "export " prefix, skipped
|
||||
// KEY=VALUE KEY is [A-Za-z_][A-Za-z0-9_]*; VALUE is the rest
|
||||
// of the line, trimmed; one matched pair of
|
||||
// surrounding single or double quotes is stripped
|
||||
// (no escape processing)
|
||||
//
|
||||
// A later duplicate KEY overrides an earlier one (same order semantics
|
||||
// as sourcing the file). Malformed lines fail loudly: the error carries
|
||||
// the line NUMBER only, never the line contents (contents may be
|
||||
// material).
|
||||
func parseEnvFile(data []byte) (map[string]string, error) {
|
||||
out := map[string]string{}
|
||||
for i, line := range strings.Split(string(data), "\n") {
|
||||
line = strings.TrimRight(line, "\r")
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
|
||||
continue
|
||||
}
|
||||
line = trimmed
|
||||
if strings.HasPrefix(line, "export ") || strings.HasPrefix(line, "export\t") {
|
||||
line = strings.TrimSpace(line[len("export"):])
|
||||
}
|
||||
eq := strings.IndexByte(line, '=')
|
||||
if eq <= 0 {
|
||||
return nil, fmt.Errorf("line %d: malformed KEY=VALUE line", i+1)
|
||||
}
|
||||
key := strings.TrimSpace(line[:eq])
|
||||
if !validEnvKey(key) {
|
||||
return nil, fmt.Errorf("line %d: malformed KEY=VALUE line", i+1)
|
||||
}
|
||||
value := strings.TrimSpace(line[eq+1:])
|
||||
if idx := commentIndex(value); idx >= 0 {
|
||||
value = strings.TrimSpace(value[:idx])
|
||||
}
|
||||
out[key] = unquote(value)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// validEnvKey reports whether key is a legal env-file identifier.
|
||||
func validEnvKey(key string) bool {
|
||||
if key == "" {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len(key); i++ {
|
||||
c := key[i]
|
||||
switch {
|
||||
case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c == '_':
|
||||
case c >= '0' && c <= '9':
|
||||
if i == 0 {
|
||||
return false
|
||||
}
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// commentIndex finds an inline comment start (a # preceded by whitespace)
|
||||
// OUTSIDE a quoted value; -1 if none.
|
||||
func commentIndex(value string) int {
|
||||
var quote byte
|
||||
for i := 0; i < len(value); i++ {
|
||||
c := value[i]
|
||||
switch {
|
||||
case quote != 0:
|
||||
if c == quote {
|
||||
quote = 0
|
||||
}
|
||||
case c == '"' || c == '\'':
|
||||
quote = c
|
||||
case c == '#' && (i == 0 || value[i-1] == ' ' || value[i-1] == '\t'):
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// unquote strips one matched pair of surrounding quotes.
|
||||
func unquote(v string) string {
|
||||
if len(v) >= 2 {
|
||||
if (v[0] == '"' && v[len(v)-1] == '"') || (v[0] == '\'' && v[len(v)-1] == '\'') {
|
||||
return v[1 : len(v)-1]
|
||||
}
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseEnvFile(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
want map[string]string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "simple lines",
|
||||
in: "A=1\nB=two words\n",
|
||||
want: map[string]string{"A": "1", "B": "two words"},
|
||||
},
|
||||
{
|
||||
name: "comments and blanks",
|
||||
in: "# header\n\nA=1\n # indented comment\nB=2\n",
|
||||
want: map[string]string{"A": "1", "B": "2"},
|
||||
},
|
||||
{
|
||||
name: "export prefix",
|
||||
in: "export A=1\nexport\tB=2\n",
|
||||
want: map[string]string{"A": "1", "B": "2"},
|
||||
},
|
||||
{
|
||||
name: "quoted values stripped",
|
||||
in: "A=\"1 2\"\nB='3 4'\n",
|
||||
want: map[string]string{"A": "1 2", "B": "3 4"},
|
||||
},
|
||||
{
|
||||
name: "spaces around key and value trimmed",
|
||||
in: " A = 1 \n",
|
||||
want: map[string]string{"A": "1"},
|
||||
},
|
||||
{
|
||||
name: "inline comment after value",
|
||||
in: "A=1 # trailing\nB=\"2 # kept\"\n",
|
||||
want: map[string]string{"A": "1", "B": "2 # kept"},
|
||||
},
|
||||
{
|
||||
name: "equals inside value",
|
||||
in: "A=b=c\n",
|
||||
want: map[string]string{"A": "b=c"},
|
||||
},
|
||||
{
|
||||
name: "crlf line endings",
|
||||
in: "A=1\r\nB=2\r\n",
|
||||
want: map[string]string{"A": "1", "B": "2"},
|
||||
},
|
||||
{
|
||||
name: "later duplicate wins",
|
||||
in: "A=first\nA=second\n",
|
||||
want: map[string]string{"A": "second"},
|
||||
},
|
||||
{
|
||||
name: "line without equals",
|
||||
in: "SECRET_MATERIAL_NO_EQUALS\n",
|
||||
wantErr: "line 1: malformed",
|
||||
},
|
||||
{
|
||||
name: "illegal key character",
|
||||
in: "A-B=supersecret\n",
|
||||
wantErr: "line 1: malformed",
|
||||
},
|
||||
{
|
||||
name: "key starting with digit",
|
||||
in: "1KEY=supersecret\n",
|
||||
wantErr: "line 1: malformed",
|
||||
},
|
||||
{
|
||||
name: "empty key",
|
||||
in: "=value\n",
|
||||
wantErr: "line 1: malformed",
|
||||
},
|
||||
{
|
||||
name: "line number reported",
|
||||
in: "A=1\nB=2\nbroken line here\n",
|
||||
wantErr: "line 3: malformed",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := parseEnvFile([]byte(tt.in))
|
||||
if tt.wantErr != "" {
|
||||
if err == nil {
|
||||
t.Fatalf("want error %q, got nil (parsed %v)", tt.wantErr, got)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("error %q does not contain %q", err, tt.wantErr)
|
||||
}
|
||||
// Parse errors carry line numbers only — never line
|
||||
// contents (contents may be material).
|
||||
if strings.Contains(err.Error(), "supersecret") || strings.Contains(err.Error(), "SECRET_MATERIAL") {
|
||||
t.Fatalf("parse error leaks line contents: %q", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(got) != len(tt.want) {
|
||||
t.Fatalf("got %v, want %v", got, tt.want)
|
||||
}
|
||||
for k, v := range tt.want {
|
||||
if got[k] != v {
|
||||
t.Fatalf("key %s: got %q, want %q", k, got[k], v)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// File resolves refs from env files on disk (KEY=VALUE lines, parsed in
|
||||
// Go, never sourced). House rule: secret files are 0600. A file whose
|
||||
// permission bits reach beyond the allowed mask (default 0600; stricter
|
||||
// masks such as 0400 always pass) is refused before it is read. Material
|
||||
// is read on demand per resolve and kept in memory only.
|
||||
type File struct{}
|
||||
|
||||
// NewFile returns the file backend.
|
||||
func NewFile() *File { return &File{} }
|
||||
|
||||
// Name implements Backend.
|
||||
func (f *File) Name() string { return "file" }
|
||||
|
||||
// Resolve implements Backend.
|
||||
func (f *File) Resolve(ctx context.Context, ref Ref) (string, error) {
|
||||
path := expandHome(ref.Source)
|
||||
info, err := os.Stat(path)
|
||||
if err != nil || info.IsDir() {
|
||||
return "", Err(ref, f.Name(), ReasonUnreadableSource)
|
||||
}
|
||||
if info.Mode().Perm()&^allowedMode(ref.Mode) != 0 {
|
||||
return "", Err(ref, f.Name(), ReasonInsecureMode)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", Err(ref, f.Name(), ReasonUnreadableSource)
|
||||
}
|
||||
kv, err := parseEnvFile(data)
|
||||
if err != nil {
|
||||
return "", Err(ref, f.Name(), ReasonMalformedSource)
|
||||
}
|
||||
v, ok := kv[ref.Key]
|
||||
if !ok {
|
||||
return "", Err(ref, f.Name(), ReasonMissingKey)
|
||||
}
|
||||
if v == "" {
|
||||
return "", Err(ref, f.Name(), ReasonEmptyValue)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// allowedMode parses ref.Mode ("0600"-style octal, default 0600) into a
|
||||
// permission mask; unparsable masks fail closed to 0 (nothing allowed).
|
||||
func allowedMode(mode string) os.FileMode {
|
||||
if mode == "" {
|
||||
return 0o600
|
||||
}
|
||||
m, err := strconv.ParseUint(strings.TrimPrefix(mode, "0o"), 8, 32)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return os.FileMode(m) & os.ModePerm
|
||||
}
|
||||
|
||||
// expandHome resolves a leading ~ / ~/ to the user's home directory;
|
||||
// every other path is returned unchanged.
|
||||
func expandHome(path string) string {
|
||||
if path == "~" || strings.HasPrefix(path, "~/") {
|
||||
home, err := os.UserHomeDir()
|
||||
if err == nil {
|
||||
if path == "~" {
|
||||
return home
|
||||
}
|
||||
return home + path[1:]
|
||||
}
|
||||
}
|
||||
return path
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const testMaterial = "sk-live-supersecret-material-0123456789"
|
||||
|
||||
// writeEnvFile writes an env file with the given permission mask.
|
||||
func writeEnvFile(t *testing.T, dir, name, content string, mode os.FileMode) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, name)
|
||||
if err := os.WriteFile(path, []byte(content), mode); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chmod(path, mode); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func TestFileResolve(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
secretFile := writeEnvFile(t, dir, "creds.env", "API_KEY="+testMaterial+"\nOTHER=x\n", 0o600)
|
||||
emptyFile := writeEnvFile(t, dir, "empty.env", "API_KEY=\n", 0o600)
|
||||
looseFile := writeEnvFile(t, dir, "loose.env", "API_KEY="+testMaterial+"\n", 0o644)
|
||||
strictFile := writeEnvFile(t, dir, "strict.env", "API_KEY="+testMaterial+"\n", 0o400)
|
||||
groupFile := writeEnvFile(t, dir, "group.env", "API_KEY="+testMaterial+"\n", 0o640)
|
||||
brokenFile := writeEnvFile(t, dir, "broken.env", "this line has no equals\n", 0o600)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
ref Ref
|
||||
wantValue string
|
||||
wantErr string // substring of the ResolveError message
|
||||
}{
|
||||
{
|
||||
name: "0600 file resolves",
|
||||
ref: Ref{Name: "mpk-test", Backend: "file", Source: secretFile, Key: "API_KEY"},
|
||||
wantValue: testMaterial,
|
||||
},
|
||||
{
|
||||
name: "0400 stricter than mask passes",
|
||||
ref: Ref{Name: "mpk-test", Backend: "file", Source: strictFile, Key: "API_KEY"},
|
||||
wantValue: testMaterial,
|
||||
},
|
||||
{
|
||||
name: "0644 refused insecure mode",
|
||||
ref: Ref{Name: "mpk-test", Backend: "file", Source: looseFile, Key: "API_KEY"},
|
||||
wantErr: "insecure_source_mode",
|
||||
},
|
||||
{
|
||||
name: "0640 refused under default 0600 mask",
|
||||
ref: Ref{Name: "mpk-test", Backend: "file", Source: groupFile, Key: "API_KEY"},
|
||||
wantErr: "insecure_source_mode",
|
||||
},
|
||||
{
|
||||
name: "0640 passes when mask says 0640",
|
||||
ref: Ref{Name: "mpk-test", Backend: "file", Source: groupFile, Key: "API_KEY", Mode: "0640"},
|
||||
wantValue: testMaterial,
|
||||
},
|
||||
{
|
||||
name: "missing file",
|
||||
ref: Ref{Name: "mpk-test", Backend: "file", Source: filepath.Join(dir, "nope.env"), Key: "API_KEY"},
|
||||
wantErr: "unreadable_source",
|
||||
},
|
||||
{
|
||||
name: "directory as source",
|
||||
ref: Ref{Name: "mpk-test", Backend: "file", Source: dir, Key: "API_KEY"},
|
||||
wantErr: "unreadable_source",
|
||||
},
|
||||
{
|
||||
name: "missing key",
|
||||
ref: Ref{Name: "mpk-test", Backend: "file", Source: secretFile, Key: "NOT_THERE"},
|
||||
wantErr: "missing_key",
|
||||
},
|
||||
{
|
||||
name: "empty value",
|
||||
ref: Ref{Name: "mpk-test", Backend: "file", Source: emptyFile, Key: "API_KEY"},
|
||||
wantErr: "empty_value",
|
||||
},
|
||||
{
|
||||
name: "malformed file",
|
||||
ref: Ref{Name: "mpk-test", Backend: "file", Source: brokenFile, Key: "API_KEY"},
|
||||
wantErr: "malformed_source",
|
||||
},
|
||||
{
|
||||
name: "mode mask fails closed on junk",
|
||||
ref: Ref{Name: "mpk-test", Backend: "file", Source: secretFile, Key: "API_KEY", Mode: "junk"},
|
||||
wantErr: "insecure_source_mode",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f := NewFile()
|
||||
got, err := f.Resolve(context.Background(), tt.ref)
|
||||
if tt.wantErr != "" {
|
||||
if err == nil {
|
||||
t.Fatalf("want error %q, got value", tt.wantErr)
|
||||
}
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, tt.wantErr) {
|
||||
t.Fatalf("error %q does not contain %q", msg, tt.wantErr)
|
||||
}
|
||||
assertRedactedError(t, msg, tt.ref.Name, "file", testMaterial)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != tt.wantValue {
|
||||
t.Fatalf("got %q, want %q", got, tt.wantValue)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileHomeExpansion covers ~/ expansion (generic home indirection,
|
||||
// not an org-specific path).
|
||||
func TestFileHomeExpansion(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
dir, _ := os.LookupEnv("HOME")
|
||||
path := writeEnvFile(t, dir, "creds.env", "API_KEY="+testMaterial+"\n", 0o600)
|
||||
f := NewFile()
|
||||
got, err := f.Resolve(context.Background(), Ref{Name: "mpk-home", Backend: "file", Source: "~/creds.env", Key: "API_KEY"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != testMaterial {
|
||||
t.Fatalf("got %q, want the material", got)
|
||||
}
|
||||
_ = path
|
||||
}
|
||||
|
||||
// assertRedactedError enforces the house rule: resolution failures name
|
||||
// the ref and the backend, NEVER material.
|
||||
func assertRedactedError(t *testing.T, msg, refName, backendName, material string) {
|
||||
t.Helper()
|
||||
if !strings.Contains(msg, refName) || !strings.Contains(msg, backendName) {
|
||||
t.Fatalf("error %q must name ref %q and backend %q", msg, refName, backendName)
|
||||
}
|
||||
if strings.Contains(msg, material) {
|
||||
t.Fatalf("ERROR LEAKS MATERIAL: %q", msg)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package backend
|
||||
|
||||
import "context"
|
||||
|
||||
// Vault is the phase-3 HashiCorp Vault backend (KV v2 + AppRole via the
|
||||
// official Go api package, MPL-2.0, vendored). v0 ships the interface
|
||||
// slot ONLY: it resolves nothing and returns an explicit
|
||||
// not-implemented error naming the ref and backend, so the connector is
|
||||
// a drop-in behind the same interface later and misconfigured rollouts
|
||||
// fail loudly today.
|
||||
type Vault struct{}
|
||||
|
||||
// NewVault returns the vault stub.
|
||||
func NewVault() *Vault { return &Vault{} }
|
||||
|
||||
// Name implements Backend.
|
||||
func (v *Vault) Name() string { return "vault" }
|
||||
|
||||
// Resolve implements Backend.
|
||||
func (v *Vault) Resolve(ctx context.Context, ref Ref) (string, error) {
|
||||
return "", Err(ref, v.Name(), ReasonNotImplemented)
|
||||
}
|
||||
Reference in New Issue
Block a user