blob: fc6b493fe1db500822b8170a0ea6230a52b1f4be [file] [log] [blame] [edit]
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package sql
import (
"fmt"
"log"
"strings"
"time"
"github.com/hashicorp/terraform-provider-google/google/tpgresource"
transport_tpg "github.com/hashicorp/terraform-provider-google/google/transport"
"github.com/hashicorp/errwrap"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/customdiff"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation"
sqladmin "google.golang.org/api/sqladmin/v1beta4"
)
func diffSuppressIamUserName(_, old, new string, d *schema.ResourceData) bool {
strippedName := strings.Split(new, "@")[0]
userType := d.Get("type").(string)
if old == strippedName && strings.Contains(userType, "IAM") {
return true
}
return false
}
func handleUserNotFoundError(err error, d *schema.ResourceData, resource string) error {
if transport_tpg.IsGoogleApiErrorWithCode(err, 404) || transport_tpg.IsGoogleApiErrorWithCode(err, 403) {
log.Printf("[WARN] Removing %s because it's gone", resource)
// The resource doesn't exist anymore
d.SetId("")
return nil
}
return errwrap.Wrapf(
fmt.Sprintf("Error when reading or editing %s: {{err}}", resource), err)
}
func ResourceSqlUser() *schema.Resource {
return &schema.Resource{
Create: resourceSqlUserCreate,
Read: resourceSqlUserRead,
Update: resourceSqlUserUpdate,
Delete: resourceSqlUserDelete,
Importer: &schema.ResourceImporter{
State: resourceSqlUserImporter,
},
Timeouts: &schema.ResourceTimeout{
Create: schema.DefaultTimeout(10 * time.Minute),
Update: schema.DefaultTimeout(10 * time.Minute),
Delete: schema.DefaultTimeout(10 * time.Minute),
},
CustomizeDiff: customdiff.All(
tpgresource.DefaultProviderProject,
),
SchemaVersion: 1,
MigrateState: resourceSqlUserMigrateState,
Schema: map[string]*schema.Schema{
"host": {
Type: schema.TypeString,
Optional: true,
Computed: true,
ForceNew: true,
Description: `The host the user can connect from. This is only supported for MySQL instances. Don't set this field for PostgreSQL instances. Can be an IP address. Changing this forces a new resource to be created.`,
},
"instance": {
Type: schema.TypeString,
Required: true,
ForceNew: true,
Description: `The name of the Cloud SQL instance. Changing this forces a new resource to be created.`,
},
"name": {
Type: schema.TypeString,
Required: true,
ForceNew: true,
DiffSuppressFunc: diffSuppressIamUserName,
Description: `The name of the user. Changing this forces a new resource to be created.`,
},
"password": {
Type: schema.TypeString,
Optional: true,
Sensitive: true,
Description: `The password for the user. Can be updated. For Postgres instances this is a Required field, unless type is set to
either CLOUD_IAM_USER or CLOUD_IAM_SERVICE_ACCOUNT.`,
},
"type": {
Type: schema.TypeString,
Optional: true,
ForceNew: true,
DiffSuppressFunc: tpgresource.EmptyOrDefaultStringSuppress("BUILT_IN"),
Description: `The user type. It determines the method to authenticate the user during login.
The default is the database's built-in user type.`,
},
"sql_server_user_details": {
Type: schema.TypeList,
Computed: true,
Elem: &schema.Resource{
Schema: map[string]*schema.Schema{
"disabled": {
Type: schema.TypeBool,
Computed: true,
Description: `If the user has been disabled.`,
},
"server_roles": {
Type: schema.TypeList,
Computed: true,
Description: `The server roles for this user in the database.`,
Elem: &schema.Schema{Type: schema.TypeString},
},
},
},
},
"password_policy": {
Type: schema.TypeList,
Optional: true,
MaxItems: 1,
Elem: &schema.Resource{
Schema: map[string]*schema.Schema{
"allowed_failed_attempts": {
Type: schema.TypeInt,
Optional: true,
Description: `Number of failed attempts allowed before the user get locked.`,
},
"password_expiration_duration": {
Type: schema.TypeString,
Optional: true,
Description: `Password expiration duration with one week grace period.`,
},
"enable_failed_attempts_check": {
Type: schema.TypeBool,
Optional: true,
Description: `If true, the check that will lock user after too many failed login attempts will be enabled.`,
},
"enable_password_verification": {
Type: schema.TypeBool,
Optional: true,
Description: `If true, the user must specify the current password before changing the password. This flag is supported only for MySQL.`,
},
"status": {
Type: schema.TypeList,
Computed: true,
Elem: &schema.Resource{
Schema: map[string]*schema.Schema{
"locked": {
Type: schema.TypeBool,
Computed: true,
Description: `If true, user does not have login privileges.`,
},
"password_expiration_time": {
Type: schema.TypeString,
Computed: true,
Description: `Password expiration duration with one week grace period.`,
},
},
},
},
},
},
},
"project": {
Type: schema.TypeString,
Optional: true,
Computed: true,
ForceNew: true,
Description: `The ID of the project in which the resource belongs. If it is not provided, the provider project is used.`,
},
"deletion_policy": {
Type: schema.TypeString,
Optional: true,
Description: `The deletion policy for the user. Setting ABANDON allows the resource
to be abandoned rather than deleted. This is useful for Postgres, where users cannot be deleted from the API if they
have been granted SQL roles. Possible values are: "ABANDON".`,
ValidateFunc: validation.StringInSlice([]string{"ABANDON", ""}, false),
},
},
UseJSONNumber: true,
}
}
func flattenSqlServerUserDetails(v *sqladmin.SqlServerUserDetails) []interface{} {
if v == nil {
return []interface{}{}
}
transformed := make(map[string]interface{})
transformed["disabled"] = v.Disabled
transformed["server_roles"] = v.ServerRoles
return []interface{}{transformed}
}
func expandPasswordPolicy(cfg interface{}) *sqladmin.UserPasswordValidationPolicy {
if len(cfg.([]interface{})) == 0 || cfg.([]interface{})[0] == nil {
return nil
}
raw := cfg.([]interface{})[0].(map[string]interface{})
upvp := &sqladmin.UserPasswordValidationPolicy{}
if v, ok := raw["allowed_failed_attempts"]; ok {
upvp.AllowedFailedAttempts = int64(v.(int))
}
if v, ok := raw["password_expiration_duration"]; ok {
upvp.PasswordExpirationDuration = v.(string)
}
if v, ok := raw["enable_failed_attempts_check"]; ok {
upvp.EnableFailedAttemptsCheck = v.(bool)
}
if v, ok := raw["enable_password_verification"]; ok {
upvp.EnablePasswordVerification = v.(bool)
}
return upvp
}
func resourceSqlUserCreate(d *schema.ResourceData, meta interface{}) error {
config := meta.(*transport_tpg.Config)
userAgent, err := tpgresource.GenerateUserAgentString(d, config.UserAgent)
if err != nil {
return err
}
project, err := tpgresource.GetProject(d, config)
if err != nil {
return err
}
name := d.Get("name").(string)
instance := d.Get("instance").(string)
password := d.Get("password").(string)
host := d.Get("host").(string)
typ := d.Get("type").(string)
user := &sqladmin.User{
Name: name,
Instance: instance,
Password: password,
Host: host,
Type: typ,
}
if v, ok := d.GetOk("password_policy"); ok {
pp := expandPasswordPolicy(v)
user.PasswordPolicy = pp
}
transport_tpg.MutexStore.Lock(instanceMutexKey(project, instance))
defer transport_tpg.MutexStore.Unlock(instanceMutexKey(project, instance))
if v, ok := d.GetOk("host"); ok {
if v.(string) != "" {
var fetchedInstance *sqladmin.DatabaseInstance
err = transport_tpg.Retry(transport_tpg.RetryOptions{
RetryFunc: func() (rerr error) {
fetchedInstance, rerr = config.NewSqlAdminClient(userAgent).Instances.Get(project, instance).Do()
return rerr
},
Timeout: d.Timeout(schema.TimeoutRead),
ErrorRetryPredicates: []transport_tpg.RetryErrorPredicateFunc{transport_tpg.IsSqlOperationInProgressError},
})
if err != nil {
return transport_tpg.HandleNotFoundError(err, d, fmt.Sprintf("SQL Database Instance %q", d.Get("instance").(string)))
}
if !strings.Contains(fetchedInstance.DatabaseVersion, "MYSQL") {
return fmt.Errorf("Error: Host field is only supported for MySQL instances: %s", fetchedInstance.DatabaseVersion)
}
}
}
var op *sqladmin.Operation
insertFunc := func() error {
op, err = config.NewSqlAdminClient(userAgent).Users.Insert(project, instance,
user).Do()
return err
}
err = transport_tpg.Retry(transport_tpg.RetryOptions{
RetryFunc: insertFunc,
Timeout: d.Timeout(schema.TimeoutCreate),
})
if err != nil {
return fmt.Errorf("Error, failed to insert "+
"user %s into instance %s: %s", name, instance, err)
}
// This will include a double-slash (//) for postgres instances,
// for which user.Host is an empty string. That's okay.
d.SetId(fmt.Sprintf("%s/%s/%s", user.Name, user.Host, user.Instance))
err = SqlAdminOperationWaitTime(config, op, project, "Insert User", userAgent, d.Timeout(schema.TimeoutCreate))
if err != nil {
return fmt.Errorf("Error, failure waiting for insertion of %s "+
"into %s: %s", name, instance, err)
}
return resourceSqlUserRead(d, meta)
}
func resourceSqlUserRead(d *schema.ResourceData, meta interface{}) error {
config := meta.(*transport_tpg.Config)
userAgent, err := tpgresource.GenerateUserAgentString(d, config.UserAgent)
if err != nil {
return err
}
project, err := tpgresource.GetProject(d, config)
if err != nil {
return err
}
instance := d.Get("instance").(string)
name := d.Get("name").(string)
host := d.Get("host").(string)
var users *sqladmin.UsersListResponse
err = nil
err = transport_tpg.Retry(transport_tpg.RetryOptions{
RetryFunc: func() error {
users, err = config.NewSqlAdminClient(userAgent).Users.List(project, instance).Do()
return err
},
Timeout: 5 * time.Minute,
})
if err != nil {
// move away from transport_tpg.HandleNotFoundError() as we need to handle both 404 and 403
return handleUserNotFoundError(err, d, fmt.Sprintf("SQL User %q in instance %q", name, instance))
}
var user *sqladmin.User
databaseInstance, err := config.NewSqlAdminClient(userAgent).Instances.Get(project, instance).Do()
if err != nil {
return err
}
for _, currentUser := range users.Items {
var username string
if !(strings.Contains(databaseInstance.DatabaseVersion, "POSTGRES") || currentUser.Type == "CLOUD_IAM_GROUP") {
username = strings.Split(name, "@")[0]
} else {
username = name
}
if currentUser.Name == username {
// Host can only be empty for postgres instances,
// so don't compare the host if the API host is empty.
if host == "" || currentUser.Host == host {
user = currentUser
break
}
}
}
if user == nil {
log.Printf("[WARN] Removing SQL User %q because it's gone", d.Get("name").(string))
d.SetId("")
return nil
}
if err := d.Set("host", user.Host); err != nil {
return fmt.Errorf("Error setting host: %s", err)
}
if err := d.Set("instance", user.Instance); err != nil {
return fmt.Errorf("Error setting instance: %s", err)
}
if err := d.Set("name", user.Name); err != nil {
return fmt.Errorf("Error setting name: %s", err)
}
if err := d.Set("type", user.Type); err != nil {
return fmt.Errorf("Error setting type: %s", err)
}
if err := d.Set("project", project); err != nil {
return fmt.Errorf("Error setting project: %s", err)
}
if err := d.Set("sql_server_user_details", flattenSqlServerUserDetails(user.SqlserverUserDetails)); err != nil {
return fmt.Errorf("Error setting sql server user details: %s", err)
}
if user.PasswordPolicy != nil {
passwordPolicy := flattenPasswordPolicy(user.PasswordPolicy)
if len(passwordPolicy.([]map[string]interface{})[0]) != 0 {
if err := d.Set("password_policy", passwordPolicy); err != nil {
return fmt.Errorf("Error setting password_policy: %s", err)
}
}
}
d.SetId(fmt.Sprintf("%s/%s/%s", user.Name, user.Host, user.Instance))
return nil
}
func flattenPasswordPolicy(passwordPolicy *sqladmin.UserPasswordValidationPolicy) interface{} {
data := map[string]interface{}{}
if passwordPolicy.AllowedFailedAttempts != 0 {
data["allowed_failed_attempts"] = passwordPolicy.AllowedFailedAttempts
}
if passwordPolicy.EnableFailedAttemptsCheck != false {
data["enable_failed_attempts_check"] = passwordPolicy.EnableFailedAttemptsCheck
}
if passwordPolicy.EnablePasswordVerification != false {
data["enable_password_verification"] = passwordPolicy.EnablePasswordVerification
}
if len(passwordPolicy.PasswordExpirationDuration) != 0 {
data["password_expiration_duration"] = passwordPolicy.PasswordExpirationDuration
}
if passwordPolicy.Status != nil {
status := flattenPasswordStatus(passwordPolicy.Status)
if len(status.([]map[string]interface{})[0]) != 0 {
data["status"] = flattenPasswordStatus(passwordPolicy.Status)
}
}
return []map[string]interface{}{data}
}
func flattenPasswordStatus(status *sqladmin.PasswordStatus) interface{} {
data := map[string]interface{}{}
if status.Locked != false {
data["locked"] = status.Locked
}
if len(status.PasswordExpirationTime) != 0 {
data["password_expiration_time"] = status.PasswordExpirationTime
}
return []map[string]interface{}{data}
}
func resourceSqlUserUpdate(d *schema.ResourceData, meta interface{}) error {
config := meta.(*transport_tpg.Config)
userAgent, err := tpgresource.GenerateUserAgentString(d, config.UserAgent)
if err != nil {
return err
}
if d.HasChange("password") || d.HasChange("password_policy") {
project, err := tpgresource.GetProject(d, config)
if err != nil {
return err
}
name := d.Get("name").(string)
instance := d.Get("instance").(string)
password := d.Get("password").(string)
host := d.Get("host").(string)
user := &sqladmin.User{
Name: name,
Instance: instance,
Password: password,
}
transport_tpg.MutexStore.Lock(instanceMutexKey(project, instance))
defer transport_tpg.MutexStore.Unlock(instanceMutexKey(project, instance))
var op *sqladmin.Operation
updateFunc := func() error {
op, err = config.NewSqlAdminClient(userAgent).Users.Update(project, instance, user).Host(host).Name(name).Do()
return err
}
err = transport_tpg.Retry(transport_tpg.RetryOptions{
RetryFunc: updateFunc,
Timeout: d.Timeout(schema.TimeoutUpdate),
})
if err != nil {
return fmt.Errorf("Error, failed to update"+
"user %s into user %s: %s", name, instance, err)
}
err = SqlAdminOperationWaitTime(config, op, project, "Insert User", userAgent, d.Timeout(schema.TimeoutUpdate))
if err != nil {
return fmt.Errorf("Error, failure waiting for update of %s "+
"in %s: %s", name, instance, err)
}
return resourceSqlUserRead(d, meta)
}
return nil
}
func resourceSqlUserDelete(d *schema.ResourceData, meta interface{}) error {
config := meta.(*transport_tpg.Config)
if deletionPolicy := d.Get("deletion_policy"); deletionPolicy == "ABANDON" {
// Allows for user to be abandoned without deletion to avoid deletion failing
// for Postgres users in some circumstances due to existing SQL roles
return nil
}
userAgent, err := tpgresource.GenerateUserAgentString(d, config.UserAgent)
if err != nil {
return err
}
project, err := tpgresource.GetProject(d, config)
if err != nil {
return err
}
name := d.Get("name").(string)
host := d.Get("host").(string)
instance := d.Get("instance").(string)
transport_tpg.MutexStore.Lock(instanceMutexKey(project, instance))
defer transport_tpg.MutexStore.Unlock(instanceMutexKey(project, instance))
var op *sqladmin.Operation
err = transport_tpg.Retry(transport_tpg.RetryOptions{
RetryFunc: func() error {
op, err = config.NewSqlAdminClient(userAgent).Users.Delete(project, instance).Host(host).Name(name).Do()
if err != nil {
return err
}
if err := SqlAdminOperationWaitTime(config, op, project, "Delete User", userAgent, d.Timeout(schema.TimeoutDelete)); err != nil {
return err
}
return nil
},
Timeout: d.Timeout(schema.TimeoutDelete),
ErrorRetryPredicates: []transport_tpg.RetryErrorPredicateFunc{transport_tpg.IsSqlOperationInProgressError, IsSqlInternalError},
})
if err != nil {
return fmt.Errorf("Error, failed to delete"+
"user %s in instance %s: %s", name,
instance, err)
}
return nil
}
func resourceSqlUserImporter(d *schema.ResourceData, meta interface{}) ([]*schema.ResourceData, error) {
parts := strings.Split(d.Id(), "/")
if len(parts) == 3 {
if err := d.Set("project", parts[0]); err != nil {
return nil, fmt.Errorf("Error setting project: %s", err)
}
if err := d.Set("instance", parts[1]); err != nil {
return nil, fmt.Errorf("Error setting instance: %s", err)
}
if err := d.Set("name", parts[2]); err != nil {
return nil, fmt.Errorf("Error setting name: %s", err)
}
} else if len(parts) == 4 {
if err := d.Set("project", parts[0]); err != nil {
return nil, fmt.Errorf("Error setting project: %s", err)
}
if err := d.Set("instance", parts[1]); err != nil {
return nil, fmt.Errorf("Error setting instance: %s", err)
}
if err := d.Set("host", parts[2]); err != nil {
return nil, fmt.Errorf("Error setting host: %s", err)
}
if err := d.Set("name", parts[3]); err != nil {
return nil, fmt.Errorf("Error setting name: %s", err)
}
} else if len(parts) == 5 {
if err := d.Set("project", parts[0]); err != nil {
return nil, fmt.Errorf("Error setting project: %s", err)
}
if err := d.Set("instance", parts[1]); err != nil {
return nil, fmt.Errorf("Error setting instance: %s", err)
}
if err := d.Set("host", fmt.Sprintf("%s/%s", parts[2], parts[3])); err != nil {
return nil, fmt.Errorf("Error setting host: %s", err)
}
if err := d.Set("name", parts[4]); err != nil {
return nil, fmt.Errorf("Error setting name: %s", err)
}
} else {
return nil, fmt.Errorf("Invalid specifier. Expecting {project}/{instance}/{name} for postgres instance and {project}/{instance}/{host}/{name} for MySQL instance")
}
return []*schema.ResourceData{d}, nil
}