Record audit events for API token authorization
This commit is contained in:
@@ -10,6 +10,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.julianfamily.org/keepassgo/apiaudit"
|
||||||
"git.julianfamily.org/keepassgo/apiapproval"
|
"git.julianfamily.org/keepassgo/apiapproval"
|
||||||
"git.julianfamily.org/keepassgo/apitokens"
|
"git.julianfamily.org/keepassgo/apitokens"
|
||||||
"git.julianfamily.org/keepassgo/clipboard"
|
"git.julianfamily.org/keepassgo/clipboard"
|
||||||
@@ -35,6 +36,7 @@ type Server struct {
|
|||||||
profiles map[string]passwords.Profile
|
profiles map[string]passwords.Profile
|
||||||
clipboard clipboard.Writer
|
clipboard clipboard.Writer
|
||||||
approvals *apiapproval.Broker
|
approvals *apiapproval.Broker
|
||||||
|
audit *apiaudit.Log
|
||||||
}
|
}
|
||||||
|
|
||||||
type lifecycleBackend interface {
|
type lifecycleBackend interface {
|
||||||
@@ -57,6 +59,7 @@ func NewServer(model vault.Model, profiles map[string]passwords.Profile, clipboa
|
|||||||
profiles: profiles,
|
profiles: profiles,
|
||||||
clipboard: clipboardWriter,
|
clipboard: clipboardWriter,
|
||||||
approvals: apiapproval.NewBroker(30 * time.Second),
|
approvals: apiapproval.NewBroker(30 * time.Second),
|
||||||
|
audit: apiaudit.New(200),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,6 +73,10 @@ func (s *Server) ApprovalBroker() *apiapproval.Broker {
|
|||||||
return s.approvals
|
return s.approvals
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) AuditLog() *apiaudit.Log {
|
||||||
|
return s.audit
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) ResolveApproval(id string, outcome apiapproval.Outcome) (apiapproval.Request, error) {
|
func (s *Server) ResolveApproval(id string, outcome apiapproval.Outcome) (apiapproval.Request, error) {
|
||||||
request, _, err := s.approvals.Resolve(id, outcome)
|
request, _, err := s.approvals.Resolve(id, outcome)
|
||||||
return request, err
|
return request, err
|
||||||
@@ -777,10 +784,12 @@ func (s *Server) authenticateRequest(ctx context.Context) (apitokens.Token, erro
|
|||||||
}
|
}
|
||||||
values := md.Get("authorization")
|
values := md.Get("authorization")
|
||||||
if len(values) == 0 {
|
if len(values) == 0 {
|
||||||
|
s.audit.Record(apiaudit.Event{Type: apiaudit.EventAuthRejected, Message: "missing authorization"})
|
||||||
return apitokens.Token{}, status.Error(codes.Unauthenticated, "missing authorization")
|
return apitokens.Token{}, status.Error(codes.Unauthenticated, "missing authorization")
|
||||||
}
|
}
|
||||||
const prefix = "Bearer "
|
const prefix = "Bearer "
|
||||||
if !strings.HasPrefix(values[0], prefix) {
|
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")
|
return apitokens.Token{}, status.Error(codes.Unauthenticated, "invalid bearer token")
|
||||||
}
|
}
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
@@ -793,6 +802,7 @@ func (s *Server) authenticateRequest(ctx context.Context) (apitokens.Token, erro
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
switch err {
|
switch err {
|
||||||
case apitokens.ErrInvalidToken, apitokens.ErrExpiredToken, apitokens.ErrDisabledToken:
|
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())
|
return apitokens.Token{}, status.Error(codes.Unauthenticated, err.Error())
|
||||||
default:
|
default:
|
||||||
return apitokens.Token{}, status.Errorf(codes.Internal, "authenticate api token: %v", err)
|
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:
|
case apitokens.DecisionDeny:
|
||||||
return apitokens.Token{}, status.Error(codes.PermissionDenied, "access is not allowed for this token")
|
return apitokens.Token{}, status.Error(codes.PermissionDenied, "access is not allowed for this token")
|
||||||
case apitokens.DecisionPrompt:
|
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)
|
result, err := s.approvals.Request(ctx, token, op, resource)
|
||||||
if result.Rule != nil {
|
if result.Rule != nil {
|
||||||
if persistErr := s.persistApprovalRule(token.ID, *result.Rule); persistErr != 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 {
|
switch {
|
||||||
case err == nil:
|
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
|
return token, nil
|
||||||
case errors.Is(err, apiapproval.ErrRequestDenied):
|
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")
|
return apitokens.Token{}, status.Error(codes.PermissionDenied, "access denied by user approval")
|
||||||
case errors.Is(err, apiapproval.ErrRequestCanceled):
|
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")
|
return apitokens.Token{}, status.Error(codes.Unauthenticated, "authorization request canceled")
|
||||||
case errors.Is(err, apiapproval.ErrRequestTimedOut):
|
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")
|
return apitokens.Token{}, status.Error(codes.DeadlineExceeded, "authorization request timed out")
|
||||||
case errors.Is(err, context.Canceled):
|
case errors.Is(err, context.Canceled):
|
||||||
return apitokens.Token{}, status.Error(codes.Canceled, "authorization request canceled")
|
return apitokens.Token{}, status.Error(codes.Canceled, "authorization request canceled")
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.julianfamily.org/keepassgo/apiaudit"
|
||||||
"git.julianfamily.org/keepassgo/apiapproval"
|
"git.julianfamily.org/keepassgo/apiapproval"
|
||||||
"git.julianfamily.org/keepassgo/apitokens"
|
"git.julianfamily.org/keepassgo/apitokens"
|
||||||
"git.julianfamily.org/keepassgo/passwords"
|
"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) {
|
func TestVaultServiceReportsSessionStatusAndSupportsLockUnlock(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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])
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user