Repository: hashicorp/terraform-plugin-sdk

Issue:
writeOnly attributes is not possible if no updateFunc is present in resource
### SDK version
<!---
Inspect your go.mod as below to find the version, and paste the result between the ``` marks below.

go list -m github.com/hashicorp/terraform-plugin-sdk/...

If you are not running the latest version of the SDK, please try upgrading
because your bug may have already been fixed.

If the command above doesn't yield any results, it means you may either be using v1 of the SDK or
have not have migrated to the standalone SDK yet. See https://www.terraform.io/docs/extend/plugin-sdk.html
for more.
-->

```
v2.36
```

[This is present in the PR](https://github.com/GoogleCloudPlatform/magic-modules/pull/13552) that I have that attempts to implement a write-only attribute in a resource that *does not* have an update function.

Due to write-only attributes not having the ability to have a `ForceNew: True` we run into a blocker for implementating certain attributes.

Based on the code where the error is handled it seems like the fix could be as simple as adding a `!p.WriteOnly` check that adds it as part of the list of attributes to not include. [This specific block of code can be found here](https://github.com/hashicorp/terraform-plugin-sdk/blob/b2774456b4dca59a421be962420d59513defdf3d/helper/schema/resource.go#L1216-L1226)

### Implementation

Error comes from my implementation of[ write-only attributes in this PR](https://github.com/GoogleCloudPlatform/magic-modules/pull/13552), a look at the GHA will show how it errors due to write-only attributes not being set as ForceNew.

### Debug Output
```hcl
--- FAIL: TestProvider (0.01s)
    provider_test.go:25: err: resource google_compute_ssl_certificate: No Update defined, must set ForceNew on: []string{"certificate_wo", "private_key_wo"}
FAIL
FAIL    github.com/hashicorp/terraform-provider-google/google/provider  2.157s
FAIL
make: *** [testnolint] Error 1
```


### Expected Behavior
No Error should occur

### Actual Behavior
Errors out



Discussion:

Solution patch:
diff --git a/helper/schema/provider.go b/helper/schema/provider.go
index cb7e2a89..28c39bb0 100644
--- a/helper/schema/provider.go
+++ b/helper/schema/provider.go
@@ -505,7 +505,7 @@ func (p *Provider) ImportStateWithIdentity(
 		if err != nil {
 			return nil, err // this should not happen, as we checked above
 		}
-		identityData.raw = identity // is this too hacky / unexpected?
+		identityData.raw = identity
 	} else if identity != nil {
 		return nil, fmt.Errorf("resource %s doesn't support identity import", info.Type)
 	}
diff --git a/helper/schema/resource.go b/helper/schema/resource.go
index c206df6c..0bf7f959 100644
--- a/helper/schema/resource.go
+++ b/helper/schema/resource.go
@@ -1216,7 +1216,7 @@ func (r *Resource) InternalValidate(topSchemaMap schemaMap, writable bool) error
 		if !r.updateFuncSet() {
 			nonForceNewAttrs := make([]string, 0)
 			for k, v := range schema {
-				if !v.ForceNew && !v.Computed {
+				if !v.ForceNew && !v.Computed && !v.WriteOnly {
 					nonForceNewAttrs = append(nonForceNewAttrs, k)
 				}
 			}
diff --git a/helper/schema/resource_importer.go b/helper/schema/resource_importer.go
index e2c4d08b..4dd691d2 100644
--- a/helper/schema/resource_importer.go
+++ b/helper/schema/resource_importer.go
@@ -6,6 +6,7 @@ package schema
 import (
 	"context"
 	"errors"
+	"fmt"
 )
 
 // ResourceImporter defines how a resource is imported in Terraform. This
@@ -83,3 +84,49 @@ func ImportStatePassthrough(d *ResourceData, m interface{}) ([]*ResourceData, er
 func ImportStatePassthroughContext(ctx context.Context, d *ResourceData, m interface{}) ([]*ResourceData, error) {
 	return []*ResourceData{d}, nil
 }
+
+// ImportStatePassthroughWithIdentity creates a StateContextFunc that supports both
+// identity-based and ID-only resource import scenarios. This function is useful
+// when a resource can be imported either by its unique ID or by an identity attribute.
+//
+// The `idAttributePath` parameter specifies the name of the identity attribute
+// to use when importing by identity. Since identity attributes are "flat",
+// `idAttributePath` should be a simple attribute name (e.g., "name" or "identifier").
+// Note that the identity attribute must be a string, as this function expects
+// to set the resource ID using the value of the specified attribute.
+//
+// If the resource is imported by ID (i.e., `d.Id()` is already set), the function
+// simply returns the resource data as-is. Otherwise, it attempts to retrieve the
+// identity attribute specified by `idAttributePath` and sets it as the resource ID.
+//
+// Parameters:
+//   - idAttributePath: The name of the identity attribute to use for setting the ID.
+//
+// Returns:
+//   - A StateContextFunc that handles the import logic.
+func ImportStatePassthroughWithIdentity(idAttributePath string) StateContextFunc {
+	return func(ctx context.Context, d *ResourceData, m interface{}) ([]*ResourceData, error) {
+		// If we import by id, we just return the resource data as is, no need to change it
+		if d.Id() != "" {
+			return []*ResourceData{d}, nil
+		}
+
+		// If we import by identity, we need to set the id based on the idAttributePath
+		identity, err := d.Identity()
+		if err != nil {
+			return nil, fmt.Errorf("error getting identity: %s", err)
+		}
+		id, exists := identity.GetOk(idAttributePath)
+		if !exists {
+			return nil, fmt.Errorf("expected identity to contain key %s", idAttributePath)
+		}
+		idStr, ok := id.(string)
+		if !ok {
+			return nil, fmt.Errorf("expected identity key %s to be a string, was: %T", idAttributePath, id)
+		}
+
+		d.SetId(idStr)
+
+		return []*ResourceData{d}, nil
+	}
+}



Test patch:
diff --git a/helper/schema/resource_importer_test.go b/helper/schema/resource_importer_test.go
index 36e4256a..6a08634c 100644
--- a/helper/schema/resource_importer_test.go
+++ b/helper/schema/resource_importer_test.go
@@ -3,7 +3,11 @@
 
 package schema
 
-import "testing"
+import (
+	"testing"
+
+	"github.com/hashicorp/terraform-plugin-sdk/v2/terraform"
+)
 
 func TestInternalValidate(t *testing.T) {
 	r := &ResourceImporter{
@@ -14,3 +18,186 @@ func TestInternalValidate(t *testing.T) {
 		t.Fatal("ResourceImporter should not allow State and StateContext to be set")
 	}
 }
+
+func TestImportStatePassthroughWithIdentity(t *testing.T) {
+	// shared among all tests, defined once to keep them shorter
+	identitySchema := map[string]*Schema{
+		"email": {
+			Type:              TypeString,
+			RequiredForImport: true,
+		},
+		"region": {
+			Type:              TypeString,
+			OptionalForImport: true,
+		},
+	}
+
+	tests := []struct {
+		name                 string
+		idAttributePath      string
+		resourceData         *ResourceData
+		expectedResourceData *ResourceData
+		expectedError        string
+	}{
+		{
+			name:            "import from id just sets id",
+			idAttributePath: "email",
+			resourceData: &ResourceData{
+				identitySchema: identitySchema,
+				state: &terraform.InstanceState{
+					ID: "hello@example.internal",
+				},
+			},
+			expectedResourceData: &ResourceData{
+				identitySchema: identitySchema,
+				state: &terraform.InstanceState{
+					ID: "hello@example.internal",
+				},
+			},
+		},
+		{
+			name:            "import from identity sets id and identity",
+			idAttributePath: "email",
+			resourceData: &ResourceData{
+				identitySchema: identitySchema,
+				state: &terraform.InstanceState{
+					Identity: map[string]string{
+						"email": "hello@example.internal",
+					},
+				},
+			},
+			expectedResourceData: &ResourceData{
+				identitySchema: identitySchema,
+				state: &terraform.InstanceState{
+					ID: "hello@example.internal",
+				},
+				newIdentity: &IdentityData{
+					schema: identitySchema,
+					raw: map[string]string{
+						"email": "hello@example.internal",
+					},
+				},
+			},
+		},
+		{
+			name:            "import from identity sets id and identity (with region set)",
+			idAttributePath: "email",
+			resourceData: &ResourceData{
+				identitySchema: identitySchema,
+				state: &terraform.InstanceState{
+					Identity: map[string]string{
+						"email":  "hello@example.internal",
+						"region": "eu-west-1",
+					},
+				},
+			},
+			expectedResourceData: &ResourceData{
+				identitySchema: identitySchema,
+				state: &terraform.InstanceState{
+					ID: "hello@example.internal",
+				},
+				newIdentity: &IdentityData{
+					schema: identitySchema,
+					raw: map[string]string{
+						"email":  "hello@example.internal",
+						"region": "eu-west-1",
+					},
+				},
+			},
+		},
+		{
+			name:            "import from identity fails without required field",
+			idAttributePath: "email",
+			resourceData: &ResourceData{
+				identitySchema: identitySchema,
+				state: &terraform.InstanceState{
+					Identity: map[string]string{
+						"region": "eu-west-1",
+					},
+				},
+			},
+			expectedError: "expected identity to contain key email",
+		},
+		{
+			name:            "import from identity fails if attribute is not a string",
+			idAttributePath: "number",
+			resourceData: &ResourceData{
+				identitySchema: map[string]*Schema{
+					"number": {
+						Type:              TypeInt,
+						RequiredForImport: true,
+					},
+				},
+				state: &terraform.InstanceState{
+					Identity: map[string]string{
+						"number": "1",
+					},
+				},
+			},
+			expectedError: "expected identity key number to be a string, was: int",
+		},
+		{
+			name:            "import from identity fails without schema",
+			idAttributePath: "email",
+			resourceData: &ResourceData{
+				state: &terraform.InstanceState{
+					Identity: map[string]string{
+						"email": "hello@example.internal",
+					},
+				},
+			},
+			expectedError: "error getting identity: Resource does not have Identity schema. Please set one in order to use Identity(). This is always a problem in the provider code.",
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			results, err := ImportStatePassthroughWithIdentity(test.idAttributePath)(nil, test.resourceData, nil)
+			if err != nil {
+				if test.expectedError == "" {
+					t.Fatalf("unexpected error: %s", err)
+				}
+				if err.Error() != test.expectedError {
+					t.Fatalf("expected error: %s, got: %s", test.expectedError, err)
+				}
+				return // we don't expect any results if there is an error
+			}
+			if len(results) != 1 {
+				t.Fatalf("expected 1 result, got: %d", len(results))
+			}
+			// compare id and identity in resource data
+			if results[0].Id() != test.expectedResourceData.Id() {
+				t.Fatalf("expected id: %s, got: %s", test.expectedResourceData.Id(), results[0].Id())
+			}
+			// compare identity
+			expectedIdentity, err := test.expectedResourceData.Identity()
+			if err != nil {
+				t.Fatalf("unexpected error: %s", err)
+			}
+			resultIdentity, err := results[0].Identity()
+			if err != nil {
+				t.Fatalf("unexpected error: %s", err)
+			}
+
+			// check whether all result identity attributes exist as expected
+			for key := range expectedIdentity.schema {
+				expected := expectedIdentity.getRaw(key)
+				if expected.Exists {
+					result := resultIdentity.getRaw(key)
+					if !result.Exists {
+						t.Fatalf("expected identity attribute %s to exist", key)
+					}
+					if expected.Value != result.Value {
+						t.Fatalf("expected identity attribute %s to be %s, got: %s", key, expected.Value, result.Value)
+					}
+				}
+			}
+			// check whether there are no additional attributes in the result identity
+			for key := range resultIdentity.schema {
+				if _, ok := expectedIdentity.schema[key]; !ok {
+					t.Fatalf("unexpected identity attribute %s", key)
+				}
+			}
+		})
+	}
+}
diff --git a/helper/schema/testing.go b/helper/schema/testing.go
index bdf56d90..202f8a31 100644
--- a/helper/schema/testing.go
+++ b/helper/schema/testing.go
@@ -30,3 +30,20 @@ func TestResourceDataRaw(t testing.T, schema map[string]*Schema, raw map[string]
 
 	return result
 }
+
+// TestResourceDataWithIdentityRaw creates a ResourceData with an identity from a raw identity map.
+func TestResourceDataWithIdentityRaw(t testing.T, schema map[string]*Schema, identitySchema map[string]*Schema, raw map[string]string) *ResourceData {
+	t.Helper()
+
+	sm := schemaMapWithIdentity{schema, identitySchema}
+	state := terraform.InstanceState{
+		Identity: raw,
+	}
+
+	result, err := sm.Data(&state, nil)
+	if err != nil {
+		t.Fatalf("err: %s", err)
+	}
+
+	return result
+}