From ff5090b1c0954e582d924b8986be68e4e35e6826 Mon Sep 17 00:00:00 2001 From: "M. J. Fromberger" Date: Wed, 15 Apr 2026 16:31:14 -0700 Subject: [PATCH 1/9] audit: rename New to NewWriter This is preparation for adding a Reader type in the next commit. Update usage throughout. --- audit/audit.go | 12 ++++++------ audit/audit_test.go | 2 +- db/db_test.go | 6 +++--- server/server_test.go | 4 ++-- setectest/dbtest.go | 2 +- setectest/server.go | 2 +- 6 files changed, 14 insertions(+), 14 deletions(-) diff --git a/audit/audit.go b/audit/audit.go index 7749779..78b0c29 100644 --- a/audit/audit.go +++ b/audit/audit.go @@ -68,11 +68,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 +86,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 diff --git a/audit/audit_test.go b/audit/audit_test.go index 4b69bbe..1761036 100644 --- a/audit/audit_test.go +++ b/audit/audit_test.go @@ -17,7 +17,7 @@ import ( func TestWriter(t *testing.T) { out := new(testWriter) - w := audit.New(out) + w := audit.NewWriter(out) entries := []*audit.Entry{ { diff --git a/db/db_test.go b/db/db_test.go index 17cb8c3..3c7258a 100644 --- a/db/db_test.go +++ b/db/db_test.go @@ -29,7 +29,7 @@ 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(tdb.Path, tdb.Key, audit.NewWriter(io.Discard)); err != nil { t.Fatalf("opening test DB: %v", err) } @@ -106,7 +106,7 @@ func TestList(t *testing.T) { }, }) - d2, err := db.Open(d.Path, d.Key, audit.New(io.Discard)) + d2, err := db.Open(d.Path, d.Key, audit.NewWriter(io.Discard)) if err != nil { t.Fatalf("reopening database: %v", err) } @@ -156,7 +156,7 @@ func TestGet(t *testing.T) { } } - d2, err := db.Open(d.Path, d.Key, audit.New(io.Discard)) + d2, err := db.Open(d.Path, d.Key, audit.NewWriter(io.Discard)) if err != nil { t.Fatalf("reopening database: %v", err) } diff --git a/server/server_test.go b/server/server_test.go index 2d9c558..a6cd20e 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,7 @@ 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(path, &tinktestutil.DummyAEAD{Name: t.Name()}, audit.NewWriter(io.Discard)) if err != nil { t.Fatalf("Open database: %v", err) } diff --git a/setectest/dbtest.go b/setectest/dbtest.go index d9c3fb3..1959f27 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 } diff --git a/setectest/server.go b/setectest/server.go index 995fa46..2d3f07e 100644 --- a/setectest/server.go +++ b/setectest/server.go @@ -60,7 +60,7 @@ 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 } From ce69a5ff40c4d92c582b3d2232ffbcf3706d110f Mon Sep 17 00:00:00 2001 From: "M. J. Fromberger" Date: Wed, 15 Apr 2026 17:01:52 -0700 Subject: [PATCH 2/9] audit: add a Reader type that consumes JSON logs This allows a caller to read back through the contents of an audit log. The Reader exposes a basic iterator interface. --- audit/audit.go | 39 ++++++++++++++++++++++++++ audit/audit_test.go | 68 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+) diff --git a/audit/audit.go b/audit/audit.go index 78b0c29..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" @@ -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 1761036..3d5955a 100644 --- a/audit/audit_test.go +++ b/audit/audit_test.go @@ -9,8 +9,11 @@ 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" ) @@ -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) + } +} From a2b04cf77ebf105a8f49a5300b2bdcf87e5dad62 Mon Sep 17 00:00:00 2001 From: "M. J. Fromberger" Date: Thu, 16 Apr 2026 08:57:25 -0700 Subject: [PATCH 3/9] server: fix an error message typo --- server/server.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/server.go b/server/server.go index 153f29c..c85f52c 100644 --- a/server/server.go +++ b/server/server.go @@ -406,7 +406,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 } From 1cfc9acecab46bb84f6c081abe10fa101abfdcfa Mon Sep 17 00:00:00 2001 From: "M. J. Fromberger" Date: Fri, 17 Apr 2026 09:28:02 -0700 Subject: [PATCH 4/9] types/api: add a LastAccess field to SecretInfo This field can be used to report the last time the server recorded a use of each secret, where "use" is defined as any authorized query that is not the "info" operation. --- types/api/api.go | 5 +++++ 1 file changed, 5 insertions(+) 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. From b977a8f1d2404c4ac0e796344d33c6559a107c59 Mon Sep 17 00:00:00 2001 From: "M. J. Fromberger" Date: Fri, 17 Apr 2026 09:54:50 -0700 Subject: [PATCH 5/9] all: move parameters for db.Open to a Config type This is in preparation for adding a new option for the access index. --- db/db.go | 28 +++++++++++++++++++++------- db/db_test.go | 18 +++++++++++++++--- server/server.go | 6 +++++- server/server_test.go | 6 +++++- setectest/dbtest.go | 6 +++++- 5 files changed, 51 insertions(+), 13 deletions(-) diff --git a/db/db.go b/db/db.go index d619c34..42c5c83 100644 --- a/db/db.go +++ b/db/db.go @@ -59,21 +59,35 @@ 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 +} + // 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 } ret := &DB{ kv: kv, - auditLog: auditLog, + auditLog: c.AuditLog, } return ret, nil @@ -100,14 +114,14 @@ func (db *DB) checkAndLog(caller Caller, action acl.Action, secret string, secre if !authorized { errs = append(errs, ErrAccessDenied) } - err := db.auditLog.WriteEntries(&audit.Entry{ + + if err := db.auditLog.WriteEntries(&audit.Entry{ Principal: caller.Principal, Action: action, Secret: secret, SecretVersion: secretVersion, Authorized: authorized, - }) - if err != nil { + }); err != nil { errs = append(errs, fmt.Errorf("writing audit log: %w", err)) } return multierr.New(errs...) diff --git a/db/db_test.go b/db/db_test.go index 3c7258a..ebe9406 100644 --- a/db/db_test.go +++ b/db/db_test.go @@ -29,7 +29,11 @@ func TestCreate(t *testing.T) { t.Fatalf("reading back database: %v", err) } - if _, err = db.Open(tdb.Path, tdb.Key, audit.NewWriter(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) } @@ -106,7 +110,11 @@ func TestList(t *testing.T) { }, }) - d2, err := db.Open(d.Path, d.Key, audit.NewWriter(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) } @@ -156,7 +164,11 @@ func TestGet(t *testing.T) { } } - d2, err := db.Open(d.Path, d.Key, audit.NewWriter(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 c85f52c..879aba9 100644 --- a/server/server.go +++ b/server/server.go @@ -109,7 +109,11 @@ 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, + }) if err != nil { return nil, fmt.Errorf("opening DB: %w", err) } diff --git a/server/server_test.go b/server/server_test.go index a6cd20e..ba291d8 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -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.NewWriter(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/setectest/dbtest.go b/setectest/dbtest.go index 1959f27..7cbeed5 100644 --- a/setectest/dbtest.go +++ b/setectest/dbtest.go @@ -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) } From e26ed0b59286492f05351f99d15409cdc1095d88 Mon Sep 17 00:00:00 2001 From: "M. J. Fromberger" Date: Fri, 17 Apr 2026 10:01:39 -0700 Subject: [PATCH 6/9] db: track and update a last-accessed index for each secret Wire in an initial access index, and ensure it gets updated whenever we successfully authorize an operation besides "info". Note that we will update the index even if the operation reports an error, because we are using the audit log as the source of truth, and we did in fact allow the operation even if it did not wind up doing anything. --- db/db.go | 100 +++++++++++++++++-------- db/db_test.go | 198 ++++++++++++++++++++++++++++++++------------------ 2 files changed, 200 insertions(+), 98 deletions(-) diff --git a/db/db.go b/db/db.go index 42c5c83..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 @@ -71,6 +73,10 @@ type Config struct { // 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. @@ -85,9 +91,15 @@ func Open(c Config) (*DB, error) { return nil, err } + index := c.AccessIndex + if index == nil { + index = make(AccessIndex) + } + ret := &DB{ kv: kv, auditLog: c.AuditLog, + index: index, } return ret, nil @@ -114,16 +126,29 @@ func (db *DB) checkAndLog(caller Caller, action acl.Action, secret string, secre if !authorized { errs = append(errs, ErrAccessDenied) } - - if err := db.auditLog.WriteEntries(&audit.Entry{ + entry := &audit.Entry{ Principal: caller.Principal, Action: action, Secret: secret, SecretVersion: secretVersion, Authorized: authorized, - }); 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...) } @@ -176,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) }) @@ -184,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 @@ -232,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) } @@ -249,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) } @@ -283,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) } @@ -297,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) } @@ -319,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) } @@ -339,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) } @@ -354,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 ebe9406..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" @@ -47,88 +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(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, - }, +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, + }, + }) }) } From ccc62c85fc891bec13af356099d788217c69b479 Mon Sep 17 00:00:00 2001 From: "M. J. Fromberger" Date: Fri, 17 Apr 2026 10:38:26 -0700 Subject: [PATCH 7/9] all: plumb in db.AccessIndex to database constructors --- server/server.go | 11 ++++++++--- setectest/server.go | 21 +++++++++++++++++---- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/server/server.go b/server/server.go index 879aba9..d739000 100644 --- a/server/server.go +++ b/server/server.go @@ -50,6 +50,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. @@ -110,9 +114,10 @@ func New(ctx context.Context, cfg Config) (*Server, error) { if kdb == nil { var err error kdb, err = db.Open(db.Config{ - Path: cfg.DBPath, - AccessKey: cfg.Key, - AuditLog: cfg.AuditLog, + Path: cfg.DBPath, + AccessKey: cfg.Key, + AuditLog: cfg.AuditLog, + AccessIndex: cfg.AccessIndex, }) if err != nil { return nil, fmt.Errorf("opening DB: %w", err) diff --git a/setectest/server.go b/setectest/server.go index 2d3f07e..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) { @@ -65,6 +70,13 @@ func (o *ServerOptions) auditLog() *audit.Writer { 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) From f81a93d6bdf595d8a1edfe4c0932cd6adddf9ad5 Mon Sep 17 00:00:00 2001 From: "M. J. Fromberger" Date: Fri, 17 Apr 2026 10:46:11 -0700 Subject: [PATCH 8/9] cmd/setec: construct an access index at startup Surface the last accessed information in list and info responses. --- cmd/setec/setec.go | 45 ++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 42 insertions(+), 3 deletions(-) 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 +} From 0cdea43ef155533bb290b499edb27b11b863443a Mon Sep 17 00:00:00 2001 From: "M. J. Fromberger" Date: Fri, 17 Apr 2026 12:00:14 -0700 Subject: [PATCH 9/9] server: update the HTML UI template with LastAccess times --- server/server.go | 4 ++++ server/templates/index.html | 7 ++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/server/server.go b/server/server.go index d739000..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" @@ -128,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) 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}} +