diff --git a/audit/audit.go b/audit/audit.go index 7749779..05862f2 100644 --- a/audit/audit.go +++ b/audit/audit.go @@ -5,9 +5,11 @@ package audit import ( + "bufio" "encoding/json" "errors" "io" + "iter" "math/rand" "net/netip" "os" @@ -68,11 +70,11 @@ type Writer struct { enc *json.Encoder } -// New returns a Writer that outputs audit log entries to w as JSON -// objects. If w also implements io.Closer, Writer.Close closes w. If -// w also implements a Sync method with the same signature as os.File, -// Writer.Sync calls w.Sync. -func New(w io.Writer) *Writer { +// NewWriter returns a Writer that outputs audit log entries to w as JSON +// objects. If w also implements [io.Closer], [Writer.Close] closes w. If w +// also implements a Sync method with the same signature as [os.File], +// [Writer.Sync] calls w.Sync. +func NewWriter(w io.Writer) *Writer { return &Writer{ w: w, enc: json.NewEncoder(w), @@ -86,7 +88,7 @@ func NewFile(path string) (*Writer, error) { if err != nil { return nil, err } - return New(f), nil + return NewWriter(f), nil } // Sync commits the current contents of the file to stable storage if @@ -128,3 +130,40 @@ func (l *Writer) WriteEntries(entries ...*Entry) error { } return l.Sync() } + +// A Reader is an audit log reader, that consume records in the JSON format +// generated by a [Writer]. +type Reader struct { + dec *json.Decoder +} + +// NewReader returns a Reader that consumes audit log entries from r as JSON +// objects, using the format written by a [Writer]. +func NewReader(r io.Reader) Reader { + br := bufio.NewReader(r) + return Reader{dec: json.NewDecoder(br)} +} + +// All iterates the records in r in sequence. Each pair reported by the iterator +// includes either a valid [Entry] and a nil error, or a nil entry and a non-nil +// error. After any error occurs, no further items are reported. +// +// The error values [io.EOF] and [io.ErrUnexpectedEOF] are treated as the end +// of the sequence without error. +func (r Reader) All() iter.Seq2[*Entry, error] { + return func(yield func(*Entry, error) bool) { + for { + var next Entry + if err := r.dec.Decode(&next); err != nil { + if err == io.EOF || err == io.ErrUnexpectedEOF { + return // no more elements available + } + yield(nil, err) + return + } + if !yield(&next, nil) { + return + } + } + } +} diff --git a/audit/audit_test.go b/audit/audit_test.go index 4b69bbe..3d5955a 100644 --- a/audit/audit_test.go +++ b/audit/audit_test.go @@ -9,15 +9,18 @@ import ( "errors" "net/netip" "testing" + "time" "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/tailscale/setec/acl" "github.com/tailscale/setec/audit" ) func TestWriter(t *testing.T) { out := new(testWriter) - w := audit.New(out) + w := audit.NewWriter(out) entries := []*audit.Entry{ { @@ -91,3 +94,68 @@ func (t *testWriter) Sync() error { t.synced = true; return t.syncErr } func (t *testWriter) Close() error { t.closed = true; return nil } func addrEqual(x, y netip.Addr) bool { return x == y } + +func TestReader(t *testing.T) { + // To test the audit.Reader, encode some fixed entries, then verify that + // reading them back in produces the same values. + base := time.Now() + entries := []*audit.Entry{{ + ID: 123, + Time: base, + Principal: audit.Principal{ + Hostname: "window", + IP: netip.MustParseAddr("1.2.3.4"), + User: "anathema", + }, + Action: acl.ActionGet, + Authorized: true, + Secret: "grey/mousie", + SecretVersion: 1, + }, { + ID: 456, + Time: base.Add(3 * time.Second), + Principal: audit.Principal{ + Hostname: "bookshelf", + IP: netip.MustParseAddr("2.3.4.5"), + User: "zuul", + }, + Action: acl.ActionPut, + Authorized: true, + Secret: "brown/rabbit", + SecretVersion: 4, + }, { + ID: 789, + Time: base.Add(5 * time.Second), + Principal: audit.Principal{ + Hostname: "fireplace", + IP: netip.MustParseAddr("3.4.5.6"), + Tags: []string{"tag:asha", "tag:athena"}, + }, + Action: acl.ActionActivate, + Authorized: false, + Secret: "white/mushroom", + SecretVersion: 101, + }} + + // Write the test log entries out into a memory buffer. + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + for i, e := range entries { + if err := enc.Encode(e); err != nil { + t.Fatalf("Encode entry %d: %v", i+1, err) + } + } + + // Scan back through the buffer to decode the entries. + var got []*audit.Entry + for e, err := range audit.NewReader(&buf).All() { + if err != nil { + t.Errorf("Next entry: unexpected error: %v", err) + continue + } + got = append(got, e) + } + if diff := cmp.Diff(got, entries, cmpopts.EquateComparable(netip.Addr{})); diff != "" { + t.Errorf("Read results (-got, +want):\n%s", diff) + } +} diff --git a/cmd/setec/setec.go b/cmd/setec/setec.go index 4364c04..f14402c 100644 --- a/cmd/setec/setec.go +++ b/cmd/setec/setec.go @@ -27,8 +27,10 @@ import ( "github.com/creachadair/command" "github.com/creachadair/flax" + "github.com/tailscale/setec/acl" "github.com/tailscale/setec/audit" "github.com/tailscale/setec/client/setec" + "github.com/tailscale/setec/db" "github.com/tailscale/setec/internal/tinktestutil" "github.com/tailscale/setec/server" "github.com/tailscale/setec/types/api" @@ -246,15 +248,21 @@ func runServer(env *command.Env) error { mux := http.NewServeMux() tsweb.Debugger(mux) - audit, err := audit.NewFile(filepath.Join(serverArgs.StateDir, "audit.log")) + auditPath := filepath.Join(serverArgs.StateDir, "audit.log") + audit, err := audit.NewFile(auditPath) if err != nil { return fmt.Errorf("opening audit log: %w", err) } + index, err := loadAccessIndex(auditPath) + if err != nil { + return fmt.Errorf("reading access index: %w", err) + } srv, err := server.New(env.Context(), server.Config{ DBPath: filepath.Join(serverArgs.StateDir, "database"), Key: kek, AuditLog: audit, + AccessIndex: index, // may be nil, that's OK WhoIs: lc.WhoIs, BackupBucket: serverArgs.BackupBucket, BackupBucketRegion: serverArgs.BackupBucketRegion, @@ -319,13 +327,17 @@ func runList(env *command.Env) error { } tw := newTabWriter(os.Stdout) - io.WriteString(tw, "NAME\tACTIVE\tVERSIONS\n") + io.WriteString(tw, "NAME\tACTIVE\tVERSIONS\tLAST ACCESSED\n") for _, s := range secrets { vers := make([]string, 0, len(s.Versions)) for _, v := range s.Versions { vers = append(vers, v.String()) } - fmt.Fprintf(tw, "%s\t%s\t%s\n", s.Name, s.ActiveVersion, strings.Join(vers, ",")) + lastAccess := "(unknown)" + 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) } return tw.Flush() } @@ -348,6 +360,9 @@ func runInfo(env *command.Env, name string) error { fmt.Fprintf(tw, "Name:\t%s\n", info.Name) fmt.Fprintf(tw, "Active version:\t%s\n", info.ActiveVersion) fmt.Fprintf(tw, "Versions:\t%s\n", strings.Join(vers, ", ")) + if !info.LastAccess.IsZero() { + fmt.Fprintf(tw, "Last access:\t%s\n", info.LastAccess.Format(time.RFC3339)) + } return tw.Flush() } @@ -579,3 +594,27 @@ func checkPutText(value []byte) ([]byte, error) { return nil, errors.New("text value has surrounding whitespace, " + "specify --verbatim to keep the space or --trim-space to remove it") } + +// loadAccessIndex reads an audit log from the specified path and constructs a +// last-access index from it. It reports nil without error if the path does not +// exist. +func loadAccessIndex(path string) (db.AccessIndex, error) { + f, err := os.Open(path) + if errors.Is(err, os.ErrNotExist) { + return nil, nil // ok, nothing to do + } else if err != nil { + return nil, err + } + defer f.Close() + index := make(db.AccessIndex) + for e, err := range audit.NewReader(f).All() { + if err != nil { + return nil, err + } + if !e.Authorized || e.Action == acl.ActionInfo { + continue // this is not a successful access + } + index[e.Secret] = db.LastAccess{Time: e.Time} + } + return index, nil +} diff --git a/db/db.go b/db/db.go index d619c34..45e96cd 100644 --- a/db/db.go +++ b/db/db.go @@ -22,6 +22,7 @@ import ( "slices" "strings" "sync" + "time" "github.com/tailscale/setec/acl" "github.com/tailscale/setec/audit" @@ -35,6 +36,7 @@ type DB struct { mu sync.Mutex kv *kv auditLog *audit.Writer + index AccessIndex } // We might store some of setec's configuration in the secrets @@ -59,21 +61,45 @@ var ( ErrInvalidVersion = errors.New("invalid version") ) +// Config carries the parameters required to construct a [DB]. +type Config struct { + // Path is the path of the database file, it must be non-empty. + Path string + + // AccessKey is the key (KEK) used to decrypt the data-encryption key (DEK) + // to read the contents of the database. It must be non-nil. + AccessKey tink.AEAD + + // AuditLog is the log writer used to capture audit logs. + // It must be non-nil. + AuditLog *audit.Writer + + // AccessIndex, if non-nil, is used to initialize the last-access index for + // the contents of the database. If nil, a new empty index is created. + AccessIndex AccessIndex +} + // Open loads the secrets database at path, decrypting it using key. // If no database exists at path, a new empty database is created. -func Open(path string, key tink.AEAD, auditLog *audit.Writer) (*DB, error) { - if auditLog == nil { +func Open(c Config) (*DB, error) { + if c.AuditLog == nil { return nil, errors.New("must provide an audit.Writer to db.Open") } - kv, err := openOrCreateKV(path, key) + kv, err := openOrCreateKV(c.Path, c.AccessKey) if err != nil { return nil, err } + index := c.AccessIndex + if index == nil { + index = make(AccessIndex) + } + ret := &DB{ kv: kv, - auditLog: auditLog, + auditLog: c.AuditLog, + index: index, } return ret, nil @@ -100,16 +126,29 @@ func (db *DB) checkAndLog(caller Caller, action acl.Action, secret string, secre if !authorized { errs = append(errs, ErrAccessDenied) } - err := db.auditLog.WriteEntries(&audit.Entry{ + entry := &audit.Entry{ Principal: caller.Principal, Action: action, Secret: secret, SecretVersion: secretVersion, Authorized: authorized, - }) - if err != nil { + } + if err := db.auditLog.WriteEntries(entry); err != nil { errs = append(errs, fmt.Errorf("writing audit log: %w", err)) } + + // If there were no errors, meaning the access is allowed and we + // successfully recorded a log entry, and the operation is not "info", + // update the last-access index. + // + // Note that we do not yet know, at this point, whether the operation will + // succeed: For example, someone may have tried to access a secret version + // that does not exist). We still treat this as an access, because we are + // using the log as the source of truth, and we allowed the operation on the + // secret. + if len(errs) == 0 && action != acl.ActionInfo { + db.index[secret] = LastAccess{Time: entry.Time} + } return multierr.New(errs...) } @@ -162,6 +201,9 @@ func (db *DB) List(caller Caller) ([]*api.SecretInfo, error) { if err != nil { return nil, err } + if a, ok := db.index[name]; ok { + info.LastAccess = a.Time + } ret = append(ret, info) } slices.SortFunc(ret, func(a, b *api.SecretInfo) int { return strings.Compare(a.Name, b.Name) }) @@ -170,37 +212,44 @@ func (db *DB) List(caller Caller) ([]*api.SecretInfo, error) { // Info returns metadata for the given secret. func (db *DB) Info(caller Caller, name string) (*api.SecretInfo, error) { + db.mu.Lock() + defer db.mu.Unlock() if err := db.checkAndLog(caller, acl.ActionInfo, name, 0); err != nil { return nil, err } - db.mu.Lock() - defer db.mu.Unlock() - return db.kv.info(name) + info, err := db.kv.info(name) + if err != nil { + return nil, err + } + if a, ok := db.index[name]; ok { + info.LastAccess = a.Time + } + return info, nil } // Get returns a secret's active value. func (db *DB) Get(caller Caller, name string) (*api.SecretValue, error) { + db.mu.Lock() + defer db.mu.Unlock() if err := db.checkAndLog(caller, acl.ActionGet, name, 0); err != nil { return nil, err } - - db.mu.Lock() - defer db.mu.Unlock() return db.kv.get(name) } // 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) { + db.mu.Lock() + defer db.mu.Unlock() + // This case is special in that we only log an access if the condition // succeeds and we report a fresh value to the caller. 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() sv, err := db.kv.get(name) if err != nil { return nil, err @@ -218,12 +267,11 @@ func (db *DB) GetConditional(caller Caller, name string, oldVersion api.SecretVe // GetVersion returns a secret's value at a specific version. func (db *DB) GetVersion(caller Caller, name string, version api.SecretVersion) (*api.SecretValue, error) { + db.mu.Lock() + defer db.mu.Unlock() if err := db.checkAndLog(caller, acl.ActionGet, name, version); err != nil { return nil, err } - - db.mu.Lock() - defer db.mu.Unlock() return db.kv.getVersion(name, version) } @@ -235,12 +283,12 @@ func (db *DB) Put(caller Caller, name string, value []byte) (api.SecretVersion, if name == "" { return 0, errors.New("empty secret name") } - if err := db.checkAndLog(caller, acl.ActionPut, name, 0); err != nil { - return 0, err - } db.mu.Lock() defer db.mu.Unlock() + if err := db.checkAndLog(caller, acl.ActionPut, name, 0); err != nil { + return 0, err + } if strings.HasPrefix(name, configPrefix) { return db.putConfigLocked(name, value) } @@ -269,12 +317,12 @@ func (db *DB) CreateVersion(caller Caller, name string, version api.SecretVersio 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() + if err := db.checkAndLog(caller, acl.ActionCreateVersion, name, version); err != nil { + return err + } return db.kv.createVersion(name, version, value) } @@ -283,12 +331,12 @@ func (db *DB) Activate(caller Caller, name string, version api.SecretVersion) er if name == "" { return errors.New("empty secret name") } - if err := db.checkAndLog(caller, acl.ActionActivate, name, version); err != nil { - return err - } db.mu.Lock() defer db.mu.Unlock() + if err := db.checkAndLog(caller, acl.ActionActivate, name, version); err != nil { + return err + } if strings.HasPrefix(name, configPrefix) { return db.activateConfigLocked(name, version) } @@ -305,12 +353,12 @@ func (db *DB) activateConfigLocked(name string, version api.SecretVersion) error // DeleteVersion deletes the specified version of a secret. // It reports an error without change if version is the active version. func (db *DB) DeleteVersion(caller Caller, name string, version api.SecretVersion) error { + db.mu.Lock() + defer db.mu.Unlock() + if err := db.checkAndLog(caller, acl.ActionDelete, name, version); err != nil { return err } - - db.mu.Lock() - defer db.mu.Unlock() if cfg, ok := strings.CutPrefix(name, configPrefix); ok { return db.deleteConfigVersionLocked(cfg, version) } @@ -325,12 +373,11 @@ func (db *DB) deleteConfigVersionLocked(name string, version api.SecretVersion) // not exist, this is a no-op without error, provided the caller has access to // delete things at all. func (db *DB) Delete(caller Caller, name string) error { + db.mu.Lock() + defer db.mu.Unlock() if err := db.checkAndLog(caller, acl.ActionDelete, name, 0); err != nil { return err } - - db.mu.Lock() - defer db.mu.Unlock() if cfg, ok := strings.CutPrefix(name, configPrefix); ok { return db.deleteConfigLocked(cfg) } @@ -340,3 +387,12 @@ func (db *DB) Delete(caller Caller, name string) error { func (db *DB) deleteConfigLocked(name string) error { return fmt.Errorf("unknown config value %q", name) } + +// AccessIndex is an index mapping secret names to last-access records. +type AccessIndex map[string]LastAccess + +// LastAccess is an entry in an [AccessIndex], recording information about the +// most recent access to a given secret. +type LastAccess struct { + Time time.Time // in UTC +} diff --git a/db/db_test.go b/db/db_test.go index 17cb8c3..1aef7b1 100644 --- a/db/db_test.go +++ b/db/db_test.go @@ -10,6 +10,7 @@ import ( "os" "strconv" "testing" + "testing/synctest" "time" "github.com/google/go-cmp/cmp" @@ -29,7 +30,11 @@ func TestCreate(t *testing.T) { t.Fatalf("reading back database: %v", err) } - if _, err = db.Open(tdb.Path, tdb.Key, audit.New(io.Discard)); err != nil { + if _, err = db.Open(db.Config{ + Path: tdb.Path, + AccessKey: tdb.Key, + AuditLog: audit.NewWriter(io.Discard), + }); err != nil { t.Fatalf("opening test DB: %v", err) } @@ -43,84 +48,147 @@ func TestCreate(t *testing.T) { } } -func TestList(t *testing.T) { - d := setectest.NewDB(t, nil) - id := d.Superuser +func TestInfo(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + d := setectest.NewDB(t, nil) - checkList := func(d *db.DB, want []*api.SecretInfo) { - t.Helper() - l, err := d.List(id) - if err != nil { - t.Fatalf("listing secrets: %v", err) + if info, err := d.Actual.Info(d.Superuser, "test"); err == nil { + t.Errorf("Info test: got %+v, want error", info) } - if diff := cmp.Diff(l, want); diff != "" { - t.Fatalf("unexpected secret list (-got+want):\n%s", diff) - } - } - checkList(d.Actual, []*api.SecretInfo(nil)) + d.MustPut(d.Superuser, "test", "foo") - d.MustPut(id, "test", "foo") - checkList(d.Actual, []*api.SecretInfo{ - { + info, err := d.Actual.Info(d.Superuser, "test") + if err != nil { + t.Fatalf("Info test: unexpected error: %v", err) + } + if diff := cmp.Diff(info, &api.SecretInfo{ Name: "test", Versions: []api.SecretVersion{1}, ActiveVersion: 1, - }, - }) - - d.MustPut(id, "test", "bar") - checkList(d.Actual, []*api.SecretInfo{ - { - Name: "test", - Versions: []api.SecretVersion{1, 2}, - ActiveVersion: 1, - }, - }) + LastAccess: time.Now(), + }); diff != "" { + t.Errorf("Info test (-got, +want):\n%s", diff) + } - d.MustPut(id, "test2", "quux") - checkList(d.Actual, []*api.SecretInfo{ - { - Name: "test", - Versions: []api.SecretVersion{1, 2}, - ActiveVersion: 1, - }, - { - Name: "test2", - Versions: []api.SecretVersion{1}, - ActiveVersion: 1, - }, - }) + // Touch the value again after some time, and verify we reflected that in + // the results of a subsequent info lookup. + time.Sleep(time.Second) + d.MustPut(d.Superuser, "test", "bar") - d.MustActivate(id, "test", 2) - checkList(d.Actual, []*api.SecretInfo{ - { + info2, err := d.Actual.Info(d.Superuser, "test") + if err != nil { + t.Fatalf("Info test: unexpected error: %v", err) + } + if diff := cmp.Diff(info2, &api.SecretInfo{ Name: "test", Versions: []api.SecretVersion{1, 2}, - ActiveVersion: 2, - }, - { - Name: "test2", - Versions: []api.SecretVersion{1}, - ActiveVersion: 1, - }, + ActiveVersion: 1, // unchanged + LastAccess: time.Now(), + }); diff != "" { + t.Errorf("Info test (-got, +want):\n%s", diff) + } }) +} - d2, err := db.Open(d.Path, d.Key, audit.New(io.Discard)) - if err != nil { - t.Fatalf("reopening database: %v", err) - } - checkList(d2, []*api.SecretInfo{ - { - Name: "test", - Versions: []api.SecretVersion{1, 2}, - ActiveVersion: 2, - }, - { - Name: "test2", - Versions: []api.SecretVersion{1}, - ActiveVersion: 1, - }, +func TestList(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + d := setectest.NewDB(t, nil) + id := d.Superuser + + checkList := func(d *db.DB, want []*api.SecretInfo) { + t.Helper() + l, err := d.List(id) + if err != nil { + t.Fatalf("listing secrets: %v", err) + } + if diff := cmp.Diff(l, want); diff != "" { + t.Fatalf("unexpected secret list (-got+want):\n%s", diff) + } + } + wait := func(d time.Duration) time.Time { time.Sleep(d); return time.Now() } + + checkList(d.Actual, nil) + + op1 := wait(0) + d.MustPut(id, "test", "foo") + checkList(d.Actual, []*api.SecretInfo{ + { + Name: "test", + Versions: []api.SecretVersion{1}, + ActiveVersion: 1, + LastAccess: op1, + }, + }) + + op1 = wait(time.Second) + d.MustPut(id, "test", "bar") + checkList(d.Actual, []*api.SecretInfo{ + { + Name: "test", + Versions: []api.SecretVersion{1, 2}, + ActiveVersion: 1, + LastAccess: op1, + }, + }) + + op2 := wait(time.Second) + d.MustPut(id, "test2", "quux") + checkList(d.Actual, []*api.SecretInfo{ + { + Name: "test", + Versions: []api.SecretVersion{1, 2}, + ActiveVersion: 1, + LastAccess: op1, + }, + { + Name: "test2", + Versions: []api.SecretVersion{1}, + ActiveVersion: 1, + LastAccess: op2, + }, + }) + + op1 = wait(time.Second) + d.MustActivate(id, "test", 2) + checkList(d.Actual, []*api.SecretInfo{ + { + Name: "test", + Versions: []api.SecretVersion{1, 2}, + ActiveVersion: 2, + LastAccess: op1, + }, + { + Name: "test2", + Versions: []api.SecretVersion{1}, + ActiveVersion: 1, + LastAccess: op2, + }, + }) + + // Reopen the database, and verify that we got the same state. + // Note that in this case, we did not provide an index, so these records + // do not have last-access times (that is fine). + d2, err := db.Open(db.Config{ + Path: d.Path, + AccessKey: d.Key, + AuditLog: audit.NewWriter(io.Discard), + }) + if err != nil { + t.Fatalf("reopening database: %v", err) + } + checkList(d2, []*api.SecretInfo{ + { + Name: "test", + Versions: []api.SecretVersion{1, 2}, + ActiveVersion: 2, + }, + { + Name: "test2", + Versions: []api.SecretVersion{1}, + ActiveVersion: 1, + }, + }) }) } @@ -156,7 +224,11 @@ func TestGet(t *testing.T) { } } - d2, err := db.Open(d.Path, d.Key, audit.New(io.Discard)) + d2, err := db.Open(db.Config{ + Path: d.Path, + AccessKey: d.Key, + AuditLog: audit.NewWriter(io.Discard), + }) if err != nil { t.Fatalf("reopening database: %v", err) } diff --git a/server/server.go b/server/server.go index 153f29c..9e2b08a 100644 --- a/server/server.go +++ b/server/server.go @@ -15,6 +15,7 @@ import ( "log" "net/http" "net/netip" + "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/config" @@ -50,6 +51,10 @@ type Config struct { // It must be set if DB is nil. AuditLog *audit.Writer + // AccessIndex, if set, is used to initialize the last-access index for the + // database. It is ignored if DB is set. + AccessIndex db.AccessIndex + // WhoIs is a function that reports an identity for a client IP // address. Outside of tests, it will be the WhoIs of a Tailscale // LocalClient. @@ -109,7 +114,12 @@ func New(ctx context.Context, cfg Config) (*Server, error) { kdb := cfg.DB if kdb == nil { var err error - kdb, err = db.Open(cfg.DBPath, cfg.Key, cfg.AuditLog) + kdb, err = db.Open(db.Config{ + Path: cfg.DBPath, + AccessKey: cfg.Key, + AuditLog: cfg.AuditLog, + AccessIndex: cfg.AccessIndex, + }) if err != nil { return nil, fmt.Errorf("opening DB: %w", err) } @@ -119,6 +129,9 @@ func New(ctx context.Context, cfg Config) (*Server, error) { "lastSecretVersion": func(i int, l []api.SecretVersion) bool { return i == len(l)-1 }, + "timeFormat": func(t time.Time, fmt string) string { + return t.Format(fmt) + }, }) if _, err := tmpl.ParseFS(dashboardTemplates, "templates/*.html"); err != nil { return nil, fmt.Errorf("parsing dashboard templates: %w", err) @@ -406,7 +419,7 @@ func serveJSON[REQ any, RESP any](s *Server, w http.ResponseWriter, r *http.Requ bs, err := json.Marshal(resp) if err != nil { s.countCallInternalError.Add(apiMethod, 1) - http.Error(w, "failed to encode respnse", http.StatusInternalServerError) + http.Error(w, "failed to encode response", http.StatusInternalServerError) return } diff --git a/server/server_test.go b/server/server_test.go index 2d9c558..ba291d8 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -38,7 +38,7 @@ func TestNew(t *testing.T) { _, err := server.New(ctx, server.Config{ DBPath: path, Key: &tinktestutil.DummyAEAD{Name: t.Name()}, - AuditLog: audit.New(io.Discard), + AuditLog: audit.NewWriter(io.Discard), Mux: http.NewServeMux(), }) if err != nil { @@ -47,7 +47,11 @@ func TestNew(t *testing.T) { }) t.Run("DB", func(t *testing.T) { path := filepath.Join(t.TempDir(), "test.db") - kdb, err := db.Open(path, &tinktestutil.DummyAEAD{Name: t.Name()}, audit.New(io.Discard)) + kdb, err := db.Open(db.Config{ + Path: path, + AccessKey: &tinktestutil.DummyAEAD{Name: t.Name()}, + AuditLog: audit.NewWriter(io.Discard), + }) if err != nil { t.Fatalf("Open database: %v", err) } diff --git a/server/templates/index.html b/server/templates/index.html index 98cd14e..98f706f 100644 --- a/server/templates/index.html +++ b/server/templates/index.html @@ -9,7 +9,7 @@

Secrets List

- + {{- range $info := .}} @@ -26,6 +26,11 @@

Secrets List

{{- end}} + {{- end}}
NameVersions
NameVersionsLast Accessed
{{$info.Name}} + {{with $info.LastAccess}} + {{- timeFormat . "2006-01-02T15:04:05Z"}} + {{- else}}(unknown){{end}} +
diff --git a/setectest/dbtest.go b/setectest/dbtest.go index d9c3fb3..7cbeed5 100644 --- a/setectest/dbtest.go +++ b/setectest/dbtest.go @@ -59,7 +59,7 @@ type DBOptions struct { func (o *DBOptions) auditWriter() *audit.Writer { if o == nil || o.AuditLog == nil { - return audit.New(io.Discard) + return audit.NewWriter(io.Discard) } return o.AuditLog } @@ -72,7 +72,11 @@ func NewDB(t *testing.T, opts *DBOptions) *DB { path := filepath.Join(t.TempDir(), "test.db") key := &tinktestutil.DummyAEAD{Name: "setectest.DB." + t.Name()} - adb, err := db.Open(path, key, opts.auditWriter()) + adb, err := db.Open(db.Config{ + Path: path, + AccessKey: key, + AuditLog: opts.auditWriter(), + }) if err != nil { t.Fatalf("Creating test DB: %v", err) } diff --git a/setectest/server.go b/setectest/server.go index 995fa46..7761fda 100644 --- a/setectest/server.go +++ b/setectest/server.go @@ -27,6 +27,7 @@ import ( "github.com/tailscale/setec/acl" "github.com/tailscale/setec/audit" + "github.com/tailscale/setec/db" "github.com/tailscale/setec/server" "tailscale.com/client/tailscale/apitype" "tailscale.com/tailcfg" @@ -49,6 +50,10 @@ type ServerOptions struct { // AuditLog is where audit logs are written; if nil, audit logs are // discarded without error. AuditLog *audit.Writer + + // AccessIndex is used to initialize the last-access index for the database. + // If nil, the database creates its own empty index. + AccessIndex db.AccessIndex } func (o *ServerOptions) whoIs() func(context.Context, string) (*apitype.WhoIsResponse, error) { @@ -60,11 +65,18 @@ func (o *ServerOptions) whoIs() func(context.Context, string) (*apitype.WhoIsRes func (o *ServerOptions) auditLog() *audit.Writer { if o == nil || o.AuditLog == nil { - return audit.New(io.Discard) + return audit.NewWriter(io.Discard) } return o.AuditLog } +func (o *ServerOptions) accessIndex() db.AccessIndex { + if o == nil { + return nil + } + return o.AccessIndex +} + // NewServer constructs a new Server that reads data from db and persists for // the duration of the test and subtests governed by t. When t ends, the server // and its database are cleaned up. If opts == nil, default options are used @@ -75,10 +87,11 @@ func NewServer(t *testing.T, db *DB, opts *ServerOptions) *Server { ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) s, err := server.New(ctx, server.Config{ - DB: db.Actual, - AuditLog: opts.auditLog(), - WhoIs: opts.whoIs(), - Mux: mux, + DB: db.Actual, + AuditLog: opts.auditLog(), + AccessIndex: opts.accessIndex(), + WhoIs: opts.whoIs(), + Mux: mux, }) if err != nil { t.Fatalf("Creating new server: %v", err) diff --git a/types/api/api.go b/types/api/api.go index 321fdd8..671dabe 100644 --- a/types/api/api.go +++ b/types/api/api.go @@ -8,6 +8,7 @@ package api import ( "errors" "strconv" + "time" ) var ( @@ -63,6 +64,10 @@ type SecretInfo struct { Name string Versions []SecretVersion ActiveVersion SecretVersion + + // 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"` } // ListRequest is a request to list secrets.