diff --git a/docs/resources/credential_json.md b/docs/resources/credential_json.md new file mode 100644 index 00000000..52f0cdea --- /dev/null +++ b/docs/resources/credential_json.md @@ -0,0 +1,69 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "boundary_credential_json Resource - terraform-provider-boundary" +subcategory: "" +description: |- + The json credential resource allows you to congiure a credential using a json object. +--- + +# boundary_credential_json (Resource) + +The json credential resource allows you to congiure a credential using a json object. + +## Example Usage + +```terraform +resource "boundary_scope" "org" { + name = "organization_one" + description = "global scope" + scope_id = "global" + auto_create_admin_role = true + auto_create_default_role = true +} + +resource "boundary_scope" "project" { + name = "project_one" + description = "My first scope!" + scope_id = boundary_scope.org.id + auto_create_admin_role = true +} + +resource "boundary_credential_store_static" "example" { + name = "example_static_credential_store" + description = "My first static credential store!" + scope_id = boundary_scope.project.id +} + +resource "boundary_credential_json" "example" { + name = "example_json" + description = "My first json credential!" + credential_store_id = boundary_credential_store_static.example.id + object = file("~/object.json") # change to valid json file +} +``` + + +## Schema + +### Required + +- `credential_store_id` (String) The credential store in which to save this json credential. +- `object` (String, Sensitive) The object for the this json credential. Either values encoded with the "jsonencode" function, pre-escaped JSON string, or a file + +### Optional + +- `description` (String) The description of this json credential. +- `name` (String) The name of this json credential. Defaults to the resource name. + +### Read-Only + +- `id` (String) The ID of this json credential. +- `object_hmac` (String) The object hmac. + +## Import + +Import is supported using the following syntax: + +```shell +terraform import boundary_credential_json.example_json +``` diff --git a/examples/resources/boundary_credential_json/import.sh b/examples/resources/boundary_credential_json/import.sh new file mode 100644 index 00000000..378610ab --- /dev/null +++ b/examples/resources/boundary_credential_json/import.sh @@ -0,0 +1 @@ +terraform import boundary_credential_json.example_json \ No newline at end of file diff --git a/examples/resources/boundary_credential_json/resource.tf b/examples/resources/boundary_credential_json/resource.tf new file mode 100644 index 00000000..1a33e2cf --- /dev/null +++ b/examples/resources/boundary_credential_json/resource.tf @@ -0,0 +1,27 @@ +resource "boundary_scope" "org" { + name = "organization_one" + description = "global scope" + scope_id = "global" + auto_create_admin_role = true + auto_create_default_role = true +} + +resource "boundary_scope" "project" { + name = "project_one" + description = "My first scope!" + scope_id = boundary_scope.org.id + auto_create_admin_role = true +} + +resource "boundary_credential_store_static" "example" { + name = "example_static_credential_store" + description = "My first static credential store!" + scope_id = boundary_scope.project.id +} + +resource "boundary_credential_json" "example" { + name = "example_json" + description = "My first json credential!" + credential_store_id = boundary_credential_store_static.example.id + object = file("~/object.json") # change to valid json file +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 7b08a480..33fc3e17 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -73,6 +73,7 @@ func New() *schema.Provider { "boundary_credential_store_static": resourceCredentialStoreStatic(), "boundary_credential_username_password": resourceCredentialUsernamePassword(), "boundary_credential_ssh_private_key": resourceCredentialSshPrivateKey(), + "boundary_credential_json": resourceCredentialJson(), "boundary_managed_group": resourceManagedGroup(), "boundary_group": resourceGroup(), "boundary_host": resourceHost(), diff --git a/internal/provider/resource_credential_json.go b/internal/provider/resource_credential_json.go new file mode 100644 index 00000000..8988ec49 --- /dev/null +++ b/internal/provider/resource_credential_json.go @@ -0,0 +1,222 @@ +package provider + +import ( + "context" + "encoding/json" + "net/http" + + "github.com/hashicorp/boundary/api" + "github.com/hashicorp/boundary/api/credentials" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +const ( + credentialJsonCredentialType = "json" + credentialJsonObjectKey = "object" + credentialJsonObjectHmacKey = "object_hmac" +) + +func resourceCredentialJson() *schema.Resource { + return &schema.Resource{ + Description: "The json credential resource allows you to congiure a credential using a json object.", + + CreateContext: resourceCredentialJsonCreate, + ReadContext: resourceCredentialJsonRead, + UpdateContext: resourceCredentialJsonUpdate, + DeleteContext: resourceCredentialJsonDelete, + Importer: &schema.ResourceImporter{ + StateContext: schema.ImportStatePassthroughContext, + }, + + Schema: map[string]*schema.Schema{ + IDKey: { + Description: "The ID of this json credential.", + Type: schema.TypeString, + Computed: true, + }, + NameKey: { + Description: "The name of this json credential. Defaults to the resource name.", + Type: schema.TypeString, + Optional: true, + }, + DescriptionKey: { + Description: "The description of this json credential.", + Type: schema.TypeString, + Optional: true, + }, + credentialStoreIdKey: { + Description: "The credential store in which to save this json credential.", + Type: schema.TypeString, + ForceNew: true, + Required: true, + }, + credentialJsonObjectKey: { + Description: `The object for the this json credential. Either values encoded with the "jsonencode" function, pre-escaped JSON string, or a file`, + Type: schema.TypeString, + Required: true, + Sensitive: true, + }, + credentialJsonObjectHmacKey: { + Description: "The object hmac.", + Type: schema.TypeString, + Computed: true, + }, + }, + } +} + +func setFromCredentialJsonResponseMap(d *schema.ResourceData, raw map[string]interface{}, fromRead bool) error { + if err := d.Set(NameKey, raw[NameKey]); err != nil { + return err + } + if err := d.Set(DescriptionKey, raw[DescriptionKey]); err != nil { + return err + } + if err := d.Set(credentialStoreIdKey, raw[credentialStoreIdKey]); err != nil { + return err + } + + if attrsVal, ok := raw["attributes"]; ok { + attrs := attrsVal.(map[string]interface{}) + stateObjectHmac := d.Get(credentialJsonObjectHmacKey) + boundaryObjectHmac := attrs[credentialJsonObjectHmacKey].(string) + if stateObjectHmac.(string) != boundaryObjectHmac && fromRead { + if err := d.Set(credentialJsonObjectKey, "(changed in Boundary)"); err != nil { + return err + } + } + if err := d.Set(credentialJsonObjectHmacKey, boundaryObjectHmac); err != nil { + return nil + } + } + + d.SetId(raw["id"].(string)) + + return nil +} + +func resourceCredentialJsonCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + md := meta.(*metaData) + + var opts []credentials.Option + if v, ok := d.GetOk(NameKey); ok { + opts = append(opts, credentials.WithName(v.(string))) + } + if v, ok := d.GetOk(DescriptionKey); ok { + opts = append(opts, credentials.WithDescription(v.(string))) + } + + if v, ok := d.GetOk(credentialJsonObjectKey); ok { + var jsonObject map[string]interface{} + if err := json.Unmarshal([]byte(v.(string)), &jsonObject); err != nil { + return diag.Errorf("error unmarshaling json: %v", err) + } + opts = append(opts, credentials.WithJsonCredentialObject(jsonObject)) + } + + var credentialStoreId string + retrievedStoreId, ok := d.GetOk(credentialStoreIdKey) + if ok { + credentialStoreId = retrievedStoreId.(string) + } else { + return diag.Errorf("credential store id is unset") + } + + client := credentials.NewClient(md.client) + cred, err := client.Create(ctx, credentialJsonCredentialType, credentialStoreId, opts...) + if err != nil { + return diag.Errorf("error creating credential: %v", err) + } + if cred == nil { + return diag.Errorf("nil credential after create") + } + + if err := setFromCredentialJsonResponseMap(d, cred.GetResponse().Map, false); err != nil { + return diag.Errorf("error generating credential from response map: %v", err) + } + + return nil +} + +func resourceCredentialJsonRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + md := meta.(*metaData) + client := credentials.NewClient(md.client) + + cred, err := client.Read(ctx, d.Id()) + if err != nil { + if apiErr := api.AsServerError(err); apiErr != nil && apiErr.Response().StatusCode() == http.StatusNotFound { + d.SetId("") + return nil + } + return diag.Errorf("error reading credential: %v", err) + } + if cred == nil { + return diag.Errorf("credential nil after read") + } + + if err := setFromCredentialJsonResponseMap(d, cred.GetResponse().Map, true); err != nil { + return diag.Errorf("error generating credential from response map: %v", err) + } + + return nil +} + +func resourceCredentialJsonUpdate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + md := meta.(*metaData) + client := credentials.NewClient(md.client) + + var opts []credentials.Option + if d.HasChange(NameKey) { + opts = append(opts, credentials.DefaultName()) + if v, ok := d.GetOk(NameKey); ok { + opts = append(opts, credentials.WithName(v.(string))) + } + } + + if d.HasChange(DescriptionKey) { + opts = append(opts, credentials.DefaultDescription()) + if v, ok := d.GetOk(DescriptionKey); ok { + opts = append(opts, credentials.WithDescription(v.(string))) + } + } + + if d.HasChange(credentialJsonObjectKey) { + if v, ok := d.GetOk(credentialJsonObjectKey); ok { + var jsonObject map[string]interface{} + if err := json.Unmarshal([]byte(v.(string)), &jsonObject); err != nil { + return diag.Errorf("error unmarshaling json: %v", err) + } + opts = append(opts, credentials.WithJsonCredentialObject(jsonObject)) + } + } + + if len(opts) > 0 { + opts = append(opts, credentials.WithAutomaticVersioning(true)) + credUpdate, err := client.Update(ctx, d.Id(), 0, opts...) + if err != nil { + return diag.Errorf("error updating credential: %v", err) + } + if credUpdate == nil { + return diag.Errorf("credential nil after update") + } + + if err = setFromCredentialJsonResponseMap(d, credUpdate.GetResponse().Map, false); err != nil { + return diag.Errorf("error generating credential from response map: %v", err) + } + } + + return nil +} + +func resourceCredentialJsonDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + md := meta.(*metaData) + client := credentials.NewClient(md.client) + + _, err := client.Delete(ctx, d.Id()) + if err != nil { + return diag.Errorf("error deleting credential: %v", err) + } + + return nil +} diff --git a/internal/provider/resource_credential_json_test.go b/internal/provider/resource_credential_json_test.go new file mode 100644 index 00000000..e0bf11c3 --- /dev/null +++ b/internal/provider/resource_credential_json_test.go @@ -0,0 +1,198 @@ +package provider + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/hashicorp/boundary/api" + "github.com/hashicorp/boundary/api/credentials" + "github.com/hashicorp/boundary/testing/controller" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" +) + +const ( + jsonCredResc = "boundary_credential_json.example" + jsonCredName = "bar" + jsonCredNameUpdate = "foo" + jsonCredDesc = "the bar" + jsonCredDescUpdate = "the foo" + jsonCredObj = `jsonencode({ + password = "password", + username = "admin" + })` + jsonCredObjUpdate = `jsonencode({ + password = "password", + username = "db-admin" + })` +) + +func jsonCredResource(name, description, object string) string { + return fmt.Sprintf(` +resource "boundary_credential_store_static" "example" { + name = "static store name" + description = "static store description" + scope_id = boundary_scope.proj1.id + depends_on = [boundary_role.proj1_admin] +} + +resource "boundary_credential_json" "example" { + name = "%s" + description = "%s" + credential_store_id = boundary_credential_store_static.example.id + object = %s +}`, name, description, object) +} + +func TestAccCredentialJson(t *testing.T) { + tc := controller.NewTestController(t, tcConfig...) + defer tc.Shutdown() + url := tc.ApiAddrs()[0] + + res := jsonCredResource( + jsonCredName, + jsonCredDesc, + jsonCredObj, + ) + + resUpdate := jsonCredResource( + jsonCredNameUpdate, + jsonCredDescUpdate, + jsonCredObjUpdate, + ) + + var provider *schema.Provider + resource.Test(t, resource.TestCase{ + ProviderFactories: providerFactories(&provider), + CheckDestroy: testAccCheckCredentialJsonResourceDestroy(t, provider), + Steps: []resource.TestStep{ + { + // create + Config: testConfig(url, fooOrg, firstProjectFoo, res), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(jsonCredResc, NameKey, jsonCredName), + resource.TestCheckResourceAttr(jsonCredResc, DescriptionKey, jsonCredDesc), + resource.TestCheckResourceAttr(jsonCredResc, credentialJsonObjectKey, `{"password":"password","username":"admin"}`), + + testAccCheckCredentialJsonObjectHmac(provider), + testAccCheckCredentialJsonResourceExists(provider, jsonCredResc), + ), + }, + importStep(jsonCredResc, credentialJsonObjectKey), + { + // update + Config: testConfig(url, fooOrg, firstProjectFoo, resUpdate), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(jsonCredResc, NameKey, jsonCredNameUpdate), + resource.TestCheckResourceAttr(jsonCredResc, DescriptionKey, jsonCredDescUpdate), + resource.TestCheckResourceAttr(jsonCredResc, credentialJsonObjectKey, `{"password":"password","username":"db-admin"}`), + + testAccCheckCredentialJsonObjectHmac(provider), + testAccCheckCredentialJsonResourceExists(provider, jsonCredResc), + ), + }, + importStep(jsonCredResc, credentialJsonObjectKey), + { + // Run a plan only update and verify no changes + PlanOnly: true, + Config: testConfig(url, fooOrg, firstProjectFoo, resUpdate), + }, + importStep(jsonCredResc, credentialJsonObjectKey), + { + // update again but apply a preConfig to externally update resource + PreConfig: func() { jsonCredExternalUpdate(t, provider) }, + Config: testConfig(url, fooOrg, firstProjectFoo, resUpdate), + }, + importStep(jsonCredResc, credentialJsonObjectKey), + }, + }) +} + +func testAccCheckCredentialJsonResourceDestroy(t *testing.T, testProvider *schema.Provider) resource.TestCheckFunc { + return func(s *terraform.State) error { + if testProvider.Meta() == nil { + t.Fatal("got nil provider metadata") + } + md := testProvider.Meta().(*metaData) + + for _, rs := range s.RootModule().Resources { + switch rs.Type { + case "boundary_credential_json": + id := rs.Primary.ID + + c := credentials.NewClient(md.client) + _, err := c.Read(context.Background(), id) + if apiErr := api.AsServerError(err); apiErr == nil || apiErr.Response().StatusCode() != http.StatusNotFound { + return fmt.Errorf("didn't get a 404 when reading destroyed json credential %q: %v", id, err) + } + default: + continue + } + } + return nil + } +} + +func testAccCheckCredentialJsonResourceExists(testProvider *schema.Provider, name string) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[name] + if !ok { + return fmt.Errorf("not found: %s", name) + } + + id := rs.Primary.ID + if id == "" { + return fmt.Errorf("no ID is set") + } + storeId = id + + md := testProvider.Meta().(*metaData) + c := credentials.NewClient(md.client) + if _, err := c.Read(context.Background(), id); err != nil { + return fmt.Errorf("got an error reading %q: %w", id, err) + } + + return nil + } +} + +func testAccCheckCredentialJsonObjectHmac(testProvider *schema.Provider) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[jsonCredResc] + if !ok { + return fmt.Errorf("not found: %s", jsonCredResc) + } + + computed := rs.Primary.Attributes["object_hmac"] + if len(computed) != 43 { + return fmt.Errorf("Computed password hmac not the expected length of 43 characters. hmac: %q", computed) + } + + return nil + } +} + +func jsonCredExternalUpdate(t *testing.T, testProvider *schema.Provider) { + if storeId == "" { + t.Fatal("storeId must be set before testing an external update") + } + + md := testProvider.Meta().(*metaData) + c := credentials.NewClient(md.client) + cr, err := c.Read(context.Background(), storeId) + if err != nil { + t.Fatal(fmt.Errorf("got an error reading %q: %w", storeId, err)) + } + + // update credential options + var opts []credentials.Option + opts = append(opts, credentials.WithDescription("this is an updated description")) + + _, err = c.Update(context.Background(), cr.Item.Id, cr.Item.Version, opts...) + if err != nil { + t.Fatal(fmt.Errorf("got an error updating %q: %w", cr.Item.Id, err)) + } +}