diff --git a/cmd/jimmsrv/service/service.go b/cmd/jimmsrv/service/service.go index 890416d37..9a5578259 100644 --- a/cmd/jimmsrv/service/service.go +++ b/cmd/jimmsrv/service/service.go @@ -1,4 +1,4 @@ -// Copyright 2024 Canonical. +// Copyright 2025 Canonical. // service defines the methods necessary to start a JIMM server // alongside all the config options that can be supplied to configure JIMM. @@ -201,8 +201,7 @@ type Service struct { jimm *jimm.JIMM jwkService *jimmjwx.JWKSService - isLeader bool - auditLogCleanupPeriod int + isLeader bool mux *chi.Mux cleanups []func() error @@ -314,6 +313,17 @@ func NewService(ctx context.Context, p Params) (*Service, error) { jimmParameters.UUID = uuid.NewString() } + if p.AuditLogRetentionPeriodInDays != "" { + retentionPeriod, err := strconv.Atoi(p.AuditLogRetentionPeriodInDays) + if err != nil { + return nil, errors.E(op, "failed to parse audit log retention period") + } + if retentionPeriod < 0 { + return nil, errors.E(op, "retention period cannot be less than 0") + } + jimmParameters.AuditLogRetentionDays = retentionPeriod + } + if p.DSN == "" { return nil, errors.E(op, "missing DSN") } @@ -486,16 +496,6 @@ func NewService(ctx context.Context, p Params) (*Service, error) { jimmhttp.NewHTTPProxyHandler(s.jimm), ) - if p.AuditLogRetentionPeriodInDays != "" { - var err error - s.auditLogCleanupPeriod, err = strconv.Atoi(p.AuditLogRetentionPeriodInDays) - if err != nil { - return nil, errors.E(op, "failed to parse audit log retention period") - } - if s.auditLogCleanupPeriod < 0 { - return nil, errors.E(op, "retention period cannot be less than 0") - } - } s.isLeader = p.IsLeader return s, nil @@ -505,12 +505,10 @@ func (s *Service) StartServices(ctx context.Context, svc *service.Service) { // on the leader unit we start additional routines if s.isLeader { // audit log cleanup routine - if s.auditLogCleanupPeriod != 0 { - svc.Go(func() error { - jimm.NewAuditLogCleanupService(s.jimm.Database, s.auditLogCleanupPeriod).Start(ctx) - return nil - }) - } + svc.Go(func() error { + s.jimm.AuditLogManager().StartCleanup(ctx) + return nil + }) // the JWKS rotator svc.Go(func() error { diff --git a/internal/jimm/audit_log.go b/internal/jimm/audit_log.go deleted file mode 100644 index a66deb3c9..000000000 --- a/internal/jimm/audit_log.go +++ /dev/null @@ -1,201 +0,0 @@ -// Copyright 2024 Canonical. - -package jimm - -import ( - "context" - "encoding/json" - "time" - - "github.com/juju/juju/rpc" - "github.com/juju/juju/rpc/params" - "github.com/juju/names/v5" - "github.com/juju/zaputil/zapctx" - "go.uber.org/zap" - - "github.com/canonical/jimm/v3/internal/db" - "github.com/canonical/jimm/v3/internal/dbmodel" - "github.com/canonical/jimm/v3/internal/servermon" - "github.com/canonical/jimm/v3/internal/utils" -) - -// AuditLoggerBackend defines the interface used by the DbAuditLogger to store -// audit events. -type AuditLoggerBackend interface { - AddAuditLogEntry(*dbmodel.AuditLogEntry) -} - -type DbAuditLogger struct { - backend AuditLoggerBackend - conversationId string - getUser func() names.UserTag -} - -// NewDbAuditLogger returns a new audit logger that logs to the database. -func NewDbAuditLogger(backend AuditLoggerBackend, getUserFunc func() names.UserTag) DbAuditLogger { - logger := DbAuditLogger{ - backend: backend, - conversationId: utils.NewConversationID(), - getUser: getUserFunc, - } - return logger -} - -func (r DbAuditLogger) newAuditLogEntry(header *rpc.Header) dbmodel.AuditLogEntry { - ale := dbmodel.AuditLogEntry{ - Time: time.Now().UTC().Round(time.Millisecond), - MessageId: header.RequestId, - IdentityTag: r.getUser().String(), - ConversationId: r.conversationId, - } - return ale -} - -// LogRequest creates an audit log entry from a client request. -func (r DbAuditLogger) LogRequest(header *rpc.Header, body interface{}) error { - ale := r.newAuditLogEntry(header) - ale.ObjectId = header.Request.Id - ale.FacadeName = header.Request.Type - ale.FacadeMethod = header.Request.Action - ale.FacadeVersion = header.Request.Version - if body != nil { - jsonBody, err := json.Marshal(body) - if err != nil { - zapctx.Error(context.Background(), "failed to marshal body", zap.Error(err)) - return err - } - ale.Params = jsonBody - } - r.backend.AddAuditLogEntry(&ale) - return nil -} - -// LogResponse creates an audit log entry from a controller response. -func (o DbAuditLogger) LogResponse(r rpc.Request, header *rpc.Header, body interface{}) error { - var allErrors params.ErrorResults - bulkError, ok := body.(params.ErrorResults) - if ok { - allErrors.Results = append(allErrors.Results, bulkError.Results...) - } - singleError := params.Error{ - Message: header.Error, - Code: header.ErrorCode, - Info: header.ErrorInfo, - } - allErrors.Results = append(allErrors.Results, params.ErrorResult{Error: &singleError}) - jsonErr, err := json.Marshal(allErrors) - if err != nil { - return err - } - ale := o.newAuditLogEntry(header) - ale.ObjectId = r.Id - ale.FacadeName = r.Type - ale.FacadeMethod = r.Action - ale.FacadeVersion = r.Version - ale.Errors = jsonErr - ale.IsResponse = true - o.backend.AddAuditLogEntry(&ale) - return nil -} - -// recorder implements an rpc.Recorder. -type recorder struct { - start time.Time - logger DbAuditLogger - conversationId string -} - -// NewRecorder returns a new recorder struct useful for recording RPC events. -func NewRecorder(logger DbAuditLogger) recorder { - return recorder{ - start: time.Now(), - conversationId: utils.NewConversationID(), - logger: logger, - } -} - -// HandleRequest implements rpc.Recorder. -func (r recorder) HandleRequest(header *rpc.Header, body interface{}) error { - return r.logger.LogRequest(header, body) -} - -// HandleReply implements rpc.Recorder. -func (o recorder) HandleReply(r rpc.Request, header *rpc.Header, body interface{}) error { - d := time.Since(o.start) - servermon.WebsocketRequestDuration.WithLabelValues(r.Type, r.Action).Observe(float64(d) / float64(time.Second)) - return o.logger.LogResponse(r, header, body) -} - -// AuditLogCleanupService is a service capable of cleaning up audit logs -// on a defined retention period. The retention period is in DAYS. -type auditLogCleanupService struct { - auditLogRetentionPeriodInDays int - db *db.Database -} - -// pollTimeOfDay holds the time hour, minutes and seconds to poll at. -type pollTimeOfDay struct { - Hours int - Minutes int - Seconds int -} - -var pollDuration = pollTimeOfDay{ - Hours: 9, -} - -// NewAuditLogCleanupService returns a service capable of cleaning up audit logs -// on a defined retention period. The retention period is in DAYS. -func NewAuditLogCleanupService(db *db.Database, auditLogRetentionPeriodInDays int) *auditLogCleanupService { - return &auditLogCleanupService{ - auditLogRetentionPeriodInDays: auditLogRetentionPeriodInDays, - db: db, - } -} - -// Start starts a routine which checks daily for any logs -// needed to be cleaned up. -func (a *auditLogCleanupService) Start(ctx context.Context) { - go a.poll(ctx) -} - -// poll is designed to be run in a routine where it can be cancelled safely -// from the service's context. It calculates the poll duration at 9am each day -// UTC. -func (a *auditLogCleanupService) poll(ctx context.Context) { - - for { - select { - case <-time.After(calculateNextPollDuration(time.Now().UTC())): - retentionDate := time.Now().AddDate(0, 0, -(a.auditLogRetentionPeriodInDays)) - deleted, err := a.db.DeleteAuditLogsBefore(ctx, retentionDate) - if err != nil { - zapctx.Error(ctx, "failed to cleanup audit logs", zap.Error(err)) - continue - } - zapctx.Debug(ctx, "audit log cleanup run successfully", zap.Int64("count", deleted)) - case <-ctx.Done(): - zapctx.Debug(ctx, "exiting audit log cleanup polling") - return - } - } -} - -// calculateNextPollDuration returns the next duration to poll on. -// We recalculate each time and not rely on running every 24 hours -// for absolute consistency within ns apart. -func calculateNextPollDuration(startingTime time.Time) time.Duration { - now := startingTime - nineAM := time.Date(now.Year(), now.Month(), now.Day(), pollDuration.Hours, 0, 0, 0, time.UTC) - nineAMDuration := nineAM.Sub(now) - var d time.Duration - // If 9am is behind the current time, i.e., 1pm - if nineAMDuration < 0 { - // Add 24 hours, flip it to an absolute duration, i.e., -10h == 10h - // and subtract it from 24 hours to calculate 9am tomorrow - d = time.Hour*24 - nineAMDuration.Abs() - } else { - d = nineAMDuration.Abs() - } - return d -} diff --git a/internal/jimm/auditlog/auditlog.go b/internal/jimm/auditlog/auditlog.go new file mode 100644 index 000000000..5982951e8 --- /dev/null +++ b/internal/jimm/auditlog/auditlog.go @@ -0,0 +1,110 @@ +// Copyright 2025 Canonical. + +// The auditlog package provides business logic for handling audit log related methods. +package auditlog + +import ( + "context" + "strings" + "time" + + "github.com/juju/names/v5" + "github.com/juju/zaputil/zapctx" + "go.uber.org/zap" + + "github.com/canonical/jimm/v3/internal/db" + "github.com/canonical/jimm/v3/internal/dbmodel" + "github.com/canonical/jimm/v3/internal/errors" + "github.com/canonical/jimm/v3/internal/openfga" + ofganames "github.com/canonical/jimm/v3/internal/openfga/names" +) + +// auditLogManager provides a means to manage audit logs within JIMM. +type auditLogManager struct { + store *db.Database + authSvc *openfga.OFGAClient + jimmTag names.ControllerTag + retentionPeriodInDays int +} + +// NewAuditLogManager returns a new auditLog manager that provides audit Log +// creation, and removal. +func NewAuditLogManager(store *db.Database, authSvc *openfga.OFGAClient, jimmTag names.ControllerTag, retentionDays int) (*auditLogManager, error) { + if store == nil { + return nil, errors.E("auditlog store cannot be nil") + } + if authSvc == nil { + return nil, errors.E("auditlog authorisation service cannot be nil") + } + if jimmTag.String() == "" { + return nil, errors.E("auditlog jimm tag cannot be empty") + } + return &auditLogManager{store, authSvc, jimmTag, retentionDays}, nil +} + +// addAuditLogEntry causes an entry to be added the the audit log. +func (j *auditLogManager) AddAuditLogEntry(ale *dbmodel.AuditLogEntry) { + ctx := context.Background() + redactSensitiveParams(ale) + if err := j.store.AddAuditLogEntry(ctx, ale); err != nil { + zapctx.Error(ctx, "cannot store audit log entry", zap.Error(err), zap.Any("entry", *ale)) + } +} + +var sensitiveMethods = map[string]struct{}{ + "login": {}, + "logindevice": {}, + "getdevicesessiontoken": {}, + "loginwithsessiontoken": {}, + "addcredentials": {}, + "updatecredentials": {}} +var redactJSON = dbmodel.JSON(`{"params":"redacted"}`) + +func redactSensitiveParams(ale *dbmodel.AuditLogEntry) { + if ale.Params == nil { + return + } + method := strings.ToLower(ale.FacadeMethod) + if _, ok := sensitiveMethods[method]; ok { + newRedactMessage := make(dbmodel.JSON, len(redactJSON)) + copy(newRedactMessage, redactJSON) + ale.Params = newRedactMessage + } +} + +// FindAuditEvents returns audit events matching the given filter. +func (j *auditLogManager) FindAuditEvents(ctx context.Context, user *openfga.User, filter db.AuditLogFilter) ([]dbmodel.AuditLogEntry, error) { + const op = errors.Op("jimm.FindAuditEvents") + + access := user.GetAuditLogViewerAccess(ctx, j.jimmTag) + if access != ofganames.AuditLogViewerRelation { + return nil, errors.E(op, errors.CodeUnauthorized, "unauthorized") + } + + var entries []dbmodel.AuditLogEntry + err := j.store.ForEachAuditLogEntry(ctx, filter, func(entry *dbmodel.AuditLogEntry) error { + entries = append(entries, *entry) + return nil + }) + if err != nil { + return nil, errors.E(op, err) + } + + return entries, nil +} + +// PurgeLogs removes all audit logs before the given timestamp. Only JIMM +// administrators can perform this operation. The number of logs purged is +// returned. +func (j *auditLogManager) PurgeLogs(ctx context.Context, user *openfga.User, before time.Time) (int64, error) { + op := errors.Op("jimm.PurgeLogs") + if !user.JimmAdmin { + return 0, errors.E(op, errors.CodeUnauthorized, "unauthorized") + } + count, err := j.store.DeleteAuditLogsBefore(ctx, before) + if err != nil { + zapctx.Error(ctx, "failed to purge logs", zap.Error(err)) + return 0, errors.E(op, "failed to purge logs", err) + } + return count, nil +} diff --git a/internal/jimm/auditlog/auditlog_test.go b/internal/jimm/auditlog/auditlog_test.go new file mode 100644 index 000000000..a9b5aec97 --- /dev/null +++ b/internal/jimm/auditlog/auditlog_test.go @@ -0,0 +1,186 @@ +// Copyright 2025 Canonical. + +package auditlog_test + +import ( + "context" + "testing" + "time" + + qt "github.com/frankban/quicktest" + "github.com/frankban/quicktest/qtsuite" + "github.com/juju/names/v5" + + "github.com/canonical/jimm/v3/internal/db" + "github.com/canonical/jimm/v3/internal/dbmodel" + "github.com/canonical/jimm/v3/internal/jimm/auditlog" + "github.com/canonical/jimm/v3/internal/openfga" + ofganames "github.com/canonical/jimm/v3/internal/openfga/names" + "github.com/canonical/jimm/v3/internal/testutils/jimmtest" +) + +type auditLogManagerSuite struct { + manager *auditlog.AuditLogManager + adminUser *openfga.User + priveligedUser *openfga.User + user *openfga.User + db *db.Database + ofgaClient *openfga.OFGAClient + jimmTag names.ControllerTag +} + +func (s *auditLogManagerSuite) Init(c *qt.C) { + db := &db.Database{ + DB: jimmtest.PostgresDB(c, time.Now), + } + err := db.Migrate(context.Background()) + c.Assert(err, qt.IsNil) + + s.db = db + + ofgaClient, _, _, err := jimmtest.SetupTestOFGAClient(c.Name()) + c.Assert(err, qt.IsNil) + + s.ofgaClient = ofgaClient + + s.jimmTag = names.NewControllerTag("foo") + + s.manager, err = auditlog.NewAuditLogManager(db, ofgaClient, s.jimmTag, 1) + c.Assert(err, qt.IsNil) + + // Create test identity + i, err := dbmodel.NewIdentity("alice") + c.Assert(err, qt.IsNil) + s.adminUser = openfga.NewUser(i, ofgaClient) + err = s.adminUser.SetControllerAccess(context.Background(), s.jimmTag, ofganames.AdministratorRelation) + c.Assert(err, qt.IsNil) + s.adminUser.JimmAdmin = true + + i2, err := dbmodel.NewIdentity("bob") + c.Assert(err, qt.IsNil) + s.priveligedUser = openfga.NewUser(i2, ofgaClient) + err = s.priveligedUser.SetControllerAccess(context.Background(), s.jimmTag, ofganames.AuditLogViewerRelation) + c.Assert(err, qt.IsNil) + + i3, err := dbmodel.NewIdentity("eve") + c.Assert(err, qt.IsNil) + s.user = openfga.NewUser(i3, ofgaClient) +} + +func (s *auditLogManagerSuite) TestFindAuditEvents(c *qt.C) { + c.Parallel() + + now := (time.Time{}).UTC().Round(time.Millisecond) + + events := []dbmodel.AuditLogEntry{{ + Time: now, + IdentityTag: s.adminUser.Identity.Tag().String(), + FacadeMethod: "Login", + }, { + Time: now.Add(time.Hour), + IdentityTag: s.adminUser.Identity.Tag().String(), + FacadeMethod: "AddModel", + }, { + Time: now.Add(2 * time.Hour), + IdentityTag: s.priveligedUser.Identity.Tag().String(), + Model: "TestModel", + FacadeMethod: "Deploy", + }, { + Time: now.Add(3 * time.Hour), + IdentityTag: s.priveligedUser.Identity.Tag().String(), + Model: "TestModel", + FacadeMethod: "DestroyModel", + }} + for i, event := range events { + e := event + s.manager.AddAuditLogEntry(&e) + events[i] = e + } + + found, err := s.manager.FindAuditEvents(context.Background(), s.adminUser, db.AuditLogFilter{}) + c.Assert(err, qt.IsNil) + c.Assert(found, qt.HasLen, len(events)) + + tests := []struct { + about string + users []*openfga.User + filter db.AuditLogFilter + expectedEvents []dbmodel.AuditLogEntry + expectedError string + }{{ + about: "admin/privileged user is allowed to find audit events by time", + users: []*openfga.User{s.adminUser, s.priveligedUser}, + filter: db.AuditLogFilter{ + Start: now.Add(-time.Hour), + End: now.Add(time.Minute), + }, + expectedEvents: []dbmodel.AuditLogEntry{events[0]}, + }, { + about: "admin/privileged user is allowed to find audit events by user", + users: []*openfga.User{s.adminUser, s.priveligedUser}, + filter: db.AuditLogFilter{ + IdentityTag: s.adminUser.Tag().String(), + }, + expectedEvents: []dbmodel.AuditLogEntry{events[0], events[1]}, + }, { + about: "admin/privileged user is allowed to find audit events by method", + users: []*openfga.User{s.adminUser, s.priveligedUser}, + filter: db.AuditLogFilter{ + Method: "Deploy", + }, + expectedEvents: []dbmodel.AuditLogEntry{events[2]}, + }, { + about: "admin/privileged user is allowed to find audit events by model", + users: []*openfga.User{s.adminUser, s.priveligedUser}, + filter: db.AuditLogFilter{ + Model: "TestModel", + }, + expectedEvents: []dbmodel.AuditLogEntry{events[2], events[3]}, + }, { + about: "admin/privileged user is allowed to find audit events by model and sort by time", + users: []*openfga.User{s.adminUser, s.priveligedUser}, + filter: db.AuditLogFilter{ + Model: "TestModel", + SortTime: true, + }, + expectedEvents: []dbmodel.AuditLogEntry{events[3], events[2]}, + }, { + about: "admin/privileged user is allowed to find audit events with limit/offset", + users: []*openfga.User{s.adminUser, s.priveligedUser}, + filter: db.AuditLogFilter{ + Offset: 1, + Limit: 2, + }, + expectedEvents: []dbmodel.AuditLogEntry{events[1], events[2]}, + }, { + about: "admin/privileged user - no events found", + users: []*openfga.User{s.adminUser, s.priveligedUser}, + filter: db.AuditLogFilter{ + IdentityTag: "no-such-user", + }, + }, { + about: "unprivileged user is not allowed to access audit events", + users: []*openfga.User{s.user}, + filter: db.AuditLogFilter{ + IdentityTag: s.adminUser.Tag().String(), + }, + expectedError: "unauthorized", + }} + for _, test := range tests { + c.Run(test.about, func(c *qt.C) { + for _, user := range test.users { + events, err := s.manager.FindAuditEvents(context.Background(), user, test.filter) + if test.expectedError != "" { + c.Assert(err, qt.ErrorMatches, test.expectedError) + } else { + c.Assert(err, qt.Equals, nil) + c.Assert(events, qt.DeepEquals, test.expectedEvents) + } + } + }) + } +} + +func TestAuditLogManager(t *testing.T) { + qtsuite.Run(qt.New(t), &auditLogManagerSuite{}) +} diff --git a/internal/jimm/auditlog/cleanup.go b/internal/jimm/auditlog/cleanup.go new file mode 100644 index 000000000..683565e3c --- /dev/null +++ b/internal/jimm/auditlog/cleanup.go @@ -0,0 +1,70 @@ +// Copyright 2025 Canonical. + +package auditlog + +import ( + "context" + "time" + + "github.com/juju/zaputil/zapctx" + "go.uber.org/zap" +) + +// auditLogCleanupTime indicates that we poll at 9 AM. +var auditLogCleanupTime = pollTimeOfDay{ + Hours: 9, +} + +// StartCleanup loop forever and checks daily for any logs +// that need to be cleaned up. This method should be run +// in a separate Go routine to avoid blocking, it will terminate +// when the provided context is cancelled. +func (j *auditLogManager) StartCleanup(ctx context.Context) { + if j.retentionPeriodInDays == 0 { + return + } + for { + select { + case <-time.After(calculateNextPollDuration(auditLogCleanupTime, time.Now().UTC())): + j.cleanup(ctx) + case <-ctx.Done(): + zapctx.Debug(ctx, "exiting audit log cleanup polling") + return + } + } +} + +// pollTimeOfDay holds the time hour, minutes and seconds to poll for cleanup. +type pollTimeOfDay struct { + Hours int + Minutes int + Seconds int +} + +func (j *auditLogManager) cleanup(ctx context.Context) { + retentionDate := time.Now().AddDate(0, 0, -(j.retentionPeriodInDays)) + deleted, err := j.store.DeleteAuditLogsBefore(ctx, retentionDate) + if err != nil { + zapctx.Error(ctx, "failed to cleanup audit logs", zap.Error(err)) + } + zapctx.Debug(ctx, "audit log cleanup run successfully", zap.Int64("count", deleted)) +} + +// calculateNextPollDuration returns the next duration to poll on. +// We recalculate each time and not rely on running every 24 hours +// for absolute consistency within ns apart. +func calculateNextPollDuration(pollTime pollTimeOfDay, startingTime time.Time) time.Duration { + now := startingTime + pollTimeToday := time.Date(now.Year(), now.Month(), now.Day(), pollTime.Hours, pollTime.Minutes, pollTime.Seconds, 0, time.UTC) + tillNextPoll := pollTimeToday.Sub(now) + var d time.Duration + // If the next poll time is behind the current time + if tillNextPoll < 0 { + // Add 24 hours, flip it to an absolute duration, i.e., -10h == 10h + // and subtract it from 24 hours to calculate the poll time for tomorrow + d = time.Hour*24 - tillNextPoll.Abs() + } else { + d = tillNextPoll.Abs() + } + return d +} diff --git a/internal/jimm/audit_log_test.go b/internal/jimm/auditlog/cleanup_test.go similarity index 61% rename from internal/jimm/audit_log_test.go rename to internal/jimm/auditlog/cleanup_test.go index fcb44ae9d..b30bad421 100644 --- a/internal/jimm/audit_log_test.go +++ b/internal/jimm/auditlog/cleanup_test.go @@ -1,6 +1,6 @@ // Copyright 2025 Canonical. -package jimm_test +package auditlog_test import ( "context" @@ -8,32 +8,41 @@ import ( "time" qt "github.com/frankban/quicktest" + "github.com/juju/names/v5" "github.com/canonical/jimm/v3/internal/db" "github.com/canonical/jimm/v3/internal/dbmodel" - "github.com/canonical/jimm/v3/internal/errors" - "github.com/canonical/jimm/v3/internal/jimm" + "github.com/canonical/jimm/v3/internal/jimm/auditlog" "github.com/canonical/jimm/v3/internal/testutils/jimmtest" ) func TestAuditLogCleanupServicePurgesLogs(t *testing.T) { c := qt.New(t) + c.Parallel() ctx := context.Background() - now := time.Now().UTC().Round(time.Millisecond) db := &db.Database{ - DB: jimmtest.PostgresDB(c, func() time.Time { return now }), + DB: jimmtest.PostgresDB(c, time.Now), } + err := db.Migrate(context.Background()) + c.Assert(err, qt.IsNil) - err := db.AddAuditLogEntry(ctx, &dbmodel.AuditLogEntry{ - Time: now.AddDate(0, 0, -1), - }) - c.Check(errors.ErrorCode(err), qt.Equals, errors.CodeUpgradeInProgress) + ofgaClient, _, _, err := jimmtest.SetupTestOFGAClient(c.Name()) + c.Assert(err, qt.IsNil) + + jimmTag := names.NewControllerTag("foo") - err = db.Migrate(context.Background()) + manager, err := auditlog.NewAuditLogManager(db, ofgaClient, jimmTag, 1) c.Assert(err, qt.IsNil) + now := time.Now().UTC() + + // A log from today + c.Assert(db.AddAuditLogEntry(ctx, &dbmodel.AuditLogEntry{ + Time: now.AddDate(0, 0, 0), + }), qt.IsNil) + // A log from 1 day ago c.Assert(db.AddAuditLogEntry(ctx, &dbmodel.AuditLogEntry{ Time: now.AddDate(0, 0, -1), @@ -44,40 +53,34 @@ func TestAuditLogCleanupServicePurgesLogs(t *testing.T) { Time: now.AddDate(0, 0, -2), }), qt.IsNil) - // A log from 3 days ago - c.Assert(db.AddAuditLogEntry(ctx, &dbmodel.AuditLogEntry{ - Time: now.AddDate(0, 0, -3), - }), qt.IsNil) - // Check 3 created logs := make([]dbmodel.AuditLogEntry, 0) err = db.DB.Find(&logs).Error c.Assert(err, qt.IsNil) c.Assert(logs, qt.HasLen, 3) - jimm.PollDuration.Hours = now.Hour() - jimm.PollDuration.Minutes = now.Minute() - jimm.PollDuration.Seconds = now.Second() + 2 - svc := jimm.NewAuditLogCleanupService(db, 1) - svc.Start(ctx) + // Manager is setup above to remove logs older than 1 day. + manager.Cleanup(ctx) // Check 2 were purged logs = make([]dbmodel.AuditLogEntry, 0) err = db.DB.Find(&logs).Error c.Assert(err, qt.IsNil) - c.Assert(logs, qt.HasLen, 3) + c.Assert(logs, qt.HasLen, 1) } func TestCalculateNextPollDuration(t *testing.T) { c := qt.New(t) + pollTime := auditlog.PollTimeOfDay{Hours: 9} + // Test where 9am is behind 12pm startingTime := time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC) - d := jimm.CalculateNextPollDuration(startingTime) + d := auditlog.CalculateNextPollDuration(pollTime, startingTime) c.Assert(d, qt.Equals, time.Hour*21) - // Test where 9am is ahead of 7pm + // Test where 9am is ahead of 7am startingTime = time.Date(2023, 1, 1, 7, 0, 0, 0, time.UTC) - d = jimm.CalculateNextPollDuration(startingTime) + d = auditlog.CalculateNextPollDuration(pollTime, startingTime) c.Assert(d, qt.Equals, time.Hour*2) } diff --git a/internal/jimm/auditlog/export_test.go b/internal/jimm/auditlog/export_test.go new file mode 100644 index 000000000..4122f157e --- /dev/null +++ b/internal/jimm/auditlog/export_test.go @@ -0,0 +1,17 @@ +// Copyright 2025 Canonical. + +package auditlog + +import "context" + +// AuditLogManager is a type alias to export auditLogManager for use in tests. +type AuditLogManager = auditLogManager +type PollTimeOfDay = pollTimeOfDay + +var ( + CalculateNextPollDuration = calculateNextPollDuration +) + +func (j *auditLogManager) Cleanup(ctx context.Context) { + j.cleanup(ctx) +} diff --git a/internal/jimm/export_test.go b/internal/jimm/export_test.go index be83b8797..960869887 100644 --- a/internal/jimm/export_test.go +++ b/internal/jimm/export_test.go @@ -14,11 +14,9 @@ import ( ) var ( - PollDuration = pollDuration - CalculateNextPollDuration = calculateNextPollDuration - NewControllerClient = &newControllerClient - FillMigrationTarget = fillMigrationTarget - InitiateMigration = &initiateMigration + NewControllerClient = &newControllerClient + FillMigrationTarget = fillMigrationTarget + InitiateMigration = &initiateMigration ) func NewWatcherWithControllerUnavailableChan(db *db.Database, dialer Dialer, pubsub Publisher, testChannel chan error) *Watcher { diff --git a/internal/jimm/jimm.go b/internal/jimm/jimm.go index 3cd13ebe1..90ed55dd2 100644 --- a/internal/jimm/jimm.go +++ b/internal/jimm/jimm.go @@ -19,9 +19,7 @@ import ( "github.com/juju/juju/core/crossmodel" jujuparams "github.com/juju/juju/rpc/params" "github.com/juju/names/v5" - "github.com/juju/zaputil/zapctx" "github.com/lestrrat-go/jwx/v2/jwt" - "go.uber.org/zap" "golang.org/x/oauth2" "golang.org/x/sync/errgroup" @@ -29,6 +27,7 @@ import ( "github.com/canonical/jimm/v3/internal/db" "github.com/canonical/jimm/v3/internal/dbmodel" "github.com/canonical/jimm/v3/internal/errors" + "github.com/canonical/jimm/v3/internal/jimm/auditlog" "github.com/canonical/jimm/v3/internal/jimm/credentials" "github.com/canonical/jimm/v3/internal/jimm/group" "github.com/canonical/jimm/v3/internal/jimm/identity" @@ -220,6 +219,18 @@ type PermissionManager interface { ToJAASTag(ctx context.Context, tag *ofganames.Tag, resolveUUIDs bool) (string, error) } +// AuditLogManager provides methods to add/find/cleanup audit logs. +type AuditLogManager interface { + // AddAuditLogEntry saves an audit log entry. + AddAuditLogEntry(ale *dbmodel.AuditLogEntry) + // FindAuditEvents queries for audit log entries that match the specified filter(s). + FindAuditEvents(ctx context.Context, user *openfga.User, filter db.AuditLogFilter) ([]dbmodel.AuditLogEntry, error) + // PurgeLogs removes logs older than the specified date. + PurgeLogs(ctx context.Context, user *openfga.User, before time.Time) (int64, error) + // StartCleanup removes log older than the retention period. + StartCleanup(ctx context.Context) +} + // Parameters holds the services and static fields passed to the jimm.New() constructor. // You can provide mock implementations of certain services where necessary for dependency injection. type Parameters struct { @@ -258,6 +269,10 @@ type Parameters struct { // OAuthAuthenticator is responsible for handling authentication // via OAuth2.0 AND JWT access tokens to JIMM. OAuthAuthenticator OAuthAuthenticator + + // AuditLogRetentionDays is the number of days to keep audit logs. + // The default value of 0 indicates that logs will never be deleted. + AuditLogRetentionDays int } func (p *Parameters) Validate() error { @@ -345,6 +360,12 @@ func New(p Parameters) (*JIMM, error) { j.jujuAuthFactory = jujuauth.NewFactory(j.Database, j.JWTService, permissionManager) + auditLogManager, err := auditlog.NewAuditLogManager(j.Database, j.OpenFGAClient, j.ResourceTag(), p.AuditLogRetentionDays) + if err != nil { + return nil, err + } + j.auditLogManager = auditLogManager + return j, nil } @@ -370,6 +391,9 @@ type JIMM struct { permissionManager PermissionManager jujuAuthFactory *jujuauth.Factory + + // auditLogManager provides a means to manage audit logs within JIMM. + auditLogManager AuditLogManager } // ResourceTag returns JIMM's controller tag stating its UUID. @@ -397,7 +421,7 @@ func (j *JIMM) IdentityManager() IdentityManager { return j.identityManager } -// Login manager returns a manager that enables login and authentication. +// LoginManager returns a manager that enables login and authentication. func (j *JIMM) LoginManager() LoginManager { return j.loginManager } @@ -414,6 +438,11 @@ func (j *JIMM) NewJujuAuthenticator() jujuauth.TokenGenerator { return j.jujuAuthFactory.New() } +// AuditLogManager returns a manager that handles audit logging. +func (j *JIMM) AuditLogManager() AuditLogManager { + return j.auditLogManager +} + type permission struct { resource string relation string @@ -631,57 +660,6 @@ func (j *JIMM) forEachController(ctx context.Context, controllers []dbmodel.Cont return eg.Wait() } -// addAuditLogEntry causes an entry to be added the the audit log. -func (j *JIMM) AddAuditLogEntry(ale *dbmodel.AuditLogEntry) { - ctx := context.Background() - redactSensitiveParams(ale) - if err := j.Database.AddAuditLogEntry(ctx, ale); err != nil { - zapctx.Error(ctx, "cannot store audit log entry", zap.Error(err), zap.Any("entry", *ale)) - } -} - -var sensitiveMethods = map[string]struct{}{ - "login": {}, - "logindevice": {}, - "getdevicesessiontoken": {}, - "loginwithsessiontoken": {}, - "addcredentials": {}, - "updatecredentials": {}} -var redactJSON = dbmodel.JSON(`{"params":"redacted"}`) - -func redactSensitiveParams(ale *dbmodel.AuditLogEntry) { - if ale.Params == nil { - return - } - method := strings.ToLower(ale.FacadeMethod) - if _, ok := sensitiveMethods[method]; ok { - newRedactMessage := make(dbmodel.JSON, len(redactJSON)) - copy(newRedactMessage, redactJSON) - ale.Params = newRedactMessage - } -} - -// FindAuditEvents returns audit events matching the given filter. -func (j *JIMM) FindAuditEvents(ctx context.Context, user *openfga.User, filter db.AuditLogFilter) ([]dbmodel.AuditLogEntry, error) { - const op = errors.Op("jimm.FindAuditEvents") - - access := user.GetAuditLogViewerAccess(ctx, j.ResourceTag()) - if access != ofganames.AuditLogViewerRelation { - return nil, errors.E(op, errors.CodeUnauthorized, "unauthorized") - } - - var entries []dbmodel.AuditLogEntry - err := j.Database.ForEachAuditLogEntry(ctx, filter, func(entry *dbmodel.AuditLogEntry) error { - entries = append(entries, *entry) - return nil - }) - if err != nil { - return nil, errors.E(op, err) - } - - return entries, nil -} - // ControllerInfo returns info about a controller connected to JIMM. func (j *JIMM) ControllerInfo(ctx context.Context, name string) (*dbmodel.Controller, error) { const op = errors.Op("jimm.ListControllers") diff --git a/internal/jimm/jimm_test.go b/internal/jimm/jimm_test.go index 2e9352607..00d4f1a37 100644 --- a/internal/jimm/jimm_test.go +++ b/internal/jimm/jimm_test.go @@ -18,145 +18,10 @@ import ( "github.com/canonical/jimm/v3/internal/dbmodel" "github.com/canonical/jimm/v3/internal/jimm" "github.com/canonical/jimm/v3/internal/openfga" - ofganames "github.com/canonical/jimm/v3/internal/openfga/names" "github.com/canonical/jimm/v3/internal/testutils/jimmtest" "github.com/canonical/jimm/v3/pkg/api/params" ) -func TestFindAuditEvents(t *testing.T) { - c := qt.New(t) - - j := jimmtest.NewJIMM(c, nil) - - ctx := context.Background() - - alice, err := dbmodel.NewIdentity("alice@canonical.com") - c.Assert(err, qt.IsNil) - - admin := openfga.NewUser(alice, j.OpenFGAClient) - err = admin.SetControllerAccess(ctx, j.ResourceTag(), ofganames.AdministratorRelation) - c.Assert(err, qt.IsNil) - - bob, err := dbmodel.NewIdentity("bob@canonical.com") - c.Assert(err, qt.IsNil) - - privileged := openfga.NewUser(bob, j.OpenFGAClient) - err = privileged.SetControllerAccess(ctx, j.ResourceTag(), ofganames.AuditLogViewerRelation) - c.Assert(err, qt.IsNil) - - eve, err := dbmodel.NewIdentity("eve@canonical.com") - c.Assert(err, qt.IsNil) - unprivileged := openfga.NewUser(eve, j.OpenFGAClient) - - events := []dbmodel.AuditLogEntry{{ - Time: now, - IdentityTag: admin.Identity.Tag().String(), - FacadeMethod: "Login", - }, { - Time: now.Add(time.Hour), - IdentityTag: admin.Identity.Tag().String(), - FacadeMethod: "AddModel", - }, { - Time: now.Add(2 * time.Hour), - IdentityTag: privileged.Identity.Tag().String(), - Model: "TestModel", - FacadeMethod: "Deploy", - }, { - Time: now.Add(3 * time.Hour), - IdentityTag: privileged.Identity.Tag().String(), - Model: "TestModel", - FacadeMethod: "DestroyModel", - }} - for i, event := range events { - e := event - j.AddAuditLogEntry(&e) - events[i] = e - } - - found, err := j.FindAuditEvents(context.Background(), admin, db.AuditLogFilter{}) - c.Assert(err, qt.IsNil) - c.Assert(found, qt.HasLen, len(events)) - - tests := []struct { - about string - users []*openfga.User - filter db.AuditLogFilter - expectedEvents []dbmodel.AuditLogEntry - expectedError string - }{{ - about: "admin/privileged user is allowed to find audit events by time", - users: []*openfga.User{admin, privileged}, - filter: db.AuditLogFilter{ - Start: now.Add(-time.Hour), - End: now.Add(time.Minute), - }, - expectedEvents: []dbmodel.AuditLogEntry{events[0]}, - }, { - about: "admin/privileged user is allowed to find audit events by user", - users: []*openfga.User{admin, privileged}, - filter: db.AuditLogFilter{ - IdentityTag: admin.Tag().String(), - }, - expectedEvents: []dbmodel.AuditLogEntry{events[0], events[1]}, - }, { - about: "admin/privileged user is allowed to find audit events by method", - users: []*openfga.User{admin, privileged}, - filter: db.AuditLogFilter{ - Method: "Deploy", - }, - expectedEvents: []dbmodel.AuditLogEntry{events[2]}, - }, { - about: "admin/privileged user is allowed to find audit events by model", - users: []*openfga.User{admin, privileged}, - filter: db.AuditLogFilter{ - Model: "TestModel", - }, - expectedEvents: []dbmodel.AuditLogEntry{events[2], events[3]}, - }, { - about: "admin/privileged user is allowed to find audit events by model and sort by time", - users: []*openfga.User{admin, privileged}, - filter: db.AuditLogFilter{ - Model: "TestModel", - SortTime: true, - }, - expectedEvents: []dbmodel.AuditLogEntry{events[3], events[2]}, - }, { - about: "admin/privileged user is allowed to find audit events with limit/offset", - users: []*openfga.User{admin, privileged}, - filter: db.AuditLogFilter{ - Offset: 1, - Limit: 2, - }, - expectedEvents: []dbmodel.AuditLogEntry{events[1], events[2]}, - }, { - about: "admin/privileged user - no events found", - users: []*openfga.User{admin, privileged}, - filter: db.AuditLogFilter{ - IdentityTag: "no-such-user", - }, - }, { - about: "unprivileged user is not allowed to access audit events", - users: []*openfga.User{unprivileged}, - filter: db.AuditLogFilter{ - IdentityTag: admin.Tag().String(), - }, - expectedError: "unauthorized", - }} - for _, test := range tests { - c.Run(test.about, func(c *qt.C) { - for _, user := range test.users { - events, err := j.FindAuditEvents(context.Background(), user, test.filter) - if test.expectedError != "" { - c.Assert(err, qt.ErrorMatches, test.expectedError) - } else { - c.Assert(err, qt.Equals, nil) - c.Assert(events, qt.DeepEquals, test.expectedEvents) - } - } - }) - } -} - const testControllersEnv = `clouds: - name: test type: test diff --git a/internal/jimm/purge_logs.go b/internal/jimm/purge_logs.go deleted file mode 100644 index c5a891ade..000000000 --- a/internal/jimm/purge_logs.go +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright 2024 Canonical. - -package jimm - -import ( - "context" - "time" - - "github.com/juju/zaputil/zapctx" - "go.uber.org/zap" - - "github.com/canonical/jimm/v3/internal/errors" - "github.com/canonical/jimm/v3/internal/openfga" -) - -// PurgeLogs removes all audit logs before the given timestamp. Only JIMM -// administrators can perform this operation. The number of logs purged is -// returned. -func (j *JIMM) PurgeLogs(ctx context.Context, user *openfga.User, before time.Time) (int64, error) { - op := errors.Op("jimm.PurgeLogs") - if !user.JimmAdmin { - return 0, errors.E(op, errors.CodeUnauthorized, "unauthorized") - } - count, err := j.Database.DeleteAuditLogsBefore(ctx, before) - if err != nil { - zapctx.Error(ctx, "failed to purge logs", zap.Error(err)) - return 0, errors.E(op, "failed to purge logs", err) - } - return count, nil -} diff --git a/internal/jujuapi/admin.go b/internal/jujuapi/admin.go index 49a5bb925..4982f8086 100644 --- a/internal/jujuapi/admin.go +++ b/internal/jujuapi/admin.go @@ -1,4 +1,4 @@ -// Copyright 2024 Canonical. +// Copyright 2025 Canonical. package jujuapi diff --git a/internal/jujuapi/controllerroot.go b/internal/jujuapi/controllerroot.go index a21700e6a..1b73237a7 100644 --- a/internal/jujuapi/controllerroot.go +++ b/internal/jujuapi/controllerroot.go @@ -1,4 +1,4 @@ -// Copyright 2024 Canonical. +// Copyright 2025 Canonical. package jujuapi @@ -12,7 +12,6 @@ import ( "golang.org/x/oauth2" "github.com/canonical/jimm/v3/internal/errors" - "github.com/canonical/jimm/v3/internal/jimm" "github.com/canonical/jimm/v3/internal/jujuapi/rpc" "github.com/canonical/jimm/v3/internal/openfga" jimmnames "github.com/canonical/jimm/v3/pkg/names" @@ -135,8 +134,8 @@ func (r *controllerRoot) setupUUIDGenerator() error { return nil } -func (r *controllerRoot) newAuditLogger() jimm.DbAuditLogger { - return jimm.NewDbAuditLogger(r.jimm, r.getUser) +func (r *controllerRoot) newAuditLogger() auditLogger { + return newAuditLogger(r.jimm.AuditLogManager(), r.getUser) } // getUser implements jujuapi.root interface to return the currently logged in user. diff --git a/internal/jujuapi/interface.go b/internal/jujuapi/interface.go index c05e56aea..c6ec9543a 100644 --- a/internal/jujuapi/interface.go +++ b/internal/jujuapi/interface.go @@ -1,10 +1,9 @@ -// Copyright 2024 Canonical. +// Copyright 2025 Canonical. package jujuapi import ( "context" - "time" "github.com/go-macaroon-bakery/macaroon-bakery/v3/bakery" "github.com/juju/juju/api/base" @@ -23,14 +22,12 @@ import ( type JIMM interface { ControllerService ModelManager - AddAuditLogEntry(ale *dbmodel.AuditLogEntry) AddCloudToController(ctx context.Context, user *openfga.User, controllerName string, tag names.CloudTag, cloud jujuparams.Cloud, force bool) error AddHostedCloud(ctx context.Context, user *openfga.User, tag names.CloudTag, cloud jujuparams.Cloud, force bool) error AddServiceAccount(ctx context.Context, u *openfga.User, clientId string) error CopyServiceAccountCredential(ctx context.Context, u *openfga.User, svcAcc *openfga.User, cloudCredentialTag names.CloudCredentialTag) (names.CloudCredentialTag, []jujuparams.UpdateCredentialModelResult, error) DestroyOffer(ctx context.Context, user *openfga.User, offerURL string, force bool) error FindApplicationOffers(ctx context.Context, user *openfga.User, filters ...jujuparams.OfferFilter) ([]jujuparams.ApplicationOfferAdminDetailsV5, error) - FindAuditEvents(ctx context.Context, user *openfga.User, filter db.AuditLogFilter) ([]dbmodel.AuditLogEntry, error) ForEachCloud(ctx context.Context, user *openfga.User, f func(*dbmodel.Cloud) error) error ForEachUserCloud(ctx context.Context, user *openfga.User, f func(*dbmodel.Cloud) error) error ForEachUserCloudCredential(ctx context.Context, u *dbmodel.Identity, ct names.CloudTag, f func(cred *dbmodel.CloudCredential) error) error @@ -44,6 +41,7 @@ type JIMM interface { IdentityManager() jimm.IdentityManager LoginManager() jimm.LoginManager PermissionManager() jimm.PermissionManager + AuditLogManager() jimm.AuditLogManager InitiateInternalMigration(ctx context.Context, user *openfga.User, modelNameOrUUID string, targetController string) (jujuparams.InitiateMigrationResult, error) InitiateMigration(ctx context.Context, user *openfga.User, spec jujuparams.MigrationSpec) (jujuparams.InitiateMigrationResult, error) @@ -52,7 +50,6 @@ type JIMM interface { ListResources(ctx context.Context, user *openfga.User, filter pagination.LimitOffsetPagination, namePrefixFilter, typeFilter string) ([]db.Resource, error) Offer(ctx context.Context, user *openfga.User, offer jimm.AddApplicationOfferParams) error PubSubHub() *pubsub.Hub - PurgeLogs(ctx context.Context, user *openfga.User, before time.Time) (int64, error) RemoveCloud(ctx context.Context, u *openfga.User, ct names.CloudTag) error RemoveCloudFromController(ctx context.Context, u *openfga.User, controllerName string, ct names.CloudTag) error RemoveController(ctx context.Context, user *openfga.User, controllerName string, force bool) error diff --git a/internal/jujuapi/jimm.go b/internal/jujuapi/jimm.go index 5bd868660..748c2f25d 100644 --- a/internal/jujuapi/jimm.go +++ b/internal/jujuapi/jimm.go @@ -1,4 +1,4 @@ -// Copyright 2024 Canonical. +// Copyright 2025 Canonical. package jujuapi @@ -346,7 +346,7 @@ func (r *controllerRoot) FindAuditEvents(ctx context.Context, req apiparams.Find if err != nil { return apiparams.AuditEvents{}, errors.E(op, err) } - entries, err := r.jimm.FindAuditEvents(ctx, r.user, filter) + entries, err := r.jimm.AuditLogManager().FindAuditEvents(ctx, r.user, filter) if err != nil { return apiparams.AuditEvents{}, errors.E(op, err) } @@ -488,7 +488,7 @@ func (r *controllerRoot) CrossModelQuery(ctx context.Context, req apiparams.Cros func (r *controllerRoot) PurgeLogs(ctx context.Context, req apiparams.PurgeLogsRequest) (apiparams.PurgeLogsResponse, error) { const op = errors.Op("jujuapi.PurgeLogs") - deleted_count, err := r.jimm.PurgeLogs(ctx, r.user, req.Date) + deleted_count, err := r.jimm.AuditLogManager().PurgeLogs(ctx, r.user, req.Date) if err != nil { return apiparams.PurgeLogsResponse{}, errors.E(op, err) } diff --git a/internal/jujuapi/recorder.go b/internal/jujuapi/recorder.go new file mode 100644 index 000000000..0e3537214 --- /dev/null +++ b/internal/jujuapi/recorder.go @@ -0,0 +1,128 @@ +// Copyright 2025 Canonical. + +package jujuapi + +import ( + "context" + "encoding/json" + "time" + + "github.com/juju/juju/rpc" + "github.com/juju/juju/rpc/params" + "github.com/juju/names/v5" + "github.com/juju/zaputil/zapctx" + "go.uber.org/zap" + + "github.com/canonical/jimm/v3/internal/dbmodel" + "github.com/canonical/jimm/v3/internal/servermon" + "github.com/canonical/jimm/v3/internal/utils" +) + +// LogBackend defines the interface used by the Logger to store +// audit events. +type LogBackend interface { + AddAuditLogEntry(ale *dbmodel.AuditLogEntry) +} + +// auditLogger determines how to convert Juju RPC messages to the desired +// format and then sends logs to the backend for persistence. +type auditLogger struct { + backend LogBackend + conversationId string + getUser func() names.UserTag +} + +// newAuditLogger returns a new audit logger that logs to the provided backend. +func newAuditLogger(backend LogBackend, getUserFunc func() names.UserTag) auditLogger { + logger := auditLogger{ + backend: backend, + conversationId: utils.NewConversationID(), + getUser: getUserFunc, + } + return logger +} + +func (r auditLogger) newEntry(header *rpc.Header) dbmodel.AuditLogEntry { + ale := dbmodel.AuditLogEntry{ + Time: time.Now().UTC().Round(time.Millisecond), + MessageId: header.RequestId, + IdentityTag: r.getUser().String(), + ConversationId: r.conversationId, + } + return ale +} + +// LogRequest creates an audit log entry from a client request. +func (r auditLogger) LogRequest(header *rpc.Header, body interface{}) error { + ale := r.newEntry(header) + ale.ObjectId = header.Request.Id + ale.FacadeName = header.Request.Type + ale.FacadeMethod = header.Request.Action + ale.FacadeVersion = header.Request.Version + if body != nil { + jsonBody, err := json.Marshal(body) + if err != nil { + zapctx.Error(context.Background(), "failed to marshal body", zap.Error(err)) + return err + } + ale.Params = jsonBody + } + r.backend.AddAuditLogEntry(&ale) + return nil +} + +// LogResponse creates an audit log entry from a controller response. +func (o auditLogger) LogResponse(r rpc.Request, header *rpc.Header, body interface{}) error { + var allErrors params.ErrorResults + bulkError, ok := body.(params.ErrorResults) + if ok { + allErrors.Results = append(allErrors.Results, bulkError.Results...) + } + singleError := params.Error{ + Message: header.Error, + Code: header.ErrorCode, + Info: header.ErrorInfo, + } + allErrors.Results = append(allErrors.Results, params.ErrorResult{Error: &singleError}) + jsonErr, err := json.Marshal(allErrors) + if err != nil { + return err + } + ale := o.newEntry(header) + ale.ObjectId = r.Id + ale.FacadeName = r.Type + ale.FacadeMethod = r.Action + ale.FacadeVersion = r.Version + ale.Errors = jsonErr + ale.IsResponse = true + o.backend.AddAuditLogEntry(&ale) + return nil +} + +// recorder implements an rpc.Recorder. +type recorder struct { + start time.Time + logger auditLogger + conversationId string +} + +// NewRecorder returns a new recorder struct useful for recording RPC events. +func NewRecorder(logger auditLogger) recorder { + return recorder{ + start: time.Now(), + conversationId: utils.NewConversationID(), + logger: logger, + } +} + +// HandleRequest implements rpc.Recorder. +func (r recorder) HandleRequest(header *rpc.Header, body interface{}) error { + return r.logger.LogRequest(header, body) +} + +// HandleReply implements rpc.Recorder. +func (o recorder) HandleReply(r rpc.Request, header *rpc.Header, body interface{}) error { + d := time.Since(o.start) + servermon.WebsocketRequestDuration.WithLabelValues(r.Type, r.Action).Observe(float64(d) / float64(time.Second)) + return o.logger.LogResponse(r, header, body) +} diff --git a/internal/jujuapi/service_account.go b/internal/jujuapi/service_account.go index b89b83c11..9c3dcddf6 100644 --- a/internal/jujuapi/service_account.go +++ b/internal/jujuapi/service_account.go @@ -1,4 +1,4 @@ -// Copyright 2024 Canonical. +// Copyright 2025 Canonical. package jujuapi diff --git a/internal/jujuapi/streamproxy.go b/internal/jujuapi/streamproxy.go index f86de3c9a..5500c849a 100644 --- a/internal/jujuapi/streamproxy.go +++ b/internal/jujuapi/streamproxy.go @@ -1,4 +1,4 @@ -// Copyright 2024 Canonical. +// Copyright 2025 Canonical. package jujuapi diff --git a/internal/jujuapi/websocket.go b/internal/jujuapi/websocket.go index d807f3bb4..981f83abc 100644 --- a/internal/jujuapi/websocket.go +++ b/internal/jujuapi/websocket.go @@ -1,4 +1,4 @@ -// Copyright 2024 Canonical. +// Copyright 2025 Canonical. package jujuapi @@ -100,7 +100,7 @@ func (s *apiServer) Kill() { } // serveRoot serves an RPC root object on a websocket connection. -func serveRoot(ctx context.Context, root root, logger jimm.DbAuditLogger, wsConn *websocket.Conn) { +func serveRoot(ctx context.Context, root root, logger auditLogger, wsConn *websocket.Conn) { // Note that although NewConn accepts a `RecorderFactory` input, the call to conn.ServeRoot // also accepts a `RecorderFactory` and will override anything set during the call to NewConn. conn := rpc.NewConn( @@ -108,7 +108,7 @@ func serveRoot(ctx context.Context, root root, logger jimm.DbAuditLogger, wsConn nil, ) rpcRecorderFactory := func() rpc.Recorder { - return jimm.NewRecorder(logger) + return NewRecorder(logger) } conn.ServeRoot(root, rpcRecorderFactory, func(err error) error { return mapError(err) @@ -176,7 +176,7 @@ func (s apiProxier) ServeWS(ctx context.Context, clientConn *websocket.Conn) { jwtGenerator := s.jimm.NewJujuAuthenticator() connectionFunc := controllerConnectionFunc(s, &jwtGenerator) zapctx.Debug(ctx, "Starting proxier") - auditLogger := s.jimm.AddAuditLogEntry + auditLogger := s.jimm.AuditLogManager().AddAuditLogEntry proxyHelpers := jimmRPC.ProxyHelpers{ ConnClient: clientConn, TokenGen: &jwtGenerator, diff --git a/internal/testutils/jimmtest/jimm_mock.go b/internal/testutils/jimmtest/jimm_mock.go index 3e5cdee90..6073234e5 100644 --- a/internal/testutils/jimmtest/jimm_mock.go +++ b/internal/testutils/jimmtest/jimm_mock.go @@ -31,6 +31,7 @@ type JIMM struct { mocks.ControllerService mocks.ModelManager + AuditLogManager_ func() jimm.AuditLogManager GroupManager_ func() jimm.GroupManager IdentityManager_ func() jimm.IdentityManager LoginManager_ func() jimm.LoginManager @@ -215,6 +216,13 @@ func (j *JIMM) PermissionManager() jimm.PermissionManager { return j.PermissionManager_() } +func (j *JIMM) AuditLogManager() jimm.AuditLogManager { + if j.AuditLogManager_ == nil { + return nil + } + return j.AuditLogManager_() +} + func (j *JIMM) InitiateMigration(ctx context.Context, user *openfga.User, spec jujuparams.MigrationSpec) (jujuparams.InitiateMigrationResult, error) { if j.InitiateMigration_ == nil { return jujuparams.InitiateMigrationResult{}, errors.E(errors.CodeNotImplemented) diff --git a/internal/testutils/jimmtest/mocks/jimm_auditlog_mock.go b/internal/testutils/jimmtest/mocks/jimm_auditlog_mock.go new file mode 100644 index 000000000..38334f5e8 --- /dev/null +++ b/internal/testutils/jimmtest/mocks/jimm_auditlog_mock.go @@ -0,0 +1,38 @@ +// Copyright 2025 Canonical. + +package mocks + +import ( + "context" + "time" + + "github.com/canonical/jimm/v3/internal/db" + "github.com/canonical/jimm/v3/internal/dbmodel" + "github.com/canonical/jimm/v3/internal/errors" + "github.com/canonical/jimm/v3/internal/openfga" +) + +// AuditLogManager is an implementation of the jimm.AuditLogManager interface. +type AuditLogManager struct { + AddAuditLogEntry_ func(ale *dbmodel.AuditLogEntry) + FindAuditEvents_ func(ctx context.Context, user *openfga.User, filter db.AuditLogFilter) ([]dbmodel.AuditLogEntry, error) + PurgeLogs_ func(ctx context.Context, user *openfga.User, before time.Time) (int64, error) +} + +func (j *AuditLogManager) AddAuditLogEntry(ale *dbmodel.AuditLogEntry) { + if j.AddAuditLogEntry_ == nil { + return + } +} +func (j *AuditLogManager) FindAuditEvents(ctx context.Context, user *openfga.User, filter db.AuditLogFilter) ([]dbmodel.AuditLogEntry, error) { + if j.FindAuditEvents_ == nil { + return nil, errors.E(errors.CodeNotImplemented) + } + return j.FindAuditEvents_(ctx, user, filter) +} +func (j *AuditLogManager) PurgeLogs(ctx context.Context, user *openfga.User, before time.Time) (int64, error) { + if j.PurgeLogs_ == nil { + return 0, errors.E(errors.CodeNotImplemented) + } + return j.PurgeLogs_(ctx, user, before) +}