diff --git a/.github/actions/test-go-tfe/action.yml b/.github/actions/test-go-tfe/action.yml index bb226954a..6c5bbeabd 100644 --- a/.github/actions/test-go-tfe/action.yml +++ b/.github/actions/test-go-tfe/action.yml @@ -97,6 +97,14 @@ runs: GITHUB_REGISTRY_MODULE_IDENTIFIER: "hashicorp/terraform-random-module" GITHUB_REGISTRY_NO_CODE_MODULE_IDENTIFIER: "hashicorp/terraform-random-no-code-module" OAUTH_CLIENT_GITHUB_TOKEN: "${{ inputs.oauth-client-github-token }}" + SKIP_HYOK_INTEGRATION_TESTS: "${{ inputs.skip-hyok-integration-tests }}" + HYOK_ORGANIZATION_NAME: "${{ inputs.hyok-organization-name }}" + HYOK_WORKSPACE_NAME: "${{ inputs.hyok-workspace-name }}" + HYOK_POOL_ID: "${{ inputs.hyok-pool-id }}" + HYOK_PLAN_ID: "${{ inputs.hyok-plan-id }}" + HYOK_STATE_VERSION_ID: "${{ inputs.hyok-state-version-id }}" + HYOK_CUSTOMER_KEY_VERSION_ID: "${{ inputs.hyok-customer-key-version-id }}" + HYOK_ENCRYPTED_DATA_KEY_ID: "${{ inputs.hyok-encrypted-data-key-id }}" GO111MODULE: "on" ENABLE_TFE: ${{ inputs.enterprise }} run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 91b37c3ed..829522fd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Enhancements * Exports the StackConfiguration UploadTarGzip receiver function[#1219](https://github.com/hashicorp/go-tfe/pull/1219) +* Adds support for Hold Your Own Key [#1201](https://github.com/hashicorp/go-tfe/pull/1201) # v1.92.0 diff --git a/agent_pool.go b/agent_pool.go index 47778820b..adf66145d 100644 --- a/agent_pool.go +++ b/agent_pool.go @@ -66,11 +66,12 @@ type AgentPool struct { CreatedAt time.Time `jsonapi:"attr,created-at,iso8601"` // Relations - Organization *Organization `jsonapi:"relation,organization"` - Workspaces []*Workspace `jsonapi:"relation,workspaces"` - AllowedWorkspaces []*Workspace `jsonapi:"relation,allowed-workspaces"` - AllowedProjects []*Project `jsonapi:"relation,allowed-projects"` - ExcludedWorkspaces []*Workspace `jsonapi:"relation,excluded-workspaces"` + Organization *Organization `jsonapi:"relation,organization"` + HYOKConfigurations []*HYOKConfiguration `jsonapi:"relation,hyok-configurations"` + Workspaces []*Workspace `jsonapi:"relation,workspaces"` + AllowedWorkspaces []*Workspace `jsonapi:"relation,allowed-workspaces"` + AllowedProjects []*Project `jsonapi:"relation,allowed-projects"` + ExcludedWorkspaces []*Workspace `jsonapi:"relation,excluded-workspaces"` } // A list of relations to include @@ -78,6 +79,7 @@ type AgentPool struct { type AgentPoolIncludeOpt string const AgentPoolWorkspaces AgentPoolIncludeOpt = "workspaces" +const AgentPoolHYOKConfigurations AgentPoolIncludeOpt = "hyok-configurations" type AgentPoolReadOptions struct { Include []AgentPoolIncludeOpt `url:"include,omitempty"` @@ -188,7 +190,7 @@ func (s *agentPools) ReadWithOptions(ctx context.Context, agentpoolID string, op } u := fmt.Sprintf("agent-pools/%s", url.PathEscape(agentpoolID)) - req, err := s.client.NewRequest("GET", u, nil) + req, err := s.client.NewRequest("GET", u, &options) if err != nil { return nil, err } diff --git a/agent_pool_integration_test.go b/agent_pool_integration_test.go index 2bad0c15e..3e06cc554 100644 --- a/agent_pool_integration_test.go +++ b/agent_pool_integration_test.go @@ -5,6 +5,7 @@ package tfe import ( "context" + "os" "testing" "github.com/stretchr/testify/assert" @@ -343,6 +344,22 @@ func TestAgentPoolsRead(t *testing.T) { require.NoError(t, err) assert.NotEmpty(t, k.Workspaces[0]) }) + + t.Run("read hyok configurations of an agent pool", func(t *testing.T) { + skipHYOKIntegrationTests(t) + + // replace the environment variable with a valid agent pool ID that has HYOK configurations + hyokPoolID := os.Getenv("HYOK_POOL_ID") + if hyokPoolID == "" { + t.Fatal("Export a valid HYOK_POOL_ID before running this test!") + } + + k, err := client.AgentPools.ReadWithOptions(ctx, hyokPoolID, &AgentPoolReadOptions{ + Include: []AgentPoolIncludeOpt{AgentPoolHYOKConfigurations}, + }) + require.NoError(t, err) + assert.NotEmpty(t, k.HYOKConfigurations) + }) } func TestAgentPoolsReadCreatedAt(t *testing.T) { diff --git a/aws_oidc_configuration.go b/aws_oidc_configuration.go new file mode 100644 index 000000000..e0590942f --- /dev/null +++ b/aws_oidc_configuration.go @@ -0,0 +1,147 @@ +package tfe + +import ( + "context" + "fmt" + "net/url" +) + +const OIDCConfigPathFormat = "oidc-configurations/%s" + +// AWSOIDCConfigurations describes all the AWS OIDC configuration related methods that the HCP Terraform API supports. +// HCP Terraform API docs: +// https://developer.hashicorp.com/terraform/cloud-docs/api-docs/hold-your-own-key/oidc-configurations/aws +type AWSOIDCConfigurations interface { + Create(ctx context.Context, organization string, options AWSOIDCConfigurationCreateOptions) (*AWSOIDCConfiguration, error) + + Read(ctx context.Context, oidcID string) (*AWSOIDCConfiguration, error) + + Update(ctx context.Context, oidcID string, options AWSOIDCConfigurationUpdateOptions) (*AWSOIDCConfiguration, error) + + Delete(ctx context.Context, oidcID string) error +} + +type awsOIDCConfigurations struct { + client *Client +} + +var _ AWSOIDCConfigurations = &awsOIDCConfigurations{} + +type AWSOIDCConfiguration struct { + ID string `jsonapi:"primary,aws-oidc-configurations"` + RoleARN string `jsonapi:"attr,role-arn"` + + Organization *Organization `jsonapi:"relation,organization"` +} + +type AWSOIDCConfigurationCreateOptions struct { + // Type is a public field utilized by JSON:API to + // set the resource type via the field tag. + // It is not a user-defined value and does not need to be set. + // https://jsonapi.org/format/#crud-creating + Type string `jsonapi:"primary,aws-oidc-configurations"` + + // Attributes + RoleARN string `jsonapi:"attr,role-arn"` +} + +type AWSOIDCConfigurationUpdateOptions struct { + // Type is a public field utilized by JSON:API to + // set the resource type via the field tag. + // It is not a user-defined value and does not need to be set. + // https://jsonapi.org/format/#crud-creating + Type string `jsonapi:"primary,aws-oidc-configurations"` + + // Attributes + RoleARN string `jsonapi:"attr,role-arn"` +} + +func (o *AWSOIDCConfigurationCreateOptions) valid() error { + if o.RoleARN == "" { + return ErrRequiredRoleARN + } + + return nil +} + +func (aoc *awsOIDCConfigurations) Create(ctx context.Context, organization string, options AWSOIDCConfigurationCreateOptions) (*AWSOIDCConfiguration, error) { + if !validStringID(&organization) { + return nil, ErrInvalidOrg + } + + if err := options.valid(); err != nil { + return nil, err + } + + req, err := aoc.client.NewRequest("POST", fmt.Sprintf("organizations/%s/oidc-configurations", organization), &options) + if err != nil { + return nil, err + } + + awsOIDCConfiguration := &AWSOIDCConfiguration{} + err = req.Do(ctx, awsOIDCConfiguration) + if err != nil { + return nil, err + } + + return awsOIDCConfiguration, nil +} + +func (aoc *awsOIDCConfigurations) Read(ctx context.Context, oidcID string) (*AWSOIDCConfiguration, error) { + req, err := aoc.client.NewRequest("GET", fmt.Sprintf(OIDCConfigPathFormat, url.PathEscape(oidcID)), nil) + if err != nil { + return nil, err + } + + awsOIDCConfiguration := &AWSOIDCConfiguration{} + err = req.Do(ctx, awsOIDCConfiguration) + if err != nil { + return nil, err + } + + return awsOIDCConfiguration, nil +} + +func (o *AWSOIDCConfigurationUpdateOptions) valid() error { + if o.RoleARN == "" { + return ErrRequiredRoleARN + } + + return nil +} + +func (aoc *awsOIDCConfigurations) Update(ctx context.Context, oidcID string, options AWSOIDCConfigurationUpdateOptions) (*AWSOIDCConfiguration, error) { + if !validStringID(&oidcID) { + return nil, ErrInvalidOIDC + } + + if err := options.valid(); err != nil { + return nil, err + } + + req, err := aoc.client.NewRequest("PATCH", fmt.Sprintf(OIDCConfigPathFormat, url.PathEscape(oidcID)), &options) + if err != nil { + return nil, err + } + + awsOIDCConfiguration := &AWSOIDCConfiguration{} + err = req.Do(ctx, awsOIDCConfiguration) + if err != nil { + return nil, err + } + + return awsOIDCConfiguration, nil +} + +func (aoc *awsOIDCConfigurations) Delete(ctx context.Context, oidcID string) error { + if !validStringID(&oidcID) { + return ErrInvalidOIDC + } + + req, err := aoc.client.NewRequest("DELETE", fmt.Sprintf(OIDCConfigPathFormat, url.PathEscape(oidcID)), nil) + if err != nil { + return err + } + + return req.Do(ctx, nil) +} diff --git a/aws_oidc_configuration_integration_test.go b/aws_oidc_configuration_integration_test.go new file mode 100644 index 000000000..55b12717e --- /dev/null +++ b/aws_oidc_configuration_integration_test.go @@ -0,0 +1,122 @@ +package tfe + +import ( + "context" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// These tests are intended for local execution only, as OIDC configurations for HYOK requires specific conditions. +// To run them locally, follow the instructions outlined in hyok_configuration_integration_test.go + +func TestAWSOIDCConfigurationCreateDelete(t *testing.T) { + skipHYOKIntegrationTests(t) + + client := testClient(t) + ctx := context.Background() + + // replace the environment variable with a valid organization name that has AWS OIDC HYOK configurations + hyokOrganizationName := os.Getenv("HYOK_ORGANIZATION_NAME") + if hyokOrganizationName == "" { + t.Fatal("Export a valid HYOK_ORGANIZATION_NAME before running this test!") + } + + orgTest, err := client.Organizations.Read(ctx, hyokOrganizationName) + if err != nil { + t.Fatal(err) + } + + t.Run("with valid options", func(t *testing.T) { + opts := AWSOIDCConfigurationCreateOptions{ + RoleARN: "arn:aws:iam::123456789012:role/some-role", + } + + oidcConfig, err := client.AWSOIDCConfigurations.Create(ctx, orgTest.Name, opts) + require.NoError(t, err) + require.NotNil(t, oidcConfig) + assert.Equal(t, oidcConfig.RoleARN, opts.RoleARN) + + // delete the created configuration + err = client.AWSOIDCConfigurations.Delete(ctx, oidcConfig.ID) + require.NoError(t, err) + }) + + t.Run("missing role ARN", func(t *testing.T) { + opts := AWSOIDCConfigurationCreateOptions{} + + _, err := client.AWSOIDCConfigurations.Create(ctx, orgTest.Name, opts) + assert.ErrorIs(t, err, ErrRequiredRoleARN) + }) +} + +func TestAWSOIDCConfigurationRead(t *testing.T) { + skipHYOKIntegrationTests(t) + + client := testClient(t) + ctx := context.Background() + + // replace the environment variable with a valid organization name that has AWS OIDC HYOK configurations + hyokOrganizationName := os.Getenv("HYOK_ORGANIZATION_NAME") + if hyokOrganizationName == "" { + t.Fatal("Export a valid HYOK_ORGANIZATION_NAME before running this test!") + } + + orgTest, err := client.Organizations.Read(ctx, hyokOrganizationName) + if err != nil { + t.Fatal(err) + } + + oidcConfig, oidcConfigCleanup := createAWSOIDCConfiguration(t, client, orgTest) + t.Cleanup(oidcConfigCleanup) + + t.Run("fetch existing configuration", func(t *testing.T) { + fetched, err := client.AWSOIDCConfigurations.Read(ctx, oidcConfig.ID) + require.NoError(t, err) + require.NotEmpty(t, fetched) + }) + + t.Run("fetching non-existing configuration", func(t *testing.T) { + _, err := client.AWSOIDCConfigurations.Read(ctx, "awsoidc-notreal") + assert.ErrorIs(t, err, ErrResourceNotFound) + }) +} + +func TestAWSOIDCConfigurationsUpdate(t *testing.T) { + skipHYOKIntegrationTests(t) + + client := testClient(t) + ctx := context.Background() + + // replace the environment variable with a valid organization name that has AWS OIDC HYOK configurations + hyokOrganizationName := os.Getenv("HYOK_ORGANIZATION_NAME") + if hyokOrganizationName == "" { + t.Fatal("Export a valid HYOK_ORGANIZATION_NAME before running this test!") + } + + orgTest, err := client.Organizations.Read(ctx, hyokOrganizationName) + if err != nil { + t.Fatal(err) + } + + oidcConfig, oidcConfigCleanup := createAWSOIDCConfiguration(t, client, orgTest) + t.Cleanup(oidcConfigCleanup) + + t.Run("with valid options", func(t *testing.T) { + opts := AWSOIDCConfigurationUpdateOptions{ + RoleARN: "arn:aws:iam::123456789012:role/some-role-2", + } + updated, err := client.AWSOIDCConfigurations.Update(ctx, oidcConfig.ID, opts) + require.NoError(t, err) + require.NotEmpty(t, updated) + assert.Equal(t, opts.RoleARN, updated.RoleARN) + }) + + t.Run("missing role ARN", func(t *testing.T) { + opts := AWSOIDCConfigurationUpdateOptions{} + _, err := client.AWSOIDCConfigurations.Update(ctx, oidcConfig.ID, opts) + assert.ErrorIs(t, err, ErrRequiredRoleARN) + }) +} diff --git a/azure_oidc_configuration.go b/azure_oidc_configuration.go new file mode 100644 index 000000000..df197e043 --- /dev/null +++ b/azure_oidc_configuration.go @@ -0,0 +1,147 @@ +package tfe + +import ( + "context" + "fmt" + "net/url" +) + +// AzureOIDCConfigurations describes all the Azure OIDC configuration related methods that the HCP Terraform API supports. +// HCP Terraform API docs: +// https://developer.hashicorp.com/terraform/cloud-docs/api-docs/hold-your-own-key/oidc-configurations/azure +type AzureOIDCConfigurations interface { + Create(ctx context.Context, organization string, options AzureOIDCConfigurationCreateOptions) (*AzureOIDCConfiguration, error) + + Read(ctx context.Context, oidcID string) (*AzureOIDCConfiguration, error) + + Update(ctx context.Context, oidcID string, options AzureOIDCConfigurationUpdateOptions) (*AzureOIDCConfiguration, error) + + Delete(ctx context.Context, oidcID string) error +} + +type azureOIDCConfigurations struct { + client *Client +} + +var _ AzureOIDCConfigurations = &azureOIDCConfigurations{} + +type AzureOIDCConfiguration struct { + ID string `jsonapi:"primary,azure-oidc-configurations"` + ClientID string `jsonapi:"attr,client-id"` + SubscriptionID string `jsonapi:"attr,subscription-id"` + TenantID string `jsonapi:"attr,tenant-id"` + + Organization *Organization `jsonapi:"relation,organization"` +} + +type AzureOIDCConfigurationCreateOptions struct { + // Type is a public field utilized by JSON:API to + // set the resource type via the field tag. + // It is not a user-defined value and does not need to be set. + // https://jsonapi.org/format/#crud-creating + Type string `jsonapi:"primary,azure-oidc-configurations"` + + // Attributes + ClientID string `jsonapi:"attr,client-id"` + SubscriptionID string `jsonapi:"attr,subscription-id"` + TenantID string `jsonapi:"attr,tenant-id"` +} + +type AzureOIDCConfigurationUpdateOptions struct { + // Type is a public field utilized by JSON:API to + // set the resource type via the field tag. + // It is not a user-defined value and does not need to be set. + // https://jsonapi.org/format/#crud-creating + Type string `jsonapi:"primary,azure-oidc-configurations"` + + // Attributes + ClientID *string `jsonapi:"attr,client-id,omitempty"` + SubscriptionID *string `jsonapi:"attr,subscription-id,omitempty"` + TenantID *string `jsonapi:"attr,tenant-id,omitempty"` +} + +func (o *AzureOIDCConfigurationCreateOptions) valid() error { + if o.ClientID == "" { + return ErrRequiredClientID + } + + if o.SubscriptionID == "" { + return ErrRequiredSubscriptionID + } + + if o.TenantID == "" { + return ErrRequiredTenantID + } + + return nil +} + +func (aoc *azureOIDCConfigurations) Create(ctx context.Context, organization string, options AzureOIDCConfigurationCreateOptions) (*AzureOIDCConfiguration, error) { + if !validStringID(&organization) { + return nil, ErrInvalidOrg + } + + if err := options.valid(); err != nil { + return nil, err + } + + req, err := aoc.client.NewRequest("POST", fmt.Sprintf("organizations/%s/oidc-configurations", organization), &options) + if err != nil { + return nil, err + } + + azureOIDCConfiguration := &AzureOIDCConfiguration{} + err = req.Do(ctx, azureOIDCConfiguration) + if err != nil { + return nil, err + } + + return azureOIDCConfiguration, nil +} + +func (aoc *azureOIDCConfigurations) Read(ctx context.Context, oidcID string) (*AzureOIDCConfiguration, error) { + req, err := aoc.client.NewRequest("GET", fmt.Sprintf(OIDCConfigPathFormat, url.PathEscape(oidcID)), nil) + if err != nil { + return nil, err + } + + azureOIDCConfiguration := &AzureOIDCConfiguration{} + err = req.Do(ctx, azureOIDCConfiguration) + if err != nil { + return nil, err + } + + return azureOIDCConfiguration, nil +} + +func (aoc *azureOIDCConfigurations) Update(ctx context.Context, oidcID string, options AzureOIDCConfigurationUpdateOptions) (*AzureOIDCConfiguration, error) { + if !validStringID(&oidcID) { + return nil, ErrInvalidOIDC + } + + req, err := aoc.client.NewRequest("PATCH", fmt.Sprintf(OIDCConfigPathFormat, url.PathEscape(oidcID)), &options) + if err != nil { + return nil, err + } + + azureOIDCConfiguration := &AzureOIDCConfiguration{} + err = req.Do(ctx, azureOIDCConfiguration) + if err != nil { + return nil, err + } + + return azureOIDCConfiguration, nil +} + +func (aoc *azureOIDCConfigurations) Delete(ctx context.Context, oidcID string) error { + if !validStringID(&oidcID) { + return ErrInvalidOIDC + } + + req, err := aoc.client.NewRequest("DELETE", fmt.Sprintf(OIDCConfigPathFormat, url.PathEscape(oidcID)), nil) + if err != nil { + return err + } + + return req.Do(ctx, nil) +} diff --git a/azure_oidc_configuration_integration_test.go b/azure_oidc_configuration_integration_test.go new file mode 100644 index 000000000..9b0c640db --- /dev/null +++ b/azure_oidc_configuration_integration_test.go @@ -0,0 +1,212 @@ +package tfe + +import ( + "context" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// These tests are intended for local execution only, as OIDC configurations for HYOK requires specific conditions. +// To run them locally, follow the instructions outlined in hyok_configuration_integration_test.go + +func TestAzureOIDCConfigurationCreateDelete(t *testing.T) { + skipHYOKIntegrationTests(t) + + client := testClient(t) + ctx := context.Background() + + // replace the environment variable with a valid organization name that has Azure OIDC HYOK configurations + hyokOrganizationName := os.Getenv("HYOK_ORGANIZATION_NAME") + if hyokOrganizationName == "" { + t.Fatal("Export a valid HYOK_ORGANIZATION_NAME before running this test!") + } + + orgTest, err := client.Organizations.Read(ctx, hyokOrganizationName) + if err != nil { + t.Fatal(err) + } + + t.Run("with valid options", func(t *testing.T) { + opts := AzureOIDCConfigurationCreateOptions{ + ClientID: "your-azure-client-id", + SubscriptionID: "your-azure-subscription-id", + TenantID: "your-azure-tenant-id", + } + + oidcConfig, err := client.AzureOIDCConfigurations.Create(ctx, orgTest.Name, opts) + require.NoError(t, err) + require.NotNil(t, oidcConfig) + assert.Equal(t, oidcConfig.ClientID, opts.ClientID) + assert.Equal(t, oidcConfig.SubscriptionID, opts.SubscriptionID) + assert.Equal(t, oidcConfig.TenantID, opts.TenantID) + + // delete the created configuration + err = client.AzureOIDCConfigurations.Delete(ctx, oidcConfig.ID) + require.NoError(t, err) + }) + + t.Run("missing client ID", func(t *testing.T) { + opts := AzureOIDCConfigurationCreateOptions{ + SubscriptionID: "your-azure-subscription-id", + TenantID: "your-azure-tenant-id", + } + + _, err := client.AzureOIDCConfigurations.Create(ctx, orgTest.Name, opts) + assert.ErrorIs(t, err, ErrRequiredClientID) + }) + + t.Run("missing subscription ID", func(t *testing.T) { + opts := AzureOIDCConfigurationCreateOptions{ + ClientID: "your-azure-client-id", + TenantID: "your-azure-tenant-id", + } + + _, err := client.AzureOIDCConfigurations.Create(ctx, orgTest.Name, opts) + assert.ErrorIs(t, err, ErrRequiredSubscriptionID) + }) + + t.Run("missing tenant ID", func(t *testing.T) { + opts := AzureOIDCConfigurationCreateOptions{ + ClientID: "your-azure-client-id", + SubscriptionID: "your-azure-subscription-id", + } + + _, err := client.AzureOIDCConfigurations.Create(ctx, orgTest.Name, opts) + assert.ErrorIs(t, err, ErrRequiredTenantID) + }) +} + +func TestAzureOIDCConfigurationRead(t *testing.T) { + skipHYOKIntegrationTests(t) + + client := testClient(t) + ctx := context.Background() + + // replace the environment variable with a valid organization name that has Azure OIDC HYOK configurations + hyokOrganizationName := os.Getenv("HYOK_ORGANIZATION_NAME") + if hyokOrganizationName == "" { + t.Fatal("Export a valid HYOK_ORGANIZATION_NAME before running this test!") + } + + orgTest, err := client.Organizations.Read(ctx, hyokOrganizationName) + if err != nil { + t.Fatal(err) + } + + oidcConfig, oidcConfigCleanup := createAzureOIDCConfiguration(t, client, orgTest) + t.Cleanup(oidcConfigCleanup) + + t.Run("fetch existing configuration", func(t *testing.T) { + fetched, err := client.AzureOIDCConfigurations.Read(ctx, oidcConfig.ID) + require.NoError(t, err) + require.NotEmpty(t, fetched) + }) + + t.Run("fetching non-existing configuration", func(t *testing.T) { + _, err := client.AzureOIDCConfigurations.Read(ctx, "azoidc-notreal") + assert.ErrorIs(t, err, ErrResourceNotFound) + }) +} + +func TestAzureOIDCConfigurationUpdate(t *testing.T) { + skipHYOKIntegrationTests(t) + + client := testClient(t) + ctx := context.Background() + + // replace the environment variable with a valid organization name that has Azure OIDC HYOK configurations + hyokOrganizationName := os.Getenv("HYOK_ORGANIZATION_NAME") + if hyokOrganizationName == "" { + t.Fatal("Export a valid HYOK_ORGANIZATION_NAME before running this test!") + } + + orgTest, err := client.Organizations.Read(ctx, hyokOrganizationName) + if err != nil { + t.Fatal(err) + } + + t.Run("update all fields", func(t *testing.T) { + oidcConfig, oidcConfigCleanup := createAzureOIDCConfiguration(t, client, orgTest) + t.Cleanup(oidcConfigCleanup) + + clientID := "your-azure-client-id" + subscriptionID := "your-azure-subscription-id" + tenantID := "your-azure-tenant-id" + + opts := AzureOIDCConfigurationUpdateOptions{ + ClientID: &clientID, + SubscriptionID: &subscriptionID, + TenantID: &tenantID, + } + + updated, err := client.AzureOIDCConfigurations.Update(ctx, oidcConfig.ID, opts) + require.NoError(t, err) + require.NotEmpty(t, updated) + assert.Equal(t, *opts.ClientID, updated.ClientID) + assert.Equal(t, *opts.SubscriptionID, updated.SubscriptionID) + assert.Equal(t, *opts.TenantID, updated.TenantID) + }) + + t.Run("client ID not provided", func(t *testing.T) { + oidcConfig, oidcConfigCleanup := createAzureOIDCConfiguration(t, client, orgTest) + t.Cleanup(oidcConfigCleanup) + + subscriptionID := "your-azure-subscription-id" + tenantID := "your-azure-tenant-id" + + opts := AzureOIDCConfigurationUpdateOptions{ + SubscriptionID: &subscriptionID, + TenantID: &tenantID, + } + + updated, err := client.AzureOIDCConfigurations.Update(ctx, oidcConfig.ID, opts) + require.NoError(t, err) + require.NotEmpty(t, updated) + assert.Equal(t, oidcConfig.ClientID, updated.ClientID) // not updated + assert.Equal(t, *opts.SubscriptionID, updated.SubscriptionID) + assert.Equal(t, *opts.TenantID, updated.TenantID) + }) + + t.Run("subscription ID not provided", func(t *testing.T) { + oidcConfig, oidcConfigCleanup := createAzureOIDCConfiguration(t, client, orgTest) + t.Cleanup(oidcConfigCleanup) + + clientID := "your-azure-client-id" + tenantID := "your-azure-tenant-id" + + opts := AzureOIDCConfigurationUpdateOptions{ + ClientID: &clientID, + TenantID: &tenantID, + } + + updated, err := client.AzureOIDCConfigurations.Update(ctx, oidcConfig.ID, opts) + require.NoError(t, err) + require.NotEmpty(t, updated) + assert.Equal(t, *opts.ClientID, updated.ClientID) + assert.Equal(t, oidcConfig.SubscriptionID, updated.SubscriptionID) // not updated + assert.Equal(t, *opts.TenantID, updated.TenantID) + }) + + t.Run("tenant ID not provided", func(t *testing.T) { + oidcConfig, oidcConfigCleanup := createAzureOIDCConfiguration(t, client, orgTest) + t.Cleanup(oidcConfigCleanup) + + clientID := "your-azure-client-id" + subscriptionID := "your-azure-subscription-id" + + opts := AzureOIDCConfigurationUpdateOptions{ + ClientID: &clientID, + SubscriptionID: &subscriptionID, + } + + updated, err := client.AzureOIDCConfigurations.Update(ctx, oidcConfig.ID, opts) + require.NoError(t, err) + require.NotEmpty(t, updated) + assert.Equal(t, *opts.ClientID, updated.ClientID) + assert.Equal(t, *opts.SubscriptionID, updated.SubscriptionID) + assert.Equal(t, oidcConfig.TenantID, updated.TenantID) // not updated + }) +} diff --git a/errors.go b/errors.go index a3f7dabc6..97530df40 100644 --- a/errors.go +++ b/errors.go @@ -89,6 +89,9 @@ var ( // it is locked. "conflict" followed by newline is used to preserve go-tfe version // compatibility with the error constructed at runtime before it was defined here. ErrWorkspaceLockedCannotDelete = errors.New("conflict\nWorkspace is currently locked. Workspace must be unlocked before it can be safely deleted") + + // ErrHYOKCannotBeDisabled is returned when attempting to disable HYOK on a workspace that already has it enabled. + ErrHYOKCannotBeDisabled = errors.New("bad request\n\nhyok may not be disabled once it has been turned on for a workspace") ) // Invalid values for resources/struct fields @@ -240,6 +243,14 @@ var ( ErrInvalidTaskResultsCallbackStatus = fmt.Errorf("invalid value for task result status. Must be either `%s`, `%s`, or `%s`", TaskFailed, TaskPassed, TaskRunning) ErrInvalidDescriptionConflict = errors.New("invalid attributes\n\nValidation failed: Description has already been taken") + + ErrInvalidOIDC = errors.New("invalid value for OIDC configuration ID") + + ErrInvalidHYOK = errors.New("invalid value for HYOK configuration ID") + + ErrInvalidHYOKCustomerKeyVersion = errors.New("invalid value for HYOK Customer key version ID") + + ErrInvalidHYOKEncryptedDataKey = errors.New("invalid value for HYOK encrypted data key ID") ) var ( @@ -401,4 +412,38 @@ var ( ErrRequiredRawState = errors.New("RawState is required") ErrStateVersionUploadNotSupported = errors.New("upload not supported by this version of Terraform Enterprise") + + ErrSanitizedStateUploadURLMissing = errors.New("sanitized state upload URL is missing") + + ErrRequiredRoleARN = errors.New("role-arn is required for AWS OIDC configuration") + + ErrRequiredServiceAccountEmail = errors.New("service-account-email is required for GCP OIDC configuration") + + ErrRequiredProjectNumber = errors.New("project-number is required for GCP OIDC configuration") + + ErrRequiredWorkloadProviderName = errors.New("workload-provider-name is required for GCP OIDC configuration") + + ErrRequiredClientID = errors.New("client-id is required for Azure OIDC configuration") + + ErrRequiredSubscriptionID = errors.New("subscription-id is required for Azure OIDC configuration") + + ErrRequiredTenantID = errors.New("tenant-id is required for Azure OIDC configuration") + + ErrRequiredVaultAddress = errors.New("address is required for Vault OIDC configuration") + + ErrRequiredRoleName = errors.New("role is required for Vault OIDC configuration") + + ErrRequiredKEKID = errors.New("kek-id is required for HYOK configuration") + + ErrRequiredOIDCConfiguration = errors.New("oidc-configuration is required for HYOK configuration") + + ErrRequiredAgentPool = errors.New("agent-pool is required for HYOK configuration") + + ErrRequiredKMSOptions = errors.New("kms-options is required for HYOK configuration") + + ErrRequiredKMSOptionsKeyRegion = errors.New("kms-options.key-region is required for HYOK configuration with AWS OIDC") + + ErrRequiredKMSOptionsKeyLocation = errors.New("kms-options.key-location is required for HYOK configuration with GCP OIDC") + + ErrRequiredKMSOptionsKeyRingID = errors.New("kms-options.key-ring-id is required for HYOK configuration with GCP OIDC") ) diff --git a/gcp_oidc_configuration.go b/gcp_oidc_configuration.go new file mode 100644 index 000000000..2e51debe2 --- /dev/null +++ b/gcp_oidc_configuration.go @@ -0,0 +1,147 @@ +package tfe + +import ( + "context" + "fmt" + "net/url" +) + +// GCPOIDCConfigurations describes all the GCP OIDC configuration related methods that the HCP Terraform API supports. +// HCP Terraform API docs: +// https://developer.hashicorp.com/terraform/cloud-docs/api-docs/hold-your-own-key/oidc-configurations/gcp +type GCPOIDCConfigurations interface { + Create(ctx context.Context, organization string, options GCPOIDCConfigurationCreateOptions) (*GCPOIDCConfiguration, error) + + Read(ctx context.Context, oidcID string) (*GCPOIDCConfiguration, error) + + Update(ctx context.Context, oidcID string, options GCPOIDCConfigurationUpdateOptions) (*GCPOIDCConfiguration, error) + + Delete(ctx context.Context, oidcID string) error +} + +type gcpOIDCConfigurations struct { + client *Client +} + +var _ GCPOIDCConfigurations = &gcpOIDCConfigurations{} + +type GCPOIDCConfiguration struct { + ID string `jsonapi:"primary,gcp-oidc-configurations"` + ServiceAccountEmail string `jsonapi:"attr,service-account-email"` + ProjectNumber string `jsonapi:"attr,project-number"` + WorkloadProviderName string `jsonapi:"attr,workload-provider-name"` + + Organization *Organization `jsonapi:"relation,organization"` +} + +type GCPOIDCConfigurationCreateOptions struct { + // Type is a public field utilized by JSON:API to + // set the resource type via the field tag. + // It is not a user-defined value and does not need to be set. + // https://jsonapi.org/format/#crud-creating + Type string `jsonapi:"primary,gcp-oidc-configurations"` + + // Attributes + ServiceAccountEmail string `jsonapi:"attr,service-account-email"` + ProjectNumber string `jsonapi:"attr,project-number"` + WorkloadProviderName string `jsonapi:"attr,workload-provider-name"` +} + +type GCPOIDCConfigurationUpdateOptions struct { + // Type is a public field utilized by JSON:API to + // set the resource type via the field tag. + // It is not a user-defined value and does not need to be set. + // https://jsonapi.org/format/#crud-creating + Type string `jsonapi:"primary,gcp-oidc-configurations"` + + // Attributes + ServiceAccountEmail *string `jsonapi:"attr,service-account-email,omitempty"` + ProjectNumber *string `jsonapi:"attr,project-number,omitempty"` + WorkloadProviderName *string `jsonapi:"attr,workload-provider-name,omitempty"` +} + +func (o *GCPOIDCConfigurationCreateOptions) valid() error { + if o.ServiceAccountEmail == "" { + return ErrRequiredServiceAccountEmail + } + + if o.ProjectNumber == "" { + return ErrRequiredProjectNumber + } + + if o.WorkloadProviderName == "" { + return ErrRequiredWorkloadProviderName + } + + return nil +} + +func (goc *gcpOIDCConfigurations) Create(ctx context.Context, organization string, options GCPOIDCConfigurationCreateOptions) (*GCPOIDCConfiguration, error) { + if !validStringID(&organization) { + return nil, ErrInvalidOrg + } + + if err := options.valid(); err != nil { + return nil, err + } + + req, err := goc.client.NewRequest("POST", fmt.Sprintf("organizations/%s/oidc-configurations", organization), &options) + if err != nil { + return nil, err + } + + gcpOIDCConfiguration := &GCPOIDCConfiguration{} + err = req.Do(ctx, gcpOIDCConfiguration) + if err != nil { + return nil, err + } + + return gcpOIDCConfiguration, nil +} + +func (goc *gcpOIDCConfigurations) Read(ctx context.Context, oidcID string) (*GCPOIDCConfiguration, error) { + req, err := goc.client.NewRequest("GET", fmt.Sprintf(OIDCConfigPathFormat, url.PathEscape(oidcID)), nil) + if err != nil { + return nil, err + } + + gcpOIDCConfiguration := &GCPOIDCConfiguration{} + err = req.Do(ctx, gcpOIDCConfiguration) + if err != nil { + return nil, err + } + + return gcpOIDCConfiguration, nil +} + +func (goc *gcpOIDCConfigurations) Update(ctx context.Context, oidcID string, options GCPOIDCConfigurationUpdateOptions) (*GCPOIDCConfiguration, error) { + if !validStringID(&oidcID) { + return nil, ErrInvalidOIDC + } + + req, err := goc.client.NewRequest("PATCH", fmt.Sprintf(OIDCConfigPathFormat, url.PathEscape(oidcID)), &options) + if err != nil { + return nil, err + } + + gcpOIDCConfiguration := &GCPOIDCConfiguration{} + err = req.Do(ctx, gcpOIDCConfiguration) + if err != nil { + return nil, err + } + + return gcpOIDCConfiguration, nil +} + +func (goc *gcpOIDCConfigurations) Delete(ctx context.Context, oidcID string) error { + if !validStringID(&oidcID) { + return ErrInvalidOIDC + } + + req, err := goc.client.NewRequest("DELETE", fmt.Sprintf(OIDCConfigPathFormat, url.PathEscape(oidcID)), nil) + if err != nil { + return err + } + + return req.Do(ctx, nil) +} diff --git a/gcp_oidc_configuration_integration_test.go b/gcp_oidc_configuration_integration_test.go new file mode 100644 index 000000000..9d28d2537 --- /dev/null +++ b/gcp_oidc_configuration_integration_test.go @@ -0,0 +1,212 @@ +package tfe + +import ( + "context" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// These tests are intended for local execution only, as OIDC configurations for HYOK requires specific conditions. +// To run them locally, follow the instructions outlined in hyok_configuration_integration_test.go + +func TestGCPOIDCConfigurationCreateDelete(t *testing.T) { + skipHYOKIntegrationTests(t) + + client := testClient(t) + ctx := context.Background() + + // replace the environment variable with a valid organization name that has GCP OIDC HYOK configurations + hyokOrganizationName := os.Getenv("HYOK_ORGANIZATION_NAME") + if hyokOrganizationName == "" { + t.Fatal("Export a valid HYOK_ORGANIZATION_NAME before running this test!") + } + + orgTest, err := client.Organizations.Read(ctx, hyokOrganizationName) + if err != nil { + t.Fatal(err) + } + + t.Run("with valid options", func(t *testing.T) { + opts := GCPOIDCConfigurationCreateOptions{ + ServiceAccountEmail: "updated-service-account@example.iam.gserviceaccount.com", + ProjectNumber: "123456789012", + WorkloadProviderName: randomString(t), + } + + oidcConfig, err := client.GCPOIDCConfigurations.Create(ctx, orgTest.Name, opts) + require.NoError(t, err) + require.NotNil(t, oidcConfig) + assert.Equal(t, oidcConfig.ServiceAccountEmail, opts.ServiceAccountEmail) + assert.Equal(t, oidcConfig.ProjectNumber, opts.ProjectNumber) + assert.Equal(t, oidcConfig.WorkloadProviderName, opts.WorkloadProviderName) + + // delete the created configuration + err = client.GCPOIDCConfigurations.Delete(ctx, oidcConfig.ID) + require.NoError(t, err) + }) + + t.Run("missing workload provider name", func(t *testing.T) { + opts := GCPOIDCConfigurationCreateOptions{ + ServiceAccountEmail: "updated-service-account@example.iam.gserviceaccount.com", + ProjectNumber: "123456789012", + } + + _, err := client.GCPOIDCConfigurations.Create(ctx, orgTest.Name, opts) + assert.ErrorIs(t, err, ErrRequiredWorkloadProviderName) + }) + + t.Run("missing service account email", func(t *testing.T) { + opts := GCPOIDCConfigurationCreateOptions{ + ProjectNumber: "123456789012", + WorkloadProviderName: randomString(t), + } + + _, err := client.GCPOIDCConfigurations.Create(ctx, orgTest.Name, opts) + assert.ErrorIs(t, err, ErrRequiredServiceAccountEmail) + }) + + t.Run("missing project number", func(t *testing.T) { + opts := GCPOIDCConfigurationCreateOptions{ + ServiceAccountEmail: "updated-service-account@example.iam.gserviceaccount.com", + WorkloadProviderName: randomString(t), + } + + _, err := client.GCPOIDCConfigurations.Create(ctx, orgTest.Name, opts) + assert.ErrorIs(t, err, ErrRequiredProjectNumber) + }) +} + +func TestGCPOIDCConfigurationRead(t *testing.T) { + skipHYOKIntegrationTests(t) + + client := testClient(t) + ctx := context.Background() + + // replace the environment variable with a valid organization name that has GCP OIDC HYOK configurations + hyokOrganizationName := os.Getenv("HYOK_ORGANIZATION_NAME") + if hyokOrganizationName == "" { + t.Fatal("Export a valid HYOK_ORGANIZATION_NAME before running this test!") + } + + orgTest, err := client.Organizations.Read(ctx, hyokOrganizationName) + if err != nil { + t.Fatal(err) + } + + oidcConfig, oidcConfigCleanup := createGCPOIDCConfiguration(t, client, orgTest) + t.Cleanup(oidcConfigCleanup) + + t.Run("fetch existing configuration", func(t *testing.T) { + fetched, err := client.GCPOIDCConfigurations.Read(ctx, oidcConfig.ID) + require.NoError(t, err) + require.NotEmpty(t, fetched) + }) + + t.Run("fetching non-existing configuration", func(t *testing.T) { + _, err := client.GCPOIDCConfigurations.Read(ctx, "gcpoidc-notreal") + assert.ErrorIs(t, err, ErrResourceNotFound) + }) +} + +func TestGCPOIDCConfigurationUpdate(t *testing.T) { + skipHYOKIntegrationTests(t) + + client := testClient(t) + ctx := context.Background() + + // replace the environment variable with a valid organization name that has GCP OIDC HYOK configurations + hyokOrganizationName := os.Getenv("HYOK_ORGANIZATION_NAME") + if hyokOrganizationName == "" { + t.Fatal("Export a valid HYOK_ORGANIZATION_NAME before running this test!") + } + + orgTest, err := client.Organizations.Read(ctx, hyokOrganizationName) + if err != nil { + t.Fatal(err) + } + + t.Run("update all fields", func(t *testing.T) { + oidcConfig, oidcConfigCleanup := createGCPOIDCConfiguration(t, client, orgTest) + t.Cleanup(oidcConfigCleanup) + + serviceAccountEmail := "updated-service-account@example.iam.gserviceaccount.com" + projectNumber := "123456789012" + workloadProviderName := randomString(t) + + opts := GCPOIDCConfigurationUpdateOptions{ + ServiceAccountEmail: &serviceAccountEmail, + ProjectNumber: &projectNumber, + WorkloadProviderName: &workloadProviderName, + } + + updated, err := client.GCPOIDCConfigurations.Update(ctx, oidcConfig.ID, opts) + require.NoError(t, err) + require.NotNil(t, updated) + assert.Equal(t, *opts.ServiceAccountEmail, updated.ServiceAccountEmail) + assert.Equal(t, *opts.ProjectNumber, updated.ProjectNumber) + assert.Equal(t, *opts.WorkloadProviderName, updated.WorkloadProviderName) + }) + + t.Run("workload provider name not provided", func(t *testing.T) { + oidcConfig, oidcConfigCleanup := createGCPOIDCConfiguration(t, client, orgTest) + t.Cleanup(oidcConfigCleanup) + + serviceAccountEmail := "updated-service-account@example.iam.gserviceaccount.com" + projectNumber := "123456789012" + + opts := GCPOIDCConfigurationUpdateOptions{ + ServiceAccountEmail: &serviceAccountEmail, + ProjectNumber: &projectNumber, + } + + updated, err := client.GCPOIDCConfigurations.Update(ctx, oidcConfig.ID, opts) + require.NoError(t, err) + require.NotNil(t, updated) + assert.Equal(t, *opts.ServiceAccountEmail, updated.ServiceAccountEmail) + assert.Equal(t, *opts.ProjectNumber, updated.ProjectNumber) + assert.Equal(t, oidcConfig.WorkloadProviderName, updated.WorkloadProviderName) // not updated + }) + + t.Run("service account email not provided", func(t *testing.T) { + oidcConfig, oidcConfigCleanup := createGCPOIDCConfiguration(t, client, orgTest) + t.Cleanup(oidcConfigCleanup) + + projectNumber := "123456789012" + workloadProviderName := randomString(t) + + opts := GCPOIDCConfigurationUpdateOptions{ + ProjectNumber: &projectNumber, + WorkloadProviderName: &workloadProviderName, + } + + updated, err := client.GCPOIDCConfigurations.Update(ctx, oidcConfig.ID, opts) + require.NoError(t, err) + require.NotNil(t, updated) + assert.Equal(t, oidcConfig.ServiceAccountEmail, updated.ServiceAccountEmail) // not updated + assert.Equal(t, *opts.ProjectNumber, updated.ProjectNumber) + assert.Equal(t, *opts.WorkloadProviderName, updated.WorkloadProviderName) + }) + + t.Run("project number not provided", func(t *testing.T) { + oidcConfig, oidcConfigCleanup := createGCPOIDCConfiguration(t, client, orgTest) + t.Cleanup(oidcConfigCleanup) + + serviceAccountEmail := "updated-service-account@example.iam.gserviceaccount.com" + workloadProviderName := randomString(t) + + opts := GCPOIDCConfigurationUpdateOptions{ + ServiceAccountEmail: &serviceAccountEmail, + WorkloadProviderName: &workloadProviderName, + } + + updated, err := client.GCPOIDCConfigurations.Update(ctx, oidcConfig.ID, opts) + require.NoError(t, err) + require.NotNil(t, updated) + assert.Equal(t, *opts.ServiceAccountEmail, updated.ServiceAccountEmail) + assert.Equal(t, oidcConfig.ProjectNumber, updated.ProjectNumber) // not updated + assert.Equal(t, *opts.WorkloadProviderName, updated.WorkloadProviderName) + }) +} diff --git a/helper_test.go b/helper_test.go index 2ec5cfd6d..0b203de8b 100644 --- a/helper_test.go +++ b/helper_test.go @@ -569,6 +569,301 @@ func createGPGKey(t *testing.T, client *Client, org *Organization, provider *Reg } } +func createAWSOIDCConfiguration(t *testing.T, client *Client, org *Organization) (*AWSOIDCConfiguration, func()) { + var orgCleanup func() + + ctx := context.Background() + + if org == nil { + org, orgCleanup = createOrganization(t, client) + } + + opts := AWSOIDCConfigurationCreateOptions{ + RoleARN: fmt.Sprintf("arn:aws:iam::123456789012:role/%s", randomString(t)), + } + + oidcConfig, err := client.AWSOIDCConfigurations.Create(ctx, org.Name, opts) + if err != nil { + t.Fatal(err) + } + + return oidcConfig, func() { + if err := client.AWSOIDCConfigurations.Delete(ctx, oidcConfig.ID); err != nil { + t.Errorf("Error removing AWS OIDC Configuration! WARNING: Dangling resources\n"+ + "may exist! The full error is shown below.\n\n"+ + "AWSOIDCConfigurations: %s\nError: %s", oidcConfig.ID, err) + } + + if orgCleanup != nil { + orgCleanup() + } + } +} + +func (a *AWSOIDCConfiguration) createHYOKConfiguration(t *testing.T, client *Client, org *Organization, agentPool *AgentPool) (*HYOKConfiguration, func()) { + var orgCleanup func() + + ctx := context.Background() + + if org == nil { + org, orgCleanup = createOrganization(t, client) + } + + opts := HYOKConfigurationsCreateOptions{ + KEKID: "arn:aws:kms:us-east-1:123456789012:key/this-is-not-a-real-key", + Name: randomStringWithoutSpecialChar(t), + KMSOptions: &KMSOptions{KeyRegion: "us-east-1"}, + AgentPool: agentPool, + OIDCConfiguration: &OIDCConfigurationTypeChoice{AWSOIDCConfiguration: a}, + } + + hyokConfig, err := client.HYOKConfigurations.Create(ctx, org.Name, opts) + if err != nil { + t.Fatal(err) + } + + return hyokConfig, func() { + cleanupHYOKConfiguration(t, ctx, client, hyokConfig.ID) + + if orgCleanup != nil { + orgCleanup() + } + } +} + +func createAzureOIDCConfiguration(t *testing.T, client *Client, org *Organization) (*AzureOIDCConfiguration, func()) { + var orgCleanup func() + + ctx := context.Background() + + if org == nil { + org, orgCleanup = createOrganization(t, client) + } + + opts := AzureOIDCConfigurationCreateOptions{ + ClientID: randomString(t), + SubscriptionID: randomString(t), + TenantID: randomString(t), + } + + oidcConfig, err := client.AzureOIDCConfigurations.Create(ctx, org.Name, opts) + if err != nil { + t.Fatal(err) + } + + return oidcConfig, func() { + if err := client.AzureOIDCConfigurations.Delete(ctx, oidcConfig.ID); err != nil { + t.Errorf("Error removing Azure OIDC Configuration! WARNING: Dangling resources\n"+ + "may exist! The full error is shown below.\n\n"+ + "AzureOIDCConfigurations: %s\nError: %s", oidcConfig.ID, err) + } + + if orgCleanup != nil { + orgCleanup() + } + } +} + +func (a *AzureOIDCConfiguration) createHYOKConfiguration(t *testing.T, client *Client, org *Organization, agentPool *AgentPool) (*HYOKConfiguration, func()) { + var orgCleanup func() + + ctx := context.Background() + + if org == nil { + org, orgCleanup = createOrganization(t, client) + } + + opts := HYOKConfigurationsCreateOptions{ + KEKID: "https://vault-name.vault.azure.net/keys/key-name", + Name: randomStringWithoutSpecialChar(t), + AgentPool: agentPool, + OIDCConfiguration: &OIDCConfigurationTypeChoice{AzureOIDCConfiguration: a}, + } + + hyokConfig, err := client.HYOKConfigurations.Create(ctx, org.Name, opts) + if err != nil { + t.Fatal(err) + } + + return hyokConfig, func() { + cleanupHYOKConfiguration(t, ctx, client, hyokConfig.ID) + + if orgCleanup != nil { + orgCleanup() + } + } +} + +func createGCPOIDCConfiguration(t *testing.T, client *Client, org *Organization) (*GCPOIDCConfiguration, func()) { + var orgCleanup func() + + ctx := context.Background() + + if org == nil { + org, orgCleanup = createOrganization(t, client) + } + + opts := GCPOIDCConfigurationCreateOptions{ + ServiceAccountEmail: randomString(t), + ProjectNumber: "123456789012", + WorkloadProviderName: randomString(t), + } + + oidcConfig, err := client.GCPOIDCConfigurations.Create(ctx, org.Name, opts) + if err != nil { + t.Fatal(err) + } + + return oidcConfig, func() { + if err := client.GCPOIDCConfigurations.Delete(ctx, oidcConfig.ID); err != nil { + t.Errorf("Error removing GCP OIDC Configuration! WARNING: Dangling resources\n"+ + "may exist! The full error is shown below.\n\n"+ + "GCPOIDCConfigurations: %s\nError: %s", oidcConfig.ID, err) + } + + if orgCleanup != nil { + orgCleanup() + } + } +} + +func (g *GCPOIDCConfiguration) createHYOKConfiguration(t *testing.T, client *Client, org *Organization, agentPool *AgentPool) (*HYOKConfiguration, func()) { + var orgCleanup func() + + ctx := context.Background() + + if org == nil { + org, orgCleanup = createOrganization(t, client) + } + + opts := HYOKConfigurationsCreateOptions{ + KEKID: randomStringWithoutSpecialChar(t), + Name: randomStringWithoutSpecialChar(t), + KMSOptions: &KMSOptions{KeyLocation: "global", KeyRingID: randomString(t)}, + AgentPool: agentPool, + OIDCConfiguration: &OIDCConfigurationTypeChoice{GCPOIDCConfiguration: g}, + } + + hyokConfig, err := client.HYOKConfigurations.Create(ctx, org.Name, opts) + if err != nil { + t.Fatal(err) + } + + return hyokConfig, func() { + cleanupHYOKConfiguration(t, ctx, client, hyokConfig.ID) + + if orgCleanup != nil { + orgCleanup() + } + } +} + +func createVaultOIDCConfiguration(t *testing.T, client *Client, org *Organization) (*VaultOIDCConfiguration, func()) { + var orgCleanup func() + + ctx := context.Background() + + if org == nil { + org, orgCleanup = createOrganization(t, client) + } + + opts := VaultOIDCConfigurationCreateOptions{ + Address: "https://vault.example.com", + RoleName: randomString(t), + Namespace: randomString(t), + JWTAuthPath: "jwt", + TLSCACertificate: randomString(t), + } + + oidcConfig, err := client.VaultOIDCConfigurations.Create(ctx, org.Name, opts) + if err != nil { + t.Fatal(err) + } + + return oidcConfig, func() { + if err := client.VaultOIDCConfigurations.Delete(ctx, oidcConfig.ID); err != nil { + t.Errorf("Error removing Vault OIDC Configuration! WARNING: Dangling resources\n"+ + "may exist! The full error is shown below.\n\n"+ + "VaultOIDCConfigurations: %s\nError: %s", oidcConfig.ID, err) + } + + if orgCleanup != nil { + orgCleanup() + } + } +} + +func (v *VaultOIDCConfiguration) createHYOKConfiguration(t *testing.T, client *Client, org *Organization, agentPool *AgentPool) (*HYOKConfiguration, func()) { + var orgCleanup func() + + ctx := context.Background() + + if org == nil { + org, orgCleanup = createOrganization(t, client) + } + + opts := HYOKConfigurationsCreateOptions{ + KEKID: randomStringWithoutSpecialChar(t), + Name: randomStringWithoutSpecialChar(t), + AgentPool: agentPool, + OIDCConfiguration: &OIDCConfigurationTypeChoice{VaultOIDCConfiguration: v}, + } + + hyokConfig, err := client.HYOKConfigurations.Create(ctx, org.Name, opts) + if err != nil { + t.Fatal(err) + } + + return hyokConfig, func() { + cleanupHYOKConfiguration(t, ctx, client, hyokConfig.ID) + + if orgCleanup != nil { + orgCleanup() + } + } +} + +func waitForHYOKConfigurationStatus(t *testing.T, ctx context.Context, client *Client, hyokID string, status HYOKConfigurationStatus) (interface{}, error) { + t.Helper() + + return retryPatiently(func() (interface{}, error) { + fetched, err := client.HYOKConfigurations.Read(ctx, hyokID, nil) + if err != nil { + return nil, err + } + + if fetched.Status == status { + return fetched, nil + } + + return nil, fmt.Errorf("HYOK Configuration is not %s! HYOKConfiguration: %s\nStatus: %s", status, hyokID, fetched.Status) + }) +} + +func cleanupHYOKConfiguration(t *testing.T, ctx context.Context, client *Client, hyokID string) { + _, err := waitForHYOKConfigurationStatus(t, ctx, client, hyokID, HYOKConfigurationTestFailed) + if err != nil { + t.Errorf("Timed out waiting for HYOK configuration %s to fail test", hyokID) + } + + if err = client.HYOKConfigurations.Revoke(ctx, hyokID); err != nil { + t.Errorf("Error removing HYOK Configuration! WARNING: Dangling resources\n"+ + "may exist! The full error is shown below.\n\n"+ + "HYOKConfigurations: %s\nError: %s", hyokID, err) + } + + _, err = waitForHYOKConfigurationStatus(t, ctx, client, hyokID, HYOKConfigurationRevoked) + if err != nil { + t.Errorf("Timed out waiting for HYOK configuration %s to revoke", hyokID) + } + + if err := client.HYOKConfigurations.Delete(ctx, hyokID); err != nil { + t.Errorf("Error removing HYOK Configuration! WARNING: Dangling resources\n"+ + "may exist! The full error is shown below.\n\n"+ + "HYOKConfigurations: %s\nError: %s", hyokID, err) + } +} + func createNotificationConfiguration(t *testing.T, client *Client, w *Workspace, options *NotificationConfigurationCreateOptions) (*NotificationConfiguration, func()) { var wCleanup func() @@ -2926,6 +3221,13 @@ func skipIfEnterprise(t *testing.T) { } } +func skipHYOKIntegrationTests(t *testing.T) { + t.Helper() + if !hyokIntegrationTestsEnabled() { + t.Skip("Skipping test related to HYOK features. Set ENABLE_HYOK_INTEGRATION_TESTS=1 to run.") + } +} + // skips a test if the underlying beta feature is not available. // **Note: ENABLE_BETA is always disabled in CI, so ensure you: // @@ -2974,6 +3276,11 @@ func betaFeaturesEnabled() bool { return os.Getenv("ENABLE_BETA") == "1" } +// Checks to see if HYOK_INTEGRATION_TESTS is set to 1, thereby enabling tests for HYOK features. +func hyokIntegrationTestsEnabled() bool { + return os.Getenv("ENABLE_HYOK_INTEGRATION_TESTS") == "1" +} + // isEmpty gets whether the specified object is considered empty or not. func isEmpty(object interface{}) bool { // get nil case out of the way diff --git a/hyok_configuration.go b/hyok_configuration.go new file mode 100644 index 000000000..b8c591f5d --- /dev/null +++ b/hyok_configuration.go @@ -0,0 +1,298 @@ +package tfe + +import ( + "context" + "fmt" + "net/url" +) + +// HYOKConfigurations describes all the HYOK configuration related methods that the HCP Terraform API supports. +// HCP Terraform API docs: +// https://developer.hashicorp.com/terraform/cloud-docs/api-docs/hold-your-own-key/configurations +type HYOKConfigurations interface { + List(ctx context.Context, organization string, options *HYOKConfigurationsListOptions) (*HYOKConfigurationsList, error) + + Create(ctx context.Context, organization string, options HYOKConfigurationsCreateOptions) (*HYOKConfiguration, error) + + Read(ctx context.Context, hyokID string, options *HYOKConfigurationsReadOptions) (*HYOKConfiguration, error) + + Update(ctx context.Context, hyokID string, options HYOKConfigurationsUpdateOptions) (*HYOKConfiguration, error) + + Delete(ctx context.Context, hyokID string) error + + // Test checks the HYOK configuration and returns success if the configuration is valid. + // It returns an error along with the error message if any issues are found. + Test(ctx context.Context, hyokID string) error + + Revoke(ctx context.Context, hyokID string) error +} + +type hyokConfigurations struct { + client *Client +} + +var _ HYOKConfigurations = &hyokConfigurations{} + +type HYOKConfigurationStatus string + +const ( + HYOKConfigurationUntested HYOKConfigurationStatus = "untested" + HYOKConfigurationTesting HYOKConfigurationStatus = "testing" + HYOKConfigurationTestFailed HYOKConfigurationStatus = "test_failed" + HYOKConfigurationAvailable HYOKConfigurationStatus = "available" + HYOKConfigurationErrored HYOKConfigurationStatus = "errored" + HYOKConfigurationRevoking HYOKConfigurationStatus = "revoking" + HYOKConfigurationRevoked HYOKConfigurationStatus = "revoked" +) + +type OIDCConfigurationTypeChoice struct { + AWSOIDCConfiguration *AWSOIDCConfiguration + GCPOIDCConfiguration *GCPOIDCConfiguration + AzureOIDCConfiguration *AzureOIDCConfiguration + VaultOIDCConfiguration *VaultOIDCConfiguration +} + +type KMSOptions struct { + // AWS + KeyRegion string `jsonapi:"attr,key-region,omitempty"` + // GCP + KeyLocation string `jsonapi:"attr,key-location,omitempty"` + KeyRingID string `jsonapi:"attr,key-ring-id,omitempty"` +} + +type HYOKConfiguration struct { + ID string `jsonapi:"primary,hyok-configurations"` + + // Attributes + KEKID string `jsonapi:"attr,kek-id"` + KMSOptions *KMSOptions `jsonapi:"attr,kms-options,omitempty"` + Name string `jsonapi:"attr,name"` + Primary bool `jsonapi:"attr,primary"` + Status HYOKConfigurationStatus `jsonapi:"attr,status"` + Error *string `jsonapi:"attr,error"` + + // Relationships + Organization *Organization `jsonapi:"relation,organization"` + OIDCConfiguration *OIDCConfigurationTypeChoice `jsonapi:"polyrelation,oidc-configuration"` + AgentPool *AgentPool `jsonapi:"relation,agent-pool"` + KeyVersions []*HYOKCustomerKeyVersion `jsonapi:"relation,hyok-customer-key-versions"` +} + +type HYOKConfigurationsList struct { + *Pagination + Items []*HYOKConfiguration +} + +type HYOKConfigurationsIncludeOpt string + +const ( + HYOKConfigurationsIncludeHYOKCustomerKeyVersions HYOKConfigurationsIncludeOpt = "hyok_customer_key_versions" + HYOKConfigurationsIncludeOIDCConfiguration HYOKConfigurationsIncludeOpt = "oidc_configuration" +) + +type HYOKConfigurationsListOptions struct { + ListOptions + SearchQuery string `url:"q,omitempty"` + Include []HYOKConfigurationsIncludeOpt `url:"include,omitempty"` +} + +type HYOKConfigurationsCreateOptions struct { + // Type is a public field utilized by JSON:API to + // set the resource type via the field tag. + // It is not a user-defined value and does not need to be set. + // https://jsonapi.org/format/#crud-creating + Type string `jsonapi:"primary,hyok-configurations"` + + // Attributes + KEKID string `jsonapi:"attr,kek-id"` + KMSOptions *KMSOptions `jsonapi:"attr,kms-options"` + Name string `jsonapi:"attr,name"` + + // Relationships + OIDCConfiguration *OIDCConfigurationTypeChoice `jsonapi:"polyrelation,oidc-configuration"` + AgentPool *AgentPool `jsonapi:"relation,agent-pool"` +} + +type HYOKConfigurationsReadOptions struct { + Include []HYOKConfigurationsIncludeOpt `url:"include,omitempty"` +} + +type HYOKConfigurationsUpdateOptions struct { + // Type is a public field utilized by JSON:API to + // set the resource type via the field tag. + // It is not a user-defined value and does not need to be set. + // https://jsonapi.org/format/#crud-creating + Type string `jsonapi:"primary,hyok-configurations"` + + // Attributes + KEKID *string `jsonapi:"attr,kek-id,omitempty"` + KMSOptions *KMSOptions `jsonapi:"attr,kms-options,omitempty"` + Name *string `jsonapi:"attr,name,omitempty"` + Primary *bool `jsonapi:"attr,primary,omitempty"` + + // Relationships + AgentPool *AgentPool `jsonapi:"relation,agent-pool,omitempty"` +} + +func (h hyokConfigurations) List(ctx context.Context, organization string, options *HYOKConfigurationsListOptions) (*HYOKConfigurationsList, error) { + if !validStringID(&organization) { + return nil, ErrInvalidOrg + } + + req, err := h.client.NewRequest("GET", fmt.Sprintf("organizations/%s/hyok-configurations", organization), options) + if err != nil { + return nil, err + } + + hyokConfigurationList := &HYOKConfigurationsList{} + err = req.Do(ctx, hyokConfigurationList) + if err != nil { + return nil, err + } + + return hyokConfigurationList, nil +} + +func (h hyokConfigurations) Read(ctx context.Context, hyokID string, options *HYOKConfigurationsReadOptions) (*HYOKConfiguration, error) { + if !validStringID(&hyokID) { + return nil, ErrInvalidHYOK + } + + req, err := h.client.NewRequest("GET", fmt.Sprintf("hyok-configurations/%s", url.PathEscape(hyokID)), options) + if err != nil { + return nil, err + } + + hyokConfiguration := &HYOKConfiguration{} + err = req.Do(ctx, hyokConfiguration) + if err != nil { + return nil, err + } + + return hyokConfiguration, nil +} + +func (h *HYOKConfigurationsCreateOptions) valid() error { + if h.KEKID == "" { + return ErrRequiredKEKID + } + + if h.Name == "" { + return ErrRequiredName + } + + if h.OIDCConfiguration == nil { + return ErrRequiredOIDCConfiguration + } + + if h.AgentPool == nil { + return ErrRequiredAgentPool + } + + if h.OIDCConfiguration.AWSOIDCConfiguration != nil { + if h.KMSOptions == nil { + return ErrRequiredKMSOptions + } + + if h.KMSOptions.KeyRegion == "" { + return ErrRequiredKMSOptionsKeyRegion + } + } + + if h.OIDCConfiguration.GCPOIDCConfiguration != nil { + if h.KMSOptions == nil { + return ErrRequiredKMSOptions + } + + if h.KMSOptions.KeyLocation == "" { + return ErrRequiredKMSOptionsKeyLocation + } + + if h.KMSOptions.KeyRingID == "" { + return ErrRequiredKMSOptionsKeyRingID + } + } + + return nil +} + +func (h hyokConfigurations) Create(ctx context.Context, organization string, options HYOKConfigurationsCreateOptions) (*HYOKConfiguration, error) { + if !validStringID(&organization) { + return nil, ErrInvalidOrg + } + + if err := options.valid(); err != nil { + return nil, err + } + + req, err := h.client.NewRequest("POST", fmt.Sprintf("organizations/%s/hyok-configurations", organization), &options) + if err != nil { + return nil, err + } + + hyokConfiguration := &HYOKConfiguration{} + err = req.Do(ctx, hyokConfiguration) + if err != nil { + return nil, err + } + + return hyokConfiguration, nil +} + +func (h hyokConfigurations) Update(ctx context.Context, hyokID string, options HYOKConfigurationsUpdateOptions) (*HYOKConfiguration, error) { + if !validStringID(&hyokID) { + return nil, ErrInvalidHYOK + } + + req, err := h.client.NewRequest("PATCH", fmt.Sprintf("hyok-configurations/%s", url.PathEscape(hyokID)), &options) + if err != nil { + return nil, err + } + + hyokConfiguration := &HYOKConfiguration{} + err = req.Do(ctx, hyokConfiguration) + if err != nil { + return nil, err + } + + return hyokConfiguration, nil +} + +func (h hyokConfigurations) Delete(ctx context.Context, hyokID string) error { + if !validStringID(&hyokID) { + return ErrInvalidHYOK + } + + req, err := h.client.NewRequest("DELETE", fmt.Sprintf("hyok-configurations/%s", url.PathEscape(hyokID)), nil) + if err != nil { + return err + } + + return req.Do(ctx, nil) +} + +func (h hyokConfigurations) Test(ctx context.Context, hyokID string) error { + if !validStringID(&hyokID) { + return ErrInvalidHYOK + } + + req, err := h.client.NewRequest("POST", fmt.Sprintf("hyok-configurations/%s/actions/test", url.PathEscape(hyokID)), nil) + if err != nil { + return err + } + + return req.Do(ctx, nil) +} + +func (h hyokConfigurations) Revoke(ctx context.Context, hyokID string) error { + if !validStringID(&hyokID) { + return ErrInvalidHYOK + } + + req, err := h.client.NewRequest("POST", fmt.Sprintf("hyok-configurations/%s/actions/revoke", url.PathEscape(hyokID)), nil) + if err != nil { + return err + } + + return req.Do(ctx, nil) +} diff --git a/hyok_configuration_integration_test.go b/hyok_configuration_integration_test.go new file mode 100644 index 000000000..513034ecf --- /dev/null +++ b/hyok_configuration_integration_test.go @@ -0,0 +1,571 @@ +package tfe + +import ( + "context" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestHYOKConfigurationCreateRevokeDelete(t *testing.T) { + skipHYOKIntegrationTests(t) + + client := testClient(t) + ctx := context.Background() + + // replace the environment variable with a valid organization name that has HYOK configurations + hyokOrganizationName := os.Getenv("HYOK_ORGANIZATION_NAME") + if hyokOrganizationName == "" { + t.Fatal("Export a valid HYOK_ORGANIZATION_NAME before running this test!") + } + + orgTest, err := client.Organizations.Read(ctx, hyokOrganizationName) + if err != nil { + t.Fatal(err) + } + + agentPool, agentPoolCleanup := createAgentPool(t, client, orgTest) + t.Cleanup(agentPoolCleanup) + + t.Run("AWS with valid options", func(t *testing.T) { + awsOIDCConfig, configCleanup := createAWSOIDCConfiguration(t, client, orgTest) + t.Cleanup(configCleanup) + + keyRegion := "us-east-1" + opts := HYOKConfigurationsCreateOptions{ + Name: randomStringWithoutSpecialChar(t), + KMSOptions: &KMSOptions{ + KeyRegion: keyRegion, + }, + KEKID: "arn:aws:kms:us-east-1:123456789012:key/this-is-not-a-real-key", + AgentPool: agentPool, + OIDCConfiguration: &OIDCConfigurationTypeChoice{ + AWSOIDCConfiguration: awsOIDCConfig, + }, + } + + created, err := client.HYOKConfigurations.Create(ctx, orgTest.Name, opts) + require.NoError(t, err) + require.NotNil(t, created) + assert.Equal(t, opts.Name, created.Name) + assert.Equal(t, opts.KEKID, created.KEKID) + assert.Equal(t, opts.KMSOptions.KeyRegion, created.KMSOptions.KeyRegion) + assert.Equal(t, opts.AgentPool.ID, created.AgentPool.ID) + assert.Equal(t, opts.OIDCConfiguration.AWSOIDCConfiguration.ID, created.OIDCConfiguration.AWSOIDCConfiguration.ID) + + // Must first wait for test_failed status before revoking and deleting the HYOK config or else OIDC configs cannot be cleaned up + _, err = waitForHYOKConfigurationStatus(t, ctx, client, created.ID, HYOKConfigurationTestFailed) + require.NoError(t, err) + err = client.HYOKConfigurations.Revoke(ctx, created.ID) + require.NoError(t, err) + _, err = waitForHYOKConfigurationStatus(t, ctx, client, created.ID, HYOKConfigurationRevoked) + require.NoError(t, err, "Timed out waiting for HYOK configuration %s to revoke", created.ID) + + err = client.HYOKConfigurations.Delete(ctx, created.ID) + require.NoError(t, err) + _, err = client.HYOKConfigurations.Read(ctx, created.ID, nil) + require.ErrorIs(t, err, ErrResourceNotFound) + }) + + t.Run("AWS with missing key region", func(t *testing.T) { + awsOIDCConfig, configCleanup := createAWSOIDCConfiguration(t, client, orgTest) + t.Cleanup(configCleanup) + + opts := HYOKConfigurationsCreateOptions{ + Name: randomStringWithoutSpecialChar(t), + KMSOptions: &KMSOptions{}, + KEKID: "arn:aws:kms:us-east-1:123456789012:key/this-is-not-a-real-key", + AgentPool: agentPool, + OIDCConfiguration: &OIDCConfigurationTypeChoice{ + AWSOIDCConfiguration: awsOIDCConfig, + }, + } + + _, err := client.HYOKConfigurations.Create(ctx, orgTest.Name, opts) + require.ErrorIs(t, err, ErrRequiredKMSOptionsKeyRegion) + }) + + t.Run("GCP with valid options", func(t *testing.T) { + gcpOIDCConfig, configCleanup := createGCPOIDCConfiguration(t, client, orgTest) + t.Cleanup(configCleanup) + + keyLocation := "global" + keyRingID := randomStringWithoutSpecialChar(t) + + opts := HYOKConfigurationsCreateOptions{ + Name: randomStringWithoutSpecialChar(t), + KMSOptions: &KMSOptions{ + KeyLocation: keyLocation, + KeyRingID: keyRingID, + }, + KEKID: randomStringWithoutSpecialChar(t), + AgentPool: agentPool, + OIDCConfiguration: &OIDCConfigurationTypeChoice{ + GCPOIDCConfiguration: gcpOIDCConfig, + }, + } + + created, err := client.HYOKConfigurations.Create(ctx, orgTest.Name, opts) + require.NoError(t, err) + require.NotNil(t, created) + assert.Equal(t, opts.Name, created.Name) + assert.Equal(t, opts.KEKID, created.KEKID) + assert.Equal(t, opts.KMSOptions.KeyLocation, created.KMSOptions.KeyLocation) + assert.Equal(t, opts.KMSOptions.KeyRingID, created.KMSOptions.KeyRingID) + assert.Equal(t, opts.AgentPool.ID, created.AgentPool.ID) + assert.Equal(t, opts.OIDCConfiguration.GCPOIDCConfiguration.ID, created.OIDCConfiguration.GCPOIDCConfiguration.ID) + + // Must first wait for test_failed status before revoking and deleting the HYOK config or else OIDC configs cannot be cleaned up + _, err = waitForHYOKConfigurationStatus(t, ctx, client, created.ID, HYOKConfigurationTestFailed) + require.NoError(t, err) + err = client.HYOKConfigurations.Revoke(ctx, created.ID) + require.NoError(t, err) + _, err = waitForHYOKConfigurationStatus(t, ctx, client, created.ID, HYOKConfigurationRevoked) + require.NoError(t, err, "Timed out waiting for HYOK configuration %s to revoke", created.ID) + + err = client.HYOKConfigurations.Delete(ctx, created.ID) + require.NoError(t, err) + _, err = client.HYOKConfigurations.Read(ctx, created.ID, nil) + require.ErrorIs(t, err, ErrResourceNotFound) + }) + + t.Run("GCP with missing key location", func(t *testing.T) { + gcpOIDCConfig, configCleanup := createGCPOIDCConfiguration(t, client, orgTest) + t.Cleanup(configCleanup) + + keyRingID := randomStringWithoutSpecialChar(t) + opts := HYOKConfigurationsCreateOptions{ + Name: randomStringWithoutSpecialChar(t), + KMSOptions: &KMSOptions{ + KeyRingID: keyRingID, + }, + KEKID: randomStringWithoutSpecialChar(t), + AgentPool: agentPool, + OIDCConfiguration: &OIDCConfigurationTypeChoice{ + GCPOIDCConfiguration: gcpOIDCConfig, + }, + } + + _, err := client.HYOKConfigurations.Create(ctx, orgTest.Name, opts) + require.ErrorIs(t, err, ErrRequiredKMSOptionsKeyLocation) + }) + + t.Run("GCP with missing key ring ID", func(t *testing.T) { + gcpOIDCConfig, configCleanup := createGCPOIDCConfiguration(t, client, orgTest) + t.Cleanup(configCleanup) + + keyLocation := "global" + + opts := HYOKConfigurationsCreateOptions{ + Name: randomStringWithoutSpecialChar(t), + KMSOptions: &KMSOptions{ + KeyLocation: keyLocation, + }, + KEKID: randomStringWithoutSpecialChar(t), + AgentPool: agentPool, + OIDCConfiguration: &OIDCConfigurationTypeChoice{ + GCPOIDCConfiguration: gcpOIDCConfig, + }, + } + + _, err := client.HYOKConfigurations.Create(ctx, orgTest.Name, opts) + require.ErrorIs(t, err, ErrRequiredKMSOptionsKeyRingID) + }) + + t.Run("Vault with valid options", func(t *testing.T) { + vaultOIDCConfig, configCleanup := createVaultOIDCConfiguration(t, client, orgTest) + t.Cleanup(configCleanup) + + opts := HYOKConfigurationsCreateOptions{ + Name: randomStringWithoutSpecialChar(t), + KEKID: randomStringWithoutSpecialChar(t), + AgentPool: agentPool, + OIDCConfiguration: &OIDCConfigurationTypeChoice{ + VaultOIDCConfiguration: vaultOIDCConfig, + }, + } + + created, err := client.HYOKConfigurations.Create(ctx, orgTest.Name, opts) + require.NoError(t, err) + require.NotNil(t, created) + assert.Equal(t, opts.Name, created.Name) + assert.Equal(t, opts.KEKID, created.KEKID) + assert.Equal(t, opts.AgentPool.ID, created.AgentPool.ID) + assert.Equal(t, opts.OIDCConfiguration.VaultOIDCConfiguration.ID, created.OIDCConfiguration.VaultOIDCConfiguration.ID) + + // Must first wait for test_failed status before revoking and deleting the HYOK config or else OIDC configs cannot be cleaned up + _, err = waitForHYOKConfigurationStatus(t, ctx, client, created.ID, HYOKConfigurationTestFailed) + require.NoError(t, err) + err = client.HYOKConfigurations.Revoke(ctx, created.ID) + require.NoError(t, err) + _, err = waitForHYOKConfigurationStatus(t, ctx, client, created.ID, HYOKConfigurationRevoked) + require.NoError(t, err, "Timed out waiting for HYOK configuration %s to revoke", created.ID) + + err = client.HYOKConfigurations.Delete(ctx, created.ID) + require.NoError(t, err) + _, err = client.HYOKConfigurations.Read(ctx, created.ID, nil) + require.ErrorIs(t, err, ErrResourceNotFound) + }) + + t.Run("Azure with valid options", func(t *testing.T) { + azureOIDCConfig, configCleanup := createAzureOIDCConfiguration(t, client, orgTest) + t.Cleanup(configCleanup) + + opts := HYOKConfigurationsCreateOptions{ + Name: randomStringWithoutSpecialChar(t), + KEKID: "https://random.vault.azure.net/keys/some-key", + AgentPool: agentPool, + OIDCConfiguration: &OIDCConfigurationTypeChoice{ + AzureOIDCConfiguration: azureOIDCConfig, + }, + } + + created, err := client.HYOKConfigurations.Create(ctx, orgTest.Name, opts) + require.NoError(t, err) + require.NotNil(t, created) + assert.Equal(t, opts.Name, created.Name) + assert.Equal(t, opts.KEKID, created.KEKID) + assert.Equal(t, opts.AgentPool.ID, created.AgentPool.ID) + assert.Equal(t, opts.OIDCConfiguration.AzureOIDCConfiguration.ID, created.OIDCConfiguration.AzureOIDCConfiguration.ID) + + // Must first wait for test_failed status before revoking and deleting the HYOK config or else OIDC configs cannot be cleaned up + _, err = waitForHYOKConfigurationStatus(t, ctx, client, created.ID, HYOKConfigurationTestFailed) + require.NoError(t, err) + err = client.HYOKConfigurations.Revoke(ctx, created.ID) + require.NoError(t, err) + _, err = waitForHYOKConfigurationStatus(t, ctx, client, created.ID, HYOKConfigurationRevoked) + require.NoError(t, err, "Timed out waiting for HYOK configuration %s to revoke", created.ID) + + err = client.HYOKConfigurations.Delete(ctx, created.ID) + require.NoError(t, err) + _, err = client.HYOKConfigurations.Read(ctx, created.ID, nil) + require.ErrorIs(t, err, ErrResourceNotFound) + }) + + t.Run("with missing KEK ID", func(t *testing.T) { + awsOIDCConfig, configCleanup := createAWSOIDCConfiguration(t, client, orgTest) + t.Cleanup(configCleanup) + + keyRegion := "us-east-1" + + opts := HYOKConfigurationsCreateOptions{ + Name: randomStringWithoutSpecialChar(t), + KMSOptions: &KMSOptions{ + KeyRegion: keyRegion, + }, + AgentPool: agentPool, + OIDCConfiguration: &OIDCConfigurationTypeChoice{ + AWSOIDCConfiguration: awsOIDCConfig, + }, + } + + _, err := client.HYOKConfigurations.Create(ctx, orgTest.Name, opts) + require.ErrorIs(t, err, ErrRequiredKEKID) + }) + + t.Run("with missing agent pool", func(t *testing.T) { + awsOIDCConfig, configCleanup := createAWSOIDCConfiguration(t, client, orgTest) + t.Cleanup(configCleanup) + + keyRegion := "us-east-1" + + opts := HYOKConfigurationsCreateOptions{ + Name: randomStringWithoutSpecialChar(t), + KMSOptions: &KMSOptions{ + KeyRegion: keyRegion, + }, + KEKID: randomStringWithoutSpecialChar(t), + OIDCConfiguration: &OIDCConfigurationTypeChoice{ + AWSOIDCConfiguration: awsOIDCConfig, + }, + } + + _, err := client.HYOKConfigurations.Create(ctx, orgTest.Name, opts) + require.ErrorIs(t, err, ErrRequiredAgentPool) + }) + + t.Run("with missing OIDC config", func(t *testing.T) { + keyRegion := "us-east-1" + + opts := HYOKConfigurationsCreateOptions{ + Name: randomStringWithoutSpecialChar(t), + KMSOptions: &KMSOptions{ + KeyRegion: keyRegion, + }, + KEKID: randomStringWithoutSpecialChar(t), + AgentPool: agentPool, + } + + _, err := client.HYOKConfigurations.Create(ctx, orgTest.Name, opts) + require.ErrorIs(t, err, ErrRequiredOIDCConfiguration) + }) +} + +func TestHyokConfigurationList(t *testing.T) { + skipHYOKIntegrationTests(t) + + client := testClient(t) + ctx := context.Background() + + // replace the environment variable with a valid organization name that has HYOK configurations + hyokOrganizationName := os.Getenv("HYOK_ORGANIZATION_NAME") + if hyokOrganizationName == "" { + t.Fatal("Export a valid HYOK_ORGANIZATION_NAME before running this test!") + } + + orgTest, err := client.Organizations.Read(ctx, hyokOrganizationName) + if err != nil { + t.Fatal(err) + } + + agentPool, agentPoolCleanup := createAgentPool(t, client, orgTest) + t.Cleanup(agentPoolCleanup) + + azureOIDC, azureOIDCCleanup := createAzureOIDCConfiguration(t, client, orgTest) + t.Cleanup(azureOIDCCleanup) + hyok1, hyokCleanup1 := azureOIDC.createHYOKConfiguration(t, client, orgTest, agentPool) + t.Cleanup(hyokCleanup1) + + awsOIDC, awsOIDCCleanup := createAWSOIDCConfiguration(t, client, orgTest) + t.Cleanup(awsOIDCCleanup) + hyok2, hyokCleanup2 := awsOIDC.createHYOKConfiguration(t, client, orgTest, agentPool) + t.Cleanup(hyokCleanup2) + + gcpOIDC, gcpOIDCCleanup := createAWSOIDCConfiguration(t, client, orgTest) + t.Cleanup(gcpOIDCCleanup) + hyok3, hyokCleanup3 := gcpOIDC.createHYOKConfiguration(t, client, orgTest, agentPool) + t.Cleanup(hyokCleanup3) + + vaultOIDC, vaultOIDCCleanup := createAWSOIDCConfiguration(t, client, orgTest) + t.Cleanup(vaultOIDCCleanup) + hyok4, hyokCleanup4 := vaultOIDC.createHYOKConfiguration(t, client, orgTest, agentPool) + t.Cleanup(hyokCleanup4) + + t.Run("without list options", func(t *testing.T) { + results, err := client.HYOKConfigurations.List(ctx, orgTest.Name, nil) + + var resultingIDs []string + for _, r := range results.Items { + resultingIDs = append(resultingIDs, r.ID) + } + require.NoError(t, err) + assert.Contains(t, resultingIDs, hyok1.ID) + assert.Contains(t, resultingIDs, hyok2.ID) + assert.Contains(t, resultingIDs, hyok3.ID) + assert.Contains(t, resultingIDs, hyok4.ID) + }) +} + +func TestHyokConfigurationRead(t *testing.T) { + skipHYOKIntegrationTests(t) + + client := testClient(t) + ctx := context.Background() + + // replace the environment variable with a valid organization name that has HYOK configurations + hyokOrganizationName := os.Getenv("HYOK_ORGANIZATION_NAME") + if hyokOrganizationName == "" { + t.Fatal("Export a valid HYOK_ORGANIZATION_NAME before running this test!") + } + + orgTest, err := client.Organizations.Read(ctx, hyokOrganizationName) + if err != nil { + t.Fatal(err) + } + + agentPool, agentPoolCleanup := createAgentPool(t, client, orgTest) + t.Cleanup(agentPoolCleanup) + + t.Run("AWS", func(t *testing.T) { + oidc, oidcCleanup := createAWSOIDCConfiguration(t, client, orgTest) + t.Cleanup(oidcCleanup) + hyok, hyokCleanup := oidc.createHYOKConfiguration(t, client, orgTest, agentPool) + t.Cleanup(hyokCleanup) + + fetched, err := client.HYOKConfigurations.Read(ctx, hyok.ID, nil) + require.NoError(t, err) + require.NotNil(t, fetched) + assert.Equal(t, hyok.Name, fetched.Name) + assert.Equal(t, hyok.KEKID, fetched.KEKID) + assert.Equal(t, hyok.KMSOptions.KeyRegion, fetched.KMSOptions.KeyRegion) + assert.Equal(t, hyok.Organization.Name, fetched.Organization.Name) + assert.Equal(t, hyok.AgentPool.ID, fetched.AgentPool.ID) + assert.Equal(t, hyok.OIDCConfiguration.AWSOIDCConfiguration.ID, fetched.OIDCConfiguration.AWSOIDCConfiguration.ID) + }) + + t.Run("Azure", func(t *testing.T) { + oidc, oidcCleanup := createAzureOIDCConfiguration(t, client, orgTest) + t.Cleanup(oidcCleanup) + hyok, hyokCleanup := oidc.createHYOKConfiguration(t, client, orgTest, agentPool) + t.Cleanup(hyokCleanup) + + fetched, err := client.HYOKConfigurations.Read(ctx, hyok.ID, nil) + require.NoError(t, err) + require.NotNil(t, fetched) + assert.Equal(t, hyok.Name, fetched.Name) + assert.Equal(t, hyok.KEKID, fetched.KEKID) + assert.Equal(t, hyok.KMSOptions, fetched.KMSOptions) + assert.Equal(t, hyok.Organization.Name, fetched.Organization.Name) + assert.Equal(t, hyok.AgentPool.ID, fetched.AgentPool.ID) + assert.Equal(t, hyok.OIDCConfiguration.AzureOIDCConfiguration.ID, fetched.OIDCConfiguration.AzureOIDCConfiguration.ID) + }) + + t.Run("GCP", func(t *testing.T) { + oidc, oidcCleanup := createGCPOIDCConfiguration(t, client, orgTest) + t.Cleanup(oidcCleanup) + hyok, hyokCleanup := oidc.createHYOKConfiguration(t, client, orgTest, agentPool) + t.Cleanup(hyokCleanup) + + fetched, err := client.HYOKConfigurations.Read(ctx, hyok.ID, nil) + require.NoError(t, err) + require.NotNil(t, fetched) + assert.Equal(t, hyok.Name, fetched.Name) + assert.Equal(t, hyok.KEKID, fetched.KEKID) + assert.Equal(t, hyok.KMSOptions.KeyLocation, fetched.KMSOptions.KeyLocation) + assert.Equal(t, hyok.KMSOptions.KeyRingID, fetched.KMSOptions.KeyRingID) + assert.Equal(t, hyok.Organization.Name, fetched.Organization.Name) + assert.Equal(t, hyok.AgentPool.ID, fetched.AgentPool.ID) + assert.Equal(t, hyok.OIDCConfiguration.GCPOIDCConfiguration.ID, fetched.OIDCConfiguration.GCPOIDCConfiguration.ID) + }) + + t.Run("Vault", func(t *testing.T) { + oidc, oidcCleanup := createVaultOIDCConfiguration(t, client, orgTest) + t.Cleanup(oidcCleanup) + hyok, hyokCleanup := oidc.createHYOKConfiguration(t, client, orgTest, agentPool) + t.Cleanup(hyokCleanup) + + fetched, err := client.HYOKConfigurations.Read(ctx, hyok.ID, nil) + require.NoError(t, err) + require.NotNil(t, fetched) + assert.Equal(t, hyok.Name, fetched.Name) + assert.Equal(t, hyok.KEKID, fetched.KEKID) + assert.Equal(t, hyok.KMSOptions, fetched.KMSOptions) + assert.Equal(t, hyok.Organization.Name, fetched.Organization.Name) + assert.Equal(t, hyok.AgentPool.ID, fetched.AgentPool.ID) + assert.Equal(t, hyok.OIDCConfiguration.VaultOIDCConfiguration.ID, fetched.OIDCConfiguration.VaultOIDCConfiguration.ID) + }) + + t.Run("fetching non-existing configuration", func(t *testing.T) { + _, err := client.HYOKConfigurations.Read(ctx, "hyokc-notreal", nil) + assert.ErrorIs(t, err, ErrResourceNotFound) + }) +} + +func TestHYOKConfigurationUpdate(t *testing.T) { + skipHYOKIntegrationTests(t) + + client := testClient(t) + ctx := context.Background() + + // replace the environment variable with a valid organization name that has HYOK configurations + hyokOrganizationName := os.Getenv("HYOK_ORGANIZATION_NAME") + if hyokOrganizationName == "" { + t.Fatal("Export a valid HYOK_ORGANIZATION_NAME before running this test!") + } + + orgTest, err := client.Organizations.Read(ctx, hyokOrganizationName) + if err != nil { + t.Fatal(err) + } + + agentPool, agentPoolCleanup := createAgentPool(t, client, orgTest) + t.Cleanup(agentPoolCleanup) + + t.Run("AWS with valid options", func(t *testing.T) { + oidc, oidcCleanup := createAWSOIDCConfiguration(t, client, orgTest) + t.Cleanup(oidcCleanup) + hyok, hyokCleanup := oidc.createHYOKConfiguration(t, client, orgTest, agentPool) + t.Cleanup(hyokCleanup) + + name := randomStringWithoutSpecialChar(t) + kekID := "arn:aws:kms:us-east-1:123456789012:key/this-is-a-bad-key" + + opts := HYOKConfigurationsUpdateOptions{ + Name: &name, + KMSOptions: &KMSOptions{ + KeyRegion: "us-east-2", + }, + KEKID: &kekID, + AgentPool: agentPool, + } + + updated, err := client.HYOKConfigurations.Update(ctx, hyok.ID, opts) + require.NoError(t, err) + assert.Equal(t, *opts.Name, updated.Name) + assert.Equal(t, *opts.KEKID, updated.KEKID) + assert.Equal(t, opts.KMSOptions.KeyRegion, updated.KMSOptions.KeyRegion) + }) + + t.Run("GCP with valid options", func(t *testing.T) { + oidc, oidcCleanup := createGCPOIDCConfiguration(t, client, orgTest) + t.Cleanup(oidcCleanup) + hyok, hyokCleanup := oidc.createHYOKConfiguration(t, client, orgTest, agentPool) + t.Cleanup(hyokCleanup) + + name := randomStringWithoutSpecialChar(t) + kekID := randomStringWithoutSpecialChar(t) + + opts := HYOKConfigurationsUpdateOptions{ + Name: &name, + KMSOptions: &KMSOptions{ + KeyLocation: "ca", + KeyRingID: randomStringWithoutSpecialChar(t), + }, + KEKID: &kekID, + AgentPool: agentPool, + } + + updated, err := client.HYOKConfigurations.Update(ctx, hyok.ID, opts) + require.NoError(t, err) + assert.Equal(t, *opts.Name, updated.Name) + assert.Equal(t, *opts.KEKID, updated.KEKID) + assert.Equal(t, opts.KMSOptions.KeyLocation, updated.KMSOptions.KeyLocation) + assert.Equal(t, opts.KMSOptions.KeyRingID, updated.KMSOptions.KeyRingID) + }) + + t.Run("Vault with valid options", func(t *testing.T) { + oidc, oidcCleanup := createVaultOIDCConfiguration(t, client, orgTest) + t.Cleanup(oidcCleanup) + hyok, hyokCleanup := oidc.createHYOKConfiguration(t, client, orgTest, agentPool) + t.Cleanup(hyokCleanup) + + name := randomStringWithoutSpecialChar(t) + kekID := randomStringWithoutSpecialChar(t) + + opts := HYOKConfigurationsUpdateOptions{ + Name: &name, + KEKID: &kekID, + AgentPool: agentPool, + } + + updated, err := client.HYOKConfigurations.Update(ctx, hyok.ID, opts) + require.NoError(t, err) + assert.Equal(t, *opts.Name, updated.Name) + assert.Equal(t, *opts.KEKID, updated.KEKID) + assert.Equal(t, opts.AgentPool.ID, updated.AgentPool.ID) + }) + + t.Run("Azure with valid options", func(t *testing.T) { + oidc, oidcCleanup := createAzureOIDCConfiguration(t, client, orgTest) + t.Cleanup(oidcCleanup) + hyok, hyokCleanup := oidc.createHYOKConfiguration(t, client, orgTest, agentPool) + t.Cleanup(hyokCleanup) + + name := randomStringWithoutSpecialChar(t) + kekID := "https://random.vault.azure.net/keys/some-key-2" + + opts := HYOKConfigurationsUpdateOptions{ + Name: &name, + KEKID: &kekID, + AgentPool: agentPool, + } + + updated, err := client.HYOKConfigurations.Update(ctx, hyok.ID, opts) + require.NoError(t, err) + assert.Equal(t, *opts.Name, updated.Name) + assert.Equal(t, *opts.KEKID, updated.KEKID) + assert.Equal(t, opts.AgentPool.ID, updated.AgentPool.ID) + }) +} diff --git a/hyok_customer_key_version.go b/hyok_customer_key_version.go new file mode 100644 index 000000000..92875c2fe --- /dev/null +++ b/hyok_customer_key_version.go @@ -0,0 +1,142 @@ +package tfe + +import ( + "context" + "fmt" + "net/url" + "time" +) + +var _ HYOKCustomerKeyVersions = (*hyokCustomerKeyVersions)(nil) + +// HYOKCustomerKeyVersions describes all the hyok customer key version related methods that the HCP Terraform API supports. +// HCP Terraform API docs: +// https://developer.hashicorp.com/terraform/cloud-docs/api-docs/hold-your-own-key/key-versions +type HYOKCustomerKeyVersions interface { + // List all hyok customer key versions associated to a HYOK configuration. + List(ctx context.Context, hyokConfigurationID string, options *HYOKCustomerKeyVersionListOptions) (*HYOKCustomerKeyVersionList, error) + + // Read a hyok customer key version by its ID. + Read(ctx context.Context, hyokCustomerKeyVersionID string) (*HYOKCustomerKeyVersion, error) + + // Revoke a hyok customer key version. + Revoke(ctx context.Context, hyokCustomerKeyVersionID string) error + + // Delete a hyok customer key version. + Delete(ctx context.Context, hyokCustomerKeyVersionID string) error +} + +// hyokCustomerKeyVersions implements HYOKCustomerKeyVersions +type hyokCustomerKeyVersions struct { + client *Client +} + +// HYOKCustomerKeyVersionList represents a list of hyok customer key versions +type HYOKCustomerKeyVersionList struct { + *Pagination + Items []*HYOKCustomerKeyVersion +} + +// HYOKCustomerKeyVersion represents the resource +type HYOKCustomerKeyVersion struct { + // Attributes + ID string `jsonapi:"primary,hyok-customer-key-versions"` + KeyVersion string `jsonapi:"attr,key-version"` + CreatedAt time.Time `jsonapi:"attr,created-at,iso8601"` + Status HYOKKeyVersionStatus `jsonapi:"attr,status"` + WorkspacesSecured int `jsonapi:"attr,workspaces-secured"` + Error string `jsonapi:"attr,error"` + + // Relationships + HYOKConfiguration *HYOKConfiguration `jsonapi:"relation,hyok-configuration"` +} + +// HYOKKeyVersionStatus represents a key version status. +type HYOKKeyVersionStatus string + +// List all available configuration version statuses. +const ( + KeyVersionStatusAvailable HYOKKeyVersionStatus = "available" + KeyVersionStatusRevoking HYOKKeyVersionStatus = "revoking" + KeyVersionStatusRevoked HYOKKeyVersionStatus = "revoked" + KeyVersionStatusRevocationFailed HYOKKeyVersionStatus = "revocation_failed" +) + +// HYOKCustomerKeyVersionListOptions represents the options for listing hyok customer key versions +type HYOKCustomerKeyVersionListOptions struct { + ListOptions + Refresh bool `url:"refresh,omitempty"` +} + +// List all hyok customer key versions. +func (s *hyokCustomerKeyVersions) List(ctx context.Context, hyokConfigurationID string, options *HYOKCustomerKeyVersionListOptions) (*HYOKCustomerKeyVersionList, error) { + if !validStringID(&hyokConfigurationID) { + return nil, ErrInvalidHYOK + } + + path := fmt.Sprintf("hyok-configurations/%s/hyok-customer-key-versions", url.PathEscape(hyokConfigurationID)) + req, err := s.client.NewRequest("GET", path, options) + if err != nil { + return nil, err + } + + kvs := &HYOKCustomerKeyVersionList{} + err = req.Do(ctx, kvs) + if err != nil { + return nil, err + } + + return kvs, nil +} + +// Read a hyok customer key version by its ID. +func (s *hyokCustomerKeyVersions) Read(ctx context.Context, hyokCustomerKeyVersionID string) (*HYOKCustomerKeyVersion, error) { + if !validStringID(&hyokCustomerKeyVersionID) { + return nil, ErrInvalidHYOKCustomerKeyVersion + } + + path := fmt.Sprintf("hyok-customer-key-versions/%s", url.PathEscape(hyokCustomerKeyVersionID)) + req, err := s.client.NewRequest("GET", path, nil) + if err != nil { + return nil, err + } + + kv := &HYOKCustomerKeyVersion{} + err = req.Do(ctx, kv) + if err != nil { + return nil, err + } + + return kv, nil +} + +// Revoke a hyok customer key version. This process is asynchronous. +// Returns `error` if there was a problem triggering the revocation. Otherwise revocation has been triggered successfully. +func (s *hyokCustomerKeyVersions) Revoke(ctx context.Context, hyokCustomerKeyVersionID string) error { + if !validStringID(&hyokCustomerKeyVersionID) { + return ErrInvalidHYOKCustomerKeyVersion + } + + path := fmt.Sprintf("hyok-customer-key-versions/%s/actions/revoke", url.PathEscape(hyokCustomerKeyVersionID)) + req, err := s.client.NewRequest("POST", path, nil) + if err != nil { + return err + } + + return req.Do(ctx, nil) +} + +// Delete a hyok customer key version. +func (s *hyokCustomerKeyVersions) Delete(ctx context.Context, hyokCustomerKeyVersionID string) error { + if !validStringID(&hyokCustomerKeyVersionID) { + return ErrInvalidHYOKCustomerKeyVersion + } + + path := fmt.Sprintf("hyok-customer-key-versions/%s", url.PathEscape(hyokCustomerKeyVersionID)) + req, err := s.client.NewRequest("DELETE", path, nil) + if err != nil { + return err + } + + return req.Do(ctx, nil) +} diff --git a/hyok_customer_key_version_integration_test.go b/hyok_customer_key_version_integration_test.go new file mode 100644 index 000000000..8c2904ef2 --- /dev/null +++ b/hyok_customer_key_version_integration_test.go @@ -0,0 +1,62 @@ +package tfe + +import ( + "context" + "os" + "testing" + + "github.com/stretchr/testify/require" +) + +// These tests are intended for local execution only, as key versions for HYOK requires specific conditions +// for tests to run successfully. To test locally: +// 1. Follow the instructions outlined in hyok_configuration_integration_test.go. +// 2. Set hyokCustomerKeyVersionID to the ID of an existing HYOK customer key version + +func TestHYOKCustomerKeyVersionsList(t *testing.T) { + skipHYOKIntegrationTests(t) + + client := testClient(t) + ctx := context.Background() + + // replace the environment variable with a valid organization name that has HYOK Customer Key Versions configurations + hyokOrganizationName := os.Getenv("HYOK_ORGANIZATION_NAME") + if hyokOrganizationName == "" { + t.Fatal("Export a valid HYOK_ORGANIZATION_NAME before running this test!") + } + + orgTest, err := client.Organizations.Read(ctx, hyokOrganizationName) + if err != nil { + t.Fatal(err) + } + + agentPool, agentPoolCleanup := createAgentPool(t, client, orgTest) + t.Cleanup(agentPoolCleanup) + + oidc, oidcCleanup := createGCPOIDCConfiguration(t, client, orgTest) + t.Cleanup(oidcCleanup) + hyok, hyokCleanup := oidc.createHYOKConfiguration(t, client, orgTest, agentPool) + t.Cleanup(hyokCleanup) + + t.Run("with no list options", func(t *testing.T) { + _, err := client.HYOKCustomerKeyVersions.List(ctx, hyok.ID, nil) + require.NoError(t, err) + }) +} + +func TestHYOKCustomerKeyVersionsRead(t *testing.T) { + skipHYOKIntegrationTests(t) + + client := testClient(t) + ctx := context.Background() + + t.Run("read an existing key version", func(t *testing.T) { + hyokCustomerKeyVersionID := os.Getenv("HYOK_CUSTOMER_KEY_VERSION_ID") + if hyokCustomerKeyVersionID == "" { + t.Fatal("Export a valid HYOK_CUSTOMER_KEY_VERSION_ID before running this test!") + } + + _, err := client.HYOKCustomerKeyVersions.Read(ctx, hyokCustomerKeyVersionID) + require.NoError(t, err) + }) +} diff --git a/hyok_encrypted_data_key.go b/hyok_encrypted_data_key.go new file mode 100644 index 000000000..0dcf94ab4 --- /dev/null +++ b/hyok_encrypted_data_key.go @@ -0,0 +1,56 @@ +package tfe + +import ( + "context" + "fmt" + "net/url" + "time" +) + +var _ HYOKEncryptedDataKeys = (*hyokEncryptedDataKeys)(nil) + +// HYOKEncryptedDataKeys describes all the hyok customer key version related methods that the HCP Terraform API supports. +// HCP Terraform API docs: +// https://developer.hashicorp.com/terraform/cloud-docs/api-docs/hold-your-own-key/encrypted-data-keys +type HYOKEncryptedDataKeys interface { + // Read a HYOK encrypted data key by its ID. + Read(ctx context.Context, hyokEncryptedDataKeyID string) (*HYOKEncryptedDataKey, error) +} + +// hyokEncryptedDataKeys implements HYOKEncryptedDataKeys +type hyokEncryptedDataKeys struct { + client *Client +} + +// HYOKEncryptedDataKey represents the resource +type HYOKEncryptedDataKey struct { + // Attributes + ID string `jsonapi:"primary,hyok-encrypted-data-keys"` + EncryptedDEK string `jsonapi:"attr,encrypted-dek"` + CustomerKeyName string `jsonapi:"attr,customer-key-name"` + CreatedAt time.Time `jsonapi:"attr,created-at,iso8601"` + + // Relationships + KeyVersion *HYOKCustomerKeyVersion `jsonapi:"relation,hyok-customer-key-versions"` +} + +// Read a HYOK encrypted data key by its ID. +func (h hyokEncryptedDataKeys) Read(ctx context.Context, hyokEncryptedDataKeyID string) (*HYOKEncryptedDataKey, error) { + if !validStringID(&hyokEncryptedDataKeyID) { + return nil, ErrInvalidHYOKEncryptedDataKey + } + + path := fmt.Sprintf("hyok-encrypted-data-keys/%s", url.PathEscape(hyokEncryptedDataKeyID)) + req, err := h.client.NewRequest("GET", path, nil) + if err != nil { + return nil, err + } + + dek := &HYOKEncryptedDataKey{} + err = req.Do(ctx, dek) + if err != nil { + return nil, err + } + + return dek, nil +} diff --git a/hyok_encrypted_data_key_integration_test.go b/hyok_encrypted_data_key_integration_test.go new file mode 100644 index 000000000..0ab7a4402 --- /dev/null +++ b/hyok_encrypted_data_key_integration_test.go @@ -0,0 +1,31 @@ +package tfe + +import ( + "context" + "os" + "testing" + + "github.com/stretchr/testify/require" +) + +// These tests are intended for local execution only, as data encryption keys for HYOK requires specific conditions +// for tests to run successfully. To test locally: +// 1. Follow the instructions outlined in hyok_configuration_integration_test.go. +// 2. Set hyokEncryptedDataKeyID to the ID of an existing data encryption key + +func TestHYOKEncryptedDataKeyRead(t *testing.T) { + skipHYOKIntegrationTests(t) + + client := testClient(t) + ctx := context.Background() + + t.Run("read an existing encrypted data key", func(t *testing.T) { + hyokEncryptedDataKeyID := os.Getenv("HYOK_ENCRYPTED_DATA_KEY_ID") + if hyokEncryptedDataKeyID == "" { + t.Fatal("Export a valid HYOK_ENCRYPTED_DATA_KEY_ID before running this test!") + } + + _, err := client.HYOKEncryptedDataKeys.Read(ctx, hyokEncryptedDataKeyID) + require.NoError(t, err) + }) +} diff --git a/mocks/state_version_mocks.go b/mocks/state_version_mocks.go index cb8073cfd..82d724441 100644 --- a/mocks/state_version_mocks.go +++ b/mocks/state_version_mocks.go @@ -216,3 +216,17 @@ func (mr *MockStateVersionsMockRecorder) Upload(ctx, workspaceID, options any) * mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Upload", reflect.TypeOf((*MockStateVersions)(nil).Upload), ctx, workspaceID, options) } + +// UploadSanitizedState mocks base method. +func (m *MockStateVersions) UploadSanitizedState(ctx context.Context, sanitizedStateUploadURL string, sanitizedState []byte) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UploadSanitizedState", ctx, sanitizedStateUploadURL, sanitizedState) + ret0, _ := ret[0].(error) + return ret0 +} + +// UploadSanitizedState indicates an expected call of UploadSanitizedState. +func (mr *MockStateVersionsMockRecorder) UploadSanitizedState(ctx, sanitizedStateUploadURL, sanitizedState any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UploadSanitizedState", reflect.TypeOf((*MockStateVersions)(nil).UploadSanitizedState), ctx, sanitizedStateUploadURL, sanitizedState) +} diff --git a/organization.go b/organization.go index b69c06457..db62049f8 100644 --- a/organization.go +++ b/organization.go @@ -116,6 +116,7 @@ type Organization struct { SendPassingStatusesForUntriggeredSpeculativePlans bool `jsonapi:"attr,send-passing-statuses-for-untriggered-speculative-plans"` RemainingTestableCount int `jsonapi:"attr,remaining-testable-count"` SpeculativePlanManagementEnabled bool `jsonapi:"attr,speculative-plan-management-enabled"` + EnforceHYOK bool `jsonapi:"attr,enforce-hyok"` // Optional: If enabled, SendPassingStatusesForUntriggeredSpeculativePlans needs to be false. AggregatedCommitStatusEnabled bool `jsonapi:"attr,aggregated-commit-status-enabled,omitempty"` // Note: This will be false for TFE versions older than v202211, where the setting was introduced. @@ -123,8 +124,9 @@ type Organization struct { AllowForceDeleteWorkspaces bool `jsonapi:"attr,allow-force-delete-workspaces"` // Relations - DefaultProject *Project `jsonapi:"relation,default-project"` - DefaultAgentPool *AgentPool `jsonapi:"relation,default-agent-pool"` + DefaultProject *Project `jsonapi:"relation,default-project"` + DefaultAgentPool *AgentPool `jsonapi:"relation,default-agent-pool"` + PrimaryHYOKConfiguration *HYOKConfiguration `jsonapi:"relation,primary-hyok-configuration,omitempty"` // Deprecated: Use DataRetentionPolicyChoice instead. DataRetentionPolicy *DataRetentionPolicy @@ -197,6 +199,8 @@ type OrganizationPermissions struct { CanUpdateAPIToken bool `jsonapi:"attr,can-update-api-token"` CanUpdateOAuth bool `jsonapi:"attr,can-update-oauth"` CanUpdateSentinel bool `jsonapi:"attr,can-update-sentinel"` + CanUpdateHYOKConfiguration bool `jsonapi:"attr,can-update-hyok-configuration"` + CanViewHYOKFeatureInfo bool `jsonapi:"attr,can-view-hyok-feature-info"` } // OrganizationListOptions represents the options for listing organizations. @@ -255,6 +259,9 @@ type OrganizationCreateOptions struct { // Optional: DefaultExecutionMode the default execution mode for workspaces DefaultExecutionMode *string `jsonapi:"attr,default-execution-mode,omitempty"` + // Optional: EnforceHYOK if HYOK is enforced for the organization. + EnforceHYOK *bool `jsonapi:"attr,enforce-hyok,omitempty"` + // Optional: StacksEnabled toggles whether stacks are enabled for the organization. This setting // is considered BETA, SUBJECT TO CHANGE, and likely unavailable to most users. StacksEnabled *bool `jsonapi:"attr,stacks-enabled,omitempty"` @@ -313,6 +320,9 @@ type OrganizationUpdateOptions struct { // Optional: DefaultAgentPoolId default agent pool for workspaces, requires DefaultExecutionMode to be set to `agent` DefaultAgentPool *AgentPool `jsonapi:"relation,default-agent-pool,omitempty"` + // Optional: EnforceHYOK if HYOK is enforced for the organization. + EnforceHYOK *bool `jsonapi:"attr,enforce-hyok,omitempty"` + // Optional: StacksEnabled toggles whether stacks are enabled for the organization. This setting // is considered BETA, SUBJECT TO CHANGE, and likely unavailable to most users. StacksEnabled *bool `jsonapi:"attr,stacks-enabled,omitempty"` diff --git a/organization_integration_test.go b/organization_integration_test.go index 0883cb2ed..0e4a076d0 100644 --- a/organization_integration_test.go +++ b/organization_integration_test.go @@ -7,6 +7,7 @@ import ( "bytes" "context" "encoding/json" + "os" "testing" "time" @@ -233,6 +234,34 @@ func TestOrganizationsRead(t *testing.T) { assert.Equal(t, org.DefaultAgentPool.ID, orgAgentTest.DefaultAgentPool.ID) }) }) + + t.Run("read primary hyok configuration of an organization", func(t *testing.T) { + skipHYOKIntegrationTests(t) + + // replace the environment variable with a valid organization name that has primary hyok configuration + hyokOrganizationName := os.Getenv("HYOK_ORGANIZATION_NAME") + if hyokOrganizationName == "" { + t.Fatal("Export a valid HYOK_ORGANIZATION_NAME before running this test!") + } + + org, err := client.Organizations.Read(ctx, hyokOrganizationName) + require.NoError(t, err) + assert.NotEmpty(t, org.PrimaryHYOKConfiguration) + }) + + t.Run("read enforce hyok of an organization", func(t *testing.T) { + skipHYOKIntegrationTests(t) + + // replace the environment variable with a valid organization name that has enforce hyok set to true or false + hyokOrganizationName := os.Getenv("HYOK_ORGANIZATION_NAME") + if hyokOrganizationName == "" { + t.Fatal("Export a valid HYOK_ORGANIZATION_NAME before running this test!") + } + + org, err := client.Organizations.Read(ctx, hyokOrganizationName) + require.NoError(t, err) + assert.True(t, org.EnforceHYOK || !org.EnforceHYOK) + }) } func TestOrganizationsUpdate(t *testing.T) { @@ -388,6 +417,38 @@ func TestOrganizationsUpdate(t *testing.T) { t.Cleanup(orgAgentTestCleanup) }) + + t.Run("update enforce hyok of an organization to true", func(t *testing.T) { + skipHYOKIntegrationTests(t) + + // replace the environment variable with a valid organization name with hyok permissions + hyokOrganizationName := os.Getenv("HYOK_ORGANIZATION_NAME") + if hyokOrganizationName == "" { + t.Fatal("Export a valid HYOK_ORGANIZATION_NAME before running this test!") + } + + org, err := client.Organizations.Update(ctx, hyokOrganizationName, OrganizationUpdateOptions{ + EnforceHYOK: Bool(true), + }) + require.NoError(t, err) + assert.True(t, org.EnforceHYOK) + }) + + t.Run("update enforce hyok of an organization to false", func(t *testing.T) { + skipHYOKIntegrationTests(t) + + // replace the environment variable with a valid organization name with hyok permissions + hyokOrganizationName := os.Getenv("HYOK_ORGANIZATION_NAME") + if hyokOrganizationName == "" { + t.Fatal("Export a valid HYOK_ORGANIZATION_NAME before running this test!") + } + + org, err := client.Organizations.Update(ctx, hyokOrganizationName, OrganizationUpdateOptions{ + EnforceHYOK: Bool(false), + }) + require.NoError(t, err) + assert.False(t, org.EnforceHYOK) + }) } func TestOrganizationsDelete(t *testing.T) { diff --git a/plan.go b/plan.go index d603fc58f..9ee2a86cd 100644 --- a/plan.go +++ b/plan.go @@ -65,7 +65,11 @@ type Plan struct { StatusTimestamps *PlanStatusTimestamps `jsonapi:"attr,status-timestamps"` // Relations - Exports []*PlanExport `jsonapi:"relation,exports"` + Exports []*PlanExport `jsonapi:"relation,exports"` + HYOKEncryptedDataKey *HYOKEncryptedDataKey `jsonapi:"relation,hyok-encrypted-data-key,omitempty"` + + // Links + Links map[string]interface{} `jsonapi:"links,omitempty"` } // PlanStatusTimestamps holds the timestamps for individual plan statuses. diff --git a/plan_integration_test.go b/plan_integration_test.go index d07f27ed5..c7a3a143a 100644 --- a/plan_integration_test.go +++ b/plan_integration_test.go @@ -8,6 +8,7 @@ import ( "context" "encoding/json" "io" + "os" "testing" "time" @@ -43,6 +44,34 @@ func TestPlansRead(t *testing.T) { assert.Nil(t, p) assert.Equal(t, err, ErrInvalidPlanID) }) + + t.Run("read hyok encrypted data key of a plan", func(t *testing.T) { + skipHYOKIntegrationTests(t) + + // replace the environment variable with a valid plan ID that has a hyok encrypted data key + hyokPlanID := os.Getenv("HYOK_PLAN_ID") + if hyokPlanID == "" { + t.Fatal("Export a valid HYOK_PLAN_ID before running this test!") + } + + p, err := client.Plans.Read(ctx, hyokPlanID) + require.NoError(t, err) + assert.NotNil(t, p.HYOKEncryptedDataKey) + }) + + t.Run("read sanitized plan of a plan", func(t *testing.T) { + skipHYOKIntegrationTests(t) + + // replace the environment variable with a valid plan ID that has a sanitized plan link + hyokPlanID := os.Getenv("HYOK_PLAN_ID") + if hyokPlanID == "" { + t.Fatal("Export a valid HYOK_PLAN_ID before running this test!") + } + + p, err := client.Plans.Read(ctx, hyokPlanID) + require.NoError(t, err) + assert.NotEmpty(t, p.Links["sanitized-plan"]) + }) } func TestPlansLogs(t *testing.T) { diff --git a/scripts/hyok-testing.sh b/scripts/hyok-testing.sh new file mode 100755 index 000000000..0cdad9e0c --- /dev/null +++ b/scripts/hyok-testing.sh @@ -0,0 +1,119 @@ +#!/bin/bash + +env="STAGING_ENVCHAIN" +pairs=( + # HYOK Attributes testing + # -- Agent Pools + "TestAgentPoolsRead:read_hyok_configurations_of_an_agent_pool" + # -- Plans + "TestPlansRead:read_hyok_encrypted_data_key_of_a_plan" + "TestPlansRead:read_sanitized_plan_of_a_plan" + # -- Workspaces + "TestWorkspacesCreate:create_workspace_with_hyok_enabled_set_to_false" + "TestWorkspacesCreate:create_workspace_with_hyok_enabled_set_to_true" + "TestWorkspacesRead:read_hyok_enabled_of_a_workspace" + "TestWorkspacesRead:read_hyok_encrypted_data_key_of_a_workspace" + "TestWorkspacesUpdate:update_hyok_enabled_of_a_workspace_from_false_to_false" + "TestWorkspacesUpdate:update_hyok_enabled_of_a_workspace_from_false_to_true" + "TestWorkspacesUpdate:update_hyok_enabled_of_a_workspace_from_true_to_true" + "TestWorkspacesUpdate:update_hyok_enabled_of_a_workspace_from_true_to_false" + # -- Organizations + "TestOrganizationsRead:read_primary_hyok_configuration_of_an_organization" + "TestOrganizationsRead:read_enforce_hyok_of_an_organization" + "TestOrganizationsUpdate:update_enforce_hyok_of_an_organization_to_true" + "TestOrganizationsUpdate:update_enforce_hyok_of_an_organization_to_false" + # -- State Versions + "TestStateVersionsRead:read_encrypted_state_download_url_of_a_state_version" + "TestStateVersionsRead:read_sanitized_state_download_url_of_a_state_version" + "TestStateVersionsRead:read_hyok_encrypted_data_key_of_a_state_version" + "TestStateVersionsUpload:uploading_state_using_SanitizedStateUploadURL_and_verifying_SanitizedStateDownloadURL_exists" + "TestStateVersionsUpload:SanitizedStateUploadURL_is_required_when_uploading_sanitized_state" + + # AWS OIDC Configuration testing + "TestAWSOIDCConfigurationCreateDelete:with_valid_options" + "TestAWSOIDCConfigurationCreateDelete:missing_role_ARN" + "TestAWSOIDCConfigurationRead:fetch_existing_configuration" + "TestAWSOIDCConfigurationRead:fetching_non-existing_configuration" + "TestAWSOIDCConfigurationsUpdate:with_valid_options" + "TestAWSOIDCConfigurationsUpdate:missing_role_ARN" + + # Azure OIDC Configuration testing + "TestAzureOIDCConfigurationCreateDelete:with_valid_options" + "TestAzureOIDCConfigurationCreateDelete:missing_client_ID" + "TestAzureOIDCConfigurationCreateDelete:missing_subscription_ID" + "TestAzureOIDCConfigurationCreateDelete:missing_tenant_ID" + "TestAzureOIDCConfigurationRead:fetch_existing_configuration" + "TestAzureOIDCConfigurationRead:fetching_non-existing_configuration" + "TestAzureOIDCConfigurationUpdate:update_all_fields" + "TestAzureOIDCConfigurationUpdate:client_ID_not_provided" + "TestAzureOIDCConfigurationUpdate:subscription_ID_not_provided" + "TestAzureOIDCConfigurationUpdate:tenant_ID_not_provided" + + # GCP OIDC Configuration testing + "TestGCPOIDCConfigurationCreateDelete:with_valid_options" + "TestGCPOIDCConfigurationCreateDelete:missing_workload_provider_name" + "TestGCPOIDCConfigurationCreateDelete:missing_service_account_email" + "TestGCPOIDCConfigurationCreateDelete:missing_project_number" + "TestGCPOIDCConfigurationRead:fetch_existing_configuration" + "TestGCPOIDCConfigurationRead:fetching_non-existing_configuration" + "TestGCPOIDCConfigurationUpdate:update_all_fields" + "TestGCPOIDCConfigurationUpdate:workload_provider_name_not_provided" + "TestGCPOIDCConfigurationUpdate:service_account_email_not_provided" + "TestGCPOIDCConfigurationUpdate:project_number_not_provided" + + # Vault OIDC Configuration testing + "TestVaultOIDCConfigurationCreateDelete:with_valid_options" + "TestVaultOIDCConfigurationCreateDelete:missing_address" + "TestVaultOIDCConfigurationCreateDelete:missing_role_name" + "TestVaultOIDCConfigurationRead:fetch_existing_configuration" + "TestVaultOIDCConfigurationRead:fetching_non-existing_configuration" + "TestVaultOIDCConfigurationUpdate:update_all_fields" + "TestVaultOIDCConfigurationUpdate:address_not_provided" + "TestVaultOIDCConfigurationUpdate:role_name_not_provided" + "TestVaultOIDCConfigurationUpdate:namespace_not_provided" + "TestVaultOIDCConfigurationUpdate:JWTAuthPath_not_provided" + "TestVaultOIDCConfigurationUpdate:TLSCACertificate_not_provided" + + # HYOK Customer Key Version testing + "TestHYOKCustomerKeyVersionsList:with_no_list_options" + "TestHYOKCustomerKeyVersionsRead:read_an_existing_key_version" + + # HYOK Encrypted Data Key testing + "TestHYOKEncryptedDataKeyRead:read_an_existing_encrypted_data_key" + + # HYOK Configurations testing + "TestHYOKConfigurationCreateRevokeDelete:AWS_with_valid_options" + "TestHYOKConfigurationCreateRevokeDelete:AWS_with_missing_key_region" + "TestHYOKConfigurationCreateRevokeDelete:GCP_with_valid_options" + "TestHYOKConfigurationCreateRevokeDelete:GCP_with_missing_key_location" + "TestHYOKConfigurationCreateRevokeDelete:GCP_with_missing_key_ring_ID" + "TestHYOKConfigurationCreateRevokeDelete:Vault_with_valid_options" + "TestHYOKConfigurationCreateRevokeDelete:Azure_with_valid_options" + "TestHYOKConfigurationCreateRevokeDelete:with_missing_KEK_ID" + "TestHYOKConfigurationCreateRevokeDelete:with_missing_agent_pool" + "TestHYOKConfigurationCreateRevokeDelete:with_missing_OIDC_config" + "TestHyokConfigurationList:without_list_options" + "TestHyokConfigurationRead:AWS" + "TestHyokConfigurationRead:Azure" + "TestHyokConfigurationRead:GCP" + "TestHyokConfigurationRead:Vault" + "TestHyokConfigurationRead:fetching_non-existing_configuration" + "TestHYOKConfigurationUpdate:AWS_with_valid_options" + "TestHYOKConfigurationUpdate:GCP_with_valid_options" + "TestHYOKConfigurationUpdate:Vault_with_valid_options" + "TestHYOKConfigurationUpdate:Azure_with_valid_options" +) + +for pair in "${pairs[@]}"; do + IFS=':' read -r parent child <<< "$pair" + result=$(envchain ${env} go test -run "^${parent}$/^${child}$" -v ./...) + status="\033[33mUNKNOWN\033[0m" # yellow by default + if echo "$result" | grep -q "^ --- SKIP: ${parent}/${child}"; then + status="\033[33mSKIP\033[0m" # yellow + elif echo "$result" | grep -q "^--- PASS: ${parent}"; then + status="\033[32mPASS\033[0m" # green + elif echo "$result" | grep -q "^--- FAIL: ${parent}"; then + status="\033[31mFAIL\033[0m" # red + fi + echo -e "\033[34m${parent}/${child}\033[0m: ${status}" +done \ No newline at end of file diff --git a/state_version.go b/state_version.go index a90f8a0a0..fdd8c9b57 100644 --- a/state_version.go +++ b/state_version.go @@ -43,6 +43,10 @@ type StateVersions interface { // This is a more resilient form of Create and is the recommended approach to creating state versions. Upload(ctx context.Context, workspaceID string, options StateVersionUploadOptions) (*StateVersion, error) + // UploadSanitizedState uploads a sanitized version of the state to the provided sanitized state upload url. + // The SanitizedStateUploadURL cannot be empty. + UploadSanitizedState(ctx context.Context, sanitizedStateUploadURL string, sanitizedState []byte) error + // Read a state version by its ID. Read(ctx context.Context, svID string) (*StateVersion, error) @@ -89,17 +93,21 @@ type StateVersionList struct { // StateVersion represents a Terraform Enterprise state version. type StateVersion struct { - ID string `jsonapi:"primary,state-versions"` - CreatedAt time.Time `jsonapi:"attr,created-at,iso8601"` - DownloadURL string `jsonapi:"attr,hosted-state-download-url"` - UploadURL string `jsonapi:"attr,hosted-state-upload-url"` - Status StateVersionStatus `jsonapi:"attr,status"` - JSONUploadURL string `jsonapi:"attr,hosted-json-state-upload-url"` - JSONDownloadURL string `jsonapi:"attr,hosted-json-state-download-url"` - Serial int64 `jsonapi:"attr,serial"` - VCSCommitSHA string `jsonapi:"attr,vcs-commit-sha"` - VCSCommitURL string `jsonapi:"attr,vcs-commit-url"` - BillableRUMCount *uint32 `jsonapi:"attr,billable-rum-count"` + ID string `jsonapi:"primary,state-versions"` + CreatedAt time.Time `jsonapi:"attr,created-at,iso8601"` + DownloadURL string `jsonapi:"attr,hosted-state-download-url"` + UploadURL string `jsonapi:"attr,hosted-state-upload-url"` + Status StateVersionStatus `jsonapi:"attr,status"` + JSONUploadURL string `jsonapi:"attr,hosted-json-state-upload-url"` + JSONDownloadURL string `jsonapi:"attr,hosted-json-state-download-url"` + Serial int64 `jsonapi:"attr,serial"` + VCSCommitSHA string `jsonapi:"attr,vcs-commit-sha"` + VCSCommitURL string `jsonapi:"attr,vcs-commit-url"` + BillableRUMCount *uint32 `jsonapi:"attr,billable-rum-count"` + EncryptedStateDownloadURL string `jsonapi:"attr,encrypted-state-download-url,omitempty"` + SanitizedStateUploadURL string `jsonapi:"attr,sanitized-state-upload-url,omitempty"` + SanitizedStateDownloadURL string `jsonapi:"attr,sanitized-state-download-url,omitempty"` + // Whether HCP Terraform has finished populating any StateVersion fields that required async processing. // If `false`, some fields may appear empty even if they should actually contain data; see comments on // individual fields for details. @@ -115,8 +123,9 @@ type StateVersion struct { Resources []*StateVersionResources `jsonapi:"attr,resources"` // Relations - Run *Run `jsonapi:"relation,run"` - Outputs []*StateVersionOutput `jsonapi:"relation,outputs"` + Run *Run `jsonapi:"relation,run"` + Outputs []*StateVersionOutput `jsonapi:"relation,outputs"` + HYOKEncryptedDataKey *HYOKEncryptedDataKey `jsonapi:"relation,hyok-encrypted-data-key,omitempty"` } // StateVersionOutputsList represents a list of StateVersionOutput items. @@ -313,6 +322,16 @@ func (s *stateVersions) Upload(ctx context.Context, workspaceID string, options return s.Read(ctx, sv.ID) } +// UploadSanitizedState uploads a sanitized version of the state to the provided sanitized state upload url. +// The SanitizedStateUploadURL cannot be empty. +func (s *stateVersions) UploadSanitizedState(ctx context.Context, sanitizedStateUploadURL string, sanitizedState []byte) error { + if sanitizedStateUploadURL == "" { + return ErrSanitizedStateUploadURLMissing + } + + return s.client.doForeignPUTRequest(ctx, sanitizedStateUploadURL, bytes.NewReader(sanitizedState)) +} + // Read a state version by its ID. func (s *stateVersions) ReadWithOptions(ctx context.Context, svID string, options *StateVersionReadOptions) (*StateVersion, error) { if !validStringID(&svID) { diff --git a/state_version_integration_test.go b/state_version_integration_test.go index d740abe9d..a56f077da 100644 --- a/state_version_integration_test.go +++ b/state_version_integration_test.go @@ -187,6 +187,79 @@ func TestStateVersionsUpload(t *testing.T) { }) require.ErrorIs(t, err, ErrRequiredRawState) }) + + t.Run("uploading state using SanitizedStateUploadURL and verifying SanitizedStateDownloadURL exists", func(t *testing.T) { + skipHYOKIntegrationTests(t) + + hyokOrganizationName := os.Getenv("HYOK_ORGANIZATION_NAME") + if hyokOrganizationName == "" { + t.Fatal("Export a valid HYOK_ORGANIZATION_NAME before running this test!") + } + + hyokWorkspaceName := os.Getenv("HYOK_WORKSPACE_NAME") + if hyokWorkspaceName == "" { + t.Fatal("Export a valid HYOK_WORKSPACE_NAME before running this test!") + } + + w, err := client.Workspaces.Read(context.Background(), hyokOrganizationName, hyokWorkspaceName) + if err != nil { + t.Fatal(err) + } + + ctx := context.Background() + _, err = client.Workspaces.Lock(ctx, w.ID, WorkspaceLockOptions{}) + if err != nil { + t.Fatal(err) + } + + sv, err := client.StateVersions.Create(ctx, w.ID, StateVersionCreateOptions{ + Lineage: String("741c4949-60b9-5bb1-5bf8-b14f4bb14af3"), + MD5: String(fmt.Sprintf("%x", md5.Sum(state))), + Serial: Int64(1), + }) + require.NoError(t, err) + + err = client.StateVersions.UploadSanitizedState(ctx, sv.SanitizedStateUploadURL, jsonState) + require.NoError(t, err) + + // Get a refreshed view of the configuration version. + sv, err = client.StateVersions.Read(ctx, sv.ID) + require.NoError(t, err) + + assert.NotEmpty(t, sv.SanitizedStateDownloadURL) + assert.Empty(t, sv.SanitizedStateUploadURL) + + _, err = client.Workspaces.ForceUnlock(ctx, w.ID) + if err != nil { + t.Fatal(err) + } + }) + + t.Run("SanitizedStateUploadURL is required when uploading sanitized state", func(t *testing.T) { + skipHYOKIntegrationTests(t) + + ctx := context.Background() + _, err := client.Workspaces.Lock(ctx, wTest.ID, WorkspaceLockOptions{}) + if err != nil { + t.Fatal(err) + } + + sv, err := client.StateVersions.Create(ctx, wTest.ID, StateVersionCreateOptions{ + Lineage: String("741c4949-60b9-5bb1-5bf8-b14f4bb14af3"), + MD5: String(fmt.Sprintf("%x", md5.Sum(state))), + Serial: Int64(1), + }) + require.NoError(t, err) + + err = client.StateVersions.UploadSanitizedState(ctx, sv.SanitizedStateUploadURL, state) + require.Error(t, err, ErrSanitizedStateUploadURLMissing) + + // Workspaces must be force-unlocked when there is a pending state version + _, err = client.Workspaces.ForceUnlock(ctx, wTest.ID) + if err != nil { + t.Fatal(err) + } + }) } func TestStateVersionsCreate(t *testing.T) { @@ -465,6 +538,45 @@ func TestStateVersionsRead(t *testing.T) { assert.Nil(t, sv) assert.Equal(t, err, ErrInvalidStateVerID) }) + + t.Run("read encrypted state download url of a state version", func(t *testing.T) { + skipHYOKIntegrationTests(t) + + hyokStateVersionID := os.Getenv("HYOK_STATE_VERSION_ID") + if hyokStateVersionID == "" { + t.Fatal("Export a valid HYOK_STATE_VERSION_ID before running this test!") + } + + sv, err := client.StateVersions.Read(ctx, hyokStateVersionID) + require.NoError(t, err) + assert.NotEmpty(t, sv.EncryptedStateDownloadURL) + }) + + t.Run("read sanitized state download url of a state version", func(t *testing.T) { + skipHYOKIntegrationTests(t) + + hyokStateVersionID := os.Getenv("HYOK_STATE_VERSION_ID") + if hyokStateVersionID == "" { + t.Fatal("Export a valid HYOK_STATE_VERSION_ID before running this test!") + } + + sv, err := client.StateVersions.Read(ctx, hyokStateVersionID) + require.NoError(t, err) + assert.NotEmpty(t, sv.SanitizedStateDownloadURL) + }) + + t.Run("read hyok encrypted data key of a state version", func(t *testing.T) { + skipHYOKIntegrationTests(t) + + hyokStateVersionID := os.Getenv("HYOK_STATE_VERSION_ID") + if hyokStateVersionID == "" { + t.Fatal("Export a valid HYOK_STATE_VERSION_ID before running this test!") + } + + sv, err := client.StateVersions.Read(ctx, hyokStateVersionID) + require.NoError(t, err) + assert.NotEmpty(t, sv.HYOKEncryptedDataKey) + }) } func TestStateVersionsReadWithOptions(t *testing.T) { diff --git a/tfe.go b/tfe.go index 10e5a8ec1..4b2ae1038 100644 --- a/tfe.go +++ b/tfe.go @@ -129,6 +129,10 @@ type Client struct { AgentTokens AgentTokens Applies Applies AuditTrails AuditTrails + AWSOIDCConfigurations AWSOIDCConfigurations + GCPOIDCConfigurations GCPOIDCConfigurations + AzureOIDCConfigurations AzureOIDCConfigurations + VaultOIDCConfigurations VaultOIDCConfigurations Comments Comments ConfigurationVersions ConfigurationVersions CostEstimates CostEstimates @@ -165,6 +169,9 @@ type Client struct { RunTriggers RunTriggers SSHKeys SSHKeys Stacks Stacks + HYOKConfigurations HYOKConfigurations + HYOKCustomerKeyVersions HYOKCustomerKeyVersions + HYOKEncryptedDataKeys HYOKEncryptedDataKeys StackConfigurations StackConfigurations StackConfigurationSummaries StackConfigurationSummaries StackDeployments StackDeployments @@ -463,6 +470,10 @@ func NewClient(cfg *Config) (*Client, error) { client.AgentTokens = &agentTokens{client: client} client.Applies = &applies{client: client} client.AuditTrails = &auditTrails{client: client} + client.AWSOIDCConfigurations = &awsOIDCConfigurations{client: client} + client.GCPOIDCConfigurations = &gcpOIDCConfigurations{client: client} + client.AzureOIDCConfigurations = &azureOIDCConfigurations{client: client} + client.VaultOIDCConfigurations = &vaultOIDCConfigurations{client: client} client.Comments = &comments{client: client} client.ConfigurationVersions = &configurationVersions{client: client} client.CostEstimates = &costEstimates{client: client} @@ -500,6 +511,9 @@ func NewClient(cfg *Config) (*Client, error) { client.RunTriggers = &runTriggers{client: client} client.SSHKeys = &sshKeys{client: client} client.Stacks = &stacks{client: client} + client.HYOKConfigurations = &hyokConfigurations{client: client} + client.HYOKCustomerKeyVersions = &hyokCustomerKeyVersions{client: client} + client.HYOKEncryptedDataKeys = &hyokEncryptedDataKeys{client: client} client.StackConfigurations = &stackConfigurations{client: client} client.StackConfigurationSummaries = &stackConfigurationSummaries{client: client} client.StackDeployments = &stackDeployments{client: client} diff --git a/vault_oidc_configuration.go b/vault_oidc_configuration.go new file mode 100644 index 000000000..68b75900f --- /dev/null +++ b/vault_oidc_configuration.go @@ -0,0 +1,149 @@ +package tfe + +import ( + "context" + "fmt" + "net/url" +) + +// VaultOIDCConfigurations describes all the Vault OIDC configuration related methods that the HCP Terraform API supports. +// HCP Terraform API docs: +// https://developer.hashicorp.com/terraform/cloud-docs/api-docs/hold-your-own-key/oidc-configurations/vault +type VaultOIDCConfigurations interface { + Create(ctx context.Context, organization string, options VaultOIDCConfigurationCreateOptions) (*VaultOIDCConfiguration, error) + + Read(ctx context.Context, oidcID string) (*VaultOIDCConfiguration, error) + + Update(ctx context.Context, oidcID string, options VaultOIDCConfigurationUpdateOptions) (*VaultOIDCConfiguration, error) + + Delete(ctx context.Context, oidcID string) error +} + +type vaultOIDCConfigurations struct { + client *Client +} + +var _ VaultOIDCConfigurations = &vaultOIDCConfigurations{} + +type VaultOIDCConfiguration struct { + ID string `jsonapi:"primary,vault-oidc-configurations"` + Address string `jsonapi:"attr,address"` + RoleName string `jsonapi:"attr,role"` + Namespace string `jsonapi:"attr,namespace"` + JWTAuthPath string `jsonapi:"attr,auth-path"` + TLSCACertificate string `jsonapi:"attr,encoded-cacert"` + + Organization *Organization `jsonapi:"relation,organization"` +} + +type VaultOIDCConfigurationCreateOptions struct { + // Type is a public field utilized by JSON:API to + // set the resource type via the field tag. + // It is not a user-defined value and does not need to be set. + // https://jsonapi.org/format/#crud-creating + Type string `jsonapi:"primary,vault-oidc-configurations"` + + // Attributes + Address string `jsonapi:"attr,address"` + RoleName string `jsonapi:"attr,role"` + Namespace string `jsonapi:"attr,namespace"` + JWTAuthPath string `jsonapi:"attr,auth-path"` + TLSCACertificate string `jsonapi:"attr,encoded-cacert"` +} + +type VaultOIDCConfigurationUpdateOptions struct { + // Type is a public field utilized by JSON:API to + // set the resource type via the field tag. + // It is not a user-defined value and does not need to be set. + // https://jsonapi.org/format/#crud-creating + Type string `jsonapi:"primary,vault-oidc-configurations"` + + // Attributes + Address *string `jsonapi:"attr,address,omitempty"` + RoleName *string `jsonapi:"attr,role,omitempty"` + Namespace *string `jsonapi:"attr,namespace,omitempty"` + JWTAuthPath *string `jsonapi:"attr,auth-path,omitempty"` + TLSCACertificate *string `jsonapi:"attr,encoded-cacert,omitempty"` +} + +func (o *VaultOIDCConfigurationCreateOptions) valid() error { + if o.Address == "" { + return ErrRequiredVaultAddress + } + + if o.RoleName == "" { + return ErrRequiredRoleName + } + + return nil +} + +func (voc *vaultOIDCConfigurations) Create(ctx context.Context, organization string, options VaultOIDCConfigurationCreateOptions) (*VaultOIDCConfiguration, error) { + if !validStringID(&organization) { + return nil, ErrInvalidOrg + } + + if err := options.valid(); err != nil { + return nil, err + } + + req, err := voc.client.NewRequest("POST", fmt.Sprintf("organizations/%s/oidc-configurations", organization), &options) + if err != nil { + return nil, err + } + + vaultOIDCConfiguration := &VaultOIDCConfiguration{} + err = req.Do(ctx, vaultOIDCConfiguration) + if err != nil { + return nil, err + } + + return vaultOIDCConfiguration, nil +} + +func (voc *vaultOIDCConfigurations) Read(ctx context.Context, oidcID string) (*VaultOIDCConfiguration, error) { + req, err := voc.client.NewRequest("GET", fmt.Sprintf(OIDCConfigPathFormat, url.PathEscape(oidcID)), nil) + if err != nil { + return nil, err + } + + vaultOIDCConfiguration := &VaultOIDCConfiguration{} + err = req.Do(ctx, vaultOIDCConfiguration) + if err != nil { + return nil, err + } + + return vaultOIDCConfiguration, nil +} + +func (voc *vaultOIDCConfigurations) Update(ctx context.Context, oidcID string, options VaultOIDCConfigurationUpdateOptions) (*VaultOIDCConfiguration, error) { + if !validStringID(&oidcID) { + return nil, ErrInvalidOIDC + } + + req, err := voc.client.NewRequest("PATCH", fmt.Sprintf(OIDCConfigPathFormat, url.PathEscape(oidcID)), &options) + if err != nil { + return nil, err + } + + vaultOIDCConfiguration := &VaultOIDCConfiguration{} + err = req.Do(ctx, vaultOIDCConfiguration) + if err != nil { + return nil, err + } + + return vaultOIDCConfiguration, nil +} + +func (voc *vaultOIDCConfigurations) Delete(ctx context.Context, oidcID string) error { + if !validStringID(&oidcID) { + return ErrInvalidOIDC + } + + req, err := voc.client.NewRequest("DELETE", fmt.Sprintf(OIDCConfigPathFormat, url.PathEscape(oidcID)), nil) + if err != nil { + return err + } + + return req.Do(ctx, nil) +} diff --git a/vault_oidc_configuration_integration_test.go b/vault_oidc_configuration_integration_test.go new file mode 100644 index 000000000..9e8d061bb --- /dev/null +++ b/vault_oidc_configuration_integration_test.go @@ -0,0 +1,279 @@ +package tfe + +import ( + "context" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// These tests are intended for local execution only, as OIDC configurations for HYOK requires specific conditions. +// To run them locally, follow the instructions outlined in hyok_configuration_integration_test.go + +func TestVaultOIDCConfigurationCreateDelete(t *testing.T) { + skipHYOKIntegrationTests(t) + + client := testClient(t) + ctx := context.Background() + + // replace the environment variable with a valid organization name that has Vault OIDC HYOK configurations + hyokOrganizationName := os.Getenv("HYOK_ORGANIZATION_NAME") + if hyokOrganizationName == "" { + t.Fatal("Export a valid HYOK_ORGANIZATION_NAME before running this test!") + } + + orgTest, err := client.Organizations.Read(ctx, hyokOrganizationName) + if err != nil { + t.Fatal(err) + } + + t.Run("with valid options", func(t *testing.T) { + opts := VaultOIDCConfigurationCreateOptions{ + Address: "https://vault.example.com", + RoleName: "vault-role-name", + Namespace: "admin", + JWTAuthPath: "jwt", + TLSCACertificate: randomString(t), + } + + oidcConfig, err := client.VaultOIDCConfigurations.Create(ctx, orgTest.Name, opts) + require.NoError(t, err) + require.NotNil(t, oidcConfig) + assert.Equal(t, opts.Address, oidcConfig.Address) + assert.Equal(t, opts.RoleName, oidcConfig.RoleName) + assert.Equal(t, opts.Namespace, oidcConfig.Namespace) + assert.Equal(t, opts.JWTAuthPath, oidcConfig.JWTAuthPath) + + // delete the created configuration + err = client.VaultOIDCConfigurations.Delete(ctx, oidcConfig.ID) + require.NoError(t, err) + }) + + t.Run("missing address", func(t *testing.T) { + opts := VaultOIDCConfigurationCreateOptions{ + RoleName: "vault-role-name", + Namespace: "admin", + JWTAuthPath: "jwt", + TLSCACertificate: randomString(t), + } + + _, err := client.VaultOIDCConfigurations.Create(ctx, orgTest.Name, opts) + assert.ErrorIs(t, err, ErrRequiredVaultAddress) + }) + + t.Run("missing role name", func(t *testing.T) { + opts := VaultOIDCConfigurationCreateOptions{ + Address: "https://vault.example.com", + Namespace: "admin", + JWTAuthPath: "jwt", + TLSCACertificate: randomString(t), + } + + _, err := client.VaultOIDCConfigurations.Create(ctx, orgTest.Name, opts) + assert.ErrorIs(t, err, ErrRequiredRoleName) + }) +} + +func TestVaultOIDCConfigurationRead(t *testing.T) { + skipHYOKIntegrationTests(t) + + client := testClient(t) + ctx := context.Background() + + // replace the environment variable with a valid organization name that has Vault OIDC HYOK configurations + hyokOrganizationName := os.Getenv("HYOK_ORGANIZATION_NAME") + if hyokOrganizationName == "" { + t.Fatal("Export a valid HYOK_ORGANIZATION_NAME before running this test!") + } + + orgTest, err := client.Organizations.Read(ctx, hyokOrganizationName) + if err != nil { + t.Fatal(err) + } + + oidcConfig, oidcConfigCleanup := createVaultOIDCConfiguration(t, client, orgTest) + t.Cleanup(oidcConfigCleanup) + + t.Run("fetch existing configuration", func(t *testing.T) { + fetched, err := client.VaultOIDCConfigurations.Read(ctx, oidcConfig.ID) + require.NoError(t, err) + require.NotEmpty(t, fetched) + }) + + t.Run("fetching non-existing configuration", func(t *testing.T) { + _, err := client.VaultOIDCConfigurations.Read(ctx, "voidc-notreal") + assert.ErrorIs(t, err, ErrResourceNotFound) + }) +} + +func TestVaultOIDCConfigurationUpdate(t *testing.T) { + skipHYOKIntegrationTests(t) + + client := testClient(t) + ctx := context.Background() + + // replace the environment variable with a valid organization name that has Vault OIDC HYOK configurations + hyokOrganizationName := os.Getenv("HYOK_ORGANIZATION_NAME") + if hyokOrganizationName == "" { + t.Fatal("Export a valid HYOK_ORGANIZATION_NAME before running this test!") + } + + orgTest, err := client.Organizations.Read(ctx, hyokOrganizationName) + if err != nil { + t.Fatal(err) + } + + t.Run("update all fields", func(t *testing.T) { + oidcConfig, oidcConfigCleanup := createVaultOIDCConfiguration(t, client, orgTest) + t.Cleanup(oidcConfigCleanup) + + address := randomString(t) + roleName := randomString(t) + namespace := randomString(t) + jwtAuthPath := randomString(t) + tlscaCertificate := randomString(t) + + opts := VaultOIDCConfigurationUpdateOptions{ + Address: &address, + RoleName: &roleName, + Namespace: &namespace, + JWTAuthPath: &jwtAuthPath, + TLSCACertificate: &tlscaCertificate, + } + updated, err := client.VaultOIDCConfigurations.Update(ctx, oidcConfig.ID, opts) + require.NoError(t, err) + require.NotEmpty(t, updated) + assert.Equal(t, *opts.Address, updated.Address) + assert.Equal(t, *opts.RoleName, updated.RoleName) + assert.Equal(t, *opts.Namespace, updated.Namespace) + assert.Equal(t, *opts.JWTAuthPath, updated.JWTAuthPath) + assert.Equal(t, *opts.TLSCACertificate, updated.TLSCACertificate) + }) + + t.Run("address not provided", func(t *testing.T) { + oidcConfig, oidcConfigCleanup := createVaultOIDCConfiguration(t, client, orgTest) + t.Cleanup(oidcConfigCleanup) + + roleName := randomString(t) + namespace := randomString(t) + jwtAuthPath := randomString(t) + tlscaCertificate := randomString(t) + + opts := VaultOIDCConfigurationUpdateOptions{ + RoleName: &roleName, + Namespace: &namespace, + JWTAuthPath: &jwtAuthPath, + TLSCACertificate: &tlscaCertificate, + } + updated, err := client.VaultOIDCConfigurations.Update(ctx, oidcConfig.ID, opts) + require.NoError(t, err) + require.NotEmpty(t, updated) + assert.Equal(t, oidcConfig.Address, updated.Address) // not updated + assert.Equal(t, *opts.RoleName, updated.RoleName) + assert.Equal(t, *opts.Namespace, updated.Namespace) + assert.Equal(t, *opts.JWTAuthPath, updated.JWTAuthPath) + assert.Equal(t, *opts.TLSCACertificate, updated.TLSCACertificate) + }) + + t.Run("role name not provided", func(t *testing.T) { + oidcConfig, oidcConfigCleanup := createVaultOIDCConfiguration(t, client, orgTest) + t.Cleanup(oidcConfigCleanup) + + address := randomString(t) + namespace := randomString(t) + jwtAuthPath := randomString(t) + tlscaCertificate := randomString(t) + + opts := VaultOIDCConfigurationUpdateOptions{ + Address: &address, + Namespace: &namespace, + JWTAuthPath: &jwtAuthPath, + TLSCACertificate: &tlscaCertificate, + } + updated, err := client.VaultOIDCConfigurations.Update(ctx, oidcConfig.ID, opts) + require.NoError(t, err) + require.NotEmpty(t, updated) + assert.Equal(t, *opts.Address, updated.Address) + assert.Equal(t, oidcConfig.RoleName, updated.RoleName) // not updated + assert.Equal(t, *opts.Namespace, updated.Namespace) + assert.Equal(t, *opts.JWTAuthPath, updated.JWTAuthPath) + assert.Equal(t, *opts.TLSCACertificate, updated.TLSCACertificate) + }) + + t.Run("namespace not provided", func(t *testing.T) { + oidcConfig, oidcConfigCleanup := createVaultOIDCConfiguration(t, client, orgTest) + t.Cleanup(oidcConfigCleanup) + + address := randomString(t) + roleName := randomString(t) + jwtAuthPath := randomString(t) + tlscaCertificate := randomString(t) + + opts := VaultOIDCConfigurationUpdateOptions{ + Address: &address, + RoleName: &roleName, + JWTAuthPath: &jwtAuthPath, + TLSCACertificate: &tlscaCertificate, + } + updated, err := client.VaultOIDCConfigurations.Update(ctx, oidcConfig.ID, opts) + require.NoError(t, err) + require.NotEmpty(t, updated) + assert.Equal(t, *opts.Address, updated.Address) + assert.Equal(t, *opts.RoleName, updated.RoleName) + assert.Equal(t, oidcConfig.Namespace, updated.Namespace) // not updated + assert.Equal(t, *opts.JWTAuthPath, updated.JWTAuthPath) + assert.Equal(t, *opts.TLSCACertificate, updated.TLSCACertificate) + }) + + t.Run("JWTAuthPath not provided", func(t *testing.T) { + oidcConfig, oidcConfigCleanup := createVaultOIDCConfiguration(t, client, orgTest) + t.Cleanup(oidcConfigCleanup) + + address := randomString(t) + roleName := randomString(t) + namespace := randomString(t) + tlscaCertificate := randomString(t) + + opts := VaultOIDCConfigurationUpdateOptions{ + Address: &address, + RoleName: &roleName, + Namespace: &namespace, + TLSCACertificate: &tlscaCertificate, + } + updated, err := client.VaultOIDCConfigurations.Update(ctx, oidcConfig.ID, opts) + require.NoError(t, err) + require.NotEmpty(t, updated) + assert.Equal(t, *opts.Address, updated.Address) + assert.Equal(t, *opts.RoleName, updated.RoleName) + assert.Equal(t, *opts.Namespace, updated.Namespace) + assert.Equal(t, oidcConfig.JWTAuthPath, updated.JWTAuthPath) // not updated + assert.Equal(t, *opts.TLSCACertificate, updated.TLSCACertificate) + }) + + t.Run("TLSCACertificate not provided", func(t *testing.T) { + oidcConfig, oidcConfigCleanup := createVaultOIDCConfiguration(t, client, orgTest) + t.Cleanup(oidcConfigCleanup) + + address := randomString(t) + roleName := randomString(t) + namespace := randomString(t) + jwtAuthPath := randomString(t) + + opts := VaultOIDCConfigurationUpdateOptions{ + Address: &address, + RoleName: &roleName, + Namespace: &namespace, + JWTAuthPath: &jwtAuthPath, + } + updated, err := client.VaultOIDCConfigurations.Update(ctx, oidcConfig.ID, opts) + require.NoError(t, err) + require.NotEmpty(t, updated) + assert.Equal(t, *opts.Address, updated.Address) + assert.Equal(t, *opts.RoleName, updated.RoleName) + assert.Equal(t, *opts.Namespace, updated.Namespace) + assert.Equal(t, *opts.JWTAuthPath, updated.JWTAuthPath) + assert.Equal(t, oidcConfig.TLSCACertificate, updated.TLSCACertificate) // not updated + }) +} diff --git a/workspace.go b/workspace.go index 7d0990bb9..7e0629e13 100644 --- a/workspace.go +++ b/workspace.go @@ -226,6 +226,7 @@ type Workspace struct { RunsCount int `jsonapi:"attr,workspace-kpis-runs-count"` TagNames []string `jsonapi:"attr,tag-names"` SettingOverwrites *WorkspaceSettingOverwrites `jsonapi:"attr,setting-overwrites"` + HYOKEnabled *bool `jsonapi:"attr,hyok-enabled"` // Relations AgentPool *AgentPool `jsonapi:"relation,agent-pool"` @@ -241,6 +242,7 @@ type Workspace struct { Variables []*Variable `jsonapi:"relation,vars"` TagBindings []*TagBinding `jsonapi:"relation,tag-bindings"` EffectiveTagBindings []*EffectiveTagBinding `jsonapi:"relation,effective-tag-bindings"` + HYOKEncryptedDataKey *HYOKEncryptedDataKey `jsonapi:"relation,hyok-data-key-for-encryption,omitempty"` // Deprecated: Use DataRetentionPolicyChoice instead. DataRetentionPolicy *DataRetentionPolicy @@ -306,6 +308,7 @@ type WorkspacePermissions struct { CanForceUnlock bool `jsonapi:"attr,can-force-unlock"` CanLock bool `jsonapi:"attr,can-lock"` CanManageRunTasks bool `jsonapi:"attr,can-manage-run-tasks"` + CanManageHYOK bool `jsonapi:"attr,can-manage-hyok"` CanQueueApply bool `jsonapi:"attr,can-queue-apply"` CanQueueDestroy bool `jsonapi:"attr,can-queue-destroy"` CanQueueRun bool `jsonapi:"attr,can-queue-run"` @@ -494,6 +497,13 @@ type WorkspaceCreateOptions struct { // environment when multiple environments exist within the same repository. WorkingDirectory *string `jsonapi:"attr,working-directory,omitempty"` + // Optional: Enables HYOK in the workspace. + // If set to true, the workspace will be created with HYOK enabled. + // If set to false, the workspace will be created with HYOK disabled. + // If not specified, the workspace will be created with HYOK disabled. + // Note: HYOK is only available in HCP Terraform. + HYOKEnabled *bool `jsonapi:"attr,hyok-enabled,omitempty"` + // A list of tags to attach to the workspace. If the tag does not already // exist, it is created and added to the workspace. Tags []*Tag `jsonapi:"relation,tags,omitempty"` @@ -656,6 +666,11 @@ type WorkspaceUpdateOptions struct { // setting at the same time. SettingOverwrites *WorkspaceSettingOverwritesOptions `jsonapi:"attr,setting-overwrites,omitempty"` + // Optional: Enables HYOK in the workspace. + // If set to true, the workspace will be updated with HYOK enabled. + // This can't be set to false, as HYOK is a one-way operation. + HYOKEnabled *bool `jsonapi:"attr,hyok-enabled,omitempty"` + // Associated Project with the workspace. If not provided, default project // of the organization will be assigned to the workspace Project *Project `jsonapi:"relation,project,omitempty"` diff --git a/workspace_integration_test.go b/workspace_integration_test.go index ad18518f0..08a35e078 100644 --- a/workspace_integration_test.go +++ b/workspace_integration_test.go @@ -977,6 +977,51 @@ func TestWorkspacesCreate(t *testing.T) { assert.Equal(t, "remote", w.ExecutionMode) }) }) + + t.Run("create workspace with hyok enabled set to false", func(t *testing.T) { + skipHYOKIntegrationTests(t) + + // replace the environment variable with a valid organization name that has HYOK permissions + hyokOrganizationName := os.Getenv("HYOK_ORGANIZATION_NAME") + if hyokOrganizationName == "" { + t.Fatal("Export a valid HYOK_ORGANIZATION_NAME before running this test!") + } + workspaceCreateOptions := WorkspaceCreateOptions{ + Name: String("go-tfe-test-hyok-enabled-false"), + HYOKEnabled: Bool(false), + } + + w, err := client.Workspaces.Create(ctx, hyokOrganizationName, workspaceCreateOptions) + require.NoError(t, err) + assert.NotNil(t, w.HYOKEnabled) + assert.False(t, *w.HYOKEnabled) + + err = client.Workspaces.Delete(ctx, hyokOrganizationName, *workspaceCreateOptions.Name) + require.NoError(t, err) + }) + + t.Run("create workspace with hyok enabled set to true", func(t *testing.T) { + skipHYOKIntegrationTests(t) + + // replace the environment variable with a valid organization name that has HYOK permissions + hyokOrganizationName := os.Getenv("HYOK_ORGANIZATION_NAME") + if hyokOrganizationName == "" { + t.Fatal("Export a valid HYOK_ORGANIZATION_NAME before running this test!") + } + + workspaceCreateOptions := WorkspaceCreateOptions{ + Name: String("go-tfe-test-hyok-enabled-true"), + HYOKEnabled: Bool(true), + } + + w, err := client.Workspaces.Create(ctx, hyokOrganizationName, workspaceCreateOptions) + require.NoError(t, err) + assert.NotNil(t, w.HYOKEnabled) + assert.True(t, *w.HYOKEnabled) + + err = client.Workspaces.Delete(ctx, hyokOrganizationName, *workspaceCreateOptions.Name) + require.NoError(t, err) + }) } func TestWorkspacesRead(t *testing.T) { @@ -1062,6 +1107,46 @@ func TestWorkspacesRead(t *testing.T) { assert.Equal(t, false, *w.SettingOverwrites.ExecutionMode) }) }) + + t.Run("read hyok enabled of a workspace", func(t *testing.T) { + skipHYOKIntegrationTests(t) + + // replace the environment variable with a valid organization name that has HYOK permissions + hyokOrganizationName := os.Getenv("HYOK_ORGANIZATION_NAME") + if hyokOrganizationName == "" { + t.Fatal("Export a valid HYOK_ORGANIZATION_NAME before running this test!") + } + + // replace the environment variable with a valid workspace name that has hyok enabled set to true or false + hyokWorkspaceName := os.Getenv("HYOK_WORKSPACE_NAME") + if hyokWorkspaceName == "" { + t.Fatal("Export a valid HYOK_WORKSPACE_NAME before running this test!") + } + + w, err := client.Workspaces.Read(ctx, hyokOrganizationName, hyokWorkspaceName) + require.NoError(t, err) + assert.NotNil(t, w.HYOKEnabled) + }) + + t.Run("read hyok encrypted data key of a workspace", func(t *testing.T) { + skipHYOKIntegrationTests(t) + + // replace the environment variable with a valid organization name that has HYOK permissions + hyokOrganizationName := os.Getenv("HYOK_ORGANIZATION_NAME") + if hyokOrganizationName == "" { + t.Fatal("Export a valid HYOK_ORGANIZATION_NAME before running this test!") + } + + // replace the environment variable with a valid workspace name that has hyok encrypted data key + hyokWorkspaceName := os.Getenv("HYOK_WORKSPACE_NAME") + if hyokWorkspaceName == "" { + t.Fatal("Export a valid HYOK_WORKSPACE_NAME before running this test!") + } + + w, err := client.Workspaces.Read(ctx, hyokOrganizationName, hyokWorkspaceName) + require.NoError(t, err) + assert.NotEmpty(t, w.HYOKEncryptedDataKey) + }) } func TestWorkspacesReadSource(t *testing.T) { @@ -1601,6 +1686,133 @@ func TestWorkspacesUpdate(t *testing.T) { assert.Equal(t, options.TriggerPatterns, item.TriggerPatterns) } }) + + t.Run("update hyok enabled of a workspace from false to false", func(t *testing.T) { + skipHYOKIntegrationTests(t) + + // replace the environment variable with a valid organization name that has HYOK permissions + hyokOrganizationName := os.Getenv("HYOK_ORGANIZATION_NAME") + if hyokOrganizationName == "" { + t.Fatal("Export a valid HYOK_ORGANIZATION_NAME before running this test!") + } + + workspaceCreateOptions := WorkspaceCreateOptions{ + Name: String("go-tfe-test-hyok-enabled-false"), + HYOKEnabled: Bool(false), + } + + w, err := client.Workspaces.Create(ctx, hyokOrganizationName, workspaceCreateOptions) + require.NoError(t, err) + assert.NotNil(t, w.HYOKEnabled) + assert.False(t, *w.HYOKEnabled) + + workspaceUpdateOptions := WorkspaceUpdateOptions{ + HYOKEnabled: Bool(false), + } + + w, err = client.Workspaces.Update(ctx, hyokOrganizationName, w.Name, workspaceUpdateOptions) + require.NoError(t, err) + assert.NotNil(t, w.HYOKEnabled) + assert.False(t, *w.HYOKEnabled) + + err = client.Workspaces.Delete(ctx, hyokOrganizationName, *workspaceCreateOptions.Name) + require.NoError(t, err) + }) + + t.Run("update hyok enabled of a workspace from false to true", func(t *testing.T) { + skipHYOKIntegrationTests(t) + + // replace the environment variable with a valid organization name that has HYOK permissions + hyokOrganizationName := os.Getenv("HYOK_ORGANIZATION_NAME") + if hyokOrganizationName == "" { + t.Fatal("Export a valid HYOK_ORGANIZATION_NAME before running this test!") + } + + workspaceCreateOptions := WorkspaceCreateOptions{ + Name: String("go-tfe-test-hyok-enabled-false"), + HYOKEnabled: Bool(false), + } + + w, err := client.Workspaces.Create(ctx, hyokOrganizationName, workspaceCreateOptions) + require.NoError(t, err) + assert.NotNil(t, w.HYOKEnabled) + assert.False(t, *w.HYOKEnabled) + + workspaceUpdateOptions := WorkspaceUpdateOptions{ + HYOKEnabled: Bool(true), + } + + w, err = client.Workspaces.Update(ctx, hyokOrganizationName, w.Name, workspaceUpdateOptions) + require.NoError(t, err) + assert.NotNil(t, w.HYOKEnabled) + assert.True(t, *w.HYOKEnabled) + + err = client.Workspaces.Delete(ctx, hyokOrganizationName, *workspaceCreateOptions.Name) + require.NoError(t, err) + }) + + t.Run("update hyok enabled of a workspace from true to true", func(t *testing.T) { + skipHYOKIntegrationTests(t) + + // replace the environment variable with a valid organization name that has HYOK permissions + hyokOrganizationName := os.Getenv("HYOK_ORGANIZATION_NAME") + if hyokOrganizationName == "" { + t.Fatal("Export a valid HYOK_ORGANIZATION_NAME before running this test!") + } + + workspaceCreateOptions := WorkspaceCreateOptions{ + Name: String("go-tfe-test-hyok-enabled-true"), + HYOKEnabled: Bool(true), + } + + w, err := client.Workspaces.Create(ctx, hyokOrganizationName, workspaceCreateOptions) + require.NoError(t, err) + assert.NotNil(t, w.HYOKEnabled) + assert.True(t, *w.HYOKEnabled) + + workspaceUpdateOptions := WorkspaceUpdateOptions{ + HYOKEnabled: Bool(true), + } + + w, err = client.Workspaces.Update(ctx, hyokOrganizationName, w.Name, workspaceUpdateOptions) + require.NoError(t, err) + assert.NotNil(t, w.HYOKEnabled) + assert.True(t, *w.HYOKEnabled) + + err = client.Workspaces.Delete(ctx, hyokOrganizationName, *workspaceCreateOptions.Name) + require.NoError(t, err) + }) + + t.Run("update hyok enabled of a workspace from true to false", func(t *testing.T) { + skipHYOKIntegrationTests(t) + + // replace the environment variable with a valid organization name that has HYOK permissions + hyokOrganizationName := os.Getenv("HYOK_ORGANIZATION_NAME") + if hyokOrganizationName == "" { + t.Fatal("Export a valid HYOK_ORGANIZATION_NAME before running this test!") + } + + workspaceCreateOptions := WorkspaceCreateOptions{ + Name: String("go-tfe-test-hyok-enabled-true"), + HYOKEnabled: Bool(true), + } + + w, err := client.Workspaces.Create(ctx, hyokOrganizationName, workspaceCreateOptions) + require.NoError(t, err) + assert.NotNil(t, w.HYOKEnabled) + assert.True(t, *w.HYOKEnabled) + + workspaceUpdateOptions := WorkspaceUpdateOptions{ + HYOKEnabled: Bool(false), + } + + _, err = client.Workspaces.Update(ctx, hyokOrganizationName, w.Name, workspaceUpdateOptions) + require.Error(t, err) + assert.EqualError(t, err, ErrHYOKCannotBeDisabled.Error()) + + err = client.Workspaces.Delete(ctx, hyokOrganizationName, *workspaceCreateOptions.Name) + require.NoError(t, err) + }) } func TestWorkspacesUpdateTableDriven(t *testing.T) {