diff --git a/internal/acceptance/check/that.go b/internal/acceptance/check/that.go index 1a0bfbf0a2e4..60a9d7d38fc1 100644 --- a/internal/acceptance/check/that.go +++ b/internal/acceptance/check/that.go @@ -118,6 +118,16 @@ func (t thatWithKeyType) IsEmpty() pluginsdk.TestCheckFunc { return resource.TestCheckResourceAttr(t.resourceName, t.key, "") } +// IsNotEmpty returns a TestCheckFunc which validates that the specific key is not empty on the resource +func (t thatWithKeyType) IsNotEmpty() pluginsdk.TestCheckFunc { + return resource.TestCheckResourceAttrWith(t.resourceName, t.key, func(value string) error { + if value == "" { + return fmt.Errorf("value is empty") + } + return nil + }) +} + // IsSet returns a TestCheckFunc which validates that the specific key is set on the resource func (t thatWithKeyType) IsSet() pluginsdk.TestCheckFunc { return resource.TestCheckResourceAttrSet(t.resourceName, t.key) diff --git a/internal/provider/services.go b/internal/provider/services.go index 78b8a2bbbd68..38015e53e46c 100644 --- a/internal/provider/services.go +++ b/internal/provider/services.go @@ -154,6 +154,7 @@ func SupportedTypedServices() []sdk.TypedServiceRegistration { sentinel.Registration{}, serviceconnector.Registration{}, servicefabricmanaged.Registration{}, + storage.Registration{}, orbital.Registration{}, streamanalytics.Registration{}, search.Registration{}, diff --git a/internal/services/storage/client/client.go b/internal/services/storage/client/client.go index a606a357c2c5..29e7c0352a7a 100644 --- a/internal/services/storage/client/client.go +++ b/internal/services/storage/client/client.go @@ -9,6 +9,7 @@ import ( "github.com/Azure/go-autorest/autorest" "github.com/Azure/go-autorest/autorest/azure" storage_v2022_05_01 "github.com/hashicorp/go-azure-sdk/resource-manager/storage/2022-05-01" + "github.com/hashicorp/go-azure-sdk/resource-manager/storage/2022-05-01/localusers" "github.com/hashicorp/terraform-provider-azurerm/internal/common" "github.com/hashicorp/terraform-provider-azurerm/internal/services/storage/shim" "github.com/tombuildsstuff/giovanni/storage/2019-12-12/blob/accounts" @@ -26,6 +27,7 @@ import ( type Client struct { AccountsClient *storage.AccountsClient + LocalUsersClient *localusers.LocalUsersClient FileSystemsClient *filesystems.Client ADLSGen2PathsClient *paths.Client ManagementPoliciesClient *storage.ManagementPoliciesClient @@ -49,6 +51,9 @@ func NewClient(options *common.ClientOptions) *Client { accountsClient := storage.NewAccountsClientWithBaseURI(options.ResourceManagerEndpoint, options.SubscriptionId) options.ConfigureClient(&accountsClient.Client, options.ResourceManagerAuthorizer) + localUsersClient := localusers.NewLocalUsersClientWithBaseURI(options.ResourceManagerEndpoint) + localUsersClient.Client.Authorizer = options.ResourceManagerAuthorizer + fileSystemsClient := filesystems.NewWithEnvironment(options.Environment) options.ConfigureClient(&fileSystemsClient.Client, options.StorageAuthorizer) @@ -88,6 +93,7 @@ func NewClient(options *common.ClientOptions) *Client { // (which should fix #2977) when the storage clients have been moved in here client := Client{ AccountsClient: &accountsClient, + LocalUsersClient: &localUsersClient, FileSystemsClient: &fileSystemsClient, ADLSGen2PathsClient: &adlsGen2PathsClient, ManagementPoliciesClient: &managementPoliciesClient, diff --git a/internal/services/storage/registration.go b/internal/services/storage/registration.go index 6a29c7212288..b5f47a766f3c 100644 --- a/internal/services/storage/registration.go +++ b/internal/services/storage/registration.go @@ -67,3 +67,13 @@ func (r Registration) SupportedResources() map[string]*pluginsdk.Resource { "azurerm_storage_sync_group": resourceStorageSyncGroup(), } } + +func (r Registration) DataSources() []sdk.DataSource { + return []sdk.DataSource{} +} + +func (r Registration) Resources() []sdk.Resource { + return []sdk.Resource{ + LocalUserResource{}, + } +} diff --git a/internal/services/storage/storage_account_local_user_resource.go b/internal/services/storage/storage_account_local_user_resource.go new file mode 100644 index 000000000000..fe97370e0235 --- /dev/null +++ b/internal/services/storage/storage_account_local_user_resource.go @@ -0,0 +1,532 @@ +package storage + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/hashicorp/go-azure-helpers/lang/pointer" + "github.com/hashicorp/go-azure-helpers/lang/response" + "github.com/hashicorp/go-azure-sdk/resource-manager/storage/2022-05-01/localusers" + "github.com/hashicorp/terraform-provider-azurerm/internal/sdk" + "github.com/hashicorp/terraform-provider-azurerm/internal/services/compute" + computevalidate "github.com/hashicorp/terraform-provider-azurerm/internal/services/compute/validate" + "github.com/hashicorp/terraform-provider-azurerm/internal/services/storage/parse" + "github.com/hashicorp/terraform-provider-azurerm/internal/services/storage/validate" + "github.com/hashicorp/terraform-provider-azurerm/internal/tf/pluginsdk" + "github.com/hashicorp/terraform-provider-azurerm/internal/tf/validation" + "github.com/hashicorp/terraform-provider-azurerm/utils" +) + +type LocalUserResource struct{} + +var _ sdk.ResourceWithUpdate = LocalUserResource{} + +type PermissionsModel struct { + Create bool `tfschema:"create"` + Delete bool `tfschema:"delete"` + List bool `tfschema:"list"` + Read bool `tfschema:"read"` + Write bool `tfschema:"write"` +} +type PermissionScopeModel struct { + Permissions []PermissionsModel `tfschema:"permissions"` + ResourceName string `tfschema:"resource_name"` + Service string `tfschema:"service"` +} +type SshAuthorizedKeyModel struct { + Description string `tfschema:"description"` + Key string `tfschema:"key"` +} +type LocalUserModel struct { + HomeDirectory string `tfschema:"home_directory"` + Name string `tfschema:"name"` + Password string `tfschema:"password"` + PermissionScope []PermissionScopeModel `tfschema:"permission_scope"` + Sid string `tfschema:"sid"` + SshAuthorizedKey []SshAuthorizedKeyModel `tfschema:"ssh_authorized_key"` + SshKeyEnabled bool `tfschema:"ssh_key_enabled"` + SshPasswordEnabled bool `tfschema:"ssh_password_enabled"` + StorageAccountId string `tfschema:"storage_account_id"` +} + +func (r LocalUserResource) Arguments() map[string]*pluginsdk.Schema { + return map[string]*pluginsdk.Schema{ + "name": { + Type: pluginsdk.TypeString, + Required: true, + ForceNew: true, + ValidateFunc: validate.LocalUserName, + }, + "storage_account_id": { + Type: pluginsdk.TypeString, + Required: true, + ForceNew: true, + ValidateFunc: validate.StorageAccountID, + }, + "ssh_key_enabled": { + Type: pluginsdk.TypeBool, + Optional: true, + Default: false, + AtLeastOneOf: []string{"ssh_key_enabled", "ssh_password_enabled"}, + }, + "ssh_password_enabled": { + Type: pluginsdk.TypeBool, + Optional: true, + Default: false, + AtLeastOneOf: []string{"ssh_key_enabled", "ssh_password_enabled"}, + }, + "home_directory": { + Type: pluginsdk.TypeString, + Optional: true, + }, + "ssh_authorized_key": { + Type: pluginsdk.TypeList, + Optional: true, + ForceNew: true, + RequiredWith: []string{"ssh_key_enabled"}, + Elem: &pluginsdk.Resource{ + Schema: map[string]*pluginsdk.Schema{ + "key": { + Type: pluginsdk.TypeString, + Required: true, + ValidateFunc: computevalidate.SSHKey, + DiffSuppressFunc: compute.SSHKeyDiffSuppress, + }, + "description": { + Type: pluginsdk.TypeString, + Optional: true, + }, + }, + }, + }, + "permission_scope": { + Type: pluginsdk.TypeList, + Optional: true, + Elem: &pluginsdk.Resource{ + Schema: map[string]*pluginsdk.Schema{ + "permissions": { + Type: pluginsdk.TypeList, + Required: true, + MaxItems: 1, + Elem: &pluginsdk.Resource{ + Schema: map[string]*pluginsdk.Schema{ + "read": { + Type: pluginsdk.TypeBool, + Optional: true, + Default: false, + }, + + "write": { + Type: pluginsdk.TypeBool, + Optional: true, + Default: false, + }, + + "delete": { + Type: pluginsdk.TypeBool, + Optional: true, + Default: false, + }, + + "list": { + Type: pluginsdk.TypeBool, + Optional: true, + Default: false, + }, + + "create": { + Type: pluginsdk.TypeBool, + Optional: true, + Default: false, + }, + }, + }, + }, + "service": { + Type: pluginsdk.TypeString, + Required: true, + ValidateFunc: validation.StringInSlice( + // Replace the string literal with enum once https://github.com/Azure/azure-rest-api-specs/pull/21845 is merged + []string{"blob", "file"}, + false, + ), + }, + "resource_name": { + Type: pluginsdk.TypeString, + Required: true, + ValidateFunc: validation.StringIsNotEmpty, + }, + }, + }, + }, + } +} + +func (r LocalUserResource) Attributes() map[string]*pluginsdk.Schema { + return map[string]*pluginsdk.Schema{ + "sid": { + Type: pluginsdk.TypeString, + Sensitive: true, + Computed: true, + }, + "password": { + Type: pluginsdk.TypeString, + Sensitive: true, + Computed: true, + }, + } +} + +func (r LocalUserResource) ResourceType() string { + return "azurerm_storage_account_local_user" +} + +func (r LocalUserResource) ModelObject() interface{} { + return &LocalUserModel{} +} + +func (r LocalUserResource) IDValidationFunc() pluginsdk.SchemaValidateFunc { + return localusers.ValidateLocalUserID +} + +func (r LocalUserResource) CustomizeDiff() sdk.ResourceFunc { + return sdk.ResourceFunc{ + Func: func(ctx context.Context, metadata sdk.ResourceMetaData) error { + diff := metadata.ResourceDiff + if diff.HasChange("ssh_password_enabled") { + if err := diff.SetNewComputed("password"); err != nil { + return err + } + } + return nil + }, + Timeout: 5 * time.Minute, + } +} + +func (r LocalUserResource) Create() sdk.ResourceFunc { + return sdk.ResourceFunc{ + Timeout: 30 * time.Minute, + Func: func(ctx context.Context, metadata sdk.ResourceMetaData) error { + client := metadata.Client.Storage.LocalUsersClient + + var plan LocalUserModel + if err := metadata.Decode(&plan); err != nil { + return fmt.Errorf("decoding %+v", err) + } + + // Sanity checks on input + if plan.SshKeyEnabled != (len(plan.SshAuthorizedKey) != 0) { + if plan.SshKeyEnabled { + return fmt.Errorf("`ssh_authorized_key` should be specified when `ssh_key_enabled` is enabled") + } else { + return fmt.Errorf("`ssh_authorized_key` should not be specified when `ssh_key_enabled` is disabled") + } + } + + accountId, err := parse.StorageAccountID(plan.StorageAccountId) + if err != nil { + return err + } + + id := localusers.NewLocalUserID(accountId.SubscriptionId, accountId.ResourceGroup, accountId.Name, plan.Name) + existing, err := client.Get(ctx, id) + if err != nil { + if !response.WasNotFound(existing.HttpResponse) { + return fmt.Errorf("checking for presence of existing %s: %+v", id, err) + } + } + if !response.WasNotFound(existing.HttpResponse) { + return metadata.ResourceRequiresImport(r.ResourceType(), id) + } + + params := localusers.LocalUser{ + Properties: &localusers.LocalUserProperties{ + PermissionScopes: r.expandPermissionScopes(plan.PermissionScope), + SshAuthorizedKeys: r.expandSSHAuthorizedKeys(plan.SshAuthorizedKey), + HasSshKey: pointer.To(plan.SshKeyEnabled), + HasSshPassword: pointer.To(plan.SshPasswordEnabled), + }, + } + + if plan.HomeDirectory != "" { + params.Properties.HomeDirectory = utils.String(plan.HomeDirectory) + } + + if _, err = client.CreateOrUpdate(ctx, id, params); err != nil { + return fmt.Errorf("creating %s: %+v", id, err) + } + + state := plan + if plan.SshPasswordEnabled { + resp, err := client.RegeneratePassword(ctx, id) + if err != nil { + return fmt.Errorf("generating password for %s: %v", id.ID(), err) + } + if resp.Model == nil { + return fmt.Errorf("unexpected nil of the generate password response model for %s", id.ID()) + } + if v := resp.Model.SshPassword; v != nil { + state.Password = *v + } + if err := metadata.Encode(&state); err != nil { + return err + } + } + + metadata.SetID(id) + return nil + }, + } +} + +func (r LocalUserResource) Read() sdk.ResourceFunc { + return sdk.ResourceFunc{ + Timeout: 5 * time.Minute, + + Func: func(ctx context.Context, metadata sdk.ResourceMetaData) error { + client := metadata.Client.Storage.LocalUsersClient + id, err := localusers.ParseLocalUserID(metadata.ResourceData.Id()) + if err != nil { + return err + } + + var state LocalUserModel + if err := metadata.Decode(&state); err != nil { + return err + } + + existing, err := client.Get(ctx, *id) + if err != nil { + if response.WasNotFound(existing.HttpResponse) { + return metadata.MarkAsGone(id) + } + return fmt.Errorf("retrieving %s: %+v", id, err) + } + + model := LocalUserModel{ + Name: id.Username, + StorageAccountId: parse.NewStorageAccountID(id.SubscriptionId, id.ResourceGroupName, id.AccountName).ID(), + // Password is only accessible during creation + Password: state.Password, + // SshAuthorizedKey is only accessible during creation, whilst this should be returned as it is not a secret. + // Opened API issue: https://github.com/Azure/azure-rest-api-specs/issues/21866 + SshAuthorizedKey: state.SshAuthorizedKey, + } + + if existing.Model != nil && existing.Model.Properties != nil { + props := existing.Model.Properties + model.PermissionScope = r.flattenPermissionScopes(props.PermissionScopes) + if props.HomeDirectory != nil { + model.HomeDirectory = *props.HomeDirectory + } + if props.HasSshKey != nil { + model.SshKeyEnabled = *props.HasSshKey + } + if props.HasSshPassword != nil { + model.SshPasswordEnabled = *props.HasSshPassword + } + if props.Sid != nil { + model.Sid = *props.Sid + } + } + + return metadata.Encode(&model) + }, + } +} + +func (r LocalUserResource) Update() sdk.ResourceFunc { + return sdk.ResourceFunc{ + Timeout: 30 * time.Minute, + Func: func(ctx context.Context, metadata sdk.ResourceMetaData) error { + id, err := localusers.ParseLocalUserID(metadata.ResourceData.Id()) + if err != nil { + return err + } + + var plan LocalUserModel + if err := metadata.Decode(&plan); err != nil { + return err + } + + client := metadata.Client.Storage.LocalUsersClient + + params, err := client.Get(ctx, *id) + if err != nil { + return fmt.Errorf("retrieving %s: %+v", id, err) + } + + model := params.Model + if model == nil { + return fmt.Errorf("unexpected nil model for %s", id) + } + + props := model.Properties + if props == nil { + return fmt.Errorf("unexpected nil properties for %s", id) + } + + if metadata.ResourceData.HasChange("home_directory") { + if plan.HomeDirectory != "" { + props.HomeDirectory = &plan.HomeDirectory + } else { + props.HomeDirectory = nil + } + } + if metadata.ResourceData.HasChange("permission_scope") { + props.PermissionScopes = r.expandPermissionScopes(plan.PermissionScope) + } + + if metadata.ResourceData.HasChange("ssh_key_enabled") { + props.HasSshKey = &plan.SshKeyEnabled + } + + if metadata.ResourceData.HasChange("ssh_password_enabled") { + props.HasSshPassword = &plan.SshPasswordEnabled + _, isEnabled := metadata.ResourceData.GetChange("ssh_password_enabled") + state := plan + if isEnabled.(bool) { + // If this update is to change the `ssh_password_enabled` from false to true. We'll need to regenerate the password. + // The previously generated password will be useless, that can't be used to connect (sftp returns permission denied). + // Also, after `ssh_key_enabled` being set to back true, but without calling the RegeneratePassword(), then if you + // call GET on the local user again, it returns the `ssh_key_enabled` as false, which indicates that we shall always + // generate a password when enable the `ssh_key_enabled`. + resp, err := client.RegeneratePassword(ctx, *id) + if err != nil { + return fmt.Errorf("generating password for %s: %v", id.ID(), err) + } + if resp.Model == nil { + return fmt.Errorf("unexpected nil of the generate password response model for %s", id.ID()) + } + if v := resp.Model.SshPassword; v != nil { + state.Password = *v + } + } else { + state.Password = "" + } + if err := metadata.Encode(&state); err != nil { + return err + } + } + + if _, err := client.CreateOrUpdate(ctx, *id, localusers.LocalUser{Properties: props}); err != nil { + return fmt.Errorf("updating %s: %+v", id, err) + } + return nil + }, + } +} + +func (r LocalUserResource) Delete() sdk.ResourceFunc { + return sdk.ResourceFunc{ + Timeout: 30 * time.Minute, + Func: func(ctx context.Context, metadata sdk.ResourceMetaData) error { + client := metadata.Client.Storage.LocalUsersClient + + id, err := localusers.ParseLocalUserID(metadata.ResourceData.Id()) + if err != nil { + return err + } + + if _, err := client.Delete(ctx, *id); err != nil { + return fmt.Errorf("deleting %s: %+v", id, err) + } + + return nil + }, + } +} + +func (r LocalUserResource) expandPermissionScopes(input []PermissionScopeModel) *[]localusers.PermissionScope { + if len(input) == 0 { + return nil + } + + var output []localusers.PermissionScope + + for _, v := range input { + // The length constraint is guaranteed by schema + permissions := v.Permissions[0] + var permissionStr string + if permissions.Read { + permissionStr += "r" + } + if permissions.Write { + permissionStr += "w" + } + if permissions.Delete { + permissionStr += "d" + } + if permissions.List { + permissionStr += "l" + } + if permissions.Create { + permissionStr += "c" + } + + output = append(output, localusers.PermissionScope{ + Permissions: permissionStr, + Service: v.Service, + ResourceName: v.ResourceName, + }) + } + + return &output +} + +func (r LocalUserResource) flattenPermissionScopes(input *[]localusers.PermissionScope) []PermissionScopeModel { + if input == nil { + return nil + } + + var output []PermissionScopeModel + + for _, v := range *input { + permissions := PermissionsModel{} + // The Storage API's have a history of being case-insensitive, so we case-insensitively check the permission here. + np := strings.ToLower(v.Permissions) + if strings.Contains(np, "r") { + permissions.Read = true + } + if strings.Contains(np, "w") { + permissions.Write = true + } + if strings.Contains(np, "d") { + permissions.Delete = true + } + if strings.Contains(np, "l") { + permissions.List = true + } + if strings.Contains(np, "c") { + permissions.Create = true + } + + output = append(output, PermissionScopeModel{ + Permissions: []PermissionsModel{permissions}, + Service: v.Service, + ResourceName: v.ResourceName, + }) + } + + return output +} + +func (r LocalUserResource) expandSSHAuthorizedKeys(input []SshAuthorizedKeyModel) *[]localusers.SshPublicKey { + if len(input) == 0 { + return nil + } + + var output []localusers.SshPublicKey + + for _, v := range input { + output = append(output, localusers.SshPublicKey{ + Description: pointer.To(v.Description), + Key: pointer.To(v.Key), + }) + } + + return &output +} diff --git a/internal/services/storage/storage_account_local_user_resource_test.go b/internal/services/storage/storage_account_local_user_resource_test.go new file mode 100644 index 000000000000..f7b026ae49fc --- /dev/null +++ b/internal/services/storage/storage_account_local_user_resource_test.go @@ -0,0 +1,348 @@ +package storage_test + +import ( + "context" + "fmt" + "testing" + + "github.com/hashicorp/go-azure-helpers/lang/response" + "github.com/hashicorp/go-azure-sdk/resource-manager/storage/2022-05-01/localusers" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" + "github.com/hashicorp/terraform-provider-azurerm/internal/acceptance" + "github.com/hashicorp/terraform-provider-azurerm/internal/acceptance/check" + "github.com/hashicorp/terraform-provider-azurerm/internal/clients" + "github.com/hashicorp/terraform-provider-azurerm/utils" +) + +type LocalUserResource struct{} + +func TestAccLocalUser_passwordOnly(t *testing.T) { + data := acceptance.BuildTestData(t, "azurerm_storage_account_local_user", "test") + r := LocalUserResource{} + + data.ResourceTest(t, r, []resource.TestStep{ + { + Config: r.passwordOnly(data), + Check: resource.ComposeTestCheckFunc( + check.That(data.ResourceName).ExistsInAzure(r), + ), + }, + data.ImportStep("password"), + }) +} + +func TestAccLocalUser_sshKeyOnly(t *testing.T) { + data := acceptance.BuildTestData(t, "azurerm_storage_account_local_user", "test") + r := LocalUserResource{} + + data.ResourceTest(t, r, []resource.TestStep{ + { + Config: r.sshKeyOnly(data), + Check: resource.ComposeTestCheckFunc( + check.That(data.ResourceName).ExistsInAzure(r), + ), + }, + data.ImportStep("ssh_authorized_key"), + }) +} + +func TestAccLocalUser_passwordAndSSHKey(t *testing.T) { + data := acceptance.BuildTestData(t, "azurerm_storage_account_local_user", "test") + r := LocalUserResource{} + + data.ResourceTest(t, r, []resource.TestStep{ + { + Config: r.passwordAndSSHKey(data), + Check: resource.ComposeTestCheckFunc( + check.That(data.ResourceName).ExistsInAzure(r), + check.That(data.ResourceName).Key("password").IsNotEmpty(), + ), + }, + data.ImportStep("password", "ssh_authorized_key"), + { + Config: r.passwordAndSSHKeyMoreAuthKeys(data), + Check: resource.ComposeTestCheckFunc( + check.That(data.ResourceName).ExistsInAzure(r), + check.That(data.ResourceName).Key("password").IsNotEmpty(), + ), + }, + data.ImportStep("password", "ssh_authorized_key"), + { + Config: r.sshKeyOnly(data), + Check: resource.ComposeTestCheckFunc( + check.That(data.ResourceName).ExistsInAzure(r), + check.That(data.ResourceName).Key("password").IsEmpty(), + ), + }, + data.ImportStep("ssh_authorized_key"), + }) +} + +func TestAccLocalUser_homeDirectory(t *testing.T) { + data := acceptance.BuildTestData(t, "azurerm_storage_account_local_user", "test") + r := LocalUserResource{} + + data.ResourceTest(t, r, []resource.TestStep{ + { + Config: r.homeDirectory(data, "foo"), + Check: resource.ComposeTestCheckFunc( + check.That(data.ResourceName).ExistsInAzure(r), + ), + }, + data.ImportStep("password"), + { + Config: r.homeDirectory(data, "bar"), + Check: resource.ComposeTestCheckFunc( + check.That(data.ResourceName).ExistsInAzure(r), + ), + }, + data.ImportStep("password"), + }) +} + +func TestAccLocalUser_permissionScope(t *testing.T) { + data := acceptance.BuildTestData(t, "azurerm_storage_account_local_user", "test") + r := LocalUserResource{} + + data.ResourceTest(t, r, []resource.TestStep{ + { + Config: r.noPermissionScope(data), + Check: resource.ComposeTestCheckFunc( + check.That(data.ResourceName).ExistsInAzure(r), + ), + }, + data.ImportStep("password"), + { + Config: r.permissionScope(data), + Check: resource.ComposeTestCheckFunc( + check.That(data.ResourceName).ExistsInAzure(r), + ), + }, + data.ImportStep("password"), + { + Config: r.permissionScopeUpdate(data), + Check: resource.ComposeTestCheckFunc( + check.That(data.ResourceName).ExistsInAzure(r), + ), + }, + data.ImportStep("password"), + }) +} + +func TestAccLocalUser_requiresImport(t *testing.T) { + data := acceptance.BuildTestData(t, "azurerm_storage_account_local_user", "test") + r := LocalUserResource{} + + data.ResourceTest(t, r, []resource.TestStep{ + { + Config: r.passwordOnly(data), + Check: resource.ComposeTestCheckFunc( + check.That(data.ResourceName).ExistsInAzure(r), + ), + }, + data.RequiresImportErrorStep(r.requiresImport), + }) +} + +func (r LocalUserResource) Exists(ctx context.Context, clients *clients.Client, state *terraform.InstanceState) (*bool, error) { + client := clients.Storage.LocalUsersClient + + id, err := localusers.ParseLocalUserID(state.ID) + if err != nil { + return nil, err + } + + if resp, err := client.Get(ctx, *id); err != nil { + if response.WasNotFound(resp.HttpResponse) { + return utils.Bool(false), nil + } + return nil, fmt.Errorf("retrieving %s: %+v", id, err) + } + + return utils.Bool(true), nil +} + +func (r LocalUserResource) passwordOnly(data acceptance.TestData) string { + template := r.template(data) + return fmt.Sprintf(` +%s + +resource "azurerm_storage_account_local_user" "test" { + name = "user" + storage_account_id = azurerm_storage_account.test.id + ssh_password_enabled = true +} +`, template) +} + +func (r LocalUserResource) sshKeyOnly(data acceptance.TestData) string { + template := r.template(data) + return fmt.Sprintf(` +%s + +resource "azurerm_storage_account_local_user" "test" { + name = "user" + storage_account_id = azurerm_storage_account.test.id + ssh_key_enabled = true + ssh_authorized_key { + description = "key1" + key = local.first_public_key + } +} +`, template) +} + +func (r LocalUserResource) passwordAndSSHKey(data acceptance.TestData) string { + template := r.template(data) + return fmt.Sprintf(` +%s + +resource "azurerm_storage_account_local_user" "test" { + name = "user" + storage_account_id = azurerm_storage_account.test.id + ssh_key_enabled = true + ssh_password_enabled = true + ssh_authorized_key { + description = "key1" + key = local.first_public_key + } +} +`, template) +} + +func (r LocalUserResource) passwordAndSSHKeyMoreAuthKeys(data acceptance.TestData) string { + template := r.template(data) + return fmt.Sprintf(` +%s + +resource "azurerm_storage_account_local_user" "test" { + name = "user" + storage_account_id = azurerm_storage_account.test.id + ssh_key_enabled = true + ssh_password_enabled = true + ssh_authorized_key { + description = "key1" + key = local.first_public_key + } + ssh_authorized_key { + description = "key2" + key = local.second_public_key + } +} +`, template) +} + +func (r LocalUserResource) requiresImport(data acceptance.TestData) string { + template := r.passwordOnly(data) + return fmt.Sprintf(` +%s + +resource "azurerm_storage_account_local_user" "import" { + name = azurerm_storage_account_local_user.test.name + storage_account_id = azurerm_storage_account_local_user.test.storage_account_id + ssh_password_enabled = true +} +`, template) +} + +func (r LocalUserResource) noPermissionScope(data acceptance.TestData) string { + template := r.template(data) + return fmt.Sprintf(` +%s + +resource "azurerm_storage_account_local_user" "test" { + name = "user" + storage_account_id = azurerm_storage_account.test.id + ssh_password_enabled = true +} +`, template) +} + +func (r LocalUserResource) permissionScope(data acceptance.TestData) string { + template := r.template(data) + return fmt.Sprintf(` +%s + +resource "azurerm_storage_account_local_user" "test" { + name = "user" + storage_account_id = azurerm_storage_account.test.id + ssh_password_enabled = true + permission_scope { + permissions { + read = true + } + service = "blob" + resource_name = azurerm_storage_container.test.name + } +} +`, template) +} + +func (r LocalUserResource) permissionScopeUpdate(data acceptance.TestData) string { + template := r.template(data) + return fmt.Sprintf(` +%s + +resource "azurerm_storage_account_local_user" "test" { + name = "user" + storage_account_id = azurerm_storage_account.test.id + ssh_password_enabled = true + permission_scope { + permissions { + read = true + write = true + create = true + delete = true + list = true + } + service = "blob" + resource_name = azurerm_storage_container.test.name + } +} +`, template) +} + +func (r LocalUserResource) homeDirectory(data acceptance.TestData, directory string) string { + template := r.template(data) + return fmt.Sprintf(` +%s + +resource "azurerm_storage_account_local_user" "test" { + name = "user" + storage_account_id = azurerm_storage_account.test.id + home_directory = "%s" + ssh_password_enabled = true +} +`, template, directory) +} + +func (r LocalUserResource) template(data acceptance.TestData) string { + return fmt.Sprintf(` +provider "azurerm" { + features {} +} +resource "azurerm_resource_group" "test" { + name = "acctestRG-storage-%d" + location = "%s" +} +resource "azurerm_storage_account" "test" { + name = "unlikely23exst2acct%s" + resource_group_name = azurerm_resource_group.test.name + location = azurerm_resource_group.test.location + account_kind = "StorageV2" + account_tier = "Standard" + account_replication_type = "LRS" + is_hns_enabled = true +} +resource "azurerm_storage_container" "test" { + name = "acctestcontainer" + storage_account_name = azurerm_storage_account.test.name +} + +locals { + first_public_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC+wWK73dCr+jgQOAxNsHAnNNNMEMWOHYEccp6wJm2gotpr9katuF/ZAdou5AaW1C61slRkHRkpRRX9FA9CYBiitZgvCCz+3nWNN7l/Up54Zps/pHWGZLHNJZRYyAB6j5yVLMVHIHriY49d/GZTZVNB8GoJv9Gakwc/fuEZYYl4YDFiGMBP///TzlI4jhiJzjKnEvqPFki5p2ZRJqcbCiF4pJrxUQR/RXqVFQdbRLZgYfJ8xGB878RENq3yQ39d8dVOkq4edbkzwcUmwwwkYVPIoDGsYLaRHnG+To7FvMeyO7xDVQkMKzopTQV8AuKpyvpqu0a9pWOMaiCyDytO7GGN you@me.com" + second_public_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC0/NDMj2wG6bSa6jbn6E3LYlUsYiWMp1CQ2sGAijPALW6OrSu30lz7nKpoh8Qdw7/A4nAJgweI5Oiiw5/BOaGENM70Go+VM8LQMSxJ4S7/8MIJEZQp5HcJZ7XDTcEwruknrd8mllEfGyFzPvJOx6QAQocFhXBW6+AlhM3gn/dvV5vdrO8ihjET2GoDUqXPYC57ZuY+/Fz6W3KV8V97BvNUhpY5yQrP5VpnyvvXNFQtzDfClTvZFPuoHQi3/KYPi6O0FSD74vo8JOBZZY09boInPejkm9fvHQqfh0bnN7B6XJoUwC1Qprrx+XIy7ust5AEn5XL7d4lOvcR14MxDDKEp you@me.com" +} +`, data.RandomInteger, data.Locations.Primary, data.RandomString) +} diff --git a/internal/services/storage/validate/local_user_name.go b/internal/services/storage/validate/local_user_name.go new file mode 100644 index 000000000000..e7ad84306919 --- /dev/null +++ b/internal/services/storage/validate/local_user_name.go @@ -0,0 +1,16 @@ +package validate + +import ( + "fmt" + "regexp" +) + +func LocalUserName(v interface{}, k string) (warnings []string, errors []error) { + value := v.(string) + if !regexp.MustCompile(`^[a-z0-9]{3,64}$`).MatchString(value) { + errors = append(errors, fmt.Errorf( + "Username must be between 3 and 64 characters in length, use numbers and lower-case letters only: %q", value)) + } + + return warnings, errors +} diff --git a/website/docs/r/storage_account_local_user.html.markdown b/website/docs/r/storage_account_local_user.html.markdown new file mode 100644 index 000000000000..659dac69b5da --- /dev/null +++ b/website/docs/r/storage_account_local_user.html.markdown @@ -0,0 +1,146 @@ +--- +subcategory: "Storage" +layout: "azurerm" +page_title: "Azure Resource Manager: azurerm_storage_account_local_user" +description: |- + Manages a Storage Account Local User. +--- + +# azurerm_storage_account_local_user + +Manages a Storage Account Local User. + +## Example Usage + +```hcl +provider "azurerm" { + features {} +} + +resource "azurerm_resource_group" "example" { + name = "example-rg" + location = "WestEurope" +} + +resource "azurerm_storage_account" "example" { + name = "example-account" + resource_group_name = azurerm_resource_group.example.name + location = azurerm_resource_group.example.location + account_kind = "StorageV2" + account_tier = "Standard" + account_replication_type = "LRS" + is_hns_enabled = true +} + +resource "azurerm_storage_container" "example" { + name = "example-container" + storage_account_name = azurerm_storage_account.example.name +} + +resource "azurerm_storage_account_local_user" "example" { + name = "user1" + storage_account_id = azurerm_storage_account.example.id + ssh_key_enabled = true + ssh_password_enabled = true + home_directory = "example_path" + ssh_authorized_key { + description = "key1" + key = local.first_public_key + } + ssh_authorized_key { + description = "key2" + key = local.second_public_key + } + permission_scope { + permissions { + read = true + create = true + } + service = "blob" + resource_name = azurerm_storage_container.example.name + } +} +``` + +## Arguments Reference + +The following arguments are supported: + +* `name` - (Required) The name which should be used for this Storage Account Local User. Changing this forces a new Storage Account Local User to be created. + +* `storage_account_id` - (Required) The ID of the Storage Account that this Storage Account Local User resides in. Changing this forces a new Storage Account Local User to be created. + +--- + +* `home_directory` - (Optional) The home directory of the Storage Account Local User. + +* `permission_scope` - (Optional) One or more `permission_scope` blocks as defined below. + +* `ssh_authorized_key` - (Optional) One or more `ssh_authorized_key` blocks as defined below. Changing this forces a new Storage Account Local User to be created. + +* `ssh_key_enabled` - (Optional) Specifies whether SSH Key Authentication is enabled. Defaults to `false`. + +* `ssh_password_enabled` - (Optional) Specifies whether SSH Password Authentication is enabled. Defaults to `false`. + +--- + +A `permission_scope` block supports the following: + +* `permissions` - (Required) A `permissions` block as defined below. + +* `resource_name` - (Required) The container name (when `service` is set to `blob`) or the file share name (when `service` is set to `file`), used by the Storage Account Local User. + +* `service` - (Required) The storage service used by this Storage Account Local User. Possible values are `blob` and `file`. + +--- + +A `permissions` block supports the following: + +* `create` - (Optional) Specifies if the Local User has the create permission for this scope. Defaults to `false`. + +* `delete` - (Optional) Specifies if the Local User has the delete permission for this scope. Defaults to `false`. + +* `list` - (Optional) Specifies if the Local User has the list permission for this scope. Defaults to `false`. + +* `read` - (Optional) Specifies if the Local User has the read permission for this scope. Defaults to `false`. + +* `write` - (Optional) Specifies if the Local User has the write permission for this scope. Defaults to `false`. + +--- + +A `ssh_authorized_key` block supports the following: + +* `key` - (Required) The public key value of this SSH authorized key. + +--- + +* `description` - (Optional) The description of this SSH authorized key. + +## Attributes Reference + +In addition to the Arguments listed above - the following Attributes are exported: + +* `id` - The ID of the Storage Account Local User. + +* `password` - The value of the password, which is only available when `ssh_password_enabled` is set to `true`. + +~> **Note:** The `password` will be updated everytime when `ssh_password_enabled` got updated. If `ssh_password_enabled` is updated from `false` to `true`, the `password` is updated to be the value of the SSH password. If `ssh_password_enabled` is updated from `true` to `false`, the `password` is reset to empty string. + +* `sid` - The unique Security Identifier of this Storage Account Local User. + +## Timeouts + +The `timeouts` block allows you to specify [timeouts](https://www.terraform.io/language/resources/syntax#operation-timeouts) for certain actions: + +* `create` - (Defaults to 30 minutes) Used when creating the Storage Account Local User. +* `read` - (Defaults to 5 minutes) Used when retrieving the Storage Account Local User. +* `update` - (Defaults to 30 minutes) Used when updating the Storage Account Local User. +* `delete` - (Defaults to 30 minutes) Used when deleting the Storage Account Local User. + +## Import + +Storage Account Local Users can be imported using the `resource id`, e.g. + +```shell +terraform import azurerm_storage_account_local_user.example /subscriptions/12345678-1234-9876-4563-123456789012/resourceGroups/resGroup1/providers/Microsoft.Storage/storageAccounts/storageAccount1/localUsers/user1 +```