Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions acl/acl.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ const (
// secret values.
ActionInfo = Action("info")

// ActionSetInfo ("set-info" in the API) denotes permission to write the
// metadata for a secret, including the human-readable description, but not
// the secret values.
ActionSetInfo = Action("set-info")

// ActionPut ("put" in the API) denotes permission to put a new value of a
// secret.
ActionPut = Action("put")
Expand Down
10 changes: 10 additions & 0 deletions acl/acl_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ func TestACL(t *testing.T) {
Action: []acl.Action{acl.ActionDelete},
Secret: []acl.Secret{"dev/*"},
},
acl.Rule{
Action: []acl.Action{acl.ActionGet, acl.ActionSetInfo},
Secret: []acl.Secret{"special/*/magic"},
},
}

type testCase struct {
Expand Down Expand Up @@ -73,6 +77,12 @@ func TestACL(t *testing.T) {
deny("delete", "control/bar"),
deny("delete", "something/else"),
deny("delete", "dev"),

allow("get", "special/foo/magic"),
deny("get", "special/foo/more-magic"),
allow("set-info", "special/bar/magic"),
deny("set-info", "special/bar/more-magic"),
deny("set-info", "some/other/nonsense"),
}

for _, test := range tests {
Expand Down
9 changes: 9 additions & 0 deletions client/setec/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,15 @@ func (c Client) Info(ctx context.Context, name string) (*api.SecretInfo, error)
})
}

// SetInfo updates metadata for the given secret name.
func (c Client) SetInfo(ctx context.Context, name string, update api.SecretInfoUpdate) error {
_, err := do[struct{}](ctx, c, "/api/set-info", api.SetInfoRequest{
Name: name,
SecretInfoUpdate: update,
})
return err
}

// Put creates a secret called name, with the given value. If a secret called
// name already exist, the value is saved as a new inactive version.
//
Expand Down
34 changes: 32 additions & 2 deletions cmd/setec/setec.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import (
"golang.org/x/term"
"tailscale.com/tsnet"
"tailscale.com/tsweb"
"tailscale.com/types/opt"
)

func main() {
Expand Down Expand Up @@ -112,6 +113,13 @@ Most of the settings can be set via environment variables as well as flags.
Help: "Get metadata for the specified secret.",
Run: command.Adapt(runInfo),
},
{
Name: "set-info",
Usage: "<secret-name>",
Help: `Set metadata for the specified secret.`,
SetFlags: command.Flags(flax.MustBind, &setInfoArgs),
Run: command.Adapt(runSetInfo),
},
{
Name: "get",
Usage: "<secret-name>",
Expand Down Expand Up @@ -363,7 +371,7 @@ func runList(env *command.Env) error {
}

tw := newTabWriter(os.Stdout)
io.WriteString(tw, "NAME\tACTIVE\tVERSIONS\tLAST ACCESSED\n")
io.WriteString(tw, "NAME\tACTIVE\tVERSIONS\tLAST ACCESSED\tDESCRIPTION\n")
for _, s := range secrets {
vers := make([]string, 0, len(s.Versions))
for _, v := range s.Versions {
Expand All @@ -373,7 +381,8 @@ func runList(env *command.Env) error {
if !s.LastAccess.IsZero() {
lastAccess = s.LastAccess.Format(time.RFC3339)
}
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", s.Name, s.ActiveVersion, strings.Join(vers, ","), lastAccess)
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n",
s.Name, s.ActiveVersion, strings.Join(vers, ","), lastAccess, s.Description)
}
return tw.Flush()
}
Expand All @@ -394,6 +403,9 @@ func runInfo(env *command.Env, name string) error {
}
tw := newTabWriter(os.Stdout)
fmt.Fprintf(tw, "Name:\t%s\n", info.Name)
if d := info.Description; d != "" {
fmt.Fprintf(tw, "Description:\t%s\n", d)
}
fmt.Fprintf(tw, "Active version:\t%s\n", info.ActiveVersion)
fmt.Fprintf(tw, "Versions:\t%s\n", strings.Join(vers, ", "))
if !info.LastAccess.IsZero() {
Expand All @@ -402,6 +414,24 @@ func runInfo(env *command.Env, name string) error {
return tw.Flush()
}

var setInfoArgs struct {
Description string `flag:"description,Set the human-readable description of the secret"`
}

func runSetInfo(env *command.Env, name string) error {
c, err := newClient()
if err != nil {
return err
}

// If more metadata fields are added, this list will need to be extended.
var update api.SecretInfoUpdate
if env.IsFlagSet("description") {
update.Description = opt.ValueOf(setInfoArgs.Description)
}
return c.SetInfo(env.Context(), name, update)
}

var getArgs struct {
IfChanged bool `flag:"if-changed,Get active version if changed from --version"`
Version uint64 `flag:"version,Secret version to retrieve (default: the active version)"`
Expand Down
18 changes: 18 additions & 0 deletions db/db.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ var (
// ErrInvalidVersion indicates that an attempt was made to create a
// version of a secret using an invalid version number (<=0).
ErrInvalidVersion = errors.New("invalid version")
// ErrInvalidParams indicates that one or more request parameters are
// invalid (in an otherwise-authorized request).
ErrInvalidParams = errors.New("invalid parameters")
)

// Config carries the parameters required to construct a [DB].
Expand Down Expand Up @@ -388,6 +391,21 @@ func (db *DB) deleteConfigLocked(name string) error {
return fmt.Errorf("unknown config value %q", name)
}

// SetInfo updates the metadata of the specified secret with the specified
// values. It reports an error if the secret does not exist, or if the
func (db *DB) SetInfo(caller Caller, name string, update api.SecretInfoUpdate) error {
db.mu.Lock()
defer db.mu.Unlock()

if err := db.checkAndLog(caller, acl.ActionSetInfo, name, 0); err != nil {
return err
}
if strings.HasPrefix(name, configPrefix) {
return fmt.Errorf("cannot set info for config %q", name)
}
return db.kv.setInfo(name, update)
}

// AccessIndex is an index mapping secret names to last-access records.
type AccessIndex map[string]LastAccess

Expand Down
47 changes: 47 additions & 0 deletions db/kv.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"maps"
"os"
"slices"
"unicode/utf8"

"github.com/tailscale/setec/types/api"
"github.com/tink-crypto/tink-go/v2/aead"
Expand Down Expand Up @@ -91,15 +92,22 @@ type secret struct {
// We rely on api.SecretVersion being a type encoding/json will translate to
// a JSON string (currently an integer).
Versions map[api.SecretVersion]byteString

// ActiveVersion is the secret version that gets returned to
// clients who don't ask for a specific version of the secret.
ActiveVersion api.SecretVersion

// LatestVersion is the highest version that has already been used
// by a previous Put or CreateVersion.
LatestVersion api.SecretVersion

// DeletedVersions tracks versions that were previously set but
// have since been deleted. These are not permitted to be set again.
DeletedVersions map[api.SecretVersion]bool

// Description is an optional human-readable text describing the role or
// purpose of the secret. A valid Description must be UTF-8 encoded.
Description string
}

// byteString is an alias for a string, but encodes to JSON as the conventional
Expand Down Expand Up @@ -279,6 +287,7 @@ func (kv *kv) info(name string) (*api.SecretInfo, error) {
info := &api.SecretInfo{
Name: name,
ActiveVersion: secret.ActiveVersion,
Description: secret.Description,
}
for v := range secret.Versions {
info.Versions = append(info.Versions, v)
Expand All @@ -287,6 +296,44 @@ func (kv *kv) info(name string) (*api.SecretInfo, error) {
return info, nil
}

// setInfo applies an update to the metadata of a secret.
func (kv *kv) setInfo(name string, update api.SecretInfoUpdate) error {
secret := kv.secrets[name]
if secret == nil {
return ErrNotFound
}

// Make a shallow copy of the secret so we can revert if save fails.
// An update does not modify the maps, so it's safe to share them.
backup := *secret

// Apply any set fields of the update.
// We require that at least one update is set. When adding new metadata
// fields, add additional conditional blocks below.

var hasUpdate bool
if desc, ok := update.Description.GetOk(); ok {
if !utf8.ValidString(desc) {
return fmt.Errorf("%w: description is not utf-8", ErrInvalidParams)
} else if len(desc) > api.MaxDescriptionBytes {
return fmt.Errorf("%w: description too long (%d bytes > %d)",
ErrInvalidParams, len(desc), api.MaxDescriptionBytes)
}
secret.Description = desc
hasUpdate = true
}
// .. add additional fields here

if !hasUpdate {
return fmt.Errorf("%w: no updates specified", ErrInvalidParams)
}
if err := kv.save(); err != nil {
*secret = backup // restore
return err
}
return nil
}

// get returns a secret's active value.
func (kv *kv) get(name string) (*api.SecretValue, error) {
secret := kv.secrets[name]
Expand Down
14 changes: 14 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,20 @@ The service defines named _actions_ that are subject to access control:
{"Name":"example","Versions":[1,2,3],"ActiveVersion":2}
```

- `/api/set-info`: Set metadata for a single secret.

**Requires:** `set-info` permission for the specified secret.

**Request:** `api.SetInfoRequest`

**Example request:**
``json
{"Name":"example","Description":"a demonstration secret, not used in production"}`
```

**Constraints:** The value of the *Description* field must be valid UTF-8 and may
not exceed 1000 bytes in length.

- `/api/put`: Add a new value for a secret.

**Requires:** `put` permission for the specified name.
Expand Down
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ require (
github.com/aws/aws-sdk-go-v2/credentials v1.19.27
github.com/aws/aws-sdk-go-v2/service/s3 v1.105.0
github.com/aws/aws-sdk-go-v2/service/sts v1.44.0
github.com/creachadair/command v0.2.0
github.com/creachadair/command v0.2.4
github.com/creachadair/flax v0.0.5
github.com/creachadair/mds v0.25.15
github.com/creachadair/mds v0.27.1
github.com/creachadair/msync v0.8.1
github.com/google/go-cmp v0.7.0
github.com/google/go-tpm v0.9.8
Expand Down
8 changes: 4 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -62,12 +62,12 @@ github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 h1:8h5+bWd7R6AYUslN6c6iuZWTKsKxUFDlpnmilO6R2n0=
github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q=
github.com/creachadair/command v0.2.0 h1:qTA9cMMhZePAxFoNdnk6F6nn94s1qPndIg9hJbqI9cA=
github.com/creachadair/command v0.2.0/go.mod h1:j+Ar+uYnFsHpkMeV9kGj6lJ45y9u2xqtg8FYy6cm+0o=
github.com/creachadair/command v0.2.4 h1:dR4ZbdaSIortWQ/ZvGrNlohmtNECJaFyTIMuqlRBSV4=
github.com/creachadair/command v0.2.4/go.mod h1:oZUQWtYwThS+2p91b5OcGhdJuYpSIe5JhExYgQecxU0=
github.com/creachadair/flax v0.0.5 h1:zt+CRuXQASxwQ68e9GHAOnEgAU29nF0zYMHOCrL5wzE=
github.com/creachadair/flax v0.0.5/go.mod h1:F1PML0JZLXSNDMNiRGK2yjm5f+L9QCHchyHBldFymj8=
github.com/creachadair/mds v0.25.15 h1:i8CUqtfgbCqbvZ++L7lm8No3cOeic9YKF4vHEvEoj+Y=
github.com/creachadair/mds v0.25.15/go.mod h1:XtMfRW15sjd1iOi1Z1k+dq0pRsR5xPbulpoTrpyhk8w=
github.com/creachadair/mds v0.27.1 h1:GlO1tPbrsaoafkF6mz7dFutkGXAtIfQLI450u0ypqwA=
github.com/creachadair/mds v0.27.1/go.mod h1:dMBTCSy3iS3dwh4Rb1zxeZz2d7K8+N24GCTsayWtQRI=
github.com/creachadair/msync v0.8.1 h1:QRd8si3qZ2Q4TaDL7tS/MG/lFE3YND7U7J9fy42eAFM=
github.com/creachadair/msync v0.8.1/go.mod h1:dt0bscS09J8Ie3AdccK9JpCb7LfStaDGlAmDLukOlY4=
github.com/creachadair/taskgroup v0.13.2 h1:3KyqakBuFsm3KkXi/9XIb0QcA8tEzLHLgaoidf0MdVc=
Expand Down
12 changes: 12 additions & 0 deletions server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ func New(ctx context.Context, cfg Config) (*Server, error) {
cfg.Mux.HandleFunc("/api/list", ret.list)
cfg.Mux.HandleFunc("/api/get", ret.get)
cfg.Mux.HandleFunc("/api/info", ret.info)
cfg.Mux.HandleFunc("/api/set-info", ret.setInfo)
cfg.Mux.HandleFunc("/api/put", ret.put)
cfg.Mux.HandleFunc("/api/create-version", ret.createVersion)
cfg.Mux.HandleFunc("/api/activate", ret.activate)
Expand Down Expand Up @@ -261,6 +262,13 @@ func (s *Server) info(w http.ResponseWriter, r *http.Request) {
})
}

func (s *Server) setInfo(w http.ResponseWriter, r *http.Request) {
serveJSON(s, w, r, func(req api.SetInfoRequest, id db.Caller) (struct{}, error) {
err := s.db.SetInfo(id, req.Name, req.SecretInfoUpdate)
return struct{}{}, err
})
}

func (s *Server) put(w http.ResponseWriter, r *http.Request) {
serveJSON(s, w, r, func(req api.PutRequest, id db.Caller) (api.SecretVersion, error) {
return s.db.Put(id, req.Name, req.Value)
Expand Down Expand Up @@ -410,6 +418,10 @@ func serveJSON[REQ any, RESP any](s *Server, w http.ResponseWriter, r *http.Requ
s.countCallAlreadySet.Add(apiMethod, 1)
http.Error(w, "version already set", http.StatusPreconditionFailed)
return
} else if errors.Is(err, db.ErrInvalidParams) {
s.countCallBadRequest.Add(apiMethod, 1)
http.Error(w, err.Error(), http.StatusBadRequest)
return
} else if err != nil {
s.countCallInternalError.Add(apiMethod, 1)
http.Error(w, "internal error", http.StatusInternalServerError)
Expand Down
Loading