From 957d12a5fa389d50b131bf8bfcca910285a38965 Mon Sep 17 00:00:00 2001 From: Sebastian Rivera Date: Wed, 18 May 2022 16:06:34 -0400 Subject: [PATCH 1/3] Add support for the AuditTrail API --- CHANGELOG.md | 1 + README.md | 2 +- audit_trail.go | 140 ++++++++++++++++++++++++++++++++ audit_trail_integration_test.go | 97 ++++++++++++++++++++++ generate_mocks.sh | 1 + mocks/audit_trail.go | 51 ++++++++++++ tfe.go | 2 + 7 files changed, 293 insertions(+), 1 deletion(-) create mode 100644 audit_trail.go create mode 100644 audit_trail_integration_test.go create mode 100644 mocks/audit_trail.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 14fa8de7f..b12909a03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 5dd0ddc8b..1828af682 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/audit_trail.go b/audit_trail.go new file mode 100644 index 000000000..6515cfa83 --- /dev/null +++ b/audit_trail.go @@ -0,0 +1,140 @@ +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 an organization token for +// an organization in the Business tier and 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, orgToken string, 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, orgToken string, 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("Authorization", "Bearer "+orgToken) + 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 +} diff --git a/audit_trail_integration_test.go b/audit_trail_integration_test.go new file mode 100644 index 000000000..d4ac34660 --- /dev/null +++ b/audit_trail_integration_test.go @@ -0,0 +1,97 @@ +//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) + + client := testClient(t) + ctx := context.Background() + + org, orgCleanup := createOrganization(t, client) + t.Cleanup(orgCleanup) + + upgradeOrganizationSubscription(t, client, org) + + orgToken, orgTokenCleanup := createOrganizationToken(t, client, org) + t.Cleanup(orgTokenCleanup) + // First let's generate some audit events in this test organization + _, wkspace1Cleanup := createWorkspace(t, client, org) + t.Cleanup(wkspace1Cleanup) + + _, wkspace2Cleanup := createWorkspace(t, client, org) + t.Cleanup(wkspace2Cleanup) + + t.Run("with no specified timeframe", func(t *testing.T) { + atl, err := client.AuditTrails.List(ctx, orgToken.Token, 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, client, org) + t.Cleanup(wsCleanup) + + atl, err := client.AuditTrails.List(ctx, orgToken.Token, &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)) + } + }) +} diff --git a/generate_mocks.sh b/generate_mocks.sh index 9eece4f4f..0e933ab56 100755 --- a/generate_mocks.sh +++ b/generate_mocks.sh @@ -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 diff --git a/mocks/audit_trail.go b/mocks/audit_trail.go new file mode 100644 index 000000000..a2e0129ea --- /dev/null +++ b/mocks/audit_trail.go @@ -0,0 +1,51 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: audit_trail.go + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + gomock "github.com/golang/mock/gomock" + tfe "github.com/hashicorp/go-tfe" +) + +// MockAuditTrails is a mock of AuditTrails interface. +type MockAuditTrails struct { + ctrl *gomock.Controller + recorder *MockAuditTrailsMockRecorder +} + +// MockAuditTrailsMockRecorder is the mock recorder for MockAuditTrails. +type MockAuditTrailsMockRecorder struct { + mock *MockAuditTrails +} + +// NewMockAuditTrails creates a new mock instance. +func NewMockAuditTrails(ctrl *gomock.Controller) *MockAuditTrails { + mock := &MockAuditTrails{ctrl: ctrl} + mock.recorder = &MockAuditTrailsMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockAuditTrails) EXPECT() *MockAuditTrailsMockRecorder { + return m.recorder +} + +// List mocks base method. +func (m *MockAuditTrails) List(ctx context.Context, orgToken string, options *tfe.AuditTrailListOptions) (*tfe.AuditTrailList, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "List", ctx, orgToken, options) + ret0, _ := ret[0].(*tfe.AuditTrailList) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// List indicates an expected call of List. +func (mr *MockAuditTrailsMockRecorder) List(ctx, orgToken, options interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockAuditTrails)(nil).List), ctx, orgToken, options) +} diff --git a/tfe.go b/tfe.go index 25941f3a5..5de213310 100644 --- a/tfe.go +++ b/tfe.go @@ -110,6 +110,7 @@ type Client struct { AgentPools AgentPools AgentTokens AgentTokens Applies Applies + AuditTrails AuditTrails Comments Comments ConfigurationVersions ConfigurationVersions CostEstimates CostEstimates @@ -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} From 9980e063f0a5f10bc39989133a2e2cb574ffbe43 Mon Sep 17 00:00:00 2001 From: Sebastian Rivera Date: Mon, 6 Jun 2022 14:02:53 -0400 Subject: [PATCH 2/3] Improve audit trail interface by using client token --- audit_trail.go | 12 +++++++----- audit_trail_integration_test.go | 18 ++++++++---------- helper_test.go | 16 ++++++++++++++++ 3 files changed, 31 insertions(+), 15 deletions(-) diff --git a/audit_trail.go b/audit_trail.go index 6515cfa83..72e498f40 100644 --- a/audit_trail.go +++ b/audit_trail.go @@ -15,13 +15,14 @@ import ( var _ AuditTrails = (*auditTrails)(nil) // AuditTrails describes all the audit event related methods that the Terraform -// Cloud API supports. **Note:** These methods require an organization token for -// an organization in the Business tier and are only available in Terraform Cloud. +// 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, orgToken string, options *AuditTrailListOptions) (*AuditTrailList, error) + List(ctx context.Context, options *AuditTrailListOptions) (*AuditTrailList, error) } // auditTrails implements AuditTrails @@ -78,14 +79,15 @@ type AuditTrailListOptions struct { } // List all the audit events in an organization. -func (s *auditTrails) List(ctx context.Context, orgToken string, options *AuditTrailListOptions) (*AuditTrailList, error) { +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("Authorization", "Bearer "+orgToken) + headers.Set("User-Agent", _userAgent) + headers.Set("Authorization", "Bearer "+s.client.token) headers.Set("Content-Type", "application/json") if options != nil { diff --git a/audit_trail_integration_test.go b/audit_trail_integration_test.go index d4ac34660..b0152fc16 100644 --- a/audit_trail_integration_test.go +++ b/audit_trail_integration_test.go @@ -15,25 +15,23 @@ import ( func TestAuditTrailsList(t *testing.T) { skipIfEnterprise(t) - client := testClient(t) + userClient := testClient(t) ctx := context.Background() - org, orgCleanup := createOrganization(t, client) + org, orgCleanup := createOrganization(t, userClient) t.Cleanup(orgCleanup) - upgradeOrganizationSubscription(t, client, org) + auditTrailClient := testAuditTrailClient(t, userClient, org) - orgToken, orgTokenCleanup := createOrganizationToken(t, client, org) - t.Cleanup(orgTokenCleanup) // First let's generate some audit events in this test organization - _, wkspace1Cleanup := createWorkspace(t, client, org) + _, wkspace1Cleanup := createWorkspace(t, userClient, org) t.Cleanup(wkspace1Cleanup) - _, wkspace2Cleanup := createWorkspace(t, client, org) + _, wkspace2Cleanup := createWorkspace(t, userClient, org) t.Cleanup(wkspace2Cleanup) t.Run("with no specified timeframe", func(t *testing.T) { - atl, err := client.AuditTrails.List(ctx, orgToken.Token, nil) + atl, err := auditTrailClient.AuditTrails.List(ctx, nil) require.NoError(t, err) require.Greater(t, len(atl.Items), 0) @@ -75,10 +73,10 @@ func TestAuditTrailsList(t *testing.T) { time.Sleep(1 * time.Second) // Let's create an event that is sent to the audit log - _, wsCleanup := createWorkspace(t, client, org) + _, wsCleanup := createWorkspace(t, userClient, org) t.Cleanup(wsCleanup) - atl, err := client.AuditTrails.List(ctx, orgToken.Token, &AuditTrailListOptions{ + atl, err := auditTrailClient.AuditTrails.List(ctx, &AuditTrailListOptions{ Since: since, ListOptions: &ListOptions{ PageNumber: 1, diff --git a/helper_test.go b/helper_test.go index f4a954794..7da8427c9 100644 --- a/helper_test.go +++ b/helper_test.go @@ -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) From 3e459a18b7dc09200fa62d17abb50c4e75774fd1 Mon Sep 17 00:00:00 2001 From: Sebastian Rivera Date: Tue, 7 Jun 2022 14:01:00 -0400 Subject: [PATCH 3/3] Update audit trail mocks with new interface --- mocks/audit_trail.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/mocks/audit_trail.go b/mocks/audit_trail.go index a2e0129ea..007b10aa9 100644 --- a/mocks/audit_trail.go +++ b/mocks/audit_trail.go @@ -36,16 +36,16 @@ func (m *MockAuditTrails) EXPECT() *MockAuditTrailsMockRecorder { } // List mocks base method. -func (m *MockAuditTrails) List(ctx context.Context, orgToken string, options *tfe.AuditTrailListOptions) (*tfe.AuditTrailList, error) { +func (m *MockAuditTrails) List(ctx context.Context, options *tfe.AuditTrailListOptions) (*tfe.AuditTrailList, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "List", ctx, orgToken, options) + ret := m.ctrl.Call(m, "List", ctx, options) ret0, _ := ret[0].(*tfe.AuditTrailList) ret1, _ := ret[1].(error) return ret0, ret1 } // List indicates an expected call of List. -func (mr *MockAuditTrailsMockRecorder) List(ctx, orgToken, options interface{}) *gomock.Call { +func (mr *MockAuditTrailsMockRecorder) List(ctx, options interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockAuditTrails)(nil).List), ctx, orgToken, options) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockAuditTrails)(nil).List), ctx, options) }