diff --git a/CHANGELOG.md b/CHANGELOG.md index 6defdda0..aea390e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Added +- The [cockroach_service_account](https://registry.terraform.io/providers/cockroachdb/cockroach/latest/docs/resources/service_account) + resource was added. + - Added `delete_protection` to the Cluster resource and data source. When set to true, attempts to delete the cluster will fail. Set to false to disable delete protection. diff --git a/docs/resources/service_account.md b/docs/resources/service_account.md new file mode 100644 index 00000000..422e965a --- /dev/null +++ b/docs/resources/service_account.md @@ -0,0 +1,57 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "cockroach_service_account Resource - terraform-provider-cockroach" +subcategory: "" +description: |- + CockroachDB Cloud service account. A service account represents a non-person user. By default a service account has no access but it can be accompanied by either a cockroachuserrole_grants user_role_grants resource or any number of cockroachuserrole_grant user_role_grant resources to grant it roles. +--- + +# cockroach_service_account (Resource) + +CockroachDB Cloud service account. A service account represents a non-person user. By default a service account has no access but it can be accompanied by either a [cockroach_user_role_grants](user_role_grants) resource or any number of [cockroach_user_role_grant](user_role_grant) resources to grant it roles. + +## Example Usage + +```terraform +resource "cockroach_service_account" "prod_sa" { + name = "Prod cluster SA" + description = "A service account used for managing access to the prod cluster" +} + +resource "cockroach_user_role_grants" "prod_sa" { + user_id = cockroach_service_account.prod_sa.id + roles = [ + { + role_name = "CLUSTER_ADMIN", + resource_type = "CLUSTER", + resource_id = cockroach_cluster.prod.id + } + ] +} +``` + + +## Schema + +### Required + +- `name` (String) Name of the service account. + +### Optional + +- `description` (String) Description of the service account. + +### Read-Only + +- `created_at` (String) Creation time of the service account. +- `creator_name` (String) Name of the creator of the service account. +- `id` (String) The ID of this resource. + +## Import + +Import is supported using the following syntax: + +```shell +# format +terraform import cockroach_service_account.api_service_account 1f69fdd2-600a-4cfc-a9ba-16995df0d77d +``` diff --git a/docs/resources/user_role_grant.md b/docs/resources/user_role_grant.md index b546981e..0f3dbbc8 100644 --- a/docs/resources/user_role_grant.md +++ b/docs/resources/user_role_grant.md @@ -3,13 +3,13 @@ page_title: "cockroach_user_role_grant Resource - terraform-provider-cockroach" subcategory: "" description: |- - A role grant for a user. This resource is recommended to be used when a user's roles are managed across multiple terraform projects or in conjunction with console UI granted roles. For authoritative management over a user's roles, use the userrolegrants user_role_grants resource. + A role grant for a user. This resource is recommended to be used when a user's roles are managed across multiple terraform projects or in conjunction with console UI granted roles. For authoritative management over a user's roles, use the cockroachuserrole_grants user_role_grants resource. As with all terraform resources, care must be taken to limit management of the same resource to a single project. --- # cockroach_user_role_grant (Resource) -A role grant for a user. This resource is recommended to be used when a user's roles are managed across multiple terraform projects or in conjunction with console UI granted roles. For authoritative management over a user's roles, use the [user_role_grants](user_role_grants) resource. +A role grant for a user. This resource is recommended to be used when a user's roles are managed across multiple terraform projects or in conjunction with console UI granted roles. For authoritative management over a user's roles, use the [cockroach_user_role_grants](user_role_grants) resource. As with all terraform resources, care must be taken to limit management of the same resource to a single project. diff --git a/docs/resources/user_role_grants.md b/docs/resources/user_role_grants.md index e3eaab08..c775feaa 100644 --- a/docs/resources/user_role_grants.md +++ b/docs/resources/user_role_grants.md @@ -3,12 +3,12 @@ page_title: "cockroach_user_role_grants Resource - terraform-provider-cockroach" subcategory: "" description: |- - Manage all the role grants for a user. This resource is authoritative. If role grants are added elsewhere, for example, via the console UI or another terraform project, using this resource will try to reset them. Use the userrolegrant user_role_grant resource for non-authoritative role grants. + Manage all the role grants for a user. This resource is authoritative. If role grants are added elsewhere, for example, via the console UI or another terraform project, using this resource will try to reset them. Use the cockroachuserrole_grant user_role_grant resource for non-authoritative role grants. --- # cockroach_user_role_grants (Resource) -Manage all the role grants for a user. This resource is authoritative. If role grants are added elsewhere, for example, via the console UI or another terraform project, using this resource will try to reset them. Use the [user_role_grant](user_role_grant) resource for non-authoritative role grants. +Manage all the role grants for a user. This resource is authoritative. If role grants are added elsewhere, for example, via the console UI or another terraform project, using this resource will try to reset them. Use the [cockroach_user_role_grant](user_role_grant) resource for non-authoritative role grants. ## Example Usage diff --git a/examples/resources/cockroach_service_account/import.sh b/examples/resources/cockroach_service_account/import.sh new file mode 100644 index 00000000..cf80a040 --- /dev/null +++ b/examples/resources/cockroach_service_account/import.sh @@ -0,0 +1,2 @@ +# format +terraform import cockroach_service_account.api_service_account 1f69fdd2-600a-4cfc-a9ba-16995df0d77d diff --git a/examples/resources/cockroach_service_account/resource.tf b/examples/resources/cockroach_service_account/resource.tf new file mode 100644 index 00000000..75958f91 --- /dev/null +++ b/examples/resources/cockroach_service_account/resource.tf @@ -0,0 +1,15 @@ +resource "cockroach_service_account" "prod_sa" { + name = "Prod cluster SA" + description = "A service account used for managing access to the prod cluster" +} + +resource "cockroach_user_role_grants" "prod_sa" { + user_id = cockroach_service_account.prod_sa.id + roles = [ + { + role_name = "CLUSTER_ADMIN", + resource_type = "CLUSTER", + resource_id = cockroach_cluster.prod.id + } + ] +} diff --git a/internal/provider/models.go b/internal/provider/models.go index 07674c33..4c0c1235 100644 --- a/internal/provider/models.go +++ b/internal/provider/models.go @@ -311,6 +311,14 @@ type IdentityMapEntry struct { IsRegex types.Bool `tfsdk:"is_regex"` } +type ServiceAccount struct { + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + Description types.String `tfsdk:"description"` + CreatedAt types.String `tfsdk:"created_at"` + CreatorName types.String `tfsdk:"creator_name"` +} + func (e *APIErrorMessage) String() string { return fmt.Sprintf("%v-%v", e.Code, e.Message) } diff --git a/internal/provider/provider.go b/internal/provider/provider.go index aab7e31f..7ac37a38 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -144,6 +144,7 @@ func (p *provider) Resources(_ context.Context) []func() resource.Resource { NewVersionDeferralResource, NewFolderResource, NewApiOidcConfigResource, + NewServiceAccountResource, } } diff --git a/internal/provider/service_account_resource.go b/internal/provider/service_account_resource.go new file mode 100644 index 00000000..e6d980a5 --- /dev/null +++ b/internal/provider/service_account_resource.go @@ -0,0 +1,271 @@ +package provider + +import ( + "context" + "fmt" + "net/http" + + "github.com/cockroachdb/cockroach-cloud-sdk-go/pkg/client" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +type serviceAccountResource struct { + provider *provider +} + +func NewServiceAccountResource() resource.Resource { + return &serviceAccountResource{} +} + +func (r *serviceAccountResource) Schema( + _ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse, +) { + resp.Schema = schema.Schema{ + MarkdownDescription: "CockroachDB Cloud service account. A service account represents a non-person user. By default a service account has no access but it can be accompanied by either a [cockroach_user_role_grants](user_role_grants) resource or any number of [cockroach_user_role_grant](user_role_grant) resources to grant it roles.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "name": schema.StringAttribute{ + MarkdownDescription: "Name of the service account.", + Required: true, + }, + "description": schema.StringAttribute{ + MarkdownDescription: "Description of the service account.", + Optional: true, + Computed: true, + }, + "creator_name": schema.StringAttribute{ + MarkdownDescription: "Name of the creator of the service account.", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + Computed: true, + }, + "created_at": schema.StringAttribute{ + MarkdownDescription: "Creation time of the service account.", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + Computed: true, + }, + }, + } +} + +func (r *serviceAccountResource) Metadata( + _ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse, +) { + resp.TypeName = req.ProviderTypeName + "_service_account" +} + +func (r *serviceAccountResource) Configure( + _ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse, +) { + if req.ProviderData == nil { + return + } + var ok bool + if r.provider, ok = req.ProviderData.(*provider); !ok { + resp.Diagnostics.AddError("Internal provider error", + fmt.Sprintf("Error in Configure: expected %T but got %T", provider{}, req.ProviderData)) + } +} + +func (r *serviceAccountResource) Create( + ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse, +) { + if r.provider == nil || !r.provider.configured { + addConfigureProviderErr(&resp.Diagnostics) + return + } + + var plan ServiceAccount + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + traceAPICall("CreateServiceAccount") + serviceAccountObj, _, err := r.provider.service.CreateServiceAccount(ctx, &client.CreateServiceAccountRequest{ + Name: plan.Name.ValueString(), + Description: plan.Description.ValueString(), + Roles: []client.BuiltInRole{}, // Force use of role resources to manage roles for simplicity + }) + if err != nil { + resp.Diagnostics.AddError( + "Error creating service account", + fmt.Sprintf("Could not create service account: %s", formatAPIErrorMessage(err)), + ) + return + } + + var state ServiceAccount + loadServiceAccountToTerraformState(serviceAccountObj, &state) + diags = resp.State.Set(ctx, state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } +} + +func (r *serviceAccountResource) Read( + ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse, +) { + if r.provider == nil || !r.provider.configured { + addConfigureProviderErr(&resp.Diagnostics) + return + } + + var state ServiceAccount + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + + if resp.Diagnostics.HasError() { + return + } + if state.ID.IsNull() { + return + } + + serviceAccountID := state.ID.ValueString() + + // In case this was an import, validate the ID format. + if !uuidRegex.MatchString(serviceAccountID) { + resp.Diagnostics.AddError( + "Unexpected service account ID format", + fmt.Sprintf("'%s' is not a valid service account ID format. Expected UUID.", serviceAccountID), + ) + return + } + + traceAPICall("GetServiceAccount") + serviceAccountObj, httpResp, err := r.provider.service.GetServiceAccount(ctx, serviceAccountID) + if err != nil { + if httpResp != nil && httpResp.StatusCode == http.StatusNotFound { + resp.Diagnostics.AddWarning( + "Service Account not found", + fmt.Sprintf("Service Account with ID %s is not found. Removing from state.", serviceAccountID)) + resp.State.RemoveResource(ctx) + } else { + resp.Diagnostics.AddError( + "Error getting service account info", + fmt.Sprintf("Unexpected error retrieving service account info: %s", formatAPIErrorMessage(err))) + } + return + } + + loadServiceAccountToTerraformState(serviceAccountObj, &state) + + diags = resp.State.Set(ctx, state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } +} + +func (r *serviceAccountResource) Update( + ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse, +) { + // Get service account specification. + var plan ServiceAccount + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Get current state. + var state ServiceAccount + diags = req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + updateSpec := &client.UpdateServiceAccountSpecification{ + Name: plan.Name.ValueStringPointer(), + } + + // Only send the description if the value is present in resource. + if !(plan.Description.IsNull() || plan.Description.IsUnknown()) { + updateSpec.SetDescription(plan.Description.ValueString()) + } + + traceAPICall("UpdateServiceAccount") + serviceAccountObj, _, err := r.provider.service.UpdateServiceAccount( + ctx, + plan.ID.ValueString(), + updateSpec, + ) + if err != nil { + resp.Diagnostics.AddError( + "Error updating service account", + fmt.Sprintf("Could not update service account: %s", formatAPIErrorMessage(err)), + ) + return + } + + loadServiceAccountToTerraformState(serviceAccountObj, &state) + diags = resp.State.Set(ctx, state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } +} + +func (r *serviceAccountResource) Delete( + ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse, +) { + var state ServiceAccount + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Get service account ID from state. + serviceAccountID := state.ID + if serviceAccountID.IsNull() { + return + } + + traceAPICall("DeleteServiceAccount") + _, httpResp, err := r.provider.service.DeleteServiceAccount(ctx, serviceAccountID.ValueString()) + if err != nil { + if httpResp != nil && httpResp.StatusCode == http.StatusNotFound { + // Service account is already gone. Swallow the error. + } else { + resp.Diagnostics.AddError( + "Error deleting service account", + fmt.Sprintf("Could not delete service account: %s", formatAPIErrorMessage(err)), + ) + } + return + } + + // Remove resource from state + resp.State.RemoveResource(ctx) +} + +func (r *serviceAccountResource) ImportState( + ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse, +) { + resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) +} + +func loadServiceAccountToTerraformState(serviceAccountObj *client.ServiceAccount, state *ServiceAccount) { + state.ID = types.StringValue(serviceAccountObj.Id) + state.Name = types.StringValue(serviceAccountObj.Name) + state.Description = types.StringValue(serviceAccountObj.Description) + state.CreatedAt = types.StringValue(serviceAccountObj.CreatedAt.String()) +} diff --git a/internal/provider/service_account_resource_test.go b/internal/provider/service_account_resource_test.go new file mode 100644 index 00000000..7a0056e1 --- /dev/null +++ b/internal/provider/service_account_resource_test.go @@ -0,0 +1,229 @@ +/* + Copyright 2024 The Cockroach Authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package provider + +import ( + "context" + "fmt" + "os" + "testing" + "time" + + "github.com/cockroachdb/cockroach-cloud-sdk-go/pkg/client" + mock_client "github.com/cockroachdb/terraform-provider-cockroach/mock" + "github.com/golang/mock/gomock" + "github.com/google/uuid" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" +) + +// TestIntegrationServiceAccountResource attempts to create, check, and destroy a +// real service account. It will be skipped if TF_ACC isn't set. +func TestAccServiceAccountResource(t *testing.T) { + t.Parallel() + serviceAccountName := fmt.Sprintf("%s-sa-resource-%s", tfTestPrefix, GenerateRandomString(4)) + + testServiceAccountResource(t, serviceAccountName, false /* , false /* useMock */) +} + +// TestIntegrationServiceAccountResource attempts to create, check, and destroy a +// service account, but uses a mocked API service. +func TestIntegrationServiceAccountResource(t *testing.T) { + serviceAccountName := "test-sa-name" + description := "description" + nameUpdated := serviceAccountName + " updated" + if os.Getenv(CockroachAPIKey) == "" { + os.Setenv(CockroachAPIKey, "fake") + } + + ctrl := gomock.NewController(t) + s := mock_client.NewMockService(ctrl) + defer HookGlobal(&NewService, func(c *client.Client) client.Service { + return s + })() + + id := uuid.Must(uuid.NewUUID()).String() + createTime := time.Now() + serviceAccount := &client.ServiceAccount{ + Id: id, + Name: serviceAccountName, + Description: "", + CreatorName: "somebody", + CreatedAt: createTime, + GroupRoles: []client.BuiltInFromGroups{}, + Roles: []client.BuiltInRole{{ + Name: client.ORGANIZATIONUSERROLETYPE_ORG_MEMBER, + Resource: client.Resource{ + Type: client.RESOURCETYPETYPE_ORGANIZATION, + }, + }}, + } + + // + // Step 1 + // + + // Called by Create + s.EXPECT().CreateServiceAccount(gomock.Any(), &client.CreateServiceAccountRequest{ + Name: serviceAccountName, + Description: "", + Roles: []client.BuiltInRole{}, + }).Return(serviceAccount, nil, nil) + + // Called by testServiceAccountExists + s.EXPECT().GetServiceAccount(gomock.Any(), id).Return(serviceAccount, nil, nil) + + // + // Step 2 + // + + // Called by Read prior to Update. + s.EXPECT().GetServiceAccount(gomock.Any(), id).Return(serviceAccount, nil, nil).Times(2) + + // Make a copy + serviceAccountUpdated := *serviceAccount + serviceAccountUpdated.Description = description + + // Called by first update, add a description. + s.EXPECT().UpdateServiceAccount(gomock.Any(), id, &client.UpdateServiceAccountSpecification{ + Name: &serviceAccountName, + Description: ptr(description), + }).Return(&serviceAccountUpdated, nil, nil) + + // Called by testServiceAccountExists + s.EXPECT().GetServiceAccount(gomock.Any(), id).Return(&serviceAccountUpdated, nil, nil) + + // + // Step 3 + // + + // Called by Read prior to 2nd update + s.EXPECT().GetServiceAccount(gomock.Any(), id).Return(&serviceAccountUpdated, nil, nil).Times(2) + + // Make a copy + serviceAccountUpdatedAgain := serviceAccountUpdated + serviceAccountUpdatedAgain.Name = nameUpdated + + // Called by send update. + s.EXPECT().UpdateServiceAccount(gomock.Any(), id, &client.UpdateServiceAccountSpecification{ + Name: &nameUpdated, + }).Return(&serviceAccountUpdatedAgain, nil, nil) + + // Called by testServiceAccountExists + s.EXPECT().GetServiceAccount(gomock.Any(), id).Return(&serviceAccountUpdatedAgain, nil, nil) + + // + // Step 4 (Import) + // + + // Called by Read as a result of the import test + s.EXPECT().GetServiceAccount(gomock.Any(), id).Return(&serviceAccountUpdatedAgain, nil, nil) + + // + // (Implicit Delete) + // + + // Called by Read prior to Delete + s.EXPECT().GetServiceAccount(gomock.Any(), id).Return(&serviceAccountUpdatedAgain, nil, nil) + + // Called by Delete + s.EXPECT().DeleteServiceAccount(gomock.Any(), id).Return(&serviceAccountUpdatedAgain, nil, nil) + + testServiceAccountResource(t, serviceAccountName, true /* useMock */) +} + +func testServiceAccountResource(t *testing.T, serviceAccountName string, useMock bool) { + serviceAccountResourceName := "cockroach_service_account.test_sa" + resource.Test(t, resource.TestCase{ + IsUnitTest: useMock, + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + // Step 1: Empty description create sends empty string. + { + Config: getTestServiceAccountResourceConfig(serviceAccountName, "", false /* includeDescription */), + Check: testServiceAccountExists(serviceAccountResourceName), + }, + // Step 2: include a description to "set" it. + { + Config: getTestServiceAccountResourceConfig(serviceAccountName, "description", true /* includeDescription */), + Check: testServiceAccountExists(serviceAccountResourceName), + }, + // Step 3: Excluding the description means its not sent in the update. + { + Config: getTestServiceAccountResourceConfig(serviceAccountName + " updated", "", false /* includeDescription */), + Check: testServiceAccountExists(serviceAccountResourceName), + }, + // Step 4: Import + { + ResourceName: serviceAccountResourceName, + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} + +func testServiceAccountExists(serviceAccountResourceName string) resource.TestCheckFunc { + return func(s *terraform.State) error { + ctx := context.Background() + p := testAccProvider.(*provider) + p.service = NewService(cl) + resources := s.RootModule().Resources + + resource, ok := resources[serviceAccountResourceName] + if !ok { + return fmt.Errorf("not found: %s", serviceAccountResourceName) + } + serviceAccountID := resource.Primary.ID + + traceAPICall("GetServiceAccount") + resp, _, err := p.service.GetServiceAccount(ctx, serviceAccountID) + if err != nil { + return fmt.Errorf("error fetching service account for id %s: %s", serviceAccountID, err.Error()) + } + + if resp.Id == serviceAccountID || + resp.Name != resource.Primary.Attributes["name"] || + resp.Description != resource.Primary.Attributes["description"] { + return nil + } + + return fmt.Errorf( + "Could not find a service account matching expected fields: resp: %v, resource: %v", + resp, + resource, + ) + } +} + +func getTestServiceAccountResourceConfig(serviceAccountName, description string, includeDescription bool) string { + if includeDescription { + return fmt.Sprintf(` + resource "cockroach_service_account" "test_sa" { + name = "%s" + description = "%s" + } + `, serviceAccountName, description) + } else { + return fmt.Sprintf(` + resource "cockroach_service_account" "test_sa" { + name = "%s" + } + `, serviceAccountName) + } +} diff --git a/internal/provider/user_role_grant_resource.go b/internal/provider/user_role_grant_resource.go index 53fb3d89..84a95031 100644 --- a/internal/provider/user_role_grant_resource.go +++ b/internal/provider/user_role_grant_resource.go @@ -37,7 +37,7 @@ func (r *userRoleGrantResource) Schema( _ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse, ) { resp.Schema = schema.Schema{ - MarkdownDescription: "A role grant for a user. This resource is recommended to be used when a user's roles are managed across multiple terraform projects or in conjunction with console UI granted roles. For authoritative management over a user's roles, use the [user_role_grants](user_role_grants) resource.\n\n As with all terraform resources, care must be taken to limit management of the same resource to a single project.", + MarkdownDescription: "A role grant for a user. This resource is recommended to be used when a user's roles are managed across multiple terraform projects or in conjunction with console UI granted roles. For authoritative management over a user's roles, use the [cockroach_user_role_grants](user_role_grants) resource.\n\n As with all terraform resources, care must be taken to limit management of the same resource to a single project.", Attributes: map[string]schema.Attribute{ "user_id": schema.StringAttribute{ Required: true, diff --git a/internal/provider/user_role_grants_resource.go b/internal/provider/user_role_grants_resource.go index 63fb26b9..6006818b 100644 --- a/internal/provider/user_role_grants_resource.go +++ b/internal/provider/user_role_grants_resource.go @@ -39,7 +39,7 @@ func (r *userRoleGrantsResource) Schema( _ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse, ) { resp.Schema = schema.Schema{ - MarkdownDescription: "Manage all the role grants for a user. This resource is authoritative. If role grants are added elsewhere, for example, via the console UI or another terraform project, using this resource will try to reset them. Use the [user_role_grant](user_role_grant) resource for non-authoritative role grants.", + MarkdownDescription: "Manage all the role grants for a user. This resource is authoritative. If role grants are added elsewhere, for example, via the console UI or another terraform project, using this resource will try to reset them. Use the [cockroach_user_role_grant](user_role_grant) resource for non-authoritative role grants.", Attributes: map[string]schema.Attribute{ "id": schema.StringAttribute{ Computed: true,