From a8182526dd871761759c0625d806930ebba8ea2d Mon Sep 17 00:00:00 2001 From: Glenn Sarti Date: Wed, 31 Jul 2024 16:17:43 +0800 Subject: [PATCH] Add support for Org. Token types HCP Terraform organizations now support different types of tokens. This commit updates the org. auth. token client to support CRUD operations with a token type URL attribute. Note that the integration tests require an organization that supports audit-logging --- CHANGELOG.md | 1 + mocks/organization_token_mocks.go | 29 ++++++++ organization_token.go | 59 +++++++++++++++-- organization_token_integration_test.go | 91 ++++++++++++++++++++++++++ tfe.go | 11 ++++ 5 files changed, 187 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cab6bfdcd..f31efbc54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Enhancements * Adds more BETA support for `Stacks` resources, which is is EXPERIMENTAL, SUBJECT TO CHANGE, and may not be available to all users by @brandonc +* Adds support for creating different organization token types by @glennsarti [#943](https://github.com/hashicorp/go-tfe/pull/943) # v1.62.0 diff --git a/mocks/organization_token_mocks.go b/mocks/organization_token_mocks.go index 404152f1b..65bef252e 100644 --- a/mocks/organization_token_mocks.go +++ b/mocks/organization_token_mocks.go @@ -84,6 +84,20 @@ func (mr *MockOrganizationTokensMockRecorder) Delete(ctx, organization any) *gom return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockOrganizationTokens)(nil).Delete), ctx, organization) } +// DeleteWithOptions mocks base method. +func (m *MockOrganizationTokens) DeleteWithOptions(ctx context.Context, organization string, options tfe.OrganizationTokenDeleteOptions) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteWithOptions", ctx, organization, options) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteWithOptions indicates an expected call of DeleteWithOptions. +func (mr *MockOrganizationTokensMockRecorder) DeleteWithOptions(ctx, organization, options any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteWithOptions", reflect.TypeOf((*MockOrganizationTokens)(nil).DeleteWithOptions), ctx, organization, options) +} + // Read mocks base method. func (m *MockOrganizationTokens) Read(ctx context.Context, organization string) (*tfe.OrganizationToken, error) { m.ctrl.T.Helper() @@ -98,3 +112,18 @@ func (mr *MockOrganizationTokensMockRecorder) Read(ctx, organization any) *gomoc mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Read", reflect.TypeOf((*MockOrganizationTokens)(nil).Read), ctx, organization) } + +// ReadWithOptions mocks base method. +func (m *MockOrganizationTokens) ReadWithOptions(ctx context.Context, organization string, options tfe.OrganizationTokenReadOptions) (*tfe.OrganizationToken, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ReadWithOptions", ctx, organization, options) + ret0, _ := ret[0].(*tfe.OrganizationToken) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ReadWithOptions indicates an expected call of ReadWithOptions. +func (mr *MockOrganizationTokensMockRecorder) ReadWithOptions(ctx, organization, options any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReadWithOptions", reflect.TypeOf((*MockOrganizationTokens)(nil).ReadWithOptions), ctx, organization, options) +} diff --git a/organization_token.go b/organization_token.go index 4a7c9a1b2..91fbf8815 100644 --- a/organization_token.go +++ b/organization_token.go @@ -13,6 +13,14 @@ import ( // Compile-time proof of interface implementation. var _ OrganizationTokens = (*organizationTokens)(nil) +type TokenType string + +const ( + // A token which can only access the Audit Trails of an HCP Terraform Organization. + // See https://developer.hashicorp.com/terraform/cloud-docs/api-docs/audit-trails-tokens + AuditTrailToken TokenType = "audit-trails" +) + // OrganizationTokens describes all the organization token related methods // that the Terraform Enterprise API supports. // @@ -28,8 +36,14 @@ type OrganizationTokens interface { // Read an organization token. Read(ctx context.Context, organization string) (*OrganizationToken, error) + // Read an organization token with options. + ReadWithOptions(ctx context.Context, organization string, options OrganizationTokenReadOptions) (*OrganizationToken, error) + // Delete an organization token. Delete(ctx context.Context, organization string) error + + // Delete an organization token with options. + DeleteWithOptions(ctx context.Context, organization string, options OrganizationTokenDeleteOptions) error } // organizationTokens implements OrganizationTokens. @@ -52,7 +66,24 @@ type OrganizationToken struct { type OrganizationTokenCreateOptions struct { // Optional: The token's expiration date. // This feature is available in TFE release v202305-1 and later - ExpiredAt *time.Time `jsonapi:"attr,expired-at,iso8601,omitempty"` + ExpiredAt *time.Time `jsonapi:"attr,expired-at,iso8601,omitempty" url:"-"` + // Optional: What type of token to create + // This option is only applicable to HCP Terraform and is ignored by TFE. + TokenType *TokenType `url:"token,omitempty"` +} + +// OrganizationTokenReadOptions contains the options for reading an organization token. +type OrganizationTokenReadOptions struct { + // Optional: What type of token to read + // This option is only applicable to HCP Terraform and is ignored by TFE. + TokenType *TokenType `url:"token,omitempty"` +} + +// OrganizationTokenDeleteOptions contains the options for deleting an organization token. +type OrganizationTokenDeleteOptions struct { + // Optional: What type of token to delete + // This option is only applicable to HCP Terraform and is ignored by TFE. + TokenType *TokenType `url:"token,omitempty"` } // Create a new organization token, replacing any existing token. @@ -67,7 +98,12 @@ func (s *organizationTokens) CreateWithOptions(ctx context.Context, organization } u := fmt.Sprintf("organizations/%s/authentication-token", url.PathEscape(organization)) - req, err := s.client.NewRequest("POST", u, &options) + qp, err := decodeQueryParams(options) + if err != nil { + return nil, err + } + + req, err := s.client.NewRequestWithAdditionalQueryParams("POST", u, &options, qp) if err != nil { return nil, err } @@ -83,12 +119,17 @@ func (s *organizationTokens) CreateWithOptions(ctx context.Context, organization // Read an organization token. func (s *organizationTokens) Read(ctx context.Context, organization string) (*OrganizationToken, error) { + return s.ReadWithOptions(ctx, organization, OrganizationTokenReadOptions{}) +} + +// Read an organization token with options. +func (s *organizationTokens) ReadWithOptions(ctx context.Context, organization string, options OrganizationTokenReadOptions) (*OrganizationToken, error) { if !validStringID(&organization) { return nil, ErrInvalidOrg } u := fmt.Sprintf("organizations/%s/authentication-token", url.PathEscape(organization)) - req, err := s.client.NewRequest("GET", u, nil) + req, err := s.client.NewRequest("GET", u, options) if err != nil { return nil, err } @@ -104,12 +145,22 @@ func (s *organizationTokens) Read(ctx context.Context, organization string) (*Or // Delete an organization token. func (s *organizationTokens) Delete(ctx context.Context, organization string) error { + return s.DeleteWithOptions(ctx, organization, OrganizationTokenDeleteOptions{}) +} + +// Delete an organization token with options +func (s *organizationTokens) DeleteWithOptions(ctx context.Context, organization string, options OrganizationTokenDeleteOptions) error { if !validStringID(&organization) { return ErrInvalidOrg } u := fmt.Sprintf("organizations/%s/authentication-token", url.PathEscape(organization)) - req, err := s.client.NewRequest("DELETE", u, nil) + qp, err := decodeQueryParams(options) + if err != nil { + return err + } + + req, err := s.client.NewRequestWithAdditionalQueryParams("DELETE", u, nil, qp) if err != nil { return err } diff --git a/organization_token_integration_test.go b/organization_token_integration_test.go index 61ae81a2f..86416d8b5 100644 --- a/organization_token_integration_test.go +++ b/organization_token_integration_test.go @@ -48,6 +48,8 @@ func TestOrganizationTokens_CreateWithOptions(t *testing.T) { orgTest, orgTestCleanup := createOrganization(t, client) defer orgTestCleanup() + // We need to update the organization to business so we can create an audit trails token later. + newSubscriptionUpdater(orgTest).WithBusinessPlan().Update(t) var tkToken string t.Run("with valid options", func(t *testing.T) { @@ -89,6 +91,16 @@ func TestOrganizationTokens_CreateWithOptions(t *testing.T) { assert.Equal(t, ot.ExpiredAt, oneDayLater) tkToken = ot.Token }) + + t.Run("with a token type", func(t *testing.T) { + tt := AuditTrailToken + ot, err := client.OrganizationTokens.CreateWithOptions(ctx, orgTest.Name, OrganizationTokenCreateOptions{ + TokenType: &tt, + }) + + require.NoError(t, err) + require.NotEmpty(t, ot.Token) + }) } func TestOrganizationTokensRead(t *testing.T) { @@ -135,6 +147,40 @@ func TestOrganizationTokensRead(t *testing.T) { }) } +func TestOrganizationTokensReadWithOptions(t *testing.T) { + client := testClient(t) + ctx := context.Background() + + orgTest, orgTestCleanup := createOrganization(t, client) + defer orgTestCleanup() + // We need to update the organization to business so we can create an audit trails token later. + newSubscriptionUpdater(orgTest).WithBusinessPlan().Update(t) + + tt := AuditTrailToken + noTypeToken, _ := createOrganizationToken(t, client, orgTest) + auditTypeToken, _ := createOrganizationTokenWithOptions(t, client, orgTest, OrganizationTokenCreateOptions{TokenType: &tt}) + + t.Run("with empty options", func(t *testing.T) { + ot, err := client.OrganizationTokens.ReadWithOptions(ctx, orgTest.Name, OrganizationTokenReadOptions{}) + require.NoError(t, err) + assert.NotEmpty(t, ot) + assert.Equal(t, ot.ID, noTypeToken.ID) + }) + + t.Run("with a specific token type", func(t *testing.T) { + ot, err := client.OrganizationTokens.ReadWithOptions(ctx, orgTest.Name, OrganizationTokenReadOptions{TokenType: &tt}) + require.NoError(t, err) + assert.NotEmpty(t, ot) + assert.Equal(t, ot.ID, auditTypeToken.ID) + }) + + t.Run("without valid organization", func(t *testing.T) { + ot, err := client.OrganizationTokens.Read(ctx, badIdentifier) + assert.Nil(t, ot) + assert.EqualError(t, err, ErrInvalidOrg.Error()) + }) +} + func TestOrganizationTokensDelete(t *testing.T) { client := testClient(t) ctx := context.Background() @@ -159,3 +205,48 @@ func TestOrganizationTokensDelete(t *testing.T) { assert.EqualError(t, err, ErrInvalidOrg.Error()) }) } + +func TestOrganizationTokensDeleteWithOptions(t *testing.T) { + client := testClient(t) + ctx := context.Background() + + orgTest, orgTestCleanup := createOrganization(t, client) + defer orgTestCleanup() + // We need to update the organization to business so we can create an audit trails token later. + newSubscriptionUpdater(orgTest).WithBusinessPlan().Update(t) + + t.Run("with a token type", func(t *testing.T) { + // Create the token + tt := AuditTrailToken + _, err := client.OrganizationTokens.CreateWithOptions(ctx, orgTest.Name, OrganizationTokenCreateOptions{ + TokenType: &tt, + }) + require.NoError(t, err) + + // Delete it + deleteOptions := OrganizationTokenDeleteOptions{ + TokenType: &tt, + } + err = client.OrganizationTokens.DeleteWithOptions(ctx, orgTest.Name, deleteOptions) + require.NoError(t, err) + + // Reload the token + ot, err := client.OrganizationTokens.ReadWithOptions(ctx, orgTest.Name, OrganizationTokenReadOptions{ + TokenType: &tt, + }) + // ... it should fail + assert.Nil(t, ot) + assert.Equal(t, err, ErrResourceNotFound) + + // Delete it again + err = client.OrganizationTokens.DeleteWithOptions(ctx, orgTest.Name, deleteOptions) + // ... it should fail + assert.Equal(t, err, ErrResourceNotFound) + }) + + t.Run("without valid organization", func(t *testing.T) { + deleteOptions := OrganizationTokenDeleteOptions{} + err := client.OrganizationTokens.DeleteWithOptions(ctx, badIdentifier, deleteOptions) + assert.EqualError(t, err, ErrInvalidOrg.Error()) + }) +} diff --git a/tfe.go b/tfe.go index f80486849..8c9976af1 100644 --- a/tfe.go +++ b/tfe.go @@ -773,6 +773,17 @@ func encodeQueryParams(v url.Values) string { return buf.String() } +// decodeQueryParams types an object and converts the struct fields into +// Query Parameters, which can be used with NewRequestWithAdditionalQueryParams +// Note that a field without a `url` annotation will be converted into a query +// parameter. Use url:"-" to ignore struct fields. +func decodeQueryParams(v any) (url.Values, error) { + if v == nil { + return make(url.Values, 0), nil + } + return query.Values(v) +} + // serializeRequestBody serializes the given ptr or ptr slice into a JSON // request. It automatically uses jsonapi or json serialization, depending // on the body type's tags.