Skip to content

Commit

Permalink
fields: parse tagged fields in struct values and apply secrets to them
Browse files Browse the repository at this point in the history
Given a pointer to a struct with specially-tagged fields, use reflection to
locate these fields and plumb secrets from a setec.Store into them.
  • Loading branch information
creachadair committed Oct 12, 2023
1 parent 5851f1e commit 36230c7
Show file tree
Hide file tree
Showing 4 changed files with 485 additions and 7 deletions.
68 changes: 68 additions & 0 deletions client/setec/example_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause

package setec_test

import (
"context"
"flag"
"log"
"net/http/httptest"
"testing"
"time"

"github.com/tailscale/setec/client/setec"
"github.com/tailscale/setec/setectest"
"tailscale.com/types/logger"
)

var runEnv = flag.String("env", "dev", "Runtime environment (dev or prod)")

func TestExample(t *testing.T) {
// Set up plumbing for the example. In real usage, the client will typically
// communicate with a server running on another host.
d := setectest.NewDB(t, nil)
d.MustPut(d.Superuser, "dev/alpha", "dev-alpha")
d.MustPut(d.Superuser, "prod/alpha", "prod-alpha")
d.MustPut(d.Superuser, "dev/bravo", "dev-bravo")
d.MustPut(d.Superuser, "prod/bravo", "prod-bravo")
d.MustPut(d.Superuser, "dev/charlie", "dev-charlie")
d.MustPut(d.Superuser, "prod/charlie", "prod-charlie")
d.MustPut(d.Superuser, "dev/delta", `"2023-10-06T12:34:56Z"`)
d.MustPut(d.Superuser, "prod/delta", `"2023-10-05T01:23:45Z"`)

ts := setectest.NewServer(t, d, nil)
hs := httptest.NewServer(ts.Mux)
defer hs.Close()

// Example begins here:
setecClient := setec.Client{Server: hs.URL, DoHTTP: hs.Client().Do}

// Set up a struct type with fields to carry the secrets you care about.
var secrets struct {
Alpha []byte `setec:"alpha"`
Bravo string `setec:"bravo"`
Charlie setec.Secret `setec:"charlie"`
Delta time.Time `setec:"delta,json"`
Other int // this field is not touched by setec
}
secrets.Other = 25

// Create a setec.Store to track those secrets. Use the --env flag to
// select which set of secrets will be populated ("dev" or "prod").
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
st, err := setec.NewStore(ctx, setec.StoreConfig{
Client: setecClient,
Structs: []setec.Struct{{Value: &secrets, Prefix: *runEnv}},
Logf: logger.Discard,
})
if err != nil {
log.Fatalf("NewStore: %v", err)
}
defer st.Close()

// At this point the field values have been populated.
t.Logf("Example values:\nalpha: %q\nbravo: %q\ncharlie: %q\ndelta: %v\nother: %d\n",
secrets.Alpha, secrets.Bravo, secrets.Charlie.Get(), secrets.Delta, secrets.Other)
}
230 changes: 230 additions & 0 deletions client/setec/fields.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause

package setec

import (
"encoding/json"
"errors"
"fmt"
"path"
"reflect"
"strings"

"golang.org/x/exp/slices"
)

// ParseFields parses information about setec-tagged fields from v. The
// concrete type of v must be a pointer to a struct value with at least one
// tagged field. The namePrefix, if non-empty, is joined to the front of each
// tagged name, separated with a slash ("/").
//
// See [Fields] for a description of the struct tags and types recognized.
func ParseFields(v any, namePrefix string) (*Fields, error) {
fi, err := parseFields(v)
if err != nil {
return nil, err
}
if len(fi) == 0 {
return nil, fmt.Errorf("type %v has no setec-tagged fields", reflect.TypeOf(v).Elem())
}
return &Fields{prefix: namePrefix, fields: fi}, nil
}

// Fields is a helper for plumbing secrets to the fields of struct values. The
// [ParseFields] function recognizes fields of a struct with a tag like:
//
// setec:"base-secret-name[,json]"
//
// The resulting Fields value fetches the secrets identified by these tags from
// a setec.Store, and injects their values into the fields.
//
// # Background
//
// A program that uses multiple secret values has to plumb those secrets down
// to the code that needs them. One way to manage this is to bundle the secrets
// together into fields of a struct, and to make that struct accessible via a
// shared configuration library or through a context argument.
//
// To simplify the manual process of adding fields and hooking them up to the
// secrets service, the Fields type uses reflection to discover the names of
// secrets declared via struct tags, and to handle the boilerplate of plumbing
// secret values to those fields.
//
// # Basic usage
//
// Populate the Structs field of the [StoreConfig] with pointers to the struct
// values to be populated. You may optionally provide a prefix to prepend to
// secret names, so that it can use different secrets in different environments
// (for example dev vs. prod):
//
// st, err := setec.NewStore(ctx, setec.StoreConfig{
// Client: client,
// Structs: []setec.Struct{{Value: &v, Prefix: "dev/program-name"}},
// })
// // ...
//
// Once the store is ready, the secret values are automatically copied to the
// corresponding fields. It is also possible to explicitly populate struct
// fields after the store is constructed, see [ParseFields]. The store must be
// constructed with the AllowLookup option enabled to add new secrets after the
// store has been constructed.
//
// # Field Types
//
// The Fields type can handle struct fields of the following types:
//
// - A field of type []byte receives a copy of the secret value.
// - A field of type string receives a copy of the secret as a string.
// - A field of type [setec.Secret] is populated with a handle to the secret.
// - A field of type [setec.Watcher] is populated with a watcher for the secret.
//
// In addition, a field may have any type that supports JSON encoding, provided
// the secret value is also encoded as JSON, if its tag includes the optional
// "json" verb. For example, given:
//
// type Key struct {
// Salt []byte `json:"iv"`
// Data []byte `json:"data"`
// }
//
// the following is a valid field declaration:
//
// SecretKey Key `setec:"secret-key,json"` // note "json" verb
//
// and accepts a secret value formatted like:
//
// {"iv":"aGVsbG8sIHdvcmxk","data":"c3VwZXIgc2VjcmV0IHNxdWlycmVsIHN0dWZm"}
//
// The ParseFields function will report an error for a tagged field whose type
// does not fit within these constraints.
type Fields struct {
prefix string // empty means "no prefix"
fields []fieldInfo // fields needing populated
}

// Secrets returns the full prefix-expanded names of the secrets needed by
// fields tagged in f.
func (f *Fields) Secrets() []string {
out := make([]string, len(f.fields))
for i, fi := range f.fields {
out[i] = path.Join(f.prefix, fi.secretName)
}
return out
}

// Apply fetches and applies the secret values required by f to the
// corresponding fields of the input struct. Each secret must either be known
// to s at initialization, or s must be configured to allow lookups.
// Apply will attempt to process all tagged fields before reporting an error.
//
// Note: When applying secrets to struct fields from an existing Store, the
// AllowLookup option of the Store must be enabled, or else Apply will report
// an error for any field that refers to a secret not already available.
func (f *Fields) Apply(s *Store) error {
var errs []error
for _, fi := range f.fields {
fullName := path.Join(f.prefix, fi.secretName)
if err := fi.apply(s, fullName); err != nil {
errs = append(errs, fmt.Errorf("apply %q to field %q: %w", fullName, fi.fieldName, err))
}
}
return errors.Join(errs...)
}

// fieldInfo records information about a tagged field.
type fieldInfo struct {
fieldName string // name in the type (for diagnostics)
secretName string // name in the field tag (without prefix)
value reflect.Value // pointer to field
isJSON bool // if true, secret must be JSON encoded
vtype reflect.Type // type of field pointed to by value
}

// apply sets the target of fi.value to the secret named. It reports an error
// if the requested secret could not be fetched from the store.
//
// If f.isJSON is true, the data are unmarshaled as JSON.
// Otherwise, the data are converted to the target type and copied.
func (f fieldInfo) apply(s *Store, fullName string) error {
if f.isJSON {
v, err := s.LookupSecret(fullName)
if err != nil {
return err
}
return json.Unmarshal(v.Get(), f.value.Interface())
}

if f.vtype == watcherType {
w, err := s.LookupWatcher(fullName)
if err != nil {
return err
}
f.value.Elem().Set(reflect.ValueOf(w))
return nil
}

v, err := s.LookupSecret(fullName)
if err != nil {
return err
}
switch f.vtype {
case bytesType:
f.value.Elem().Set(reflect.ValueOf(v.Get()))
case stringType:
f.value.Elem().Set(reflect.ValueOf(string(v.Get())))
case secretType:
f.value.Elem().Set(reflect.ValueOf(v))
default:
return fmt.Errorf("unexpected field type %v", f.vtype)
}
return nil
}

var (
bytesType = reflect.TypeOf([]byte(nil))
secretType = reflect.TypeOf(Secret(nil))
stringType = reflect.TypeOf(string(""))
watcherType = reflect.TypeOf(Watcher{})
)

// parseFields constructs a field list for obj, which must be a pointer to a
// struct. The result contains one entry for each field of *obj having a
// "setec" struct tag, giving the base name of the secret to use for that
// field.
func parseFields(obj any) ([]fieldInfo, error) {
v := reflect.ValueOf(obj)
vt := v.Type()
if vt.Kind() != reflect.Pointer || vt.Elem().Kind() != reflect.Struct {
return nil, errors.New("value is not a pointer to a struct")
}
vt = vt.Elem()
var out []fieldInfo
for _, ft := range reflect.VisibleFields(vt) {
tag, ok := ft.Tag.Lookup("setec")
if !ok {
continue // not a relevant field
}
parts := strings.Split(tag, ",")
if parts[0] == "" {
return nil, fmt.Errorf("empty secret name for tagged field %q", ft.Name)
}
fi := fieldInfo{
fieldName: ft.Name,
secretName: parts[0],
value: v.Elem().FieldByIndex(ft.Index).Addr(),
isJSON: slices.Contains(parts[1:], "json"),
vtype: ft.Type,
}
if !fi.isJSON {
switch ft.Type {
case bytesType, stringType, secretType, watcherType:
// OK, these are supported
default:
return nil, fmt.Errorf("unsupported type %v for tagged field %q", ft.Type, ft.Name)
}
}
out = append(out, fi)
}
return out, nil
}
Loading

0 comments on commit 36230c7

Please sign in to comment.