Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for the Audit Trail API #407

Merged
merged 3 commits into from Jun 10, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

😎

- [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"`
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On the other hand, this looks like it should be a pointer since it is an emptyable struct ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The zero value for time.Time{} is 0001-01-01 00:00:00 +0000 UTC so it wouldn't be an issue. The only reason I wouldn't make it a pointer is time.Time isn't really ever used as a pointer.

*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")
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should set the user-agent to go-tfe, similar to tfe.go

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sebasslash Is this something that still needs to be done?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in L89: headers.Set("User-Agent", _userAgent) where _userAgent is go-tfe


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