Files
mopac-keyproxy/internal/backend/backend.go
T
mrcharles b43bc55963 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
2026-08-28 22:30:50 -05:00

108 lines
3.5 KiB
Go

// 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
}