Skip to content
Merged
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 @@ -27,6 +27,11 @@ const (
// secret.
ActionPut = Action("put")

// ActionCreateVersion ("create-version" in the API) denotes permission to
// create a specific version of a secret if and only if that version has never
// been set.
ActionCreateVersion = Action("create-version")

// ActionActivate ("activate" in the API) denotes permission to set one one
// of of the available versions of a secret as the active one.
ActionActivate = Action("activate")
Expand Down
8 changes: 7 additions & 1 deletion acl/acl_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ func TestACL(t *testing.T) {
Secret: []acl.Secret{"control/foo", "control/bar"},
},
acl.Rule{
Action: []acl.Action{acl.ActionInfo, acl.ActionPut, acl.ActionActivate},
Action: []acl.Action{acl.ActionInfo, acl.ActionPut, acl.ActionCreateVersion, acl.ActionActivate},
Secret: []acl.Secret{"*"},
},
acl.Rule{
Expand Down Expand Up @@ -55,6 +55,12 @@ func TestACL(t *testing.T) {
allow("put", "something/else"),
allow("put", "dev/foo"),

allow("create-version", "control/foo"),
allow("create-version", "control/bar"),
allow("create-version", "control/quux"),
allow("create-version", "something/else"),
allow("create-version", "dev/foo"),

allow("activate", "control/foo"),
allow("activate", "control/bar"),
allow("activate", "control/quux"),
Expand Down
13 changes: 13 additions & 0 deletions client/setec/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,19 @@ func (c Client) Put(ctx context.Context, name string, value []byte) (version api
})
}

// CreateVersion Creates a specific version of a secret, sets its value and immediately activates that version.
// It fails if this version of the secret ever had a value.
//
// Access requirement: "create-version"
func (c Client) CreateVersion(ctx context.Context, name string, version api.SecretVersion, value []byte) error {
_, err := do[struct{}](ctx, c, "/api/create-version", api.CreateVersionRequest{
Name: name,
Version: version,
Value: value,
})
return err
}

// Activate changes the active version of the secret called name to version.
//
// Access requirement: "activate"
Expand Down
31 changes: 31 additions & 0 deletions db/db.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,13 @@ var (
// ErrNotFound is the error returned by DB methods when the
// database lacks a necessary secret or secret version.
ErrNotFound = errors.New("not found")
// ErrVersionTaken indicates that an attempt was made to create a
// version of a secret that has at some point already been set,
// even if it has since been deleted.
ErrVersionTaken = errors.New("version is (or was previously) set")
// 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")
)

// Open loads the secrets database at path, decrypting it using key.
Expand Down Expand Up @@ -247,6 +254,30 @@ func (db *DB) putConfigLocked(name string, value []byte) (api.SecretVersion, err
}
}

// CreateVersion creates the specified version of the secret called name with
// the specified value. For a secret that does not yet exist, CreateVersion creates
// the secret, sets the specified version to the given value and makes this the
// secret's initial version. For a secret that already exists, CreateVersion
// returns an error if the specified version ever had a value; otherwise, CreateVersion
// sets the specified version to the given value and immediately activates this version.
//
// Access requirement: "create-version"
func (db *DB) CreateVersion(caller Caller, name string, version api.SecretVersion, value []byte) error {
if name == "" {
return errors.New("empty secret name")
}
if version <= 0 {
return ErrInvalidVersion
}
if err := db.checkAndLog(caller, acl.ActionCreateVersion, name, version); err != nil {
return err
}

db.mu.Lock()
defer db.mu.Unlock()
return db.kv.createVersion(name, version, value)
}

// Activate changes the active version of the secret called name to version.
func (db *DB) Activate(caller Caller, name string, version api.SecretVersion) error {
if name == "" {
Expand Down
160 changes: 160 additions & 0 deletions db/db_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"os"
"strconv"
"testing"
"time"

"github.com/google/go-cmp/cmp"
"github.com/tailscale/setec/audit"
Expand Down Expand Up @@ -227,6 +228,165 @@ func TestPut(t *testing.T) {
mustGetVersion(ver3, "test value 2")
}

func TestCreateVersion(t *testing.T) {
secretName := "secret1"
checkVersion := func(t *testing.T, d *setectest.DB, version api.SecretVersion, want []byte) *api.SecretValue {
t.Helper()
got := d.MustGetVersion(d.Superuser, secretName, version)
if !bytes.Equal(got.Value, want) {
t.Fatalf("Get %q version %v: got %q, want %q", secretName, version, string(got.Value), string(want))
}
return got
}
checkActiveVersion := func(t *testing.T, d *setectest.DB, want []byte) {
t.Helper()
got := d.MustGet(d.Superuser, secretName)
if !bytes.Equal(got.Value, want) {
t.Fatalf("Get active %q: got %q, want %q", secretName, string(got.Value), string(want))
}
}

testValue1 := []byte("test value 1")
testValue2 := []byte("test value 2")
testValue3 := []byte("test value 3")
testValue4 := []byte("test value 4")

// One use for setting explicit versions is doing time-based rotation using something like
// UNIX timestamps. This simulates that.
year2099 := api.SecretVersion(time.Date(2099, 12, 31, 24, 60, 60, 0, time.UTC).Unix())

t.Run("create", func(t *testing.T) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: The convention we used in the existing tests was MixedCase or MixedCase_withQualifier.

d := setectest.NewDB(t, nil)

// Creating first version should be allowed.
err := d.Actual.CreateVersion(d.Superuser, secretName, 1, testValue1)
if err != nil {
t.Fatalf("failed to create first version: %s", err)
}
checkVersion(t, d, 1, testValue1)
checkActiveVersion(t, d, testValue1)

// Creating a disjoint version for the first time should be allowed.
err = d.Actual.CreateVersion(d.Superuser, secretName, year2099, testValue2)
if err != nil {
t.Fatalf("failed to create disjoint version: %s", err)
}
checkVersion(t, d, year2099, testValue2)
checkActiveVersion(t, d, testValue2)

// Creating a disjoint version for the second time should not be allowed.
err = d.Actual.CreateVersion(d.Superuser, secretName, year2099, testValue2)
if !errors.Is(err, db.ErrVersionTaken) {
t.Fatalf("Setting existing version should have failed with %q but returned %q", db.ErrVersionTaken, err)
}
checkVersion(t, d, year2099, testValue2)
checkActiveVersion(t, d, testValue2)

// Creating an in-between version should be allowed.
err = d.Actual.CreateVersion(d.Superuser, secretName, 100, testValue3)
if err != nil {
t.Fatalf("failed to create in-between version: %s", err)
}
checkVersion(t, d, 100, testValue3)
checkActiveVersion(t, d, testValue3)
})

t.Run("create_disjoint_allowed", func(t *testing.T) {
d := setectest.NewDB(t, nil)

// Creating with disjoint version should be allowed.
err := d.Actual.CreateVersion(d.Superuser, secretName, 100, testValue1)
if err != nil {
t.Fatalf("failed to create first version: %s", err)
}
checkVersion(t, d, 100, testValue1)
checkActiveVersion(t, d, testValue1)
})

t.Run("create_zero_prohibited", func(t *testing.T) {
d := setectest.NewDB(t, nil)

// Creating with disjoint version should be allowed.
err := d.Actual.CreateVersion(d.Superuser, secretName, 0, testValue1)
if !errors.Is(err, db.ErrInvalidVersion) {
t.Fatalf("Setting version to 0 should have failed with %q but returned %q", db.ErrInvalidVersion, err)
}
})

t.Run("put_create_put_delete", func(t *testing.T) {
d := setectest.NewDB(t, nil)

// Putting a new secret works
version, err := d.Actual.Put(d.Superuser, secretName, testValue1)
if err != nil {
t.Fatalf("failed to Put new secret: %s", err)
}
if version != 1 {
t.Fatalf("expected first Put to create version 1, but created %d", version)
}
checkVersion(t, d, 1, testValue1)
checkActiveVersion(t, d, testValue1)

// Creating a higher version works
err = d.Actual.CreateVersion(d.Superuser, secretName, 100, testValue3)
if err != nil {
t.Fatalf("failed to create higher version: %s", err)
}
checkVersion(t, d, 100, testValue3)
checkActiveVersion(t, d, testValue3)

// Creating an in-between version works
err = d.Actual.CreateVersion(d.Superuser, secretName, 10, testValue2)
if err != nil {
t.Fatalf("failed to create in-between version: %s", err)
}
checkVersion(t, d, 10, testValue2)
checkActiveVersion(t, d, testValue2)

// Putting gets the next higher version, but without activating
version, err = d.Actual.Put(d.Superuser, secretName, testValue4)
if err != nil {
t.Fatalf("failed to Put new secret: %s", err)
}
if version != 101 {
t.Fatalf("expected second Put to create version 101, but created %d", version)
}
checkVersion(t, d, 101, testValue4)
checkActiveVersion(t, d, testValue2)

// Deleting highest version allowed
err = d.Actual.DeleteVersion(d.Superuser, secretName, 101)
if err != nil {
t.Fatalf("failed to delete highest version: %s", err)
}
checkActiveVersion(t, d, testValue2)

// Deleting the next highest version also allowed
err = d.Actual.DeleteVersion(d.Superuser, secretName, 100)
if err != nil {
t.Fatalf("failed to delete next-highest version: %s", err)
}
checkActiveVersion(t, d, testValue2)

// Re-creating deleted version is not allowed
err = d.Actual.CreateVersion(d.Superuser, secretName, 100, testValue2)
if !errors.Is(err, db.ErrVersionTaken) {
t.Fatalf("Recreating deleted version should have failed with %q but returned %q", db.ErrVersionTaken, err)
}

// Putting gets the next higher version despite deletes
version, err = d.Actual.Put(d.Superuser, secretName, testValue4)
if err != nil {
t.Fatalf("failed to Put new secret: %s", err)
}
if version != 102 {
t.Fatalf("expected second Put to create version 102, but created %d", version)
}
checkVersion(t, d, 102, testValue4)
checkActiveVersion(t, d, testValue2)
})
}

func TestDelete(t *testing.T) {
d := setectest.NewDB(t, nil)
id := d.Superuser
Expand Down
61 changes: 59 additions & 2 deletions db/kv.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,9 +94,12 @@ type secret struct {
// 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 latest version that has already been used
// by a previous Put.
// 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
}

// byteString is an alias for a string, but encodes to JSON as the conventional
Expand Down Expand Up @@ -354,6 +357,51 @@ func (kv *kv) put(name string, value []byte) (api.SecretVersion, error) {
return s.LatestVersion, nil
}

// createVersion creates the specified version of the secret called name with
// the specified value. For a secret that does not yet exist, createVersion creates
// the secret, sets the specified version to the given value and makes this the
// secret's initial version. For a secret that already exists, createVersion
// returns ErrVersionExists if the specified version ever had a value; otherwise,
// createVersion sets the specified version to the given value and immediately
// activates this version.
func (kv *kv) createVersion(name string, version api.SecretVersion, value []byte) error {
s := kv.secrets[name]
if s == nil {
kv.secrets[name] = &secret{
LatestVersion: version,
ActiveVersion: version,
Versions: map[api.SecretVersion]byteString{
version: byteString(value),
},
}
if err := kv.save(); err != nil {
delete(kv.secrets, name)
return err
}
return nil
}

_, hasVersion := s.Versions[version]
hadVersion := s.DeletedVersions != nil && s.DeletedVersions[version]
if hasVersion || hadVersion {
return ErrVersionTaken
}

bsValue := byteString(value)
s.Versions[version] = bsValue
priorLatestVersion := s.LatestVersion
priorActiveVersion := s.ActiveVersion
s.LatestVersion = max(priorLatestVersion, version)
s.ActiveVersion = version
if err := kv.save(); err != nil {
delete(s.Versions, version)
s.LatestVersion = priorLatestVersion
s.ActiveVersion = priorActiveVersion
return err
}
return nil
}

// setActive changes the active version of the secret called name to
// version.
func (kv *kv) setActive(name string, version api.SecretVersion) error {
Expand Down Expand Up @@ -395,8 +443,17 @@ func (kv *kv) deleteVersion(name string, version api.SecretVersion) error {
return fmt.Errorf("version %v: %w", version, ErrNotFound)
}
delete(secret.Versions, version)
if secret.DeletedVersions == nil {
secret.DeletedVersions = map[api.SecretVersion]bool{
version: true,
}
} else {
secret.DeletedVersions[version] = true
}

if err := kv.save(); err != nil {
secret.Versions[version] = old
delete(secret.DeletedVersions, version)
return err
}
return nil
Expand Down
9 changes: 9 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,13 @@ The [setec API](api.md) defines the following basic operations:
- The `put` method creates or adds a new value to a secret. The server assigns
and reports a version number for the value.

- The `create-version` method creates a specific version of a secret, sets its
value and immediately activates that version. It fails if a value has ever
been set for that version.

`put` and `create-version` are safe to use in conjunction with each other on the same
secret.

### Current Active Versions

- At any time, one version of the secret is designated as its **current active
Expand All @@ -92,6 +99,8 @@ The [setec API](api.md) defines the following basic operations:
- Thereafter, the `activate` method must be used to update the current active
version. This ensures the operator of the service has precise control over
which version of a secret should be used at a time.
- The special `create-version` method automatically activates the set
version and does not require a call to `activate`.

### Deleting Values

Expand Down
Loading