From 897b966b793c6ad43145240977994929b8559a6b Mon Sep 17 00:00:00 2001 From: "M. J. Fromberger" Date: Mon, 27 Apr 2026 13:31:03 -0700 Subject: [PATCH 1/8] go.mod: update dependencies --- go.mod | 4 ++-- go.sum | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 04076bf..c697c3f 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index bfb0b82..3461056 100644 --- a/go.sum +++ b/go.sum @@ -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= From 30a81158908585ae0efaa6977737c061e9331349 Mon Sep 17 00:00:00 2001 From: "M. J. Fromberger" Date: Mon, 27 Apr 2026 13:31:31 -0700 Subject: [PATCH 2/8] types/api: add types for secret metadata updates --- types/api/api.go | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/types/api/api.go b/types/api/api.go index 671dabe..c582559 100644 --- a/types/api/api.go +++ b/types/api/api.go @@ -9,6 +9,8 @@ import ( "errors" "strconv" "time" + + "tailscale.com/types/opt" ) var ( @@ -68,6 +70,9 @@ type SecretInfo struct { // If known, the last time in UTC when the secret was accessed for any // operation other than "info", otherwise zero. LastAccess time.Time `json:",omitzero"` + + // An optional human-readable description for the secret. + Description string `json:",omitzero"` } // ListRequest is a request to list secrets. @@ -98,6 +103,28 @@ type InfoRequest struct { Name string } +// MaxDescriptionBytes is the maximum permitted length of a secret description. +// Descriptions in excess of this length will be rejected. +const MaxDescriptionBytes = 1000 + +// SetInfoRequest is a request to update secret metadata. +type SetInfoRequest struct { + // Name is the name of the secret whose metadata should be updated. + Name string + + SecretInfoUpdate `json:",inline"` +} + +// SecretInfoUpdate describes metadata fields of a secret that should be +// updated to new values. Each field is optional: If a field of the update is +// set, its corresponding value in the secret is updated; otherwise the value +// stored with the secret preserved as-stored. +type SecretInfoUpdate struct { + // Description, if set, is a human-readable text description to apply. + // The value, if set, must be valid UTF-8 and not exceed [MaxDescriptionBytes]. + Description opt.Value[string] `json:",omitzero"` +} + // PutRequest is a request to write a secret value. type PutRequest struct { // Name is the name of the secret to write. From 7591cc0974258ce51e5a7c7cc7e2edc89dafe41a Mon Sep 17 00:00:00 2001 From: "M. J. Fromberger" Date: Mon, 27 Apr 2026 13:32:01 -0700 Subject: [PATCH 3/8] db: store descriptions and implement SetInfo --- db/db.go | 18 ++++++++++++++++++ db/kv.go | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/db/db.go b/db/db.go index 45e96cd..5b4039e 100644 --- a/db/db.go +++ b/db/db.go @@ -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]. @@ -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 diff --git a/db/kv.go b/db/kv.go index 0671f21..a19c807 100644 --- a/db/kv.go +++ b/db/kv.go @@ -13,6 +13,7 @@ import ( "maps" "os" "slices" + "unicode/utf8" "github.com/tailscale/setec/types/api" "github.com/tink-crypto/tink-go/v2/aead" @@ -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 @@ -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) @@ -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] From d3be5f3108abbfbb3c8082bee6ae08ec783c1023 Mon Sep 17 00:00:00 2001 From: "M. J. Fromberger" Date: Mon, 27 Apr 2026 13:32:24 -0700 Subject: [PATCH 4/8] acl: add set-info permission and tests --- acl/acl.go | 5 +++++ acl/acl_test.go | 10 ++++++++++ 2 files changed, 15 insertions(+) diff --git a/acl/acl.go b/acl/acl.go index 3cf31fc..cb59f7c 100644 --- a/acl/acl.go +++ b/acl/acl.go @@ -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") diff --git a/acl/acl_test.go b/acl/acl_test.go index 8fbace4..f9cdba0 100644 --- a/acl/acl_test.go +++ b/acl/acl_test.go @@ -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 { @@ -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 { From 60666daa7cd8c18b669b1591c91eac0d443810ac Mon Sep 17 00:00:00 2001 From: "M. J. Fromberger" Date: Mon, 27 Apr 2026 13:33:13 -0700 Subject: [PATCH 5/8] server,docs: implement /api/set-info and add tests --- docs/api.md | 14 +++++++++ server/server.go | 12 +++++++ server/server_test.go | 63 +++++++++++++++++++++++++++++++++++++ server/templates/index.html | 5 ++- 4 files changed, 93 insertions(+), 1 deletion(-) diff --git a/docs/api.md b/docs/api.md index c404eb0..9165e02 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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. diff --git a/server/server.go b/server/server.go index 9e2b08a..6a419d1 100644 --- a/server/server.go +++ b/server/server.go @@ -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) @@ -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) @@ -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) diff --git a/server/server_test.go b/server/server_test.go index ba291d8..bbfe84b 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -11,6 +11,7 @@ import ( "net/http" "net/http/httptest" "path/filepath" + "strings" "testing" "github.com/tailscale/setec/acl" @@ -23,6 +24,7 @@ import ( "github.com/tailscale/setec/types/api" "tailscale.com/client/tailscale/apitype" "tailscale.com/tailcfg" + "tailscale.com/types/opt" ) func TestNew(t *testing.T) { @@ -167,3 +169,64 @@ func TestServerStatus(t *testing.T) { t.Errorf("DeleteVersion %v: unexpected error %v", ov2, err) } } + +func TestServerSetInfo(t *testing.T) { + d := setectest.NewDB(t, nil) + d.MustPut(d.Superuser, "pet", "miniature giant space hamster") + + ss := setectest.NewServer(t, d, nil) + hs := httptest.NewServer(ss.Mux) + defer hs.Close() + + cli := setec.Client{Server: hs.URL, DoHTTP: hs.Client().Do} + checkDesc := func(t *testing.T, name, want string) { + t.Helper() + info, err := cli.Info(t.Context(), name) + if err != nil { + t.Fatalf("Info %q: unexpected error: %v", name, err) + } + if got := info.Description; got != want { + t.Errorf("Info %q: got description %q, want %q", name, got, want) + } + } + checkSetInfo := func(t *testing.T, name string, update api.SecretInfoUpdate, want string) { + t.Helper() + err := cli.SetInfo(t.Context(), name, update) + if want != "" { + if err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("SetInfo %q: got error %v, want %q", name, err, want) + } + } else if err != nil { + t.Errorf("SetInfo %q: unexpected error: %v", name, err) + } + } + + // Case 1: The initial description is empty. + checkDesc(t, "pet", "") + + // Case 2: An empty update should fail. + checkSetInfo(t, "pet", api.SecretInfoUpdate{}, "no updates specified") + + // Case 3: A too-long description should fail, leaving the secret unchanged. + checkSetInfo(t, "pet", api.SecretInfoUpdate{ + Description: opt.ValueOf(strings.Repeat("q", 2000)), + }, "description too long") + checkDesc(t, "pet", "") + + // Case 4: A valid update should succeed, and update the secret. + checkSetInfo(t, "pet", api.SecretInfoUpdate{ + Description: opt.ValueOf("Boo"), + }, "") + checkDesc(t, "pet", "Boo") + + // Case 5: An update to a non-existent secret should fail. + checkSetInfo(t, "fruit", api.SecretInfoUpdate{ + Description: opt.ValueOf("apple"), + }, "not found") + + // Case 6: An empty description is valid (distinct from "unspecified"). + checkSetInfo(t, "pet", api.SecretInfoUpdate{ + Description: opt.ValueOf(""), + }, "") + checkDesc(t, "pet", "") +} diff --git a/server/templates/index.html b/server/templates/index.html index 98f706f..9fff549 100644 --- a/server/templates/index.html +++ b/server/templates/index.html @@ -9,7 +9,7 @@

Secrets List

- + {{- range $info := .}} @@ -31,6 +31,9 @@

Secrets List

{{- timeFormat . "2006-01-02T15:04:05Z"}} {{- else}}(unknown){{end}} + {{- end}}
NameVersionsLast Accessed
NameVersionsLast AccessedDescription
{{$info.Name}} + {{$info.Description}} +
From 86514d8f0022b4742eacbbf0bfc8e0227e955954 Mon Sep 17 00:00:00 2001 From: "M. J. Fromberger" Date: Mon, 27 Apr 2026 13:33:34 -0700 Subject: [PATCH 6/8] setectest: add support for SetInfo --- setectest/dbtest.go | 12 +++++++++++- setectest/server.go | 3 ++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/setectest/dbtest.go b/setectest/dbtest.go index 7cbeed5..376a02f 100644 --- a/setectest/dbtest.go +++ b/setectest/dbtest.go @@ -30,7 +30,8 @@ func superuser() db.Caller { Permissions: acl.Rules{ acl.Rule{ Action: []acl.Action{ - acl.ActionGet, acl.ActionInfo, acl.ActionPut, acl.ActionCreateVersion, acl.ActionActivate, acl.ActionDelete, + acl.ActionGet, acl.ActionInfo, acl.ActionSetInfo, acl.ActionPut, + acl.ActionCreateVersion, acl.ActionActivate, acl.ActionDelete, }, Secret: []acl.Secret{"*"}, }, @@ -150,3 +151,12 @@ func (db *DB) MustCreateVersion(caller db.Caller, name string, version api.Secre db.t.Fatalf("CreateVersion %v of %q failed: %v", version, name, err) } } + +// MustSetInfo updates the metadata for the named secret or fails. +func (db *DB) MustSetInfo(caller db.Caller, name string, update api.SecretInfoUpdate) { + db.t.Helper() + + if err := db.Actual.SetInfo(caller, name, update); err != nil { + db.t.Fatalf("SetInfo %q to %+v failed: %v", name, update, err) + } +} diff --git a/setectest/server.go b/setectest/server.go index 7761fda..68822ba 100644 --- a/setectest/server.go +++ b/setectest/server.go @@ -105,7 +105,8 @@ var allAccessCap []tailcfg.RawMessage func init() { rule, err := json.Marshal(acl.Rule{ Action: []acl.Action{ - acl.ActionGet, acl.ActionInfo, acl.ActionPut, acl.ActionCreateVersion, acl.ActionActivate, acl.ActionDelete, + acl.ActionGet, acl.ActionInfo, acl.ActionSetInfo, acl.ActionPut, + acl.ActionCreateVersion, acl.ActionActivate, acl.ActionDelete, }, Secret: []acl.Secret{"*"}, }) From b1d920db718bcb702dae9cac365d4aa1afde9cb6 Mon Sep 17 00:00:00 2001 From: "M. J. Fromberger" Date: Mon, 27 Apr 2026 13:33:51 -0700 Subject: [PATCH 7/8] client/setec: add a SetInfo client method --- client/setec/client.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/client/setec/client.go b/client/setec/client.go index 59a9c36..661fd82 100644 --- a/client/setec/client.go +++ b/client/setec/client.go @@ -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. // From e7755cd52fcceb56e2d75b60ca4c99163cb6e302 Mon Sep 17 00:00:00 2001 From: "M. J. Fromberger" Date: Mon, 27 Apr 2026 13:34:06 -0700 Subject: [PATCH 8/8] cmd/setec: add set-info subcommand and update info output --- cmd/setec/setec.go | 34 ++++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/cmd/setec/setec.go b/cmd/setec/setec.go index a1fc717..99010d5 100644 --- a/cmd/setec/setec.go +++ b/cmd/setec/setec.go @@ -42,6 +42,7 @@ import ( "golang.org/x/term" "tailscale.com/tsnet" "tailscale.com/tsweb" + "tailscale.com/types/opt" ) func main() { @@ -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: "", + Help: `Set metadata for the specified secret.`, + SetFlags: command.Flags(flax.MustBind, &setInfoArgs), + Run: command.Adapt(runSetInfo), + }, { Name: "get", Usage: "", @@ -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 { @@ -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() } @@ -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() { @@ -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)"`