Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP: Adding multi-valued secrets ("keyrings") to setec #106

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from
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
64 changes: 64 additions & 0 deletions client/setec/keyring.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause

//lint:file-ignore U1000 This is work in progress.

package setec

import (
"sync"

"github.com/tailscale/setec/types/api"
)

/*
TODO:
- Cache needs to be able to hold multiple values.
- Keep track of single- vs keyring-valued secrets.
- Update polling logic to use GetAll for keyrings.
- Add settings.
*/

// A Keyring represents a a collection of one or more available versions of a
// named secret.
type Keyring struct {
name string

mu sync.Mutex
active int // index into values
values []*api.SecretValue // in increasing order by version
}

// Get returns the key at index i of the keyring. Index 0 always refers to the
// current active version, and further indexes refer to other versions in order
// from newest to oldest.
//
// If i >= r.Len(), Get returns nil. If i < 0, Get panics.
//
// The Keyring retains ownership of the bytes returned, but the store will
// never modify the contents of the secret, so it is safe to share the slice
// without copying as long as the caller does not modify them.
func (r *Keyring) Get(i int) []byte {
r.mu.Lock()
defer r.mu.Unlock()
switch {
case i < 0:
panic("index is negative")
case i < len(r.values):
return r.values[i].Value
default:
return nil
}
}

// GetString returns a copy of the key at index i of the keyring as a string.
// If i >= r.Len(), GetString returns "". Otherwise, GetString behaves as Get.
func (r *Keyring) GetString(i int) string { return string(r.Get(i)) }

// Len reports the number of keys stored in r. The length is always positive,
// counting the active version.
func (r *Keyring) Len() int {
r.mu.Lock()
defer r.mu.Unlock()
return len(r.values)
}
41 changes: 41 additions & 0 deletions db/db.go
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,47 @@ func (db *DB) Get(caller Caller, name string) (*api.SecretValue, error) {
return db.kv.get(name)
}

// GetAllFiltered returns all versions of a secret and the active version number.
func (db *DB) GetAllFiltered(caller Caller, name string, skip []api.SecretVersion) (*Filtered, error) {
// We will not log an access unless we are returning at least one unfiltered
// secret value. However, we still want a log if authorization fails.
if !caller.Permissions.Allow(acl.ActionGet, name) {
return nil, db.checkAndLog(caller, acl.ActionGet, name, 0)
}
db.mu.Lock()
defer db.mu.Unlock()
active, all, err := db.kv.getAll(name)
if err != nil {
return nil, err
}
out := &Filtered{
Active: active,
Versions: make([]api.SecretVersion, len(all)),
}
for i, sv := range all {
out.Versions[i] = sv.Version
if !slices.Contains(skip, sv.Version) {
out.Values = append(out.Values, sv)
}
}

if len(out.Values) != 0 {
// We got unfiltered secret values, log an access. We already know it's
// authorized from the original check.
if err := db.checkAndLog(caller, acl.ActionGet, name, 0); err != nil {
return nil, err
}
}
return out, nil
}

// Filtered is the result of a successful GetAllFiltered call.
type Filtered struct {
Active api.SecretVersion // the active version of the secret
Versions []api.SecretVersion // all known versions of the secret
Values []*api.SecretValue // all non-filtered values of the secret
}

// GetConditional returns a secret's active value if it is different from oldVersion.
// If the active version is the same as oldVersion, it reports api.ErrValueNotChanged.
func (db *DB) GetConditional(caller Caller, name string, oldVersion api.SecretVersion) (*api.SecretValue, error) {
Expand Down
16 changes: 16 additions & 0 deletions db/kv.go
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,22 @@ func (kv *kv) get(name string) (*api.SecretValue, error) {
}, nil
}

// getAll returns all the versions of a secret, and the active version number.
func (kv *kv) getAll(name string) (api.SecretVersion, []*api.SecretValue, error) {
secret := kv.secrets[name]
if secret == nil {
return 0, nil, ErrNotFound
}
all := make([]*api.SecretValue, 0, len(secret.Versions))
for v, bs := range secret.Versions {
all = append(all, &api.SecretValue{
Value: []byte(bs),
Version: v,
})
}
return secret.ActiveVersion, all, nil
}

// getVersion returns a secret's value at a specific version.
func (kv *kv) getVersion(name string, version api.SecretVersion) (*api.SecretValue, error) {
secret := kv.secrets[name]
Expand Down
17 changes: 17 additions & 0 deletions server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"log"
"net/http"
"net/netip"
"slices"

"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
Expand Down Expand Up @@ -131,6 +132,7 @@ func New(ctx context.Context, cfg Config) (*Server, error) {
cfg.Mux.Handle("/static/", http.FileServer(http.FS(staticFiles)))
cfg.Mux.HandleFunc("/api/list", ret.list)
cfg.Mux.HandleFunc("/api/get", ret.get)
cfg.Mux.HandleFunc("/api/get-all", ret.getAll)
cfg.Mux.HandleFunc("/api/info", ret.info)
cfg.Mux.HandleFunc("/api/put", ret.put)
cfg.Mux.HandleFunc("/api/activate", ret.activate)
Expand Down Expand Up @@ -221,6 +223,21 @@ func (s *Server) get(w http.ResponseWriter, r *http.Request) {
})
}

func (s *Server) getAll(w http.ResponseWriter, r *http.Request) {
serveJSON(s, w, r, func(req api.GetAllRequest, id db.Caller) (*api.GetAllResponse, error) {
filtered, err := s.db.GetAllFiltered(id, req.Name, req.SkipVersions)
if err != nil {
return nil, err
}
slices.Sort(filtered.Versions)
return &api.GetAllResponse{
Active: filtered.Active,
Versions: filtered.Versions,
Values: filtered.Values,
}, nil
})
}

func (s *Server) info(w http.ResponseWriter, r *http.Request) {
serveJSON(s, w, r, func(req api.InfoRequest, id db.Caller) (*api.SecretInfo, error) {
return s.db.Info(id, req.Name)
Expand Down
25 changes: 25 additions & 0 deletions types/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,31 @@ type GetRequest struct {
UpdateIfChanged bool
}

// GetAllRequest is a request to get all the available versions of a secret.
type GetAllRequest struct {
// Name is the name of the secret to fetch.
Name string

// SkipVersions, if non-empty, lists versions the caller already has and
// does not need to have returned. The server will not return any of the
// versions listed here.
SkipVersions []SecretVersion
}

// GetAllResponse is a response from a successful GetAllRequest.
type GetAllResponse struct {
// Active is the current active version of the secret. It is set even if no
// secret values are returned.
Active SecretVersion

// Versions contains all the known versions of the secret.
Versions []SecretVersion

// Values contains all the known values of the secret, but omitting any
// versions that were listed in the SkipVersions field of the request.
Values []*SecretValue
}

// InfoRequest is a request for secret metadata.
type InfoRequest struct {
// Name is the name of the secret whose metadata to return.
Expand Down