diff --git a/docs/resources/robot_account.md b/docs/resources/robot_account.md index 1154e7d..d46e00e 100644 --- a/docs/resources/robot_account.md +++ b/docs/resources/robot_account.md @@ -93,6 +93,68 @@ resource "harbor_robot_account" "project" { } ``` +### Project with Write-only Secret + +```terraform +resource "harbor_project" "main" { + name = "main" +} + +resource "harbor_robot_account" "project" { + name = "example-project" + description = "project level robot account" + level = "project" + secret_wo = "StrongSecret123!" + secret_wo_version = 1 + permissions { + access { + action = "pull" + resource = "repository" + } + access { + action = "push" + resource = "repository" + } + kind = "project" + namespace = harbor_project.main.name + } +} +``` + +### Project with Write-only Secret from Ephemeral Random Secret + +```terraform +ephemeral "random_password" "robot_secret" { + length = 16 + special = true + override_special = "!#$%&*()-_=+[]{}<>:?" +} + +resource "harbor_project" "main" { + name = "main" +} + +resource "harbor_robot_account" "project" { + name = "example-project" + description = "project level robot account" + level = "project" + secret_wo = tostring(ephemeral.random_password.robot_secret.result) + secret_wo_version = 1 + permissions { + access { + action = "pull" + resource = "repository" + } + access { + action = "push" + resource = "repository" + } + kind = "project" + namespace = harbor_project.main.name + } +} +``` + The above example creates a project level robot account with permissions to - pull repository on project "main" - push repository on project "main" @@ -111,6 +173,8 @@ The above example creates a project level robot account with permissions to - `disable` (Boolean) Disables the robot account when set to `true`. - `duration` (Number) By default, the robot account will not expire. Set it to the amount of days until the account should expire. - `secret` (String, Sensitive) The secret of the robot account used for authentication. Defaults to random generated string from Harbor. +- `secret_wo` (String, Write-only) Write-only alternative for `secret`. Must be used together with `secret_wo_version`. +- `secret_wo_version` (Number) Rotation trigger for write-only secret updates. Must be used together with `secret_wo`. ### Read-Only diff --git a/docs/resources/user.md b/docs/resources/user.md index ae775c1..9e8439e 100644 --- a/docs/resources/user.md +++ b/docs/resources/user.md @@ -14,10 +14,40 @@ description: |- ```terraform resource "harbor_user" "main" { - username = "john" - password = "Password12345!" + username = "john" + password = "Password12345!" full_name = "John Smith" - email = "john@smith.com" + email = "john@smith.com" +} +``` + +### Write-only Password + +```terraform +resource "harbor_user" "main" { + username = "john" + password_wo = "Password12345!" + password_wo_version = 1 + full_name = "John Smith" + email = "john@smith.com" +} +``` + +### Write-only Password from Ephemeral Random Secret + +```terraform +ephemeral "random_password" "user_password" { + length = 16 + special = true + override_special = "!#$%&*()-_=+[]{}<>:?" +} + +resource "harbor_user" "main" { + username = "john" + password_wo = tostring(ephemeral.random_password.user_password.result) + password_wo_version = 1 + full_name = "John Smith" + email = "john@smith.com" } ``` @@ -27,13 +57,15 @@ resource "harbor_user" "main" { - `email` (String) The email address of the internal user. - `full_name` (String) The Full Name of the internal user. -- `password` (String, Sensitive) The password for the internal user. - `username` (String) The username of the internal user. ### Optional - `admin` (Boolean) If the user will have admin rights within Harbor (Default: `false`) - `comment` (String) Any comments for that are need for the internal user. +- `password` (String, Sensitive) The password for the internal user. Conflicts with `password_wo_version`. +- `password_wo` (String, Write-only) Write-only alternative for `password`. Must be used together with `password_wo_version`. +- `password_wo_version` (Number) Rotation trigger for write-only password updates. Must be used together with `password_wo`. ### Read-Only diff --git a/provider/resource_robot_account.go b/provider/resource_robot_account.go index 2305896..2b60b05 100755 --- a/provider/resource_robot_account.go +++ b/provider/resource_robot_account.go @@ -8,11 +8,16 @@ import ( "github.com/goharbor/terraform-provider-harbor/client" "github.com/goharbor/terraform-provider-harbor/models" + "github.com/hashicorp/go-cty/cty" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" ) func resourceRobotAccount() *schema.Resource { return &schema.Resource{ + ValidateRawResourceConfigFuncs: []schema.ValidateRawResourceConfigFunc{ + validation.PreferWriteOnlyAttribute(cty.GetAttrPath("secret"), cty.GetAttrPath("secret_wo")), + }, Schema: map[string]*schema.Schema{ "robot_id": { Type: schema.TypeString, @@ -52,6 +57,31 @@ func resourceRobotAccount() *schema.Resource { Optional: true, Computed: true, Sensitive: true, + ConflictsWith: []string{ + "secret_wo", + "secret_wo_version", + }, + }, + "secret_wo": { + Type: schema.TypeString, + Optional: true, + WriteOnly: true, + RequiredWith: []string{ + "secret_wo_version", + }, + ConflictsWith: []string{ + "secret", + }, + }, + "secret_wo_version": { + Type: schema.TypeInt, + Optional: true, + RequiredWith: []string{ + "secret_wo", + }, + ConflictsWith: []string{ + "secret", + }, }, "permissions": { Type: schema.TypeSet, @@ -114,6 +144,10 @@ func resourceRobotAccountCreate(d *schema.ResourceData, m interface{}) error { apiClient := m.(*client.Client) body := client.RobotBody(d) + secretWriteOnly, err := getWriteOnlyString(d, "secret_wo") + if err != nil { + return err + } resp, headers, _, err := apiClient.SendRequest("POST", models.PathRobots, body, 201) if err != nil { @@ -131,10 +165,15 @@ func resourceRobotAccountCreate(d *schema.ResourceData, m interface{}) error { return err } - if d.Get("secret").(string) != "" { + secret := d.Get("secret").(string) + if secretWriteOnly != "" { + secret = secretWriteOnly + } + + if secret != "" { robotID := strconv.Itoa(jsonData.ID) secret := models.RobotSecret{ - Secret: d.Get("secret").(string), + Secret: secret, } _, _, _, err := apiClient.SendRequest("PATCH", models.PathRobots+"/"+robotID, secret, 200) if err != nil { @@ -241,6 +280,12 @@ func resourceRobotAccountRead(d *schema.ResourceData, m interface{}) error { func resourceRobotAccountUpdate(d *schema.ResourceData, m interface{}) error { apiClient := m.(*client.Client) + oldSecret, _ := d.GetChange("secret") + oldSecretWOVersion, _ := d.GetChange("secret_wo_version") + secretWriteOnly, err := getWriteOnlyString(d, "secret_wo") + if err != nil { + return err + } body := client.RobotBody(d) @@ -254,17 +299,34 @@ func resourceRobotAccountUpdate(d *schema.ResourceData, m interface{}) error { body.Name = robot.Name } - _, _, _, err := apiClient.SendRequest("PUT", d.Id(), body, 200) + _, _, _, err = apiClient.SendRequest("PUT", d.Id(), body, 200) if err != nil { return err } - if d.HasChange("secret") { + if d.HasChange("secret") || d.HasChange("secret_wo_version") { + secretValue := d.Get("secret").(string) + if d.HasChange("secret_wo_version") { + if secretWriteOnly == "" && !d.HasChange("secret") { + _ = d.Set("secret_wo_version", oldSecretWOVersion) + return fmt.Errorf("secret_wo must be configured when secret_wo_version changes") + } + if secretWriteOnly != "" { + secretValue = secretWriteOnly + } + } + secret := models.RobotSecret{ - Secret: d.Get("secret").(string), + Secret: secretValue, } _, _, _, err := apiClient.SendRequest("PATCH", d.Id(), secret, 200) if err != nil { + if d.HasChange("secret") { + _ = d.Set("secret", oldSecret) + } + if d.HasChange("secret_wo_version") { + _ = d.Set("secret_wo_version", oldSecretWOVersion) + } return err } } diff --git a/provider/resource_robot_account_test.go b/provider/resource_robot_account_test.go index ea67838..ac13e63 100644 --- a/provider/resource_robot_account_test.go +++ b/provider/resource_robot_account_test.go @@ -56,6 +56,28 @@ func TestAccRobotProject(t *testing.T) { }) } +func TestAccRobotProjectWriteOnlySecret(t *testing.T) { + randStr := randomString(4) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + CheckDestroy: testAccCheckRobotDestroy, + Steps: []resource.TestStep{ + { + Config: testAccCheckRobotProjectWriteOnlySecret("acctest_robot_" + strings.ToLower(randStr)), + Check: resource.ComposeTestCheckFunc( + testAccCheckResourceExists("harbor_project.main"), + + testAccCheckResourceExists(harborRobotAccount), + resource.TestCheckResourceAttr( + harborRobotAccount, "name", "test_robot_project_wo"), + ), + }, + }, + }) +} + func testAccCheckRobotDestroy(s *terraform.State) error { apiClient := testAccProvider.Meta().(*client.Client) @@ -140,3 +162,31 @@ func testAccCheckRobotProject(projectName string) string { } `, projectName) } + +func testAccCheckRobotProjectWriteOnlySecret(projectName string) string { + return fmt.Sprintf(` + resource "harbor_robot_account" "main" { + name = "test_robot_project_wo" + description = "project level robot account with write-only secret" + level = "project" + secret_wo = "robotSecret12345" + secret_wo_version = 1 + permissions { + access { + action = "pull" + resource = "repository" + } + access { + action = "push" + resource = "repository" + } + kind = "project" + namespace = harbor_project.main.name + } + } + + resource "harbor_project" "main" { + name = "%v" + } + `, projectName) +} diff --git a/provider/resource_user.go b/provider/resource_user.go index db89ea3..4921869 100644 --- a/provider/resource_user.go +++ b/provider/resource_user.go @@ -6,11 +6,16 @@ import ( "github.com/goharbor/terraform-provider-harbor/client" "github.com/goharbor/terraform-provider-harbor/models" + "github.com/hashicorp/go-cty/cty" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" ) func resourceUser() *schema.Resource { return &schema.Resource{ + ValidateRawResourceConfigFuncs: []schema.ValidateRawResourceConfigFunc{ + validation.PreferWriteOnlyAttribute(cty.GetAttrPath("password"), cty.GetAttrPath("password_wo")), + }, Schema: map[string]*schema.Schema{ "username": { Type: schema.TypeString, @@ -19,8 +24,37 @@ func resourceUser() *schema.Resource { }, "password": { Type: schema.TypeString, - Required: true, + Optional: true, Sensitive: true, + ExactlyOneOf: []string{ + "password", + "password_wo", + }, + ConflictsWith: []string{ + "password_wo_version", + }, + }, + "password_wo": { + Type: schema.TypeString, + Optional: true, + WriteOnly: true, + ExactlyOneOf: []string{ + "password", + "password_wo", + }, + RequiredWith: []string{ + "password_wo_version", + }, + }, + "password_wo_version": { + Type: schema.TypeInt, + Optional: true, + RequiredWith: []string{ + "password_wo", + }, + ConflictsWith: []string{ + "password", + }, }, "full_name": { Type: schema.TypeString, @@ -54,6 +88,18 @@ func resourceUserCreate(d *schema.ResourceData, m interface{}) error { apiClient := m.(*client.Client) body := client.UserBody(d) + passwordWriteOnly, err := getWriteOnlyString(d, "password_wo") + if err != nil { + return err + } + if passwordWriteOnly != "" { + body.Password = passwordWriteOnly + body.Newpassword = passwordWriteOnly + } + + if body.Password == "" { + return fmt.Errorf("one of password or password_wo must be configured") + } _, header, _, err := apiClient.SendRequest("POST", models.PathUsers, &body, 201) if err != nil { @@ -92,9 +138,20 @@ func resourceUserRead(d *schema.ResourceData, m interface{}) error { func resourceUserUpdate(d *schema.ResourceData, m interface{}) error { apiClient := m.(*client.Client) + oldPassword, _ := d.GetChange("password") + oldPasswordWOVersion, _ := d.GetChange("password_wo_version") body := client.UserBody(d) - _, _, _, err := apiClient.SendRequest("PUT", d.Id(), body, 200) + passwordWriteOnly, err := getWriteOnlyString(d, "password_wo") + if err != nil { + return err + } + if passwordWriteOnly != "" { + body.Password = passwordWriteOnly + body.Newpassword = passwordWriteOnly + } + + _, _, _, err = apiClient.SendRequest("PUT", d.Id(), body, 200) if err != nil { return err } @@ -104,9 +161,19 @@ func resourceUserUpdate(d *schema.ResourceData, m interface{}) error { return err } - if d.HasChange("password") == true { + if d.HasChange("password") || d.HasChange("password_wo_version") { + if d.HasChange("password_wo_version") && passwordWriteOnly == "" && !d.HasChange("password") { + _ = d.Set("password_wo_version", oldPasswordWOVersion) + return fmt.Errorf("password_wo must be configured when password_wo_version changes") + } _, _, _, err = apiClient.SendRequest("PUT", d.Id()+"/password", body, 200) if err != nil { + if d.HasChange("password") { + _ = d.Set("password", oldPassword) + } + if d.HasChange("password_wo_version") { + _ = d.Set("password_wo_version", oldPasswordWOVersion) + } return err } } diff --git a/provider/resource_user_test.go b/provider/resource_user_test.go index e9eecb7..09903f9 100644 --- a/provider/resource_user_test.go +++ b/provider/resource_user_test.go @@ -88,6 +88,28 @@ func TestAccUserUpdate(t *testing.T) { }) } +func TestAccUserWriteOnlyPassword(t *testing.T) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + CheckDestroy: testAccCheckUserDestroy, + Steps: []resource.TestStep{ + { + Config: testAccCheckUserWriteOnly(), + Check: resource.ComposeTestCheckFunc( + testAccCheckResourceExists(resourceHarborUserMain), + resource.TestCheckResourceAttr( + resourceHarborUserMain, "username", "john_wo"), + resource.TestCheckResourceAttr( + resourceHarborUserMain, "full_name", "John WriteOnly"), + resource.TestCheckResourceAttr( + resourceHarborUserMain, "email", "john.writeonly@contoso.com"), + ), + }, + }, + }) +} + func testAccCheckUserBasic() string { return fmt.Sprintf(` resource "harbor_user" "main" { @@ -109,3 +131,15 @@ func testAccCheckUserUpdate() string { } `) } + +func testAccCheckUserWriteOnly() string { + return fmt.Sprintf(` + resource "harbor_user" "main" { + username = "john_wo" + password_wo = "Password12345" + password_wo_version = 1 + full_name = "John WriteOnly" + email = "john.writeonly@contoso.com" + } + `) +} diff --git a/provider/write_only.go b/provider/write_only.go new file mode 100644 index 0000000..bd4c292 --- /dev/null +++ b/provider/write_only.go @@ -0,0 +1,31 @@ +package provider + +import ( + "fmt" + + "github.com/hashicorp/go-cty/cty" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func getWriteOnlyString(d *schema.ResourceData, key string) (string, error) { + rawValue, diags := d.GetRawConfigAt(cty.GetAttrPath(key)) + + return parseWriteOnlyString(rawValue, diags, key) +} + +func parseWriteOnlyString(rawValue cty.Value, diags diag.Diagnostics, key string) (string, error) { + if diags.HasError() { + return "", fmt.Errorf("error retrieving write-only argument %q: %v", key, diags) + } + + if rawValue.IsNull() { + return "", nil + } + + if !rawValue.Type().Equals(cty.String) { + return "", fmt.Errorf("error retrieving write-only argument %q: value must be a string", key) + } + + return rawValue.AsString(), nil +} diff --git a/provider/write_only_test.go b/provider/write_only_test.go new file mode 100644 index 0000000..cc725a1 --- /dev/null +++ b/provider/write_only_test.go @@ -0,0 +1,54 @@ +package provider + +import ( + "testing" + + "github.com/hashicorp/go-cty/cty" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" +) + +func TestParseWriteOnlyString(t *testing.T) { + t.Run("returns configured write-only value", func(t *testing.T) { + value, err := parseWriteOnlyString(cty.StringVal("my-secret"), nil, "secret_wo") + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + if value != "my-secret" { + t.Fatalf("unexpected value: got %q", value) + } + }) + + t.Run("returns empty string when value is not configured", func(t *testing.T) { + value, err := parseWriteOnlyString(cty.NullVal(cty.String), nil, "secret_wo") + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + if value != "" { + t.Fatalf("unexpected value: got %q", value) + } + }) + + t.Run("returns error for non-string write-only value", func(t *testing.T) { + _, err := parseWriteOnlyString(cty.NumberIntVal(1), nil, "secret_wo") + if err == nil { + t.Fatal("expected error but got nil") + } + }) + + t.Run("returns error when diagnostics contain error", func(t *testing.T) { + diags := diag.Diagnostics{ + diag.Diagnostic{ + Severity: diag.Error, + Summary: "Invalid config path", + Detail: "Cannot find config value for given path.", + }, + } + + _, err := parseWriteOnlyString(cty.DynamicVal, diags, "password_wo") + if err == nil { + t.Fatal("expected error but got nil") + } + }) +}