Skip to content

Commit 92b39fc

Browse files
committed
support create-version operation
The `create-version` operation Creates a specific version of a secret, sets its value and immediately activates that version. It fails with HTTP status 412 (precondition failed) if this version of the secret already has a value. Updates tailscale/corp#34020 Signed-off-by: Percy Wegmann <[email protected]>
1 parent 2ab774e commit 92b39fc

File tree

11 files changed

+159
-7
lines changed

11 files changed

+159
-7
lines changed

acl/acl.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@ const (
2727
// secret.
2828
ActionPut = Action("put")
2929

30+
// ActionCreateVersion ("create-version" in the API) denotes permission to
31+
// create a specific version of a secret if and only if that version has no
32+
// current value.
33+
ActionCreateVersion = Action("create-version")
34+
3035
// ActionActivate ("activate" in the API) denotes permission to set one one
3136
// of of the available versions of a secret as the active one.
3237
ActionActivate = Action("activate")

acl/acl_test.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ func TestACL(t *testing.T) {
1616
Secret: []acl.Secret{"control/foo", "control/bar"},
1717
},
1818
acl.Rule{
19-
Action: []acl.Action{acl.ActionInfo, acl.ActionPut, acl.ActionActivate},
19+
Action: []acl.Action{acl.ActionInfo, acl.ActionPut, acl.ActionCreateVersion, acl.ActionActivate},
2020
Secret: []acl.Secret{"*"},
2121
},
2222
acl.Rule{
@@ -55,6 +55,12 @@ func TestACL(t *testing.T) {
5555
allow("put", "something/else"),
5656
allow("put", "dev/foo"),
5757

58+
allow("create-version", "control/foo"),
59+
allow("create-version", "control/bar"),
60+
allow("create-version", "control/quux"),
61+
allow("create-version", "something/else"),
62+
allow("create-version", "dev/foo"),
63+
5864
allow("activate", "control/foo"),
5965
allow("activate", "control/bar"),
6066
allow("activate", "control/quux"),

client/setec/client.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,19 @@ func (c Client) Put(ctx context.Context, name string, value []byte) (version api
199199
})
200200
}
201201

202+
// CreateVersion Creates a specific version of a secret, sets its value and immediately activates that version.
203+
// It fails if this version of the secret already has a value.
204+
//
205+
// Access requirement: "create-version"
206+
func (c Client) CreateVersion(ctx context.Context, name string, version api.SecretVersion, value []byte) error {
207+
_, err := do[struct{}](ctx, c, "/api/create-version", api.CreateVersionRequest{
208+
Name: name,
209+
Version: version,
210+
Value: value,
211+
})
212+
return err
213+
}
214+
202215
// Activate changes the active version of the secret called name to version.
203216
//
204217
// Access requirement: "activate"

db/db.go

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@ var (
5050
// ErrNotFound is the error returned by DB methods when the
5151
// database lacks a necessary secret or secret version.
5252
ErrNotFound = errors.New("not found")
53+
// ErrVersionExists indicates that an attempt was made to create a
54+
// version of a secret that already exists.
55+
ErrVersionExists = errors.New("version already exists")
5356
)
5457

5558
// Open loads the secrets database at path, decrypting it using key.
@@ -237,7 +240,7 @@ func (db *DB) Put(caller Caller, name string, value []byte) (api.SecretVersion,
237240
if strings.HasPrefix(name, configPrefix) {
238241
return db.putConfigLocked(name, value)
239242
}
240-
return db.kv.put(name, value)
243+
return db.kv.put(name, value, 0)
241244
}
242245

243246
func (db *DB) putConfigLocked(name string, value []byte) (api.SecretVersion, error) {
@@ -247,6 +250,38 @@ func (db *DB) putConfigLocked(name string, value []byte) (api.SecretVersion, err
247250
}
248251
}
249252

253+
// CreateVersion creates the specified version of the secret called name with
254+
// the specified value. For a secret that does not yet exist, CreateVersion creates
255+
// the secret, sets the specified version to the given value and makes this the
256+
// secret's initial version. For a secret that already exists, CreateVersion
257+
// returns an error if the specified version already has a value; otherwise, CreateVersion
258+
// sets the specified version to the given value and immediately activates this version.
259+
//
260+
// Access requirement: "create-version"
261+
func (db *DB) CreateVersion(caller Caller, name string, version api.SecretVersion, value []byte) error {
262+
if name == "" {
263+
return errors.New("empty secret name")
264+
}
265+
if err := db.checkAndLog(caller, acl.ActionCreateVersion, name, version); err != nil {
266+
return err
267+
}
268+
269+
db.mu.Lock()
270+
defer db.mu.Unlock()
271+
_, err := db.kv.getVersion(name, version)
272+
if err == nil {
273+
return ErrVersionExists
274+
}
275+
if !errors.Is(err, ErrNotFound) {
276+
return err
277+
}
278+
_, err = db.kv.put(name, value, version)
279+
if err != nil {
280+
return err
281+
}
282+
return db.kv.setActive(name, version)
283+
}
284+
250285
// Activate changes the active version of the secret called name to version.
251286
func (db *DB) Activate(caller Caller, name string, version api.SecretVersion) error {
252287
if name == "" {

db/db_test.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"bytes"
88
"errors"
99
"io"
10+
"math"
1011
"os"
1112
"strconv"
1213
"testing"
@@ -227,6 +228,45 @@ func TestPut(t *testing.T) {
227228
mustGetVersion(ver3, "test value 2")
228229
}
229230

231+
func TestCreateVersion(t *testing.T) {
232+
d := setectest.NewDB(t, nil)
233+
id := d.Superuser
234+
235+
const testName = "test-secret-name"
236+
mustGetVersion := func(version api.SecretVersion, want string) *api.SecretValue {
237+
t.Helper()
238+
got := d.MustGetVersion(id, testName, version)
239+
if !bytes.Equal(got.Value, []byte(want)) {
240+
t.Fatalf("Get %q version %v: got %q, want %q", testName, id, got.Value, want)
241+
}
242+
return got
243+
}
244+
245+
testValue1 := []byte("test value 1")
246+
testValue2 := []byte("test value 2")
247+
248+
// Setting first version should be allowed.
249+
err := d.Actual.CreateVersion(id, testName, 1, testValue1)
250+
if err != nil {
251+
t.Fatalf("failed to Set first version: %s", err)
252+
}
253+
mustGetVersion(1, "test value 1")
254+
255+
// Setting a disjoint version for the first time should be allowed.
256+
err = d.Actual.CreateVersion(id, testName, math.MaxInt64, testValue2)
257+
if err != nil {
258+
t.Fatalf("failed to Set disjoint version: %s", err)
259+
}
260+
mustGetVersion(math.MaxInt64, "test value 2")
261+
262+
// Setting a disjoint version for the second time should not be allowed.
263+
err = d.Actual.CreateVersion(id, testName, math.MaxInt64, testValue2)
264+
if !errors.Is(err, db.ErrVersionExists) {
265+
t.Fatalf("Setting existing version should have failed with %s but returned %s", db.ErrVersionExists, err)
266+
}
267+
mustGetVersion(math.MaxInt64, "test value 2")
268+
}
269+
230270
func TestDelete(t *testing.T) {
231271
d := setectest.NewDB(t, nil)
232272
id := d.Superuser

db/kv.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -320,7 +320,8 @@ func (kv *kv) getVersion(name string, version api.SecretVersion) (*api.SecretVal
320320
// exists, value is saved as a new inactive version. Otherwise, value
321321
// is saved as the initial version of the secret and immediately set
322322
// active. On success, returns the secret version for the new value.
323-
func (kv *kv) put(name string, value []byte) (api.SecretVersion, error) {
323+
// If an explicit version is specified, the value is saved at that version.
324+
func (kv *kv) put(name string, value []byte, version api.SecretVersion) (api.SecretVersion, error) {
324325
s := kv.secrets[name]
325326
if s == nil {
326327
kv.secrets[name] = &secret{
@@ -344,7 +345,11 @@ func (kv *kv) put(name string, value []byte) (api.SecretVersion, error) {
344345
return s.LatestVersion, nil
345346
}
346347

347-
s.LatestVersion++
348+
if version > s.LatestVersion {
349+
s.LatestVersion = version
350+
} else {
351+
s.LatestVersion++
352+
}
348353
s.Versions[s.LatestVersion] = bsValue
349354
if err := kv.save(); err != nil {
350355
delete(s.Versions, s.LatestVersion)

docs/README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,10 @@ The [setec API](api.md) defines the following basic operations:
8080
- The `put` method creates or adds a new value to a secret. The server assigns
8181
and reports a version number for the value.
8282

83+
- The `create-version` method creates a specific version of a secret, sets its
84+
value and immediately activates that version. It fails if a value already exists for
85+
that version.
86+
8387
### Current Active Versions
8488

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

96102
### Deleting Values
97103

docs/api.md

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ The service defines named _actions_ that are subject to access control:
3333

3434
- `put`: Denotes permission to put a new value of a secret.
3535

36+
- `create-version`: Denotes permission to create a specific version of a secret, but not
37+
override an existing value.
38+
3639
- `activate`: Denotes permission to set one one of of the available versions of
3740
a secret as the active one.
3841

@@ -105,7 +108,7 @@ The service defines named _actions_ that are subject to access control:
105108
{"Name":"example","Versions":[1,2,3],"ActiveVersion":2}
106109
```
107110

108-
- `/api/put`: Add a a new value for a secret.
111+
- `/api/put`: Add a new value for a secret.
109112

110113
**Requires:** `put` permission for the specified name.
111114

@@ -127,6 +130,19 @@ The service defines named _actions_ that are subject to access control:
127130
secret, the server reports the existing active version without modifying the
128131
store.
129132

133+
- `/api/create-version`: Creates a specific version of a secret, sets its value and immediately activates that version. It fails if this version of the secret already has a value.
134+
135+
**Requires:** `create-version` permission for the specified name.
136+
137+
**Request:** `api.CreateVersionRequest`
138+
139+
**Example request:**
140+
```json
141+
{"Name":"example","Version": 2025, "Value":"YSBuZXcgYmVnaW5uaW5n"}
142+
```
143+
144+
**Response:** `null`
145+
130146
- `/api/activate`: Set the active version of an existing secret.
131147

132148
**Requires:** `activate` permission for the specified name.

server/server.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ type Server struct {
9595
countCallForbidden *metrics.LabelMap // :: method name → count
9696
countCallNotFound *metrics.LabelMap // :: method name → count
9797
countCallInternalError *metrics.LabelMap // :: method name → count
98+
countCallAlreadySet *metrics.LabelMap // :: method name → count
9899
}
99100

100101
//go:embed templates
@@ -133,6 +134,7 @@ func New(ctx context.Context, cfg Config) (*Server, error) {
133134
countCallForbidden: &metrics.LabelMap{Label: "method"},
134135
countCallNotFound: &metrics.LabelMap{Label: "method"},
135136
countCallInternalError: &metrics.LabelMap{Label: "method"},
137+
countCallAlreadySet: &metrics.LabelMap{Label: "method"},
136138
}
137139

138140
if cfg.BackupBucket != "" {
@@ -151,6 +153,7 @@ func New(ctx context.Context, cfg Config) (*Server, error) {
151153
cfg.Mux.HandleFunc("/api/get", ret.get)
152154
cfg.Mux.HandleFunc("/api/info", ret.info)
153155
cfg.Mux.HandleFunc("/api/put", ret.put)
156+
cfg.Mux.HandleFunc("/api/create-version", ret.createVersion)
154157
cfg.Mux.HandleFunc("/api/activate", ret.activate)
155158
cfg.Mux.HandleFunc("/api/delete", ret.deleteSecret)
156159
cfg.Mux.HandleFunc("/api/delete-version", ret.deleteVersion)
@@ -251,6 +254,15 @@ func (s *Server) put(w http.ResponseWriter, r *http.Request) {
251254
})
252255
}
253256

257+
func (s *Server) createVersion(w http.ResponseWriter, r *http.Request) {
258+
serveJSON(s, w, r, func(req api.CreateVersionRequest, id db.Caller) (struct{}, error) {
259+
if err := s.db.CreateVersion(id, req.Name, req.Version, req.Value); err != nil {
260+
return struct{}{}, err
261+
}
262+
return struct{}{}, nil
263+
})
264+
}
265+
254266
func (s *Server) activate(w http.ResponseWriter, r *http.Request) {
255267
serveJSON(s, w, r, func(req api.ActivateRequest, id db.Caller) (struct{}, error) {
256268
if err := s.db.Activate(id, req.Name, req.Version); err != nil {
@@ -376,6 +388,9 @@ func serveJSON[REQ any, RESP any](s *Server, w http.ResponseWriter, r *http.Requ
376388
w.Header().Set("Content-Type", "application/json")
377389
w.WriteHeader(http.StatusNotModified)
378390
return
391+
} else if errors.Is(err, db.ErrVersionExists) {
392+
s.countCallAlreadySet.Add(apiMethod, 1)
393+
http.Error(w, "version already set", http.StatusPreconditionFailed)
379394
} else if err != nil {
380395
s.countCallInternalError.Add(apiMethod, 1)
381396
http.Error(w, "internal error", http.StatusInternalServerError)

setectest/dbtest.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ func superuser() db.Caller {
3030
Permissions: acl.Rules{
3131
acl.Rule{
3232
Action: []acl.Action{
33-
acl.ActionGet, acl.ActionInfo, acl.ActionPut, acl.ActionActivate, acl.ActionDelete,
33+
acl.ActionGet, acl.ActionInfo, acl.ActionPut, acl.ActionCreateVersion, acl.ActionActivate, acl.ActionDelete,
3434
},
3535
Secret: []acl.Secret{"*"},
3636
},

0 commit comments

Comments
 (0)