diff --git a/internal/cmd/audittrail/audit_trail.go b/internal/cmd/audittrail/audit_trail.go new file mode 100644 index 0000000..597ac30 --- /dev/null +++ b/internal/cmd/audittrail/audit_trail.go @@ -0,0 +1,32 @@ +package audittrail + +import ( + "github.com/spacelift-io/spacectl/internal/cmd" + "github.com/spacelift-io/spacectl/internal/cmd/authenticated" + "github.com/urfave/cli/v2" +) + +func Command() *cli.Command { + return &cli.Command{ + Name: "audit-trail", + Usage: "Manage a Spacelift audit trail entries", + Subcommands: []*cli.Command{ + { + Name: "list", + Usage: "List the audit trail entries you have access to", + Flags: []cli.Flag{ + cmd.FlagOutputFormat, + cmd.FlagNoColor, + cmd.FlagLimit, + cmd.FlagSearch, + }, + Action: listAuditTrails(), + Before: cmd.PerformAllBefore( + cmd.HandleNoColor, + authenticated.Ensure, + ), + ArgsUsage: cmd.EmptyArgsUsage, + }, + }, + } +} diff --git a/internal/cmd/audittrail/list.go b/internal/cmd/audittrail/list.go new file mode 100644 index 0000000..7647e59 --- /dev/null +++ b/internal/cmd/audittrail/list.go @@ -0,0 +1,243 @@ +package audittrail + +import ( + "context" + "fmt" + "math" + "slices" + + "github.com/pkg/errors" + "github.com/shurcooL/graphql" + "github.com/spacelift-io/spacectl/client/structs" + "github.com/spacelift-io/spacectl/internal" + "github.com/spacelift-io/spacectl/internal/cmd" + "github.com/spacelift-io/spacectl/internal/cmd/authenticated" + "github.com/urfave/cli/v2" +) + +var defaultOrder = structs.QueryOrder{ + Field: "createdAt", + Direction: "DESC", +} + +func listAuditTrails() cli.ActionFunc { + return func(cliCtx *cli.Context) error { + outputFormat, err := cmd.GetOutputFormat(cliCtx) + if err != nil { + return err + } + + var limit *uint + if cliCtx.IsSet(cmd.FlagLimit.Name) { + if cliCtx.Uint(cmd.FlagLimit.Name) >= math.MaxInt32 { + return fmt.Errorf("limit must be less than %d", math.MaxInt32) + } + + limit = internal.Ptr(cliCtx.Uint(cmd.FlagLimit.Name)) + } + + var search *string + if cliCtx.IsSet(cmd.FlagSearch.Name) { + if cliCtx.String(cmd.FlagSearch.Name) == "" { + return fmt.Errorf("search must be non-empty") + } + + search = internal.Ptr(cliCtx.String(cmd.FlagSearch.Name)) + } + + switch outputFormat { + case cmd.OutputFormatTable: + return listAuditTrailEntriesTable(cliCtx, search, limit) + case cmd.OutputFormatJSON: + return listAuditTrailEntriesJSON(cliCtx, search, limit) + } + + return fmt.Errorf("unknown output format: %v", outputFormat) + } +} + +func listAuditTrailEntriesTable( + ctx *cli.Context, + search *string, + limit *uint, +) error { + var first *graphql.Int + if limit != nil { + first = graphql.NewInt(graphql.Int(*limit)) //nolint: gosec + } + + var fullTextSearch *graphql.String + if search != nil { + fullTextSearch = graphql.NewString(graphql.String(*search)) + } + + input := structs.SearchInput{ + First: first, + FullTextSearch: fullTextSearch, + OrderBy: &defaultOrder, + } + + entries, err := searchAllAuditTrailEntries(ctx.Context, input) + if err != nil { + return err + } + + columns := []string{"Action", "Type", "Affected Resource", "Related Resource", "Created By", "Created At"} + + tableData := [][]string{columns} + for _, b := range entries { + row := []string{ + b.Action, + b.EventType, + formatAuditTrailResource(&b.AffectedResource), + formatAuditTrailResource(b.RelatedResource), + b.Actor.Username, + cmd.HumanizeUnixSeconds(b.CreatedAt), + } + + tableData = append(tableData, row) + } + + return cmd.OutputTable(tableData, true) +} + +func listAuditTrailEntriesJSON( + ctx *cli.Context, + search *string, + limit *uint, +) error { + var first *graphql.Int + if limit != nil { + //nolint: gosec + first = graphql.NewInt(graphql.Int(*limit)) + } + + var fullTextSearch *graphql.String + if search != nil { + fullTextSearch = graphql.NewString(graphql.String(*search)) + } + + auditTrailEntries, err := searchAllAuditTrailEntries(ctx.Context, structs.SearchInput{ + First: first, + FullTextSearch: fullTextSearch, + OrderBy: &defaultOrder, + }) + if err != nil { + return err + } + + return cmd.OutputJSON(auditTrailEntries) +} + +func searchAllAuditTrailEntries(ctx context.Context, input structs.SearchInput) ([]auditTrailEntryNode, error) { + const maxPageSize = 50 + + var limit int + if input.First != nil { + limit = int(*input.First) + } + fetchAll := limit == 0 + + out := []auditTrailEntryNode{} + pageInput := structs.SearchInput{ + First: graphql.NewInt(maxPageSize), + FullTextSearch: input.FullTextSearch, + OrderBy: input.OrderBy, + } + for { + if !fetchAll { + pageInput.First = graphql.NewInt( + //nolint: gosec + graphql.Int( + slices.Min([]int{maxPageSize, limit - len(out)}), + ), + ) + } + + result, err := searchAuditTrailEntries(ctx, pageInput) + if err != nil { + return nil, err + } + + out = append(out, result.AuditTrailEntries...) + + if result.PageInfo.HasNextPage && (fetchAll || limit > len(out)) { + pageInput.After = graphql.NewString(graphql.String(result.PageInfo.EndCursor)) + } else { + break + } + } + + return out, nil +} + +func searchAuditTrailEntries(ctx context.Context, input structs.SearchInput) (searchAuditTrailEntriesResult, error) { + var query struct { + SearchAuditTrailEntriesOutput struct { + Edges []struct { + Node auditTrailEntryNode `graphql:"node"` + } `graphql:"edges"` + PageInfo structs.PageInfo `graphql:"pageInfo"` + } `graphql:"searchAuditTrailEntries(input: $input)"` + } + + if err := authenticated.Client.Query( + ctx, + &query, + map[string]interface{}{"input": input}, + ); err != nil { + return searchAuditTrailEntriesResult{}, errors.Wrap(err, "failed search for audit trail entries") + } + + nodes := make([]auditTrailEntryNode, 0) + for _, q := range query.SearchAuditTrailEntriesOutput.Edges { + nodes = append(nodes, q.Node) + } + + return searchAuditTrailEntriesResult{ + AuditTrailEntries: nodes, + PageInfo: query.SearchAuditTrailEntriesOutput.PageInfo, + }, nil +} + +func formatAuditTrailResource(resource *auditTrailResource) string { + if resource == nil { + return "" + } + + formatted := cmd.HumanizeAuditTrailResourceType(resource.ResourceType) + + if resource.ResourceID != nil { + formatted += " - " + *resource.ResourceID + } + + return formatted +} + +type searchAuditTrailEntriesResult struct { + AuditTrailEntries []auditTrailEntryNode + PageInfo structs.PageInfo +} + +type auditTrailResource struct { + ResourceID *string `json:"resourceId" graphql:"resourceId"` + ResourceType string `json:"resourceType" graphql:"resourceType"` +} + +type auditTrailEntryNode struct { + ID string `json:"id" graphql:"id"` + Action string `json:"action" graphql:"action"` + Actor struct { + Run *struct { + ID string `json:"id" graphql:"id"` + StackID string `json:"stackId" graphql:"stackId"` + } `json:"run" graphql:"run"` + Username string `json:"username" graphql:"username"` + } `json:"actor" graphql:"actor"` + AffectedResource auditTrailResource `json:"affectedResource" graphql:"affectedResource"` + Body *string `json:"body" graphql:"body"` + EventType string `json:"eventType" graphql:"eventType"` + RelatedResource *auditTrailResource `json:"relatedResource" graphql:"relatedResource"` + CreatedAt int `json:"createdAt" graphql:"createdAt"` + UpdatedAt int `json:"updatedAt" graphql:"updatedAt"` +} diff --git a/internal/cmd/humanize.go b/internal/cmd/humanize.go index ec67f91..44bc0ec 100644 --- a/internal/cmd/humanize.go +++ b/internal/cmd/humanize.go @@ -68,6 +68,91 @@ func HumanizeBlueprintState(state string) string { return state } +func HumanizeAuditTrailResourceType(resourceType string) string { + switch resourceType { + case "ACCOUNT": + return "Account" + case "API_KEY": + return "API Key" + case "AWS_INTEGRATION": + return "AWS Integration" + case "AZURE_DEVOPS_REPO_INTEGRATION": + return "Azure DevOps Repo Integration" + case "AZURE_INTEGRATION": + return "Azure Integration" + case "BITBUCKET_CLOUD_INTEGRATION": + return "Bitbucket Cloud Integration" + case "BITBUCKET_DATACENTER_INTEGRATION": + return "Bitbucket Datacenter Integration" + case "BLUEPRINT": + return "Blueprint" + case "CONTEXT": + return "Context" + case "DRIFT_DETECTION_INTEGRATION": + return "Drift Detection Integration" + case "GITHUB_APP_INSTALLATION": + return "GitHub App Installation" + case "GITHUB_ENTERPRISE_INTEGRATION": + return "GitHub Enterprise Integration" + case "GITLAB_INTEGRATION": + return "GitLab Integration" + case "GPG_KEY": + return "GPG Key" + case "LOGIN": + return "Login" + case "MODULE": + return "Module" + case "NAMED_WEBHOOKS_INTEGRATION": + return "Named Webhooks Integration" + case "NOTIFICATION": + return "Notification" + case "POLICY": + return "Policy" + case "RUN": + return "Run" + case "SCHEDULED_DELETE": + return "Scheduled Delete" + case "SCHEDULED_RUN": + return "Scheduled Run" + case "SCHEDULED_TASK": + return "Scheduled Task" + case "SECURITY_KEY": + return "Security Key" + case "SESSION": + return "Session" + case "SPACE": + return "Space" + case "STACK": + return "Stack" + case "TASK": + return "Task" + case "TERRAFORM_PROVIDER": + return "Terraform Provider" + case "TERRAFORM_PROVIDER_VERSION": + return "Terraform Provider Version" + case "UNKNOWN": + return "Unknown" + case "USER": + return "User" + case "USER_GROUP": + return "User Group" + case "USER_GROUP_INTEGRATION": + return "User Group Integration" + case "VERSION": + return "Version" + case "WEBHOOK": + return "Webhook" + case "WORKER": + return "Worker" + case "WORKER_POOL": + return "Worker Pool" + case "GENERIC_FORM": + return "Generic Form" + default: + return resourceType + } +} + func HumanizeUnixSeconds(seconds int) string { return time.Unix(int64(seconds), 0).Format(time.RFC3339) } diff --git a/main.go b/main.go index ceeb38c..9158436 100644 --- a/main.go +++ b/main.go @@ -5,6 +5,7 @@ import ( "os" "time" + "github.com/spacelift-io/spacectl/internal/cmd/audittrail" "github.com/spacelift-io/spacectl/internal/cmd/blueprint" "github.com/spacelift-io/spacectl/internal/cmd/completion" "github.com/spacelift-io/spacectl/internal/cmd/module" @@ -45,6 +46,7 @@ func main() { completion.Command(), blueprint.Command(), policy.Command(), + audittrail.Command(), }, }