Skip to content

Commit

Permalink
feat: Introduce audit-trail subcommand (#282)
Browse files Browse the repository at this point in the history
* feat: Introduce audit-trail subcommand

* Rename directory audit_trail to audittrail

* Remove value not equal 0 check for limit flag
  • Loading branch information
Axot017 authored Jan 16, 2025
1 parent 5faf757 commit 63593f3
Show file tree
Hide file tree
Showing 4 changed files with 362 additions and 0 deletions.
32 changes: 32 additions & 0 deletions internal/cmd/audittrail/audit_trail.go
Original file line number Diff line number Diff line change
@@ -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,
},
},
}
}
243 changes: 243 additions & 0 deletions internal/cmd/audittrail/list.go
Original file line number Diff line number Diff line change
@@ -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"`
}
85 changes: 85 additions & 0 deletions internal/cmd/humanize.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
2 changes: 2 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -45,6 +46,7 @@ func main() {
completion.Command(),
blueprint.Command(),
policy.Command(),
audittrail.Command(),
},
}

Expand Down

0 comments on commit 63593f3

Please sign in to comment.