From 533fb2d5504d626e80cc020d8c5568587e8d4422 Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Fri, 10 Apr 2026 23:40:34 -0700 Subject: [PATCH] Audit API token lifecycle actions --- internal/apiaudit/audit.go | 6 ++++++ internal/appstate/state.go | 36 +++++++++++++++++++++++++++++++-- internal/appstate/state_test.go | 22 +++++++++++++++++++- internal/appui/runtime.go | 1 + 4 files changed, 62 insertions(+), 3 deletions(-) diff --git a/internal/apiaudit/audit.go b/internal/apiaudit/audit.go index aabdb43..99b35b7 100644 --- a/internal/apiaudit/audit.go +++ b/internal/apiaudit/audit.go @@ -16,6 +16,12 @@ const ( EventApprovalDenied EventType = "approval_denied" EventApprovalCanceled EventType = "approval_canceled" EventApprovalTimedOut EventType = "approval_timed_out" + EventTokenIssued EventType = "token_issued" + EventTokenUpdated EventType = "token_updated" + EventTokenRotated EventType = "token_rotated" + EventTokenDisabled EventType = "token_disabled" + EventTokenRevoked EventType = "token_revoked" + EventTokenDeleted EventType = "token_deleted" EventAutofillFound EventType = "autofill_found" EventAutofillAmbiguous EventType = "autofill_ambiguous" EventAutofillBlocked EventType = "autofill_blocked" diff --git a/internal/appstate/state.go b/internal/appstate/state.go index 03bfb95..dec10aa 100644 --- a/internal/appstate/state.go +++ b/internal/appstate/state.go @@ -8,6 +8,7 @@ import ( "time" "git.julianfamily.org/keepassgo/internal/apiapproval" + "git.julianfamily.org/keepassgo/internal/apiaudit" "git.julianfamily.org/keepassgo/internal/apitokens" "git.julianfamily.org/keepassgo/internal/vault" "git.julianfamily.org/keepassgo/internal/webdav" @@ -101,6 +102,7 @@ type ApprovalManager interface { type State struct { Session CurrentSession Approvals ApprovalManager + AuditLog *apiaudit.Log Section Section CurrentPath []string SearchQuery string @@ -195,6 +197,7 @@ func (s *State) IssueAPIToken(name, clientName string, expiresAt *time.Time, now apitokens.Upsert(&model, token) session.Replace(model) s.Dirty = true + s.recordTokenAudit(apiaudit.EventTokenIssued, token, "issued API token") return token, secret, nil } @@ -218,6 +221,7 @@ func (s *State) RotateAPIToken(id string, now time.Time) (apitokens.Token, strin apitokens.Upsert(&model, token) session.Replace(model) s.Dirty = true + s.recordTokenAudit(apiaudit.EventTokenRotated, token, "rotated API token") return token, secret, nil } @@ -233,6 +237,7 @@ func (s *State) UpsertAPIToken(token apitokens.Token) error { apitokens.Upsert(&model, token) session.Replace(model) s.Dirty = true + s.recordTokenAudit(apiaudit.EventTokenUpdated, token, "updated API token") return nil } @@ -249,9 +254,11 @@ func (s *State) DisableAPIToken(id string) error { if err != nil { return err } - apitokens.Upsert(&model, apitokens.Disable(token)) + token = apitokens.Disable(token) + apitokens.Upsert(&model, token) session.Replace(model) s.Dirty = true + s.recordTokenAudit(apiaudit.EventTokenDisabled, token, "disabled API token") return nil } @@ -268,9 +275,11 @@ func (s *State) RevokeAPIToken(id string, when time.Time) error { if err != nil { return err } - apitokens.Upsert(&model, apitokens.Revoke(token, when)) + token = apitokens.Revoke(token, when) + apitokens.Upsert(&model, token) session.Replace(model) s.Dirty = true + s.recordTokenAudit(apiaudit.EventTokenRevoked, token, "revoked API token") return nil } @@ -283,14 +292,37 @@ func (s *State) DeleteAPIToken(id string) error { if err != nil { return err } + token, err := apitokens.Find(model, id) + if err != nil { + return err + } if err := apitokens.Delete(&model, id); err != nil { return err } session.Replace(model) s.Dirty = true + s.recordTokenAudit(apiaudit.EventTokenDeleted, token, "deleted API token") return nil } +func (s *State) recordTokenAudit(eventType apiaudit.EventType, token apitokens.Token, message string) { + if s.AuditLog == nil { + return + } + s.AuditLog.Record(apiaudit.Event{ + Type: eventType, + TokenID: token.ID, + TokenName: token.Name, + ClientName: token.ClientName, + Resource: apitokens.Resource{ + Kind: apitokens.ResourceEntry, + Path: apitokens.EntryPath, + EntryID: token.ID, + }, + Message: message, + }) +} + func (s *State) SecuritySettings() (vault.SecuritySettings, error) { security, ok := s.Session.(SecurityConfigurableSession) if !ok { diff --git a/internal/appstate/state_test.go b/internal/appstate/state_test.go index 2010960..b124ea9 100644 --- a/internal/appstate/state_test.go +++ b/internal/appstate/state_test.go @@ -7,6 +7,7 @@ import ( "time" "git.julianfamily.org/keepassgo/internal/apiapproval" + "git.julianfamily.org/keepassgo/internal/apiaudit" "git.julianfamily.org/keepassgo/internal/apitokens" "git.julianfamily.org/keepassgo/internal/session" "git.julianfamily.org/keepassgo/internal/vault" @@ -109,7 +110,8 @@ func TestIssueRotateDisableRevokeAndDeleteAPIToken(t *testing.T) { t.Parallel() session := &mutableStubSession{model: vault.Model{}} - state := State{Session: session} + auditLog := apiaudit.New(10) + state := State{Session: session, AuditLog: auditLog} now := time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC) expiresAt := now.Add(24 * time.Hour) @@ -162,6 +164,24 @@ func TestIssueRotateDisableRevokeAndDeleteAPIToken(t *testing.T) { if len(tokens) != 0 { t.Fatalf("APITokens() after delete = %#v, want empty", tokens) } + + events := auditLog.Events() + if len(events) != 5 { + t.Fatalf("len(AuditLog.Events()) = %d, want 5", len(events)) + } + if events[0].Type != apiaudit.EventTokenDeleted || + events[1].Type != apiaudit.EventTokenRevoked || + events[2].Type != apiaudit.EventTokenDisabled || + events[3].Type != apiaudit.EventTokenRotated || + events[4].Type != apiaudit.EventTokenIssued { + t.Fatalf("AuditLog.Events() types = %#v, want deleted/revoked/disabled/rotated/issued", events) + } + if events[0].TokenID != issued.ID || events[0].Resource.EntryID != issued.ID { + t.Fatalf("delete audit event = %#v, want token/resource id %q", events[0], issued.ID) + } + if events[4].TokenName != "CLI" || events[4].ClientName != "grpc-cli" { + t.Fatalf("issued audit event = %#v, want CLI/grpc-cli metadata", events[4]) + } } func TestRemoteProfilesReturnsVaultProfiles(t *testing.T) { diff --git a/internal/appui/runtime.go b/internal/appui/runtime.go index 450d2fa..0c09ca6 100644 --- a/internal/appui/runtime.go +++ b/internal/appui/runtime.go @@ -75,6 +75,7 @@ func run(w *app.Window, mode string, paths statePaths, grpcAddr string) error { } else if host != nil { ui.apiHost = host ui.auditLog = host.Server().AuditLog() + ui.state.AuditLog = ui.auditLog ui.grpcAddress = host.Address() ui.state.Approvals = &uiApprovalManager{server: host.Server()} defer func() { _ = host.Stop() }()