Skip to content

Commit

Permalink
Merge pull request #407 from hashicorp/sebasslash/audit-trail
Browse files Browse the repository at this point in the history
Add support for the Audit Trail API
  • Loading branch information
sebasslash committed Jun 10, 2022
2 parents e2887f5 + 3e459a1 commit 599a87f
Show file tree
Hide file tree
Showing 8 changed files with 309 additions and 1 deletion.
1 change: 1 addition & 0 deletions CHANGELOG.md
Expand Up @@ -2,6 +2,7 @@

## Enhancements
* Adds support for Microsoft Teams notification configuration by @JarrettSpiker [#398](https://github.com/hashicorp/go-tfe/pull/389)
* Add support for Audit Trail API by @sebasslash [#407](https://github.com/hashicorp/go-tfe/pull/407)

# v1.2.0

Expand Down
2 changes: 1 addition & 1 deletion README.md
Expand Up @@ -66,7 +66,7 @@ This API client covers most of the existing Terraform Cloud API calls and is upd
- [x] Agent Pools
- [x] Agent Tokens
- [x] Applies
- [ ] Audit Trails
- [x] Audit Trails
- [x] Changelog
- [x] Comments
- [x] Configuration Versions
Expand Down
142 changes: 142 additions & 0 deletions audit_trail.go
@@ -0,0 +1,142 @@
package tfe

import (
"context"
"encoding/json"
"io/ioutil"
"net/http"
"time"

"github.com/google/go-querystring/query"
retryablehttp "github.com/hashicorp/go-retryablehttp"
)

// Compile-time proof of interface implementation
var _ AuditTrails = (*auditTrails)(nil)

// AuditTrails describes all the audit event related methods that the Terraform
// Cloud API supports.
// **Note:** These methods require the client to be configured with an organization token for
// an organization in the Business tier. Furthermore, these methods are only available in Terraform Cloud.
//
// TFC API Docs: https://www.terraform.io/cloud-docs/api-docs/audit-trails
type AuditTrails interface {
// Read all the audit events in an organization.
List(ctx context.Context, options *AuditTrailListOptions) (*AuditTrailList, error)
}

// auditTrails implements AuditTrails
type auditTrails struct {
client *Client
}

// AuditTrailRequest represents the request details of the audit event.
type AuditTrailRequest struct {
ID string `json:"id"`
}

// AuditTrailAuth represents the details of the actor that invoked the audit event.
type AuditTrailAuth struct {
AccessorID string `json:"accessor_id"`
Description string `json:"description"`
Type string `json:"type"`
ImpersonatorID *string `json:"impersonator_id"`
OrganizationID string `json:"organization_id"`
}

// AuditTrailResource represents the details of the API resource in the audit event.
type AuditTrailResource struct {
ID string `json:"id"`
Type string `json:"type"`
Action string `json:"action"`
Meta map[string]interface{} `json:"meta"`
}

// AuditTrail represents an event in the TFC audit log.
type AuditTrail struct {
ID string `json:"id"`
Version string `json:"version"`
Type string `json:"type"`
Timestamp time.Time `json:"timestamp"`

Auth AuditTrailAuth `json:"auth"`
Request AuditTrailRequest `json:"request"`
Resource AuditTrailResource `json:"resource"`
}

// AuditTrailList represents a list of audit trails.
type AuditTrailList struct {
*Pagination

Items []*AuditTrail `json:"data"`
}

// AuditTrailListOptions represents the options for listing audit trails.
type AuditTrailListOptions struct {
// Optional: Returns only audit trails created after this date
Since time.Time `url:"since,omitempty"`
*ListOptions
}

// List all the audit events in an organization.
func (s *auditTrails) List(ctx context.Context, options *AuditTrailListOptions) (*AuditTrailList, error) {
u, err := s.client.baseURL.Parse("/api/v2/organization/audit-trail")
if err != nil {
return nil, err
}

headers := make(http.Header)
headers.Set("User-Agent", _userAgent)
headers.Set("Authorization", "Bearer "+s.client.token)
headers.Set("Content-Type", "application/json")

if options != nil {
q, err := query.Values(options)
if err != nil {
return nil, err
}

u.RawQuery = encodeQueryParams(q)
}

req, err := retryablehttp.NewRequest("GET", u.String(), nil)
if err != nil {
return nil, err
}

// Attach the headers to the request
for k, v := range headers {
req.Header[k] = v
}

if err := s.client.limiter.Wait(ctx); err != nil {
return nil, err
}

resp, err := s.client.http.Do(req.WithContext(ctx))
if err != nil {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
return nil, err
}
}
defer resp.Body.Close()

if err := checkResponseCode(resp); err != nil {
return nil, err
}

body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, err
}

atl := &AuditTrailList{}
if err := json.Unmarshal(body, atl); err != nil {
return nil, err
}

return atl, nil
}
95 changes: 95 additions & 0 deletions audit_trail_integration_test.go
@@ -0,0 +1,95 @@
//go:build integration
// +build integration

package tfe

import (
"context"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestAuditTrailsList(t *testing.T) {
skipIfEnterprise(t)

userClient := testClient(t)
ctx := context.Background()

org, orgCleanup := createOrganization(t, userClient)
t.Cleanup(orgCleanup)

auditTrailClient := testAuditTrailClient(t, userClient, org)

// First let's generate some audit events in this test organization
_, wkspace1Cleanup := createWorkspace(t, userClient, org)
t.Cleanup(wkspace1Cleanup)

_, wkspace2Cleanup := createWorkspace(t, userClient, org)
t.Cleanup(wkspace2Cleanup)

t.Run("with no specified timeframe", func(t *testing.T) {
atl, err := auditTrailClient.AuditTrails.List(ctx, nil)
require.NoError(t, err)
require.Greater(t, len(atl.Items), 0)

log := atl.Items[0]
assert.NotEmpty(t, log.ID)
assert.NotEmpty(t, log.Timestamp)
assert.NotEmpty(t, log.Type)
assert.NotEmpty(t, log.Version)
assert.NotNil(t, log.Resource)
assert.NotNil(t, log.Auth)
assert.NotNil(t, log.Request)

t.Run("with resource deserialized correctly", func(t *testing.T) {
assert.NotEmpty(t, log.Resource.ID)
assert.NotEmpty(t, log.Resource.Type)
assert.NotEmpty(t, log.Resource.Action)

// we don't test against log.Resource.Meta since we don't know the nature
// of the audit trail log we're testing against as it can be nil or contain a k-v map
})

t.Run("with auth deserialized correctly", func(t *testing.T) {
assert.NotEmpty(t, log.Auth.AccessorID)
assert.NotEmpty(t, log.Auth.Description)
assert.NotEmpty(t, log.Auth.Type)
assert.NotEmpty(t, log.Auth.OrganizationID)
})

t.Run("with request deserialized correctly", func(t *testing.T) {
assert.NotEmpty(t, log.Request.ID)
})
})

t.Run("using since query param", func(t *testing.T) {
since := time.Now()

// Wait some time before creating the event
// otherwise comparing time values can be flaky
time.Sleep(1 * time.Second)

// Let's create an event that is sent to the audit log
_, wsCleanup := createWorkspace(t, userClient, org)
t.Cleanup(wsCleanup)

atl, err := auditTrailClient.AuditTrails.List(ctx, &AuditTrailListOptions{
Since: since,
ListOptions: &ListOptions{
PageNumber: 1,
PageSize: 20,
},
})
require.NoError(t, err)

assert.LessOrEqual(t, len(atl.Items), 20)
assert.Greater(t, len(atl.Items), 0)

for _, log := range atl.Items {
assert.True(t, log.Timestamp.After(since))
}
})
}
1 change: 1 addition & 0 deletions generate_mocks.sh
Expand Up @@ -17,6 +17,7 @@ mockgen -source=admin_workspace.go -destination=mocks/admin_workspace_mocks.go -
mockgen -source=agent_pool.go -destination=mocks/agent_pool_mocks.go -package=mocks
mockgen -source=agent_token.go -destination=mocks/agent_token_mocks.go -package=mocks
mockgen -source=apply.go -destination=mocks/apply_mocks.go -package=mocks
mockgen -source=audit_trail.go -destination=mocks/audit_trail.go -package=mocks
mockgen -source=configuration_version.go -destination=mocks/configuration_version_mocks.go -package=mocks
mockgen -source=cost_estimate.go -destination=mocks/cost_estimate_mocks.go -package=mocks
mockgen -source=ip_ranges.go -destination=mocks/ip_ranges_mocks.go -package=mocks
Expand Down
16 changes: 16 additions & 0 deletions helper_test.go
Expand Up @@ -61,6 +61,22 @@ func testClient(t *testing.T) *Client {
return client
}

func testAuditTrailClient(t *testing.T, userClient *Client, org *Organization) *Client {
upgradeOrganizationSubscription(t, userClient, org)

orgToken, orgTokenCleanup := createOrganizationToken(t, userClient, org)
t.Cleanup(orgTokenCleanup)

client, err := NewClient(&Config{
Token: orgToken.Token,
})
if err != nil {
t.Fatal(err)
}

return client
}

func fetchTestAccountDetails(t *testing.T, client *Client) *TestAccountDetails {
if _testAccountDetails == nil {
_testAccountDetails = FetchTestAccountDetails(t, client)
Expand Down
51 changes: 51 additions & 0 deletions mocks/audit_trail.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions tfe.go
Expand Up @@ -110,6 +110,7 @@ type Client struct {
AgentPools AgentPools
AgentTokens AgentTokens
Applies Applies
AuditTrails AuditTrails
Comments Comments
ConfigurationVersions ConfigurationVersions
CostEstimates CostEstimates
Expand Down Expand Up @@ -257,6 +258,7 @@ func NewClient(cfg *Config) (*Client, error) {
client.AgentPools = &agentPools{client: client}
client.AgentTokens = &agentTokens{client: client}
client.Applies = &applies{client: client}
client.AuditTrails = &auditTrails{client: client}
client.Comments = &comments{client: client}
client.ConfigurationVersions = &configurationVersions{client: client}
client.CostEstimates = &costEstimates{client: client}
Expand Down

0 comments on commit 599a87f

Please sign in to comment.