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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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"` | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 ? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The zero value for |
||
*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") | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. we should set the user-agent to There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @sebasslash Is this something that still needs to be done? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Added in L89: |
||
|
||
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 | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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)) | ||
} | ||
}) | ||
} |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
😎