diff --git a/docs/resources/credential_ssh_private_key.md b/docs/resources/credential_ssh_private_key.md new file mode 100644 index 00000000..e57afa36 --- /dev/null +++ b/docs/resources/credential_ssh_private_key.md @@ -0,0 +1,74 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "boundary_credential_ssh_private_key Resource - terraform-provider-boundary" +subcategory: "" +description: |- + The SSH private key credential resource allows you to configure a credential using a username, private key and optional passphrase. +--- + +# boundary_credential_ssh_private_key (Resource) + +The SSH private key credential resource allows you to configure a credential using a username, private key and optional passphrase. + +## 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_ssh_private_key" "example" { + name = "example_ssh_private_key" + description = "My first ssh private key credential!" + credential_store_id = boundary_credential_store_static.example.id + username = "my-username" + private_key = "my-private-key" # change to valid SSH Private Key + private_key_passphrase = "my-optional-passphrase" # change to the passphrase of the Private Key if required +} +``` + + +## Schema + +### Required + +- `credential_store_id` (String) ID of the credential store this credential belongs to. +- `private_key` (String) The private key associated with the credential. +- `username` (String) The username associated with the credential. + +### Optional + +- `description` (String) The description of the credential. +- `name` (String) The name of the credential. Defaults to the resource name. +- `private_key_passphrase` (String) The passphrase of the private key associated with the credential. + +### Read-Only + +- `id` (String) The ID of the credential. +- `private_key_hmac` (String) The private key hmac. +- `private_key_passphrase_hmac` (String) The private key passphrase hmac. + +## Import + +Import is supported using the following syntax: + +```shell +terraform import boundary_credential_ssh_private_key.example_ssh_private_key +``` diff --git a/examples/resources/boundary_credential_ssh_private_key/import.sh b/examples/resources/boundary_credential_ssh_private_key/import.sh new file mode 100644 index 00000000..75da570a --- /dev/null +++ b/examples/resources/boundary_credential_ssh_private_key/import.sh @@ -0,0 +1 @@ +terraform import boundary_credential_ssh_private_key.example_ssh_private_key diff --git a/examples/resources/boundary_credential_ssh_private_key/resource.tf b/examples/resources/boundary_credential_ssh_private_key/resource.tf new file mode 100644 index 00000000..c2b7c449 --- /dev/null +++ b/examples/resources/boundary_credential_ssh_private_key/resource.tf @@ -0,0 +1,29 @@ +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_ssh_private_key" "example" { + name = "example_ssh_private_key" + description = "My first ssh private key credential!" + credential_store_id = boundary_credential_store_static.example.id + username = "my-username" + private_key = "my-private-key" # change to valid SSH Private Key + private_key_passphrase = "my-optional-passphrase" # change to the passphrase of the Private Key if required +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index cb684faa..7b08a480 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -72,6 +72,7 @@ func New() *schema.Provider { "boundary_credential_store_vault": resourceCredentialStoreVault(), "boundary_credential_store_static": resourceCredentialStoreStatic(), "boundary_credential_username_password": resourceCredentialUsernamePassword(), + "boundary_credential_ssh_private_key": resourceCredentialSshPrivateKey(), "boundary_managed_group": resourceManagedGroup(), "boundary_group": resourceGroup(), "boundary_host": resourceHost(), diff --git a/internal/provider/resource_credential_library_vault.go b/internal/provider/resource_credential_library_vault.go index fc2258c6..d6553b8c 100644 --- a/internal/provider/resource_credential_library_vault.go +++ b/internal/provider/resource_credential_library_vault.go @@ -6,7 +6,6 @@ import ( "github.com/hashicorp/boundary/api" "github.com/hashicorp/boundary/api/credentiallibraries" - "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" ) diff --git a/internal/provider/resource_credential_ssh_private_key.go b/internal/provider/resource_credential_ssh_private_key.go new file mode 100644 index 00000000..1da05ada --- /dev/null +++ b/internal/provider/resource_credential_ssh_private_key.go @@ -0,0 +1,276 @@ +package provider + +import ( + "context" + "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 ( + credentialSshPrivateKeyUsernameKey = "username" + credentialSshPrivateKeyPrivateKeyKey = "private_key" + credentialSshPrivateKeyPrivateKeyHmacKey = "private_key_hmac" + credentialSshPrivateKeyPassphraseKey = "private_key_passphrase" + credentialSshPrivateKeyPassphraseHmacKey = "private_key_passphrase_hmac" + credentialSshPrivateKeyCredentialType = "ssh_private_key" +) + +func resourceCredentialSshPrivateKey() *schema.Resource { + return &schema.Resource{ + Description: "The SSH private key credential resource allows you to configure a credential using a username, private key and optional passphrase.", + + CreateContext: resourceCredentialSshPrivateKeyCreate, + ReadContext: resourceCredentialSshPrivateKeyRead, + UpdateContext: resourceCredentialSshPrivateKeyUpdate, + DeleteContext: resourceCredentialSshPrivateKeyDelete, + Importer: &schema.ResourceImporter{ + StateContext: schema.ImportStatePassthroughContext, + }, + + Schema: map[string]*schema.Schema{ + IDKey: { + Description: "The ID of the credential.", + Type: schema.TypeString, + Computed: true, + }, + NameKey: { + Description: "The name of the credential. Defaults to the resource name.", + Type: schema.TypeString, + Optional: true, + }, + DescriptionKey: { + Description: "The description of the credential.", + Type: schema.TypeString, + Optional: true, + }, + credentialStoreIdKey: { + Description: "ID of the credential store this credential belongs to.", + Type: schema.TypeString, + ForceNew: true, + Required: true, + }, + credentialSshPrivateKeyUsernameKey: { + Description: "The username associated with the credential.", + Type: schema.TypeString, + Required: true, + }, + credentialSshPrivateKeyPrivateKeyKey: { + Description: "The private key associated with the credential.", + Type: schema.TypeString, + Required: true, + }, + credentialSshPrivateKeyPrivateKeyHmacKey: { + Description: "The private key hmac.", + Type: schema.TypeString, + Computed: true, + }, + credentialSshPrivateKeyPassphraseKey: { + Description: "The passphrase of the private key associated with the credential.", + Type: schema.TypeString, + Optional: true, + }, + credentialSshPrivateKeyPassphraseHmacKey: { + Description: "The private key passphrase hmac.", + Type: schema.TypeString, + Computed: true, + }, + }, + } +} + +func setFromCredentialSshPrivateKeyResponseMap(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{}) + if err := d.Set(credentialSshPrivateKeyUsernameKey, attrs[credentialSshPrivateKeyUsernameKey]); err != nil { + return err + } + + statePrivKeyHmac := d.Get(credentialSshPrivateKeyPrivateKeyHmacKey) + boundaryPrivKeyHmac := attrs[credentialSshPrivateKeyPrivateKeyHmacKey].(string) + if statePrivKeyHmac.(string) != boundaryPrivKeyHmac && fromRead { + // PrivateKeyHmac has changed in Boundary, therefore the private key has changed. + // Update private key value to force tf to attempt update. + if err := d.Set(credentialSshPrivateKeyPrivateKeyKey, "(changed in Boundary)"); err != nil { + return err + } + } + if err := d.Set(credentialSshPrivateKeyPrivateKeyHmacKey, boundaryPrivKeyHmac); err != nil { + return err + } + + var statePassphraseHmac, boundaryPassphraseHmac string + if v := d.Get(credentialSshPrivateKeyPassphraseHmacKey); v != nil { + statePassphraseHmac = v.(string) + } + if v := attrs[credentialSshPrivateKeyPassphraseHmacKey]; v != nil { + boundaryPassphraseHmac = v.(string) + } + if statePassphraseHmac != boundaryPassphraseHmac && fromRead { + // PassphraseHmac has changed in Boundary, therefore the private key passphrase has changed. + // Update private key passphrase value to force tf to attempt update. + if err := d.Set(credentialSshPrivateKeyPassphraseKey, "(changed in Boundary)"); err != nil { + return err + } + } + if err := d.Set(credentialSshPrivateKeyPassphraseHmacKey, boundaryPassphraseHmac); err != nil { + return err + } + } + + d.SetId(raw["id"].(string)) + + return nil +} + +func resourceCredentialSshPrivateKeyCreate(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(credentialSshPrivateKeyUsernameKey); ok { + opts = append(opts, credentials.WithSshPrivateKeyCredentialUsername(v.(string))) + } + if v, ok := d.GetOk(credentialSshPrivateKeyPrivateKeyKey); ok { + opts = append(opts, credentials.WithSshPrivateKeyCredentialPrivateKey(v.(string))) + } + if v, ok := d.GetOk(credentialSshPrivateKeyPassphraseKey); ok { + opts = append(opts, credentials.WithSshPrivateKeyCredentialPrivateKeyPassphrase(v.(string))) + } + + 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) + cr, err := client.Create(ctx, credentialSshPrivateKeyCredentialType, credentialStoreId, opts...) + if err != nil { + return diag.Errorf("error creating credential: %v", err) + } + if cr == nil { + return diag.Errorf("nil credential after create") + } + + if err := setFromCredentialSshPrivateKeyResponseMap(d, cr.GetResponse().Map, false); err != nil { + return diag.Errorf("error generating credential from response map: %v", err) + } + + return nil +} + +func resourceCredentialSshPrivateKeyRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + md := meta.(*metaData) + client := credentials.NewClient(md.client) + + cr, 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 cr == nil { + return diag.Errorf("credential nil after read") + } + + if err := setFromCredentialSshPrivateKeyResponseMap(d, cr.GetResponse().Map, true); err != nil { + return diag.Errorf("error generating credential from response map: %v", err) + } + + return nil +} + +func resourceCredentialSshPrivateKeyUpdate(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()) + nameVal, ok := d.GetOk(NameKey) + if ok { + opts = append(opts, credentials.WithName(nameVal.(string))) + } + } + + if d.HasChange(DescriptionKey) { + opts = append(opts, credentials.DefaultDescription()) + descVal, ok := d.GetOk(DescriptionKey) + if ok { + opts = append(opts, credentials.WithDescription(descVal.(string))) + } + } + + if d.HasChange(credentialSshPrivateKeyUsernameKey) { + usernameVal, ok := d.GetOk(credentialSshPrivateKeyUsernameKey) + if ok { + opts = append(opts, credentials.WithSshPrivateKeyCredentialUsername(usernameVal.(string))) + } + } + + if d.HasChange(credentialSshPrivateKeyPrivateKeyKey) { + privKeyVal, ok := d.GetOk(credentialSshPrivateKeyPrivateKeyKey) + if ok { + opts = append(opts, credentials.WithSshPrivateKeyCredentialPrivateKey(privKeyVal.(string))) + } + } + + if d.HasChange(credentialSshPrivateKeyPassphraseKey) { + passVal, ok := d.GetOk(credentialSshPrivateKeyPassphraseKey) + if ok { + opts = append(opts, credentials.WithSshPrivateKeyCredentialPrivateKeyPassphrase(passVal.(string))) + } + } + + if len(opts) > 0 { + opts = append(opts, credentials.WithAutomaticVersioning(true)) + crUpdate, err := client.Update(ctx, d.Id(), 0, opts...) + if err != nil { + return diag.Errorf("error updating credential: %v", err) + } + if crUpdate == nil { + return diag.Errorf("credential nil after update") + } + + if err = setFromCredentialSshPrivateKeyResponseMap(d, crUpdate.GetResponse().Map, false); err != nil { + return diag.Errorf("error generating credential from response map: %v", err) + } + } + + return nil +} + +func resourceCredentialSshPrivateKeyDelete(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_ssh_private_key_test.go b/internal/provider/resource_credential_ssh_private_key_test.go new file mode 100644 index 00000000..dd810d80 --- /dev/null +++ b/internal/provider/resource_credential_ssh_private_key_test.go @@ -0,0 +1,180 @@ +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" + "golang.org/x/crypto/ssh/testdata" +) + +const ( + sshPrivateKeyCredResc = "boundary_credential_ssh_private_key.example" + sshPrivateKeyCredName = "Dr Jekyll" + sshPrivateKeyCredDesc = "my best description" + sshPrivateKeyUpdate = "some magic update string" + sshPrivateKeyUsername = "my-user" +) + +var staticStore = fmt.Sprintf(` +resource "boundary_credential_store_static" "ssh_store" { + name = "static store name" + description = "static store description" + scope_id = boundary_scope.proj1.id + depends_on = [boundary_role.proj1_admin] +}`) + +func sshPrivateKeyResource(name, description, username, privateKey, passphrase string) string { + return fmt.Sprintf(` +resource "boundary_credential_ssh_private_key" "example" { + name = %q + description = %q + credential_store_id = boundary_credential_store_static.ssh_store.id + username = %q + private_key = %q + private_key_passphrase = %q +}`, name, description, username, privateKey, passphrase) +} + +func TestAccCredentialSshPrivateKey(t *testing.T) { + tc := controller.NewTestController(t, tcConfig...) + defer tc.Shutdown() + url := tc.ApiAddrs()[0] + + privKey := string(testdata.PEMBytes["rsa"]) + privKeyUpdate := string(testdata.PEMEncryptedKeys[0].PEMBytes) + privKeyUpdatePassphrase := testdata.PEMEncryptedKeys[0].EncryptionKey + + res := sshPrivateKeyResource( + sshPrivateKeyCredName, + sshPrivateKeyCredDesc, + sshPrivateKeyUsername, + privKey, + "", + ) + + resUpdate := sshPrivateKeyResource( + sshPrivateKeyCredName+sshPrivateKeyUpdate, + sshPrivateKeyCredDesc+sshPrivateKeyUpdate, + sshPrivateKeyUsername+sshPrivateKeyUpdate, + privKeyUpdate, + privKeyUpdatePassphrase, + ) + + var provider *schema.Provider + resource.Test(t, resource.TestCase{ + IsUnitTest: true, + ProviderFactories: providerFactories(&provider), + CheckDestroy: testAccCheckCredentialSshPrivateKeyResourceDestroy(t, provider), + Steps: []resource.TestStep{ + { + // create + Config: testConfig(url, fooOrg, firstProjectFoo, staticStore, res), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(sshPrivateKeyCredResc, NameKey, sshPrivateKeyCredName), + resource.TestCheckResourceAttr(sshPrivateKeyCredResc, DescriptionKey, sshPrivateKeyCredDesc), + resource.TestCheckResourceAttr(sshPrivateKeyCredResc, credentialSshPrivateKeyUsernameKey, sshPrivateKeyUsername), + resource.TestCheckResourceAttr(sshPrivateKeyCredResc, credentialSshPrivateKeyPrivateKeyKey, privKey), + resource.TestCheckResourceAttr(sshPrivateKeyCredResc, credentialSshPrivateKeyPassphraseKey, ""), + + testAccCheckCredentialStoreSshPrivateKeyHmac(provider), + testAccCheckCredentialSshPrivateKeyResourceExists(provider, sshPrivateKeyCredResc), + ), + }, + importStep(sshPrivateKeyCredResc, credentialSshPrivateKeyPrivateKeyKey, credentialSshPrivateKeyPassphraseKey), + { + // update + Config: testConfig(url, fooOrg, firstProjectFoo, staticStore, resUpdate), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(sshPrivateKeyCredResc, NameKey, sshPrivateKeyCredName+sshPrivateKeyUpdate), + resource.TestCheckResourceAttr(sshPrivateKeyCredResc, DescriptionKey, sshPrivateKeyCredDesc+sshPrivateKeyUpdate), + resource.TestCheckResourceAttr(sshPrivateKeyCredResc, credentialSshPrivateKeyUsernameKey, sshPrivateKeyUsername+sshPrivateKeyUpdate), + resource.TestCheckResourceAttr(sshPrivateKeyCredResc, credentialSshPrivateKeyPrivateKeyKey, privKeyUpdate), + resource.TestCheckResourceAttr(sshPrivateKeyCredResc, credentialSshPrivateKeyPassphraseKey, privKeyUpdatePassphrase), + + testAccCheckCredentialStoreSshPrivateKeyHmac(provider), + testAccCheckCredentialSshPrivateKeyResourceExists(provider, sshPrivateKeyCredResc), + ), + }, + }, + }) +} + +func testAccCheckCredentialSshPrivateKeyResourceExists(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 testAccCheckCredentialSshPrivateKeyResourceDestroy(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_ssh_private_key": + 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 credential %q: %v", id, err) + } + default: + continue + } + } + return nil + } +} + +func testAccCheckCredentialStoreSshPrivateKeyHmac(testProvider *schema.Provider) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[sshPrivateKeyCredResc] + if !ok { + return fmt.Errorf("not found: %s", sshPrivateKeyCredResc) + } + + computed := rs.Primary.Attributes["private_key_hmac"] + if len(computed) != 43 { + return fmt.Errorf("computed private key hmac not the expected length of 43 characters, got: %q", computed) + } + + if rs.Primary.Attributes["private_key_passphrase"] != "" { + // We set a passphrase, validate the computed hmac is expected length + computed := rs.Primary.Attributes["private_key_passphrase_hmac"] + if len(computed) != 43 { + return fmt.Errorf("computed private key passphrase hmac not the expected length of 43 characters, got: %q", computed) + } + } + + return nil + } +}