diff --git a/api/server.go b/api/server.go index 8d7f872..4a11b23 100644 --- a/api/server.go +++ b/api/server.go @@ -10,6 +10,7 @@ import ( "sync" "time" + "git.julianfamily.org/keepassgo/apiaudit" "git.julianfamily.org/keepassgo/apiapproval" "git.julianfamily.org/keepassgo/apitokens" "git.julianfamily.org/keepassgo/clipboard" @@ -35,6 +36,7 @@ type Server struct { profiles map[string]passwords.Profile clipboard clipboard.Writer approvals *apiapproval.Broker + audit *apiaudit.Log } type lifecycleBackend interface { @@ -57,6 +59,7 @@ func NewServer(model vault.Model, profiles map[string]passwords.Profile, clipboa profiles: profiles, clipboard: clipboardWriter, approvals: apiapproval.NewBroker(30 * time.Second), + audit: apiaudit.New(200), } } @@ -70,6 +73,10 @@ func (s *Server) ApprovalBroker() *apiapproval.Broker { return s.approvals } +func (s *Server) AuditLog() *apiaudit.Log { + return s.audit +} + func (s *Server) ResolveApproval(id string, outcome apiapproval.Outcome) (apiapproval.Request, error) { request, _, err := s.approvals.Resolve(id, outcome) return request, err @@ -777,10 +784,12 @@ func (s *Server) authenticateRequest(ctx context.Context) (apitokens.Token, erro } values := md.Get("authorization") if len(values) == 0 { + s.audit.Record(apiaudit.Event{Type: apiaudit.EventAuthRejected, Message: "missing authorization"}) return apitokens.Token{}, status.Error(codes.Unauthenticated, "missing authorization") } const prefix = "Bearer " if !strings.HasPrefix(values[0], prefix) { + s.audit.Record(apiaudit.Event{Type: apiaudit.EventAuthRejected, Message: "invalid bearer token"}) return apitokens.Token{}, status.Error(codes.Unauthenticated, "invalid bearer token") } s.mu.RLock() @@ -793,6 +802,7 @@ func (s *Server) authenticateRequest(ctx context.Context) (apitokens.Token, erro if err != nil { switch err { case apitokens.ErrInvalidToken, apitokens.ErrExpiredToken, apitokens.ErrDisabledToken: + s.audit.Record(apiaudit.Event{Type: apiaudit.EventAuthRejected, Message: err.Error()}) return apitokens.Token{}, status.Error(codes.Unauthenticated, err.Error()) default: return apitokens.Token{}, status.Errorf(codes.Internal, "authenticate api token: %v", err) @@ -824,6 +834,14 @@ func (s *Server) authorizeResourceRequest(ctx context.Context, token apitokens.T case apitokens.DecisionDeny: return apitokens.Token{}, status.Error(codes.PermissionDenied, "access is not allowed for this token") case apitokens.DecisionPrompt: + s.audit.Record(apiaudit.Event{ + Type: apiaudit.EventApprovalRequested, + TokenID: token.ID, + TokenName: token.Name, + ClientName: token.ClientName, + Operation: op, + Resource: resource, + }) result, err := s.approvals.Request(ctx, token, op, resource) if result.Rule != nil { if persistErr := s.persistApprovalRule(token.ID, *result.Rule); persistErr != nil { @@ -832,12 +850,44 @@ func (s *Server) authorizeResourceRequest(ctx context.Context, token apitokens.T } switch { case err == nil: + s.audit.Record(apiaudit.Event{ + Type: apiaudit.EventApprovalAllowed, + TokenID: token.ID, + TokenName: token.Name, + ClientName: token.ClientName, + Operation: op, + Resource: resource, + }) return token, nil case errors.Is(err, apiapproval.ErrRequestDenied): + s.audit.Record(apiaudit.Event{ + Type: apiaudit.EventApprovalDenied, + TokenID: token.ID, + TokenName: token.Name, + ClientName: token.ClientName, + Operation: op, + Resource: resource, + }) return apitokens.Token{}, status.Error(codes.PermissionDenied, "access denied by user approval") case errors.Is(err, apiapproval.ErrRequestCanceled): + s.audit.Record(apiaudit.Event{ + Type: apiaudit.EventApprovalCanceled, + TokenID: token.ID, + TokenName: token.Name, + ClientName: token.ClientName, + Operation: op, + Resource: resource, + }) return apitokens.Token{}, status.Error(codes.Unauthenticated, "authorization request canceled") case errors.Is(err, apiapproval.ErrRequestTimedOut): + s.audit.Record(apiaudit.Event{ + Type: apiaudit.EventApprovalTimedOut, + TokenID: token.ID, + TokenName: token.Name, + ClientName: token.ClientName, + Operation: op, + Resource: resource, + }) return apitokens.Token{}, status.Error(codes.DeadlineExceeded, "authorization request timed out") case errors.Is(err, context.Canceled): return apitokens.Token{}, status.Error(codes.Canceled, "authorization request canceled") diff --git a/api/server_test.go b/api/server_test.go index da39945..ec72bb2 100644 --- a/api/server_test.go +++ b/api/server_test.go @@ -10,6 +10,7 @@ import ( "testing" "time" + "git.julianfamily.org/keepassgo/apiaudit" "git.julianfamily.org/keepassgo/apiapproval" "git.julianfamily.org/keepassgo/apitokens" "git.julianfamily.org/keepassgo/passwords" @@ -238,6 +239,42 @@ func TestVaultServiceTimesOutPendingApproval(t *testing.T) { } } +func TestVaultServiceRecordsApprovalAuditEvents(t *testing.T) { + t.Parallel() + + model := vault.Model{ + Entries: []vault.Entry{ + {ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}}, + testAPITokenEntry(t), + }, + } + client, _, service, cleanup := newTestHarnessForModel(t, model) + defer cleanup() + service.approvals = apiapproval.NewBroker(time.Minute) + + errCh := make(chan error, 1) + go func() { + _, err := client.ListEntries(tokenContext(defaultTestTokenSecret), &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}}) + errCh <- err + }() + + pending := waitForServerPendingApproval(t, service, 1)[0] + if _, err := service.ResolveApproval(pending.ID, apiapproval.OutcomeAllowPermanent); err != nil { + t.Fatalf("ResolveApproval(allow permanent) error = %v", err) + } + if err := <-errCh; err != nil { + t.Fatalf("ListEntries() error = %v", err) + } + + events := service.AuditLog().Events() + if len(events) < 2 { + t.Fatalf("len(AuditLog().Events()) = %d, want at least 2", len(events)) + } + if events[0].Type != apiaudit.EventApprovalAllowed || events[1].Type != apiaudit.EventApprovalRequested { + t.Fatalf("AuditLog().Events() = %#v, want allowed then requested", events[:2]) + } +} + func TestVaultServiceReportsSessionStatusAndSupportsLockUnlock(t *testing.T) { t.Parallel() diff --git a/apiaudit/audit.go b/apiaudit/audit.go new file mode 100644 index 0000000..07ee80c --- /dev/null +++ b/apiaudit/audit.go @@ -0,0 +1,77 @@ +package apiaudit + +import ( + "slices" + "sync" + "time" + + "git.julianfamily.org/keepassgo/apitokens" +) + +type EventType string + +const ( + EventApprovalRequested EventType = "approval_requested" + EventApprovalAllowed EventType = "approval_allowed" + EventApprovalDenied EventType = "approval_denied" + EventApprovalCanceled EventType = "approval_canceled" + EventApprovalTimedOut EventType = "approval_timed_out" + EventAuthRejected EventType = "auth_rejected" +) + +type Event struct { + Type EventType + At time.Time + TokenID string + TokenName string + ClientName string + Operation apitokens.Operation + Resource apitokens.Resource + Message string +} + +type Log struct { + mu sync.Mutex + max int + now func() time.Time + events []Event +} + +func New(max int) *Log { + if max < 1 { + max = 1 + } + return &Log{ + max: max, + now: func() time.Time { + return time.Now().UTC() + }, + } +} + +func (l *Log) Record(event Event) { + if l == nil { + return + } + + l.mu.Lock() + defer l.mu.Unlock() + + if event.At.IsZero() { + event.At = l.now() + } + l.events = append([]Event{event}, l.events...) + if len(l.events) > l.max { + l.events = l.events[:l.max] + } +} + +func (l *Log) Events() []Event { + if l == nil { + return nil + } + + l.mu.Lock() + defer l.mu.Unlock() + return slices.Clone(l.events) +} diff --git a/apiaudit/audit_test.go b/apiaudit/audit_test.go new file mode 100644 index 0000000..fc8d664 --- /dev/null +++ b/apiaudit/audit_test.go @@ -0,0 +1,49 @@ +package apiaudit + +import ( + "testing" + "time" + + "git.julianfamily.org/keepassgo/apitokens" +) + +func TestLogKeepsNewestEventsWithinBound(t *testing.T) { + t.Parallel() + + log := New(2) + log.now = func() time.Time { return time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC) } + log.Record(Event{Type: EventApprovalRequested, TokenID: "token-1"}) + log.Record(Event{Type: EventApprovalAllowed, TokenID: "token-2"}) + log.Record(Event{Type: EventApprovalDenied, TokenID: "token-3"}) + + events := log.Events() + if len(events) != 2 { + t.Fatalf("len(Events()) = %d, want 2", len(events)) + } + if events[0].TokenID != "token-3" || events[1].TokenID != "token-2" { + t.Fatalf("Events() = %#v, want newest-first bounded list", events) + } +} + +func TestLogPreservesRecordedMetadata(t *testing.T) { + t.Parallel() + + log := New(5) + log.Record(Event{ + Type: EventApprovalRequested, + TokenID: "token-1", + TokenName: "CLI", + ClientName: "grpc-cli", + Operation: apitokens.OperationListEntries, + Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Internet"}}, + Message: "prompted for access", + }) + + events := log.Events() + if len(events) != 1 { + t.Fatalf("len(Events()) = %d, want 1", len(events)) + } + if events[0].Operation != apitokens.OperationListEntries || events[0].Message != "prompted for access" { + t.Fatalf("Events()[0] = %#v, want preserved metadata", events[0]) + } +}