diff --git a/.gitignore b/.gitignore index a55cda154..05141fd65 100644 --- a/.gitignore +++ b/.gitignore @@ -18,5 +18,5 @@ local/vault/roleid.txt *.crt *.key *.csr -jimmctl +/jimmctl qa-controller diff --git a/api/jimm.go b/api/jimm.go index 9ebaff7fc..559437ab2 100644 --- a/api/jimm.go +++ b/api/jimm.go @@ -176,3 +176,10 @@ func (c *Client) CrossModelQuery(req *params.CrossModelQueryRequest) (*params.Cr err := c.caller.APICall("JIMM", 4, "", "CrossModelQuery", req, &response) return &response, err } + +// PurgeLogs purges logs from the database before the given date. +func (c *Client) PurgeLogs(req *params.PurgeLogsRequest) (*params.PurgeLogsResponse, error) { + var response params.PurgeLogsResponse + err := c.caller.APICall("JIMM", 4, "", "PurgeLogs", req, &response) + return &response, err +} diff --git a/api/params/params.go b/api/params/params.go index 2c266856f..2064b26c6 100644 --- a/api/params/params.go +++ b/api/params/params.go @@ -354,3 +354,16 @@ type CrossModelQueryResponse struct { Results map[string][]any `json:"results"` Errors map[string][]string `json:"errors"` } + +// PurgeLogsRequest is the request used to purge logs. +type PurgeLogsRequest struct { + // Date is the date before which logs should be purged. + Date time.Time `json:"date"` +} + +// PurgeLogsResponse is the response returned by the PurgeLogs method. +// It has one field: +// - DeletedCount - the number of logs that were deleted. +type PurgeLogsResponse struct { + DeletedCount int64 `json:"deleted-count" yaml:"deleted-count"` +} diff --git a/cmd/jimmctl/cmd/export_test.go b/cmd/jimmctl/cmd/export_test.go index 500d073fa..0d344cefe 100644 --- a/cmd/jimmctl/cmd/export_test.go +++ b/cmd/jimmctl/cmd/export_test.go @@ -297,3 +297,15 @@ func NewCrossModelQueryCommandForTesting(store jujuclient.ClientStore, bClient * return modelcmd.WrapBase(cmd) } + +func NewPurgeLogsCommandForTesting(store jujuclient.ClientStore, bClient *httpbakery.Client) cmd.Command { + cmd := &purgeLogsCommand{ + store: store, + dialOpts: &jujuapi.DialOpts{ + InsecureSkipVerify: true, + BakeryClient: bClient, + }, + } + + return modelcmd.WrapBase(cmd) +} diff --git a/cmd/jimmctl/cmd/purge_logs.go b/cmd/jimmctl/cmd/purge_logs.go new file mode 100644 index 000000000..cbb126bb4 --- /dev/null +++ b/cmd/jimmctl/cmd/purge_logs.go @@ -0,0 +1,127 @@ +package cmd + +import ( + "time" + + "github.com/canonical/jimm/api" + apiparams "github.com/canonical/jimm/api/params" + "github.com/canonical/jimm/internal/errors" + "github.com/juju/cmd/v3" + "github.com/juju/gnuflag" + jujuapi "github.com/juju/juju/api" + jujucmd "github.com/juju/juju/cmd" + "github.com/juju/juju/cmd/modelcmd" + "github.com/juju/juju/jujuclient" +) + +const purgeLogsDoc = ` + purge-audit-logs purges logs from the database before the given date. + + Examples: + jimmctl purge-audit-logs 2021-02-03 + jimmctl purge-audit-logs 2021-02-03T00 + jimmctl purge-audit-logs 2021-02-03T15:04:05Z +` + +// NewPurgeLogsCommand returns a command to purge logs. +func NewPurgeLogsCommand() cmd.Command { + cmd := &purgeLogsCommand{} + return modelcmd.WrapBase(cmd) +} + +// purgeLogsCommand purges logs. +type purgeLogsCommand struct { + modelcmd.ControllerCommandBase + store jujuclient.ClientStore + dialOpts *jujuapi.DialOpts + out cmd.Output + + date time.Time +} + +// Info implements Command.Info. It returns the command information. +func (c *purgeLogsCommand) Info() *cmd.Info { + return jujucmd.Info(&cmd.Info{ + Name: "purge-audit-logs", + Args: "", + Purpose: "purges audit logs from the database before the given date", + Doc: purgeLogsDoc, + }) +} + +// Init implements Command.Init. It checks the number of arguments and validates +// the date. +func (c *purgeLogsCommand) Init(args []string) error { + if len(args) != 1 { + return errors.E("expected one argument (ISO8601 date)") + } + // validate date + var err error + c.date, err = parseDate(args[0]) + if err != nil { + return errors.E("invalid date. Expected ISO8601 date") + } + return nil +} + +// SetFlags implements Command.SetFlags. +func (c *purgeLogsCommand) SetFlags(f *gnuflag.FlagSet) { + c.CommandBase.SetFlags(f) + c.out.AddFlags(f, "yaml", map[string]cmd.Formatter{ + "yaml": cmd.FormatYaml, + "json": cmd.FormatJson, + }) +} + +// Run implements Command.Run. It purges logs from the database before the given +// date. +func (c *purgeLogsCommand) Run(ctx *cmd.Context) error { + currentController, err := c.store.CurrentController() + if err != nil { + return errors.E(err, "could not determine controller") + } + + apiCaller, err := c.NewAPIRootWithDialOpts(c.store, currentController, "", c.dialOpts) + if err != nil { + return err + } + + client := api.NewClient(apiCaller) + response, err := client.PurgeLogs(&apiparams.PurgeLogsRequest{ + Date: c.date, + }) + if err != nil { + return errors.E(err) + } + err = c.out.Write(ctx, response) + if err != nil { + return errors.E(err) + } + return nil +} + +// parseDate validates the date string is in ISO8601 format. If it is, it +// sets the date field in the command. +func parseDate(date string) (time.Time, error) { + // Define the possible ISO8601 date layouts + layouts := []string{ + "2006-01-02T15:04:05-0700", + "2006-01-02T15:04:05Z", + "2006-01-02T15:04:05", + "2006-01-02T15:04Z", + "2006-01-02", + } + + // Try to parse the date string using the defined layouts + for _, layout := range layouts { + date_time, err := time.Parse(layout, date) + if err == nil { + // If parsing was successful, the date is valid + // You can use the parsed time t if needed + return date_time, nil + } + } + + // If none of the layouts match, the date is not in the correct format + return time.Time{}, errors.E("invalid date. Expected ISO8601 date") +} diff --git a/cmd/jimmctl/cmd/purge_logs_test.go b/cmd/jimmctl/cmd/purge_logs_test.go new file mode 100644 index 000000000..d41425ca4 --- /dev/null +++ b/cmd/jimmctl/cmd/purge_logs_test.go @@ -0,0 +1,93 @@ +package cmd_test + +import ( + "bytes" + "context" + "time" + + "github.com/canonical/jimm/cmd/jimmctl/cmd" + "github.com/canonical/jimm/internal/dbmodel" + "github.com/juju/cmd/v3/cmdtesting" + "github.com/juju/names/v4" + gc "gopkg.in/check.v1" +) + +type purgeLogsSuite struct { + jimmSuite +} + +var _ = gc.Suite(&purgeLogsSuite{}) + +func (s *purgeLogsSuite) TestPurgeLogsSuperuser(c *gc.C) { + // alice is superuser + bClient := s.userBakeryClient("alice") + datastring := "2021-01-01T00:00:00Z" + cmdCtx, err := cmdtesting.RunCommand(c, cmd.NewPurgeLogsCommandForTesting(s.ClientStore(), bClient), datastring) + c.Assert(err, gc.IsNil) + expected := "deleted-count: 0\n" + actual := cmdCtx.Stdout.(*bytes.Buffer).String() + c.Assert(actual, gc.Equals, expected) +} + +func (s *purgeLogsSuite) TestInvalidISO8601Date(c *gc.C) { + // alice is superuser + bClient := s.userBakeryClient("alice") + datastring := "13/01/2021" + _, err := cmdtesting.RunCommand(c, cmd.NewPurgeLogsCommandForTesting(s.ClientStore(), bClient), datastring) + c.Assert(err, gc.ErrorMatches, `invalid date. Expected ISO8601 date`) + +} + +func (s *purgeLogsSuite) TestPurgeLogs(c *gc.C) { + // bob is not superuser + bClient := s.userBakeryClient("bob") + _, err := cmdtesting.RunCommand(c, cmd.NewPurgeLogsCommandForTesting(s.ClientStore(), bClient), "2021-01-01T00:00:00Z") + c.Assert(err, gc.ErrorMatches, `unauthorized \(unauthorized access\)`) +} + +func (s *purgeLogsSuite) TestPurgeLogsFromDb(c *gc.C) { + // create logs + layouts := []string{ + "2006-01-02T15:04:05-0700", + "2006-01-02T15:04:05Z", + "2006-01-02T15:04:05", + "2006-01-02T15:04Z", + "2006-01-02", + } + for _, layout := range layouts { + + ctx := context.Background() + relativeNow := time.Now().AddDate(-1, 0, 0) + ale := dbmodel.AuditLogEntry{ + Time: relativeNow.UTC().Round(time.Millisecond), + UserTag: names.NewUserTag("alice@external").String(), + } + ale_past := dbmodel.AuditLogEntry{ + Time: relativeNow.AddDate(0, 0, -1).UTC().Round(time.Millisecond), + UserTag: names.NewUserTag("alice@external").String(), + } + ale_future := dbmodel.AuditLogEntry{ + Time: relativeNow.AddDate(0, 0, 5).UTC().Round(time.Millisecond), + UserTag: names.NewUserTag("alice@external").String(), + } + + err := s.JIMM.Database.Migrate(context.Background(), false) + c.Assert(err, gc.IsNil) + err = s.JIMM.Database.AddAuditLogEntry(ctx, &ale) + c.Assert(err, gc.IsNil) + err = s.JIMM.Database.AddAuditLogEntry(ctx, &ale_past) + c.Assert(err, gc.IsNil) + err = s.JIMM.Database.AddAuditLogEntry(ctx, &ale_future) + c.Assert(err, gc.IsNil) + + tomorrow := relativeNow.AddDate(0, 0, 1).Format(layout) + //alice is superuser + bClient := s.userBakeryClient("alice") + cmdCtx, err := cmdtesting.RunCommand(c, cmd.NewPurgeLogsCommandForTesting(s.ClientStore(), bClient), tomorrow) + c.Assert(err, gc.IsNil) + // check that logs have been deleted + expectedOutput := "deleted-count: 2\n" + actual := cmdCtx.Stdout.(*bytes.Buffer).String() + c.Assert(actual, gc.Equals, expectedOutput) + } +} diff --git a/cmd/jimmctl/main.go b/cmd/jimmctl/main.go index 6e144bda3..17ffc072a 100644 --- a/cmd/jimmctl/main.go +++ b/cmd/jimmctl/main.go @@ -36,6 +36,7 @@ func NewSuperCommand() *jujucmd.SuperCommand { jimmcmd.Register(cmd.NewRemoveCloudFromControllerCommand()) jimmcmd.Register(cmd.NewAuthCommand()) jimmcmd.Register(cmd.NewCrossModelQueryCommand()) + jimmcmd.Register(cmd.NewPurgeLogsCommand()) return jimmcmd } diff --git a/internal/db/applicationoffer.go b/internal/db/applicationoffer.go index be37449d6..3820c231b 100644 --- a/internal/db/applicationoffer.go +++ b/internal/db/applicationoffer.go @@ -97,6 +97,7 @@ func (d *Database) DeleteApplicationOffer(ctx context.Context, offer *dbmodel.Ap return nil } + // ApplicationOfferFilter can be used to find application offers that match certain criteria. type ApplicationOfferFilter func(*gorm.DB) *gorm.DB diff --git a/internal/db/auditlog_test.go b/internal/db/auditlog_test.go index 769a2be68..bbcfa60e4 100644 --- a/internal/db/auditlog_test.go +++ b/internal/db/auditlog_test.go @@ -203,3 +203,34 @@ func (s *dbSuite) TestDeleteAuditLogsBefore(c *qt.C) { c.Assert(err, qt.IsNil) c.Assert(logs, qt.HasLen, 1) } + +func (s *dbSuite) TestPurgeLogsFromDb(c *qt.C) { + + ctx := context.Background() + relativeNow := time.Now().AddDate(-1, 0, 0) + ale := dbmodel.AuditLogEntry{ + Time: relativeNow.UTC().Round(time.Millisecond), + UserTag: names.NewUserTag("alice@external").String(), + } + ale_past := dbmodel.AuditLogEntry{ + Time: relativeNow.AddDate(0, 0, -1).UTC().Round(time.Millisecond), + UserTag: names.NewUserTag("alice@external").String(), + } + ale_future := dbmodel.AuditLogEntry{ + Time: relativeNow.AddDate(0, 0, 5).UTC().Round(time.Millisecond), + UserTag: names.NewUserTag("alice@external").String(), + } + + err := s.Database.Migrate(context.Background(), false) + c.Assert(err, qt.IsNil) + err = s.Database.AddAuditLogEntry(ctx, &ale) + c.Assert(err, qt.IsNil) + err = s.Database.AddAuditLogEntry(ctx, &ale_past) + c.Assert(err, qt.IsNil) + err = s.Database.AddAuditLogEntry(ctx, &ale_future) + c.Assert(err, qt.IsNil) + deleted_count, err := s.Database.DeleteAuditLogsBefore(ctx, relativeNow.AddDate(0, 0, 1)) + // check that logs have been deleted + c.Assert(err, qt.IsNil) + c.Assert(deleted_count, qt.Equals, int64(2)) +} diff --git a/internal/jimm/purge_logs.go b/internal/jimm/purge_logs.go new file mode 100644 index 000000000..2a4fcaef7 --- /dev/null +++ b/internal/jimm/purge_logs.go @@ -0,0 +1,34 @@ +// Copyright 2023 Canonical Ltd. + +package jimm + +import ( + "context" + "time" + + "github.com/canonical/jimm/internal/errors" + "github.com/canonical/jimm/internal/openfga" + "github.com/juju/zaputil/zapctx" + "go.uber.org/zap" +) + +// 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") + isJIMMAdmin, err := openfga.IsAdministrator(ctx, user, j.ResourceTag()) + if err != nil { + zapctx.Error(ctx, "failed administrator check", zap.Error(err)) + return 0, errors.E(op, "failed administrator check", err) + } + if !isJIMMAdmin { + 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/jimm.go b/internal/jujuapi/jimm.go index cc05382bd..3419e9b8a 100644 --- a/internal/jujuapi/jimm.go +++ b/internal/jujuapi/jimm.go @@ -46,6 +46,7 @@ func init() { checkRelationMethod := rpc.Method(r.CheckRelation) listRelationshipTuplesMethod := rpc.Method(r.ListRelationshipTuples) crossModelQueryMethod := rpc.Method(r.CrossModelQuery) + purgeLogsMethod := rpc.Method(r.PurgeLogs) r.AddMethod("JIMM", 2, "DisableControllerUUIDMasking", disableControllerUUIDMaskingMethod) r.AddMethod("JIMM", 2, "ListControllers", listControllersMethod) @@ -79,6 +80,7 @@ func init() { r.AddMethod("JIMM", 4, "UpdateMigratedModel", updateMigratedModelMethod) r.AddMethod("JIMM", 4, "AddCloudToController", addCloudToControllerMethod) r.AddMethod("JIMM", 4, "RemoveCloudFromController", removeCloudFromControllerMethod) + r.AddMethod("JIMM", 4, "PurgeLogs", purgeLogsMethod) // JIMM ReBAC RPC r.AddMethod("JIMM", 4, "AddGroup", addGroupMethod) r.AddMethod("JIMM", 4, "RenameGroup", renameGroupMethod) @@ -498,3 +500,16 @@ func (r *controllerRoot) CrossModelQuery(ctx context.Context, req apiparams.Cros return apiparams.CrossModelQueryResponse{}, errors.E(op, errors.Code("invalid query type"), "unable to query models") } } + +// PurgeLogs removes all audit log entries older than the specified date. +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) + if err != nil { + return apiparams.PurgeLogsResponse{}, errors.E(op, err) + } + return apiparams.PurgeLogsResponse{ + DeletedCount: deleted_count, + }, nil +}