From 71ef30a86b9d2a098e5f887ed6e4e2f998102e8d Mon Sep 17 00:00:00 2001 From: Shakeel Rao Date: Tue, 11 Nov 2025 02:19:18 -0500 Subject: [PATCH 1/9] initial impl --- internal/provider/service_account_resource.go | 311 +++++++++++++----- .../provider/service_accounts_datasource.go | 117 ++++--- 2 files changed, 308 insertions(+), 120 deletions(-) diff --git a/internal/provider/service_account_resource.go b/internal/provider/service_account_resource.go index 872afc54..baab5d67 100644 --- a/internal/provider/service_account_resource.go +++ b/internal/provider/service_account_resource.go @@ -6,6 +6,7 @@ import ( "github.com/google/uuid" "github.com/hashicorp/terraform-plugin-framework-timeouts/resource/timeouts" + "github.com/hashicorp/terraform-plugin-framework-validators/objectvalidator" "github.com/hashicorp/terraform-plugin-framework-validators/setvalidator" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" "github.com/hashicorp/terraform-plugin-framework/attr" @@ -38,12 +39,13 @@ type ( } serviceAccountResourceModel struct { - ID types.String `tfsdk:"id"` - State types.String `tfsdk:"state"` - Name types.String `tfsdk:"name"` - Description types.String `tfsdk:"description"` - AccountAccess internaltypes.CaseInsensitiveStringValue `tfsdk:"account_access"` - NamespaceAccesses types.Set `tfsdk:"namespace_accesses"` + ID types.String `tfsdk:"id"` + State types.String `tfsdk:"state"` + Name types.String `tfsdk:"name"` + Description types.String `tfsdk:"description"` + AccountAccess internaltypes.CaseInsensitiveStringValue `tfsdk:"account_access"` + NamespaceAccesses types.Set `tfsdk:"namespace_accesses"` + NamespaceScopedAccess types.Object `tfsdk:"namespace_scoped_access"` Timeouts timeouts.Value `tfsdk:"timeouts"` } @@ -124,14 +126,17 @@ func (r *serviceAccountResource) Schema(ctx context.Context, _ resource.SchemaRe }, "account_access": schema.StringAttribute{ CustomType: internaltypes.CaseInsensitiveStringType{}, - Description: "The role on the account. Must be one of admin, developer, or read (case-insensitive).", - Required: true, + Description: "The role on the account. Must be one of admin, developer, or read (case-insensitive). Cannot be set if namespace_scoped_access is provided.", + Optional: true, Validators: []validator.String{ stringvalidator.OneOfCaseInsensitive(enums.AllowedAccountAccessRoles()...), + stringvalidator.ConflictsWith(path.Expressions{ + path.MatchRoot("namespace_scoped_access"), + }...), }, }, "namespace_accesses": schema.SetNestedAttribute{ - Description: "The set of namespace accesses. Empty sets are not allowed, omit the attribute instead. Service Accounts with an account_access role of admin cannot be assigned explicit permissions to namespaces. Admins implicitly receive access to all Namespaces.", + Description: "The set of namespace accesses. Empty sets are not allowed, omit the attribute instead. Service Accounts with an account_access role of admin cannot be assigned explicit permissions to namespaces. Admins implicitly receive access to all Namespaces. Cannot be set if namespace_scoped_access is provided.", Optional: true, NestedObject: schema.NestedAttributeObject{ Attributes: map[string]schema.Attribute{ @@ -153,6 +158,36 @@ func (r *serviceAccountResource) Schema(ctx context.Context, _ resource.SchemaRe setvalidator.SizeAtLeast(1), validation.NewNamespaceAccessValidator("account_access"), validation.SetNestedAttributeMustBeUnique("namespace_id"), + setvalidator.ConflictsWith(path.Expressions{ + path.MatchRoot("namespace_scoped_access"), + }...), + }, + }, + "namespace_scoped_access": schema.SingleNestedAttribute{ + Description: "Configures this service account as a namespace-scoped service account with access to only a single namespace. The namespace assignment is immutable after creation. Cannot be set if account_access or namespace_accesses are provided.", + Optional: true, + Attributes: map[string]schema.Attribute{ + "namespace_id": schema.StringAttribute{ + Description: "The namespace to scope this service account to. This field is immutable after creation.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "permission": schema.StringAttribute{ + CustomType: internaltypes.CaseInsensitiveStringType{}, + Description: "The permission level for this namespace. Must be one of admin, write, or read (case-insensitive). This field is mutable.", + Required: true, + Validators: []validator.String{ + stringvalidator.OneOfCaseInsensitive(enums.AllowedNamespaceAccessPermissions()...), + }, + }, + }, + Validators: []validator.Object{ + objectvalidator.ConflictsWith(path.Expressions{ + path.MatchRoot("account_access"), + path.MatchRoot("namespace_accesses"), + }...), }, }, }, @@ -181,33 +216,53 @@ func (r *serviceAccountResource) Create(ctx context.Context, req resource.Create ctx, cancel := context.WithTimeout(ctx, createTimeout) defer cancel() - namespaceAccesses, d := getNamespaceAccessesFromServiceAccountModel(ctx, &plan) - resp.Diagnostics.Append(d...) - if resp.Diagnostics.HasError() { - return - } - description := "" if !plan.Description.IsNull() { description = plan.Description.ValueString() } - role, err := enums.ToAccountAccessRole(plan.AccountAccess.ValueString()) - if err != nil { - resp.Diagnostics.AddError("Failed to convert account access role", err.Error()) - return + spec := &identityv1.ServiceAccountSpec{ + Name: plan.Name.ValueString(), + Description: description, } - svcResp, err := r.client.CloudService().CreateServiceAccount(ctx, &cloudservicev1.CreateServiceAccountRequest{ - Spec: &identityv1.ServiceAccountSpec{ - Name: plan.Name.ValueString(), - Access: &identityv1.Access{ - AccountAccess: &identityv1.AccountAccess{ - Role: role, - }, - NamespaceAccesses: namespaceAccesses, + + // Handle namespace-scoped access + if !plan.NamespaceScopedAccess.IsNull() { + namespaceScopedAccess, d := getNamespaceScopedAccessFromModel(ctx, &plan) + resp.Diagnostics.Append(d...) + if resp.Diagnostics.HasError() { + return + } + spec.NamespaceScopedAccess = namespaceScopedAccess + } else { + // Handle account-scoped access + if plan.AccountAccess.IsNull() { + resp.Diagnostics.AddError("Missing access configuration", "Either account_access or namespace_scoped_access must be provided") + return + } + + namespaceAccesses, d := getNamespaceAccessesFromServiceAccountModel(ctx, &plan) + resp.Diagnostics.Append(d...) + if resp.Diagnostics.HasError() { + return + } + + role, err := enums.ToAccountAccessRole(plan.AccountAccess.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Failed to convert account access role", err.Error()) + return + } + + spec.Access = &identityv1.Access{ + AccountAccess: &identityv1.AccountAccess{ + Role: role, }, - Description: description, - }, + NamespaceAccesses: namespaceAccesses, + } + } + + svcResp, err := r.client.CloudService().CreateServiceAccount(ctx, &cloudservicev1.CreateServiceAccountRequest{ + Spec: spec, AsyncOperationId: uuid.New().String(), }) if err != nil { @@ -273,12 +328,6 @@ func (r *serviceAccountResource) Update(ctx context.Context, req resource.Update return } - namespaceAccesses, d := getNamespaceAccessesFromServiceAccountModel(ctx, &plan) - resp.Diagnostics.Append(d...) - if resp.Diagnostics.HasError() { - return - } - currentServiceAccount, err := r.client.CloudService().GetServiceAccount(ctx, &cloudservicev1.GetServiceAccountRequest{ ServiceAccountId: plan.ID.ValueString(), }) @@ -287,28 +336,73 @@ func (r *serviceAccountResource) Update(ctx context.Context, req resource.Update return } + // Prevent conversion between account-scoped and namespace-scoped service accounts + currentIsNamespaceScoped := currentServiceAccount.ServiceAccount.GetSpec().GetNamespaceScopedAccess() != nil + planIsNamespaceScoped := !plan.NamespaceScopedAccess.IsNull() + + if currentIsNamespaceScoped != planIsNamespaceScoped { + if currentIsNamespaceScoped { + resp.Diagnostics.AddError( + "Cannot convert namespace-scoped service account to account-scoped", + "This service account is currently namespace-scoped and cannot be converted to an account-scoped service account. You must delete and recreate the service account to change its scope type.", + ) + } else { + resp.Diagnostics.AddError( + "Cannot convert account-scoped service account to namespace-scoped", + "This service account is currently account-scoped and cannot be converted to a namespace-scoped service account. You must delete and recreate the service account to change its scope type.", + ) + } + return + } + description := "" if !plan.Description.IsNull() { description = plan.Description.ValueString() } - role, err := enums.ToAccountAccessRole(plan.AccountAccess.ValueString()) - if err != nil { - resp.Diagnostics.AddError("Failed to convert account access role", err.Error()) - return + spec := &identityv1.ServiceAccountSpec{ + Name: plan.Name.ValueString(), + Description: description, + } + + // Handle namespace-scoped access + if !plan.NamespaceScopedAccess.IsNull() { + namespaceScopedAccess, d := getNamespaceScopedAccessFromModel(ctx, &plan) + resp.Diagnostics.Append(d...) + if resp.Diagnostics.HasError() { + return + } + spec.NamespaceScopedAccess = namespaceScopedAccess + } else { + // Handle account-scoped access + if plan.AccountAccess.IsNull() { + resp.Diagnostics.AddError("Missing access configuration", "Either account_access or namespace_scoped_access must be provided") + return + } + + namespaceAccesses, d := getNamespaceAccessesFromServiceAccountModel(ctx, &plan) + resp.Diagnostics.Append(d...) + if resp.Diagnostics.HasError() { + return + } + + role, err := enums.ToAccountAccessRole(plan.AccountAccess.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Failed to convert account access role", err.Error()) + return + } + + spec.Access = &identityv1.Access{ + AccountAccess: &identityv1.AccountAccess{ + Role: role, + }, + NamespaceAccesses: namespaceAccesses, + } } + svcResp, err := r.client.CloudService().UpdateServiceAccount(ctx, &cloudservicev1.UpdateServiceAccountRequest{ ServiceAccountId: plan.ID.ValueString(), - Spec: &identityv1.ServiceAccountSpec{ - Name: plan.Name.ValueString(), - Access: &identityv1.Access{ - AccountAccess: &identityv1.AccountAccess{ - Role: role, - }, - NamespaceAccesses: namespaceAccesses, - }, - Description: description, - }, + Spec: spec, ResourceVersion: currentServiceAccount.ServiceAccount.GetResourceVersion(), AsyncOperationId: uuid.New().String(), }) @@ -430,55 +524,106 @@ func getNamespaceAccessesFromServiceAccountModel(ctx context.Context, model *ser return namespaceAccesses, diags } +func getNamespaceScopedAccessFromModel(ctx context.Context, model *serviceAccountResourceModel) (*identityv1.NamespaceScopedAccess, diag.Diagnostics) { + var diags diag.Diagnostics + var namespaceScopedAccessModel serviceAccountNamespaceAccessModel + diags.Append(model.NamespaceScopedAccess.As(ctx, &namespaceScopedAccessModel, basetypes.ObjectAsOptions{})...) + if diags.HasError() { + return nil, diags + } + + permission, err := enums.ToNamespaceAccessPermission(namespaceScopedAccessModel.Permission.ValueString()) + if err != nil { + diags.AddError("Failed to convert namespace access permission", err.Error()) + return nil, diags + } + + return &identityv1.NamespaceScopedAccess{ + Namespace: namespaceScopedAccessModel.NamespaceID.ValueString(), + Access: &identityv1.NamespaceAccess{ + Permission: permission, + }, + }, diags +} + func updateServiceAccountModelFromSpec(ctx context.Context, state *serviceAccountResourceModel, serviceAccount *identityv1.ServiceAccount) diag.Diagnostics { var diags diag.Diagnostics stateStr, err := enums.FromResourceState(serviceAccount.GetState()) if err != nil { diags.AddError("Failed to convert resource state", err.Error()) } - role, err := enums.FromAccountAccessRole(serviceAccount.GetSpec().GetAccess().GetAccountAccess().GetRole()) - if err != nil { - diags.AddError("Failed to convert account access role", err.Error()) - } - namespaceAccesses := types.SetNull(types.ObjectType{AttrTypes: serviceAccountNamespaceAccessAttrs}) - if len(serviceAccount.GetSpec().GetAccess().GetNamespaceAccesses()) > 0 { - namespaceAccessObjects := make([]types.Object, 0) - for ns, namespaceAccess := range serviceAccount.GetSpec().GetAccess().GetNamespaceAccesses() { - permission, err := enums.FromNamespaceAccessPermission(namespaceAccess.GetPermission()) - if err != nil { - diags.AddError("Failed to convert namespace access permission", err.Error()) - continue - } - model := serviceAccountNamespaceAccessModel{ - NamespaceID: types.StringValue(ns), - Permission: internaltypes.CaseInsensitiveString(permission), + state.ID = types.StringValue(serviceAccount.GetId()) + state.State = types.StringValue(stateStr) + state.Name = types.StringValue(serviceAccount.GetSpec().GetName()) + state.Description = types.StringValue(serviceAccount.GetSpec().GetDescription()) + + // Check if this is a namespace-scoped service account + if serviceAccount.GetSpec().GetNamespaceScopedAccess() != nil { + namespaceScopedAccess := serviceAccount.GetSpec().GetNamespaceScopedAccess() + permission, err := enums.FromNamespaceAccessPermission(namespaceScopedAccess.GetAccess().GetPermission()) + if err != nil { + diags.AddError("Failed to convert namespace access permission", err.Error()) + return diags + } + + model := serviceAccountNamespaceAccessModel{ + NamespaceID: types.StringValue(namespaceScopedAccess.GetNamespace()), + Permission: internaltypes.CaseInsensitiveString(permission), + } + + obj, d := types.ObjectValueFrom(ctx, serviceAccountNamespaceAccessAttrs, model) + diags.Append(d...) + if diags.HasError() { + return diags + } + + state.NamespaceScopedAccess = obj + state.AccountAccess = internaltypes.CaseInsensitiveStringValue{} + state.NamespaceAccesses = types.SetNull(types.ObjectType{AttrTypes: serviceAccountNamespaceAccessAttrs}) + } else { + // Handle account-scoped service account + role, err := enums.FromAccountAccessRole(serviceAccount.GetSpec().GetAccess().GetAccountAccess().GetRole()) + if err != nil { + diags.AddError("Failed to convert account access role", err.Error()) + } + + namespaceAccesses := types.SetNull(types.ObjectType{AttrTypes: serviceAccountNamespaceAccessAttrs}) + if len(serviceAccount.GetSpec().GetAccess().GetNamespaceAccesses()) > 0 { + namespaceAccessObjects := make([]types.Object, 0) + for ns, namespaceAccess := range serviceAccount.GetSpec().GetAccess().GetNamespaceAccesses() { + permission, err := enums.FromNamespaceAccessPermission(namespaceAccess.GetPermission()) + if err != nil { + diags.AddError("Failed to convert namespace access permission", err.Error()) + continue + } + model := serviceAccountNamespaceAccessModel{ + NamespaceID: types.StringValue(ns), + Permission: internaltypes.CaseInsensitiveString(permission), + } + obj, d := types.ObjectValueFrom(ctx, serviceAccountNamespaceAccessAttrs, model) + diags.Append(d...) + if d.HasError() { + continue + } + namespaceAccessObjects = append(namespaceAccessObjects, obj) } - obj, d := types.ObjectValueFrom(ctx, serviceAccountNamespaceAccessAttrs, model) + + accesses, d := types.SetValueFrom(ctx, types.ObjectType{AttrTypes: serviceAccountNamespaceAccessAttrs}, namespaceAccessObjects) diags.Append(d...) - if d.HasError() { - continue + if !diags.HasError() { + namespaceAccesses = accesses } - namespaceAccessObjects = append(namespaceAccessObjects, obj) } - accesses, d := types.SetValueFrom(ctx, types.ObjectType{AttrTypes: serviceAccountNamespaceAccessAttrs}, namespaceAccessObjects) - diags.Append(d...) - if !diags.HasError() { - namespaceAccesses = accesses + if diags.HasError() { + return diags } - } - if diags.HasError() { - return diags + state.AccountAccess = internaltypes.CaseInsensitiveString(role) + state.NamespaceAccesses = namespaceAccesses + state.NamespaceScopedAccess = types.ObjectNull(serviceAccountNamespaceAccessAttrs) } - state.ID = types.StringValue(serviceAccount.GetId()) - state.State = types.StringValue(stateStr) - state.Name = types.StringValue(serviceAccount.GetSpec().GetName()) - state.Description = types.StringValue(serviceAccount.GetSpec().GetDescription()) - state.AccountAccess = internaltypes.CaseInsensitiveString(role) - state.NamespaceAccesses = namespaceAccesses - return nil } diff --git a/internal/provider/service_accounts_datasource.go b/internal/provider/service_accounts_datasource.go index 6dfdb140..ec3fb2fb 100644 --- a/internal/provider/service_accounts_datasource.go +++ b/internal/provider/service_accounts_datasource.go @@ -26,14 +26,15 @@ type ( } serviceAccountDataModel struct { - ID types.String `tfsdk:"id"` - Name types.String `tfsdk:"name"` - Description types.String `tfsdk:"description"` - State types.String `tfsdk:"state"` - AccountAccess internaltypes.CaseInsensitiveStringValue `tfsdk:"account_access"` - NamespaceAccesses types.Set `tfsdk:"namespace_accesses"` - CreatedAt types.String `tfsdk:"created_at"` - UpdatedAt types.String `tfsdk:"updated_at"` + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + Description types.String `tfsdk:"description"` + State types.String `tfsdk:"state"` + AccountAccess internaltypes.CaseInsensitiveStringValue `tfsdk:"account_access"` + NamespaceAccesses types.Set `tfsdk:"namespace_accesses"` + NamespaceScopedAccess types.Object `tfsdk:"namespace_scoped_access"` + CreatedAt types.String `tfsdk:"created_at"` + UpdatedAt types.String `tfsdk:"updated_at"` } serviceAccountNSAccessModel struct { @@ -121,6 +122,21 @@ func serviceAccountSchema(idRequired bool) map[string]schema.Attribute { }, }, }, + "namespace_scoped_access": schema.SingleNestedAttribute{ + Description: "The namespace-scoped access configuration if this service account is scoped to a single namespace.", + Computed: true, + Attributes: map[string]schema.Attribute{ + "namespace_id": schema.StringAttribute{ + Description: "The namespace this service account is scoped to.", + Computed: true, + }, + "permission": schema.StringAttribute{ + CustomType: internaltypes.CaseInsensitiveStringType{}, + Description: "The permission level for this namespace.", + Computed: true, + }, + }, + }, "created_at": schema.StringAttribute{ Description: "The creation time of the Service Account.", Computed: true, @@ -210,46 +226,73 @@ func serviceAccountToServiceAccountDataModel(ctx context.Context, sa *identityv1 UpdatedAt: types.StringValue(sa.GetLastModifiedTime().AsTime().GoString()), } - role, err := enums.FromAccountAccessRole(sa.GetSpec().GetAccess().GetAccountAccess().GetRole()) - if err != nil { - diags.AddError("Failed to convert account access role", err.Error()) - return nil, diags - } + // Check if this is a namespace-scoped service account + if sa.GetSpec().GetNamespaceScopedAccess() != nil { + namespaceScopedAccess := sa.GetSpec().GetNamespaceScopedAccess() + permission, err := enums.FromNamespaceAccessPermission(namespaceScopedAccess.GetAccess().GetPermission()) + if err != nil { + diags.AddError("Failed to convert namespace access permission", err.Error()) + return nil, diags + } - serviceAccountModel.AccountAccess = internaltypes.CaseInsensitiveString(role) + model := serviceAccountNSAccessModel{ + NamespaceID: types.StringValue(namespaceScopedAccess.GetNamespace()), + Permission: types.StringValue(permission), + } + + obj, d := types.ObjectValueFrom(ctx, serviceAccountNamespaceAccessAttrs, model) + diags.Append(d...) + if diags.HasError() { + return nil, diags + } - namespaceAccesses := types.SetNull(types.ObjectType{AttrTypes: serviceAccountNamespaceAccessAttrs}) + serviceAccountModel.NamespaceScopedAccess = obj + serviceAccountModel.AccountAccess = internaltypes.CaseInsensitiveStringValue{} + serviceAccountModel.NamespaceAccesses = types.SetNull(types.ObjectType{AttrTypes: serviceAccountNamespaceAccessAttrs}) + } else { + // Handle account-scoped service account + role, err := enums.FromAccountAccessRole(sa.GetSpec().GetAccess().GetAccountAccess().GetRole()) + if err != nil { + diags.AddError("Failed to convert account access role", err.Error()) + return nil, diags + } - if len(sa.GetSpec().GetAccess().GetNamespaceAccesses()) > 0 { - namespaceAccessObjects := make([]types.Object, 0) - for ns, namespaceAccess := range sa.GetSpec().GetAccess().GetNamespaceAccesses() { - permission, err := enums.FromNamespaceAccessPermission(namespaceAccess.GetPermission()) - if err != nil { - diags.AddError("Failed to convert namespace access permission", err.Error()) - return nil, diags + serviceAccountModel.AccountAccess = internaltypes.CaseInsensitiveString(role) + + namespaceAccesses := types.SetNull(types.ObjectType{AttrTypes: serviceAccountNamespaceAccessAttrs}) + + if len(sa.GetSpec().GetAccess().GetNamespaceAccesses()) > 0 { + namespaceAccessObjects := make([]types.Object, 0) + for ns, namespaceAccess := range sa.GetSpec().GetAccess().GetNamespaceAccesses() { + permission, err := enums.FromNamespaceAccessPermission(namespaceAccess.GetPermission()) + if err != nil { + diags.AddError("Failed to convert namespace access permission", err.Error()) + return nil, diags + } + + model := serviceAccountNSAccessModel{ + NamespaceID: types.StringValue(ns), + Permission: types.StringValue(permission), + } + obj, d := types.ObjectValueFrom(ctx, serviceAccountNamespaceAccessAttrs, model) + diags.Append(d...) + if diags.HasError() { + return nil, diags + } + + namespaceAccessObjects = append(namespaceAccessObjects, obj) } - model := serviceAccountNSAccessModel{ - NamespaceID: types.StringValue(ns), - Permission: types.StringValue(permission), - } - obj, d := types.ObjectValueFrom(ctx, serviceAccountNamespaceAccessAttrs, model) + accesses, d := types.SetValueFrom(ctx, types.ObjectType{AttrTypes: serviceAccountNamespaceAccessAttrs}, namespaceAccessObjects) diags.Append(d...) if diags.HasError() { return nil, diags } - - namespaceAccessObjects = append(namespaceAccessObjects, obj) - } - - accesses, d := types.SetValueFrom(ctx, types.ObjectType{AttrTypes: serviceAccountNamespaceAccessAttrs}, namespaceAccessObjects) - diags.Append(d...) - if diags.HasError() { - return nil, diags + namespaceAccesses = accesses } - namespaceAccesses = accesses + serviceAccountModel.NamespaceAccesses = namespaceAccesses + serviceAccountModel.NamespaceScopedAccess = types.ObjectNull(serviceAccountNamespaceAccessAttrs) } - serviceAccountModel.NamespaceAccesses = namespaceAccesses return serviceAccountModel, diags } From 3bc372041a5dfb3dae93c5409bde5dc6b78d663f Mon Sep 17 00:00:00 2001 From: Shakeel Rao Date: Tue, 11 Nov 2025 02:21:47 -0500 Subject: [PATCH 2/9] go generate --- docs/data-sources/service_account.md | 10 ++++++++++ docs/data-sources/service_accounts.md | 10 ++++++++++ docs/resources/service_account.md | 14 ++++++++++++-- 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/docs/data-sources/service_account.md b/docs/data-sources/service_account.md index e57f7e41..045347ae 100644 --- a/docs/data-sources/service_account.md +++ b/docs/data-sources/service_account.md @@ -56,6 +56,7 @@ output "service_account" { - `created_at` (String) The creation time of the Service Account. - `description` (String) The description of the Service Account. - `name` (String) The name associated with the service account. +- `namespace_scoped_access` (Attributes) The namespace-scoped access configuration if this service account is scoped to a single namespace. (see [below for nested schema](#nestedatt--namespace_scoped_access)) - `state` (String) The current state of the Service Account. - `updated_at` (String) The last update time of the Service Account. @@ -66,3 +67,12 @@ Read-Only: - `namespace_id` (String) The namespace to assign permissions to. - `permission` (String) The permission to assign. Must be one of admin, write, or read (case-insensitive) + + + +### Nested Schema for `namespace_scoped_access` + +Read-Only: + +- `namespace_id` (String) The namespace this service account is scoped to. +- `permission` (String) The permission level for this namespace. diff --git a/docs/data-sources/service_accounts.md b/docs/data-sources/service_accounts.md index a88213ea..18c6c777 100644 --- a/docs/data-sources/service_accounts.md +++ b/docs/data-sources/service_accounts.md @@ -34,6 +34,7 @@ Read-Only: - `description` (String) The description of the Service Account. - `id` (String) The unique identifier of the Service Account. - `name` (String) The name associated with the service account. +- `namespace_scoped_access` (Attributes) The namespace-scoped access configuration if this service account is scoped to a single namespace. (see [below for nested schema](#nestedatt--service_accounts--namespace_scoped_access)) - `state` (String) The current state of the Service Account. - `updated_at` (String) The last update time of the Service Account. @@ -44,3 +45,12 @@ Read-Only: - `namespace_id` (String) The namespace to assign permissions to. - `permission` (String) The permission to assign. Must be one of admin, write, or read (case-insensitive) + + + +### Nested Schema for `service_accounts.namespace_scoped_access` + +Read-Only: + +- `namespace_id` (String) The namespace this service account is scoped to. +- `permission` (String) The permission level for this namespace. diff --git a/docs/resources/service_account.md b/docs/resources/service_account.md index cb7fee08..5fc98165 100644 --- a/docs/resources/service_account.md +++ b/docs/resources/service_account.md @@ -54,13 +54,14 @@ resource "temporalcloud_service_account" "namespace_admin" { ### Required -- `account_access` (String) The role on the account. Must be one of admin, developer, or read (case-insensitive). - `name` (String) The name associated with the service account. ### Optional +- `account_access` (String) The role on the account. Must be one of admin, developer, or read (case-insensitive). Cannot be set if namespace_scoped_access is provided. - `description` (String) The description for the service account. -- `namespace_accesses` (Attributes Set) The set of namespace accesses. Empty sets are not allowed, omit the attribute instead. Service Accounts with an account_access role of admin cannot be assigned explicit permissions to namespaces. Admins implicitly receive access to all Namespaces. (see [below for nested schema](#nestedatt--namespace_accesses)) +- `namespace_accesses` (Attributes Set) The set of namespace accesses. Empty sets are not allowed, omit the attribute instead. Service Accounts with an account_access role of admin cannot be assigned explicit permissions to namespaces. Admins implicitly receive access to all Namespaces. Cannot be set if namespace_scoped_access is provided. (see [below for nested schema](#nestedatt--namespace_accesses)) +- `namespace_scoped_access` (Attributes) Configures this service account as a namespace-scoped service account with access to only a single namespace. The namespace assignment is immutable after creation. Cannot be set if account_access or namespace_accesses are provided. (see [below for nested schema](#nestedatt--namespace_scoped_access)) - `timeouts` (Block, Optional) (see [below for nested schema](#nestedblock--timeouts)) ### Read-Only @@ -77,6 +78,15 @@ Required: - `permission` (String) The permission to assign. Must be one of admin, write, or read (case-insensitive) + +### Nested Schema for `namespace_scoped_access` + +Required: + +- `namespace_id` (String) The namespace to scope this service account to. This field is immutable after creation. +- `permission` (String) The permission level for this namespace. Must be one of admin, write, or read (case-insensitive). This field is mutable. + + ### Nested Schema for `timeouts` From 5ba7d20e00c05a3195b30087cc97edde9ea6be2f Mon Sep 17 00:00:00 2001 From: Shakeel Rao Date: Tue, 11 Nov 2025 02:30:20 -0500 Subject: [PATCH 3/9] tests' --- docs/resources/service_account.md | 2 +- .../service_account_datasource_test.go | 124 +++++++ internal/provider/service_account_resource.go | 2 +- .../provider/service_account_resource_test.go | 333 ++++++++++++++++++ .../service_accounts_datasource_test.go | 147 ++++++++ 5 files changed, 606 insertions(+), 2 deletions(-) diff --git a/docs/resources/service_account.md b/docs/resources/service_account.md index 5fc98165..ae285b05 100644 --- a/docs/resources/service_account.md +++ b/docs/resources/service_account.md @@ -84,7 +84,7 @@ Required: Required: - `namespace_id` (String) The namespace to scope this service account to. This field is immutable after creation. -- `permission` (String) The permission level for this namespace. Must be one of admin, write, or read (case-insensitive). This field is mutable. +- `permission` (String) The permission to assign. Must be one of admin, write, or read (case-insensitive). This field is mutable. diff --git a/internal/provider/service_account_datasource_test.go b/internal/provider/service_account_datasource_test.go index af7ec227..60586936 100644 --- a/internal/provider/service_account_datasource_test.go +++ b/internal/provider/service_account_datasource_test.go @@ -1,9 +1,12 @@ package provider import ( + "bufio" + "bytes" "fmt" "github.com/hashicorp/terraform-plugin-testing/terraform" "testing" + "text/template" "github.com/hashicorp/terraform-plugin-testing/helper/resource" ) @@ -72,3 +75,124 @@ output "service_account" { }, }) } + +func TestAccDataSource_NamespaceScopedServiceAccount(t *testing.T) { + type configArgs struct { + Name string + NamespaceName string + Permission string + } + + name := createRandomName() + namespaceName := randomString(10) + + tmpl := template.Must(template.New("config").Parse(` +provider "temporalcloud" { + +} + +resource "temporalcloud_namespace" "test" { + name = "{{ .NamespaceName }}" + regions = ["aws-us-east-1"] + api_key_auth = true + retention_days = 7 +} + +resource "temporalcloud_service_account" "terraform" { + name = "{{ .Name }}" + namespace_scoped_access { + namespace_id = temporalcloud_namespace.test.id + permission = "{{ .Permission }}" + } + + depends_on = [temporalcloud_namespace.test] +} + +data "temporalcloud_service_account" "terraform" { + id = temporalcloud_service_account.terraform.id +} + +output "service_account" { + value = data.temporalcloud_service_account.terraform +} +`)) + + config := func(args configArgs) string { + var buf bytes.Buffer + writer := bufio.NewWriter(&buf) + if err := tmpl.Execute(writer, args); err != nil { + t.Errorf("failed to execute template: %v", err) + t.FailNow() + } + + writer.Flush() + return buf.String() + } + + resource.Test(t, resource.TestCase{ + PreCheck: func() { + testAccPreCheck(t) + }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: config(configArgs{ + Name: name, + NamespaceName: namespaceName, + Permission: "write", + }), + Check: func(s *terraform.State) error { + output, ok := s.RootModule().Outputs["service_account"] + if !ok { + return fmt.Errorf("missing expected output") + } + + outputValue, ok := output.Value.(map[string]interface{}) + if !ok { + return fmt.Errorf("expected value to be map") + } + + outputName, ok := outputValue["name"].(string) + if !ok { + return fmt.Errorf("expected name to be a string") + } + if outputName != name { + return fmt.Errorf("expected service account name to be %s, got %s", name, outputName) + } + + outputState, ok := outputValue["state"].(string) + if !ok { + return fmt.Errorf("expected state to be a string") + } + if outputState != "active" { + return fmt.Errorf("expected service account state to be active, got %s", outputState) + } + + // Verify namespace_scoped_access is present + namespaceScopedAccess, ok := outputValue["namespace_scoped_access"].(map[string]interface{}) + if !ok { + return fmt.Errorf("expected namespace_scoped_access to be present and be a map") + } + + nsID, ok := namespaceScopedAccess["namespace_id"].(string) + if !ok || nsID == "" { + return fmt.Errorf("expected namespace_id to be a non-empty string") + } + + permission, ok := namespaceScopedAccess["permission"].(string) + if !ok || permission != "write" { + return fmt.Errorf("expected permission to be 'write', got %v", permission) + } + + // Verify account_access is not set for namespace-scoped SA + accountAccess, _ := outputValue["account_access"].(string) + if accountAccess != "" { + return fmt.Errorf("expected account_access to be empty for namespace-scoped service account, got %s", accountAccess) + } + + return nil + }, + }, + }, + }) +} diff --git a/internal/provider/service_account_resource.go b/internal/provider/service_account_resource.go index baab5d67..641f0d52 100644 --- a/internal/provider/service_account_resource.go +++ b/internal/provider/service_account_resource.go @@ -176,7 +176,7 @@ func (r *serviceAccountResource) Schema(ctx context.Context, _ resource.SchemaRe }, "permission": schema.StringAttribute{ CustomType: internaltypes.CaseInsensitiveStringType{}, - Description: "The permission level for this namespace. Must be one of admin, write, or read (case-insensitive). This field is mutable.", + Description: "The permission to assign. Must be one of admin, write, or read (case-insensitive). This field is mutable.", Required: true, Validators: []validator.String{ stringvalidator.OneOfCaseInsensitive(enums.AllowedNamespaceAccessPermissions()...), diff --git a/internal/provider/service_account_resource_test.go b/internal/provider/service_account_resource_test.go index 2ec2eb93..71a1ac76 100644 --- a/internal/provider/service_account_resource_test.go +++ b/internal/provider/service_account_resource_test.go @@ -344,6 +344,339 @@ resource "temporalcloud_service_account" "terraform" { }) } +func TestAccNamespaceScopedServiceAccount(t *testing.T) { + type configArgs struct { + Name string + NamespaceName string + Permission string + } + + name := createRandomName() + namespaceName := randomString(10) + + tmpl := template.Must(template.New("config").Parse(` +provider "temporalcloud" { + +} + +resource "temporalcloud_namespace" "test" { + name = "{{ .NamespaceName }}" + regions = ["aws-us-east-1"] + api_key_auth = true + retention_days = 7 +} + +resource "temporalcloud_service_account" "terraform" { + name = "{{ .Name }}" + namespace_scoped_access { + namespace_id = temporalcloud_namespace.test.id + permission = "{{ .Permission }}" + } + + depends_on = [temporalcloud_namespace.test] +}`)) + + config := func(args configArgs) string { + var buf bytes.Buffer + writer := bufio.NewWriter(&buf) + if err := tmpl.Execute(writer, args); err != nil { + t.Errorf("failed to execute template: %v", err) + t.FailNow() + } + + writer.Flush() + return buf.String() + } + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { + testAccPreCheck(t) + }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: config(configArgs{ + Name: name, + NamespaceName: namespaceName, + Permission: "write", + }), + Check: func(state *terraform.State) error { + id := state.RootModule().Resources["temporalcloud_service_account.terraform"].Primary.Attributes["id"] + conn := newConnection(t) + serviceAccount, err := conn.GetServiceAccount(context.Background(), &cloudservicev1.GetServiceAccountRequest{ + ServiceAccountId: id, + }) + if err != nil { + return fmt.Errorf("failed to get Service Account: %v", err) + } + nsID := state.RootModule().Resources["temporalcloud_namespace.test"].Primary.Attributes["id"] + ns, err := conn.GetNamespace(context.Background(), &cloudservicev1.GetNamespaceRequest{ + Namespace: nsID, + }) + if err != nil { + return fmt.Errorf("failed to get namespace: %v", err) + } + spec := serviceAccount.ServiceAccount.GetSpec() + + // Verify it's namespace-scoped + if spec.GetNamespaceScopedAccess() == nil { + return errors.New("expected namespace-scoped access to be set") + } + if spec.GetAccess() != nil { + return errors.New("expected account access to be nil") + } + + // Verify namespace and permission + nsa := spec.GetNamespaceScopedAccess() + if nsa.GetNamespace() != ns.Namespace.GetNamespace() { + return fmt.Errorf("expected namespace %s, got %s", ns.Namespace.GetNamespace(), nsa.GetNamespace()) + } + if nsa.GetAccess().GetPermission() != identityv1.NamespaceAccess_PERMISSION_WRITE { + return errors.New("expected namespace access permission to be write") + } + return nil + }, + }, + { + // Update permission (mutable field) + Config: config(configArgs{ + Name: name, + NamespaceName: namespaceName, + Permission: "read", + }), + Check: func(state *terraform.State) error { + id := state.RootModule().Resources["temporalcloud_service_account.terraform"].Primary.Attributes["id"] + conn := newConnection(t) + serviceAccount, err := conn.GetServiceAccount(context.Background(), &cloudservicev1.GetServiceAccountRequest{ + ServiceAccountId: id, + }) + if err != nil { + return fmt.Errorf("failed to get Service Account: %v", err) + } + spec := serviceAccount.ServiceAccount.GetSpec() + nsa := spec.GetNamespaceScopedAccess() + if nsa.GetAccess().GetPermission() != identityv1.NamespaceAccess_PERMISSION_READ { + return errors.New("expected namespace access permission to be read after update") + } + return nil + }, + }, + { + ImportState: true, + ImportStateVerify: true, + ResourceName: "temporalcloud_service_account.terraform", + }, + }, + }) +} + +func TestAccNamespaceScopedServiceAccountMutualExclusivity(t *testing.T) { + name := createRandomName() + + config := fmt.Sprintf(` +provider "temporalcloud" { + +} + +resource "temporalcloud_service_account" "terraform" { + name = "%s" + account_access = "read" + namespace_scoped_access { + namespace_id = "test-namespace" + permission = "write" + } +}`, name) + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { + testAccPreCheck(t) + }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: config, + ExpectError: regexp.MustCompile("(conflicts with|Conflicting configuration arguments)"), + }, + }, + }) +} + +func TestAccNamespaceScopedServiceAccountNamespaceAccessesConflict(t *testing.T) { + name := createRandomName() + + config := fmt.Sprintf(` +provider "temporalcloud" { + +} + +resource "temporalcloud_service_account" "terraform" { + name = "%s" + namespace_accesses = [ + { + namespace_id = "ns1" + permission = "read" + } + ] + namespace_scoped_access { + namespace_id = "test-namespace" + permission = "write" + } +}`, name) + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { + testAccPreCheck(t) + }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: config, + ExpectError: regexp.MustCompile("(conflicts with|Conflicting configuration arguments)"), + }, + }, + }) +} + +func TestAccNamespaceScopedServiceAccountConversionBlocked(t *testing.T) { + type configArgs struct { + Name string + NamespaceName string + ConfigStr string + } + + name := createRandomName() + namespaceName := randomString(10) + + tmpl := template.Must(template.New("config").Parse(` +provider "temporalcloud" { + +} + +resource "temporalcloud_namespace" "test" { + name = "{{ .NamespaceName }}" + regions = ["aws-us-east-1"] + api_key_auth = true + retention_days = 7 +} + +resource "temporalcloud_service_account" "terraform" { + name = "{{ .Name }}" + {{ .ConfigStr }} + depends_on = [temporalcloud_namespace.test] +}`)) + + config := func(args configArgs) string { + var buf bytes.Buffer + writer := bufio.NewWriter(&buf) + if err := tmpl.Execute(writer, args); err != nil { + t.Errorf("failed to execute template: %v", err) + t.FailNow() + } + + writer.Flush() + return buf.String() + } + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { + testAccPreCheck(t) + }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + // Create account-scoped service account + Config: config(configArgs{ + Name: name, + NamespaceName: namespaceName, + ConfigStr: `account_access = "read"`, + }), + }, + { + // Try to convert to namespace-scoped (should fail) + Config: config(configArgs{ + Name: name, + NamespaceName: namespaceName, + ConfigStr: `namespace_scoped_access { + namespace_id = temporalcloud_namespace.test.id + permission = "write" + }`, + }), + ExpectError: regexp.MustCompile("Cannot convert account-scoped service account to namespace-scoped"), + }, + }, + }) +} + +func TestAccAccountScopedServiceAccountConversionBlocked(t *testing.T) { + type configArgs struct { + Name string + NamespaceName string + ConfigStr string + } + + name := createRandomName() + namespaceName := randomString(10) + + tmpl := template.Must(template.New("config").Parse(` +provider "temporalcloud" { + +} + +resource "temporalcloud_namespace" "test" { + name = "{{ .NamespaceName }}" + regions = ["aws-us-east-1"] + api_key_auth = true + retention_days = 7 +} + +resource "temporalcloud_service_account" "terraform" { + name = "{{ .Name }}" + {{ .ConfigStr }} + depends_on = [temporalcloud_namespace.test] +}`)) + + config := func(args configArgs) string { + var buf bytes.Buffer + writer := bufio.NewWriter(&buf) + if err := tmpl.Execute(writer, args); err != nil { + t.Errorf("failed to execute template: %v", err) + t.FailNow() + } + + writer.Flush() + return buf.String() + } + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { + testAccPreCheck(t) + }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + // Create namespace-scoped service account + Config: config(configArgs{ + Name: name, + NamespaceName: namespaceName, + ConfigStr: `namespace_scoped_access { + namespace_id = temporalcloud_namespace.test.id + permission = "write" + }`, + }), + }, + { + // Try to convert to account-scoped (should fail) + Config: config(configArgs{ + Name: name, + NamespaceName: namespaceName, + ConfigStr: `account_access = "read"`, + }), + ExpectError: regexp.MustCompile("Cannot convert namespace-scoped service account to account-scoped"), + }, + }, + }) +} + func TestAccBasicServiceAccountOrderingNamespaceAccesses(t *testing.T) { type configArgs struct { Name string diff --git a/internal/provider/service_accounts_datasource_test.go b/internal/provider/service_accounts_datasource_test.go index d32f5c93..c09f70f8 100644 --- a/internal/provider/service_accounts_datasource_test.go +++ b/internal/provider/service_accounts_datasource_test.go @@ -1,9 +1,14 @@ package provider import ( + "bufio" + "bytes" + "fmt" "testing" + "text/template" "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" ) func TestAccServiceAccounts(t *testing.T) { @@ -29,3 +34,145 @@ provider "temporalcloud" { data "temporalcloud_service_accounts" "example" {} ` } + +func TestAccServiceAccountsWithBothTypes(t *testing.T) { + type configArgs struct { + AccountScopedName string + NamespaceScopedName string + NamespaceName string + } + + accountScopedName := createRandomName() + namespaceScopedName := createRandomName() + namespaceName := randomString(10) + + tmpl := template.Must(template.New("config").Parse(` +provider "temporalcloud" { + +} + +resource "temporalcloud_namespace" "test" { + name = "{{ .NamespaceName }}" + regions = ["aws-us-east-1"] + api_key_auth = true + retention_days = 7 +} + +resource "temporalcloud_service_account" "account_scoped" { + name = "{{ .AccountScopedName }}" + account_access = "read" +} + +resource "temporalcloud_service_account" "namespace_scoped" { + name = "{{ .NamespaceScopedName }}" + namespace_scoped_access { + namespace_id = temporalcloud_namespace.test.id + permission = "write" + } + + depends_on = [temporalcloud_namespace.test] +} + +data "temporalcloud_service_accounts" "all" { + depends_on = [ + temporalcloud_service_account.account_scoped, + temporalcloud_service_account.namespace_scoped + ] +} + +output "service_accounts" { + value = data.temporalcloud_service_accounts.all +} +`)) + + config := func(args configArgs) string { + var buf bytes.Buffer + writer := bufio.NewWriter(&buf) + if err := tmpl.Execute(writer, args); err != nil { + t.Errorf("failed to execute template: %v", err) + t.FailNow() + } + + writer.Flush() + return buf.String() + } + + resource.Test(t, resource.TestCase{ + PreCheck: func() { + testAccPreCheck(t) + }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: config(configArgs{ + AccountScopedName: accountScopedName, + NamespaceScopedName: namespaceScopedName, + NamespaceName: namespaceName, + }), + Check: func(s *terraform.State) error { + output, ok := s.RootModule().Outputs["service_accounts"] + if !ok { + return fmt.Errorf("missing expected output") + } + + outputValue, ok := output.Value.(map[string]interface{}) + if !ok { + return fmt.Errorf("expected value to be map") + } + + serviceAccounts, ok := outputValue["service_accounts"].([]interface{}) + if !ok { + return fmt.Errorf("expected service_accounts to be a list") + } + + // Find our created service accounts + var foundAccountScoped, foundNamespaceScoped bool + for _, sa := range serviceAccounts { + saMap, ok := sa.(map[string]interface{}) + if !ok { + continue + } + + name, _ := saMap["name"].(string) + + if name == accountScopedName { + foundAccountScoped = true + // Verify it's account-scoped + accountAccess, ok := saMap["account_access"].(string) + if !ok || accountAccess == "" { + return fmt.Errorf("expected account_access to be set for account-scoped service account") + } + // Verify namespace_scoped_access is not set + if namespaceScopedAccess, ok := saMap["namespace_scoped_access"].(map[string]interface{}); ok && namespaceScopedAccess != nil { + return fmt.Errorf("expected namespace_scoped_access to be null for account-scoped service account") + } + } + + if name == namespaceScopedName { + foundNamespaceScoped = true + // Verify it's namespace-scoped + namespaceScopedAccess, ok := saMap["namespace_scoped_access"].(map[string]interface{}) + if !ok || namespaceScopedAccess == nil { + return fmt.Errorf("expected namespace_scoped_access to be set for namespace-scoped service account") + } + // Verify account_access is not set + accountAccess, _ := saMap["account_access"].(string) + if accountAccess != "" { + return fmt.Errorf("expected account_access to be empty for namespace-scoped service account") + } + } + } + + if !foundAccountScoped { + return fmt.Errorf("did not find account-scoped service account '%s' in datasource results", accountScopedName) + } + if !foundNamespaceScoped { + return fmt.Errorf("did not find namespace-scoped service account '%s' in datasource results", namespaceScopedName) + } + + return nil + }, + }, + }, + }) +} From afeb7b59b6591d990cb52798957c14d4b78cea5d Mon Sep 17 00:00:00 2001 From: Shakeel Rao Date: Tue, 11 Nov 2025 02:33:25 -0500 Subject: [PATCH 4/9] additional validation --- .../provider/service_account_resource_test.go | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/internal/provider/service_account_resource_test.go b/internal/provider/service_account_resource_test.go index 71a1ac76..b4ec4279 100644 --- a/internal/provider/service_account_resource_test.go +++ b/internal/provider/service_account_resource_test.go @@ -607,6 +607,33 @@ resource "temporalcloud_service_account" "terraform" { }) } +func TestAccServiceAccountMissingAccessConfiguration(t *testing.T) { + name := createRandomName() + + config := fmt.Sprintf(` +provider "temporalcloud" { + +} + +resource "temporalcloud_service_account" "terraform" { + name = "%s" + description = "This service account has no access configuration" +}`, name) + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { + testAccPreCheck(t) + }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: config, + ExpectError: regexp.MustCompile("Missing access configuration"), + }, + }, + }) +} + func TestAccAccountScopedServiceAccountConversionBlocked(t *testing.T) { type configArgs struct { Name string From 5bcb709fc0dbd18e8208fe087c3fb5e561d42f81 Mon Sep 17 00:00:00 2001 From: Shakeel Rao Date: Tue, 11 Nov 2025 02:45:04 -0500 Subject: [PATCH 5/9] comment --- docs/data-sources/service_account.md | 6 +++--- docs/data-sources/service_accounts.md | 6 +++--- internal/provider/service_accounts_datasource.go | 7 ++++--- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/docs/data-sources/service_account.md b/docs/data-sources/service_account.md index 045347ae..e8aa7ac0 100644 --- a/docs/data-sources/service_account.md +++ b/docs/data-sources/service_account.md @@ -56,7 +56,7 @@ output "service_account" { - `created_at` (String) The creation time of the Service Account. - `description` (String) The description of the Service Account. - `name` (String) The name associated with the service account. -- `namespace_scoped_access` (Attributes) The namespace-scoped access configuration if this service account is scoped to a single namespace. (see [below for nested schema](#nestedatt--namespace_scoped_access)) +- `namespace_scoped_access` (Attributes) The namespace-scoped access configuration for this service account. (see [below for nested schema](#nestedatt--namespace_scoped_access)) - `state` (String) The current state of the Service Account. - `updated_at` (String) The last update time of the Service Account. @@ -66,7 +66,7 @@ output "service_account" { Read-Only: - `namespace_id` (String) The namespace to assign permissions to. -- `permission` (String) The permission to assign. Must be one of admin, write, or read (case-insensitive) +- `permission` (String) The permission to assign. Must be one of admin, write, or read (case-insensitive). @@ -75,4 +75,4 @@ Read-Only: Read-Only: - `namespace_id` (String) The namespace this service account is scoped to. -- `permission` (String) The permission level for this namespace. +- `permission` (String) The permission to assign. Must be one of admin, write, or read (case-insensitive). diff --git a/docs/data-sources/service_accounts.md b/docs/data-sources/service_accounts.md index 18c6c777..bbd42a1b 100644 --- a/docs/data-sources/service_accounts.md +++ b/docs/data-sources/service_accounts.md @@ -34,7 +34,7 @@ Read-Only: - `description` (String) The description of the Service Account. - `id` (String) The unique identifier of the Service Account. - `name` (String) The name associated with the service account. -- `namespace_scoped_access` (Attributes) The namespace-scoped access configuration if this service account is scoped to a single namespace. (see [below for nested schema](#nestedatt--service_accounts--namespace_scoped_access)) +- `namespace_scoped_access` (Attributes) The namespace-scoped access configuration for this service account. (see [below for nested schema](#nestedatt--service_accounts--namespace_scoped_access)) - `state` (String) The current state of the Service Account. - `updated_at` (String) The last update time of the Service Account. @@ -44,7 +44,7 @@ Read-Only: Read-Only: - `namespace_id` (String) The namespace to assign permissions to. -- `permission` (String) The permission to assign. Must be one of admin, write, or read (case-insensitive) +- `permission` (String) The permission to assign. Must be one of admin, write, or read (case-insensitive). @@ -53,4 +53,4 @@ Read-Only: Read-Only: - `namespace_id` (String) The namespace this service account is scoped to. -- `permission` (String) The permission level for this namespace. +- `permission` (String) The permission to assign. Must be one of admin, write, or read (case-insensitive). diff --git a/internal/provider/service_accounts_datasource.go b/internal/provider/service_accounts_datasource.go index ec3fb2fb..f8897200 100644 --- a/internal/provider/service_accounts_datasource.go +++ b/internal/provider/service_accounts_datasource.go @@ -3,6 +3,7 @@ package provider import ( "context" "fmt" + "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/datasource" @@ -116,14 +117,14 @@ func serviceAccountSchema(idRequired bool) map[string]schema.Attribute { }, "permission": schema.StringAttribute{ CustomType: types.StringType, - Description: "The permission to assign. Must be one of admin, write, or read (case-insensitive)", + Description: "The permission to assign. Must be one of admin, write, or read (case-insensitive).", Computed: true, }, }, }, }, "namespace_scoped_access": schema.SingleNestedAttribute{ - Description: "The namespace-scoped access configuration if this service account is scoped to a single namespace.", + Description: "The namespace-scoped access configuration for this service account.", Computed: true, Attributes: map[string]schema.Attribute{ "namespace_id": schema.StringAttribute{ @@ -132,7 +133,7 @@ func serviceAccountSchema(idRequired bool) map[string]schema.Attribute { }, "permission": schema.StringAttribute{ CustomType: internaltypes.CaseInsensitiveStringType{}, - Description: "The permission level for this namespace.", + Description: "The permission to assign. Must be one of admin, write, or read (case-insensitive).", Computed: true, }, }, From 9a22a477e7aaf3b2711a951fa642eec97810613d Mon Sep 17 00:00:00 2001 From: Shakeel Rao Date: Tue, 11 Nov 2025 02:55:41 -0500 Subject: [PATCH 6/9] consolidate --- internal/provider/service_account_resource.go | 145 +++++++----------- 1 file changed, 59 insertions(+), 86 deletions(-) diff --git a/internal/provider/service_account_resource.go b/internal/provider/service_account_resource.go index 641f0d52..e58576d8 100644 --- a/internal/provider/service_account_resource.go +++ b/internal/provider/service_account_resource.go @@ -216,49 +216,10 @@ func (r *serviceAccountResource) Create(ctx context.Context, req resource.Create ctx, cancel := context.WithTimeout(ctx, createTimeout) defer cancel() - description := "" - if !plan.Description.IsNull() { - description = plan.Description.ValueString() - } - - spec := &identityv1.ServiceAccountSpec{ - Name: plan.Name.ValueString(), - Description: description, - } - - // Handle namespace-scoped access - if !plan.NamespaceScopedAccess.IsNull() { - namespaceScopedAccess, d := getNamespaceScopedAccessFromModel(ctx, &plan) - resp.Diagnostics.Append(d...) - if resp.Diagnostics.HasError() { - return - } - spec.NamespaceScopedAccess = namespaceScopedAccess - } else { - // Handle account-scoped access - if plan.AccountAccess.IsNull() { - resp.Diagnostics.AddError("Missing access configuration", "Either account_access or namespace_scoped_access must be provided") - return - } - - namespaceAccesses, d := getNamespaceAccessesFromServiceAccountModel(ctx, &plan) - resp.Diagnostics.Append(d...) - if resp.Diagnostics.HasError() { - return - } - - role, err := enums.ToAccountAccessRole(plan.AccountAccess.ValueString()) - if err != nil { - resp.Diagnostics.AddError("Failed to convert account access role", err.Error()) - return - } - - spec.Access = &identityv1.Access{ - AccountAccess: &identityv1.AccountAccess{ - Role: role, - }, - NamespaceAccesses: namespaceAccesses, - } + spec, d := buildServiceAccountSpec(ctx, &plan) + resp.Diagnostics.Append(d...) + if resp.Diagnostics.HasError() { + return } svcResp, err := r.client.CloudService().CreateServiceAccount(ctx, &cloudservicev1.CreateServiceAccountRequest{ @@ -355,49 +316,10 @@ func (r *serviceAccountResource) Update(ctx context.Context, req resource.Update return } - description := "" - if !plan.Description.IsNull() { - description = plan.Description.ValueString() - } - - spec := &identityv1.ServiceAccountSpec{ - Name: plan.Name.ValueString(), - Description: description, - } - - // Handle namespace-scoped access - if !plan.NamespaceScopedAccess.IsNull() { - namespaceScopedAccess, d := getNamespaceScopedAccessFromModel(ctx, &plan) - resp.Diagnostics.Append(d...) - if resp.Diagnostics.HasError() { - return - } - spec.NamespaceScopedAccess = namespaceScopedAccess - } else { - // Handle account-scoped access - if plan.AccountAccess.IsNull() { - resp.Diagnostics.AddError("Missing access configuration", "Either account_access or namespace_scoped_access must be provided") - return - } - - namespaceAccesses, d := getNamespaceAccessesFromServiceAccountModel(ctx, &plan) - resp.Diagnostics.Append(d...) - if resp.Diagnostics.HasError() { - return - } - - role, err := enums.ToAccountAccessRole(plan.AccountAccess.ValueString()) - if err != nil { - resp.Diagnostics.AddError("Failed to convert account access role", err.Error()) - return - } - - spec.Access = &identityv1.Access{ - AccountAccess: &identityv1.AccountAccess{ - Role: role, - }, - NamespaceAccesses: namespaceAccesses, - } + spec, d := buildServiceAccountSpec(ctx, &plan) + resp.Diagnostics.Append(d...) + if resp.Diagnostics.HasError() { + return } svcResp, err := r.client.CloudService().UpdateServiceAccount(ctx, &cloudservicev1.UpdateServiceAccountRequest{ @@ -524,6 +446,57 @@ func getNamespaceAccessesFromServiceAccountModel(ctx context.Context, model *ser return namespaceAccesses, diags } +func buildServiceAccountSpec(ctx context.Context, plan *serviceAccountResourceModel) (*identityv1.ServiceAccountSpec, diag.Diagnostics) { + var diags diag.Diagnostics + + description := "" + if !plan.Description.IsNull() { + description = plan.Description.ValueString() + } + + spec := &identityv1.ServiceAccountSpec{ + Name: plan.Name.ValueString(), + Description: description, + } + + // Handle namespace-scoped access + if !plan.NamespaceScopedAccess.IsNull() { + namespaceScopedAccess, d := getNamespaceScopedAccessFromModel(ctx, plan) + diags.Append(d...) + if diags.HasError() { + return nil, diags + } + spec.NamespaceScopedAccess = namespaceScopedAccess + } else { + // Handle account-scoped access + if plan.AccountAccess.IsNull() { + diags.AddError("Missing access configuration", "Either account_access or namespace_scoped_access must be provided") + return nil, diags + } + + namespaceAccesses, d := getNamespaceAccessesFromServiceAccountModel(ctx, plan) + diags.Append(d...) + if diags.HasError() { + return nil, diags + } + + role, err := enums.ToAccountAccessRole(plan.AccountAccess.ValueString()) + if err != nil { + diags.AddError("Failed to convert account access role", err.Error()) + return nil, diags + } + + spec.Access = &identityv1.Access{ + AccountAccess: &identityv1.AccountAccess{ + Role: role, + }, + NamespaceAccesses: namespaceAccesses, + } + } + + return spec, diags +} + func getNamespaceScopedAccessFromModel(ctx context.Context, model *serviceAccountResourceModel) (*identityv1.NamespaceScopedAccess, diag.Diagnostics) { var diags diag.Diagnostics var namespaceScopedAccessModel serviceAccountNamespaceAccessModel From cdd66bc1367ae9768a4914bda481bef1b2e72b26 Mon Sep 17 00:00:00 2001 From: Shakeel Rao Date: Tue, 11 Nov 2025 17:13:49 -0500 Subject: [PATCH 7/9] fix ac --- internal/provider/service_account_datasource_test.go | 2 +- internal/provider/service_account_resource_test.go | 10 +++++----- internal/provider/service_accounts_datasource_test.go | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/internal/provider/service_account_datasource_test.go b/internal/provider/service_account_datasource_test.go index 60586936..ea69b37b 100644 --- a/internal/provider/service_account_datasource_test.go +++ b/internal/provider/service_account_datasource_test.go @@ -100,7 +100,7 @@ resource "temporalcloud_namespace" "test" { resource "temporalcloud_service_account" "terraform" { name = "{{ .Name }}" - namespace_scoped_access { + namespace_scoped_access = { namespace_id = temporalcloud_namespace.test.id permission = "{{ .Permission }}" } diff --git a/internal/provider/service_account_resource_test.go b/internal/provider/service_account_resource_test.go index b4ec4279..69df16bb 100644 --- a/internal/provider/service_account_resource_test.go +++ b/internal/provider/service_account_resource_test.go @@ -368,7 +368,7 @@ resource "temporalcloud_namespace" "test" { resource "temporalcloud_service_account" "terraform" { name = "{{ .Name }}" - namespace_scoped_access { + namespace_scoped_access = { namespace_id = temporalcloud_namespace.test.id permission = "{{ .Permission }}" } @@ -481,7 +481,7 @@ provider "temporalcloud" { resource "temporalcloud_service_account" "terraform" { name = "%s" account_access = "read" - namespace_scoped_access { + namespace_scoped_access = { namespace_id = "test-namespace" permission = "write" } @@ -517,7 +517,7 @@ resource "temporalcloud_service_account" "terraform" { permission = "read" } ] - namespace_scoped_access { + namespace_scoped_access = { namespace_id = "test-namespace" permission = "write" } @@ -596,7 +596,7 @@ resource "temporalcloud_service_account" "terraform" { Config: config(configArgs{ Name: name, NamespaceName: namespaceName, - ConfigStr: `namespace_scoped_access { + ConfigStr: `namespace_scoped_access = { namespace_id = temporalcloud_namespace.test.id permission = "write" }`, @@ -685,7 +685,7 @@ resource "temporalcloud_service_account" "terraform" { Config: config(configArgs{ Name: name, NamespaceName: namespaceName, - ConfigStr: `namespace_scoped_access { + ConfigStr: `namespace_scoped_access = { namespace_id = temporalcloud_namespace.test.id permission = "write" }`, diff --git a/internal/provider/service_accounts_datasource_test.go b/internal/provider/service_accounts_datasource_test.go index c09f70f8..82e0a925 100644 --- a/internal/provider/service_accounts_datasource_test.go +++ b/internal/provider/service_accounts_datasource_test.go @@ -65,7 +65,7 @@ resource "temporalcloud_service_account" "account_scoped" { resource "temporalcloud_service_account" "namespace_scoped" { name = "{{ .NamespaceScopedName }}" - namespace_scoped_access { + namespace_scoped_access = { namespace_id = temporalcloud_namespace.test.id permission = "write" } From 6672af6864c2bec0b378287a00302d23f422875f Mon Sep 17 00:00:00 2001 From: Shakeel Rao Date: Tue, 11 Nov 2025 18:02:33 -0500 Subject: [PATCH 8/9] round two: ac --- internal/provider/service_account_resource_test.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/provider/service_account_resource_test.go b/internal/provider/service_account_resource_test.go index 69df16bb..1aad39dc 100644 --- a/internal/provider/service_account_resource_test.go +++ b/internal/provider/service_account_resource_test.go @@ -495,7 +495,7 @@ resource "temporalcloud_service_account" "terraform" { Steps: []resource.TestStep{ { Config: config, - ExpectError: regexp.MustCompile("(conflicts with|Conflicting configuration arguments)"), + ExpectError: regexp.MustCompile("Invalid Attribute Combination"), }, }, }) @@ -531,7 +531,7 @@ resource "temporalcloud_service_account" "terraform" { Steps: []resource.TestStep{ { Config: config, - ExpectError: regexp.MustCompile("(conflicts with|Conflicting configuration arguments)"), + ExpectError: regexp.MustCompile("Invalid Attribute Combination"), }, }, }) @@ -601,7 +601,7 @@ resource "temporalcloud_service_account" "terraform" { permission = "write" }`, }), - ExpectError: regexp.MustCompile("Cannot convert account-scoped service account to namespace-scoped"), + ExpectError: regexp.MustCompile("(Cannot convert account-scoped service account to namespace-scoped|Invalid Attribute Combination)"), }, }, }) @@ -698,7 +698,7 @@ resource "temporalcloud_service_account" "terraform" { NamespaceName: namespaceName, ConfigStr: `account_access = "read"`, }), - ExpectError: regexp.MustCompile("Cannot convert namespace-scoped service account to account-scoped"), + ExpectError: regexp.MustCompile("(Cannot convert namespace-scoped service account to account-scoped|Invalid Attribute Combination)"), }, }, }) From 914386e8abe88d9e3ea88891cad724564eae21e6 Mon Sep 17 00:00:00 2001 From: Shakeel Rao Date: Tue, 11 Nov 2025 18:48:06 -0500 Subject: [PATCH 9/9] anotha one --- internal/provider/service_account_resource.go | 19 --- .../provider/service_account_resource_test.go | 140 ------------------ 2 files changed, 159 deletions(-) diff --git a/internal/provider/service_account_resource.go b/internal/provider/service_account_resource.go index e58576d8..61ea2670 100644 --- a/internal/provider/service_account_resource.go +++ b/internal/provider/service_account_resource.go @@ -297,25 +297,6 @@ func (r *serviceAccountResource) Update(ctx context.Context, req resource.Update return } - // Prevent conversion between account-scoped and namespace-scoped service accounts - currentIsNamespaceScoped := currentServiceAccount.ServiceAccount.GetSpec().GetNamespaceScopedAccess() != nil - planIsNamespaceScoped := !plan.NamespaceScopedAccess.IsNull() - - if currentIsNamespaceScoped != planIsNamespaceScoped { - if currentIsNamespaceScoped { - resp.Diagnostics.AddError( - "Cannot convert namespace-scoped service account to account-scoped", - "This service account is currently namespace-scoped and cannot be converted to an account-scoped service account. You must delete and recreate the service account to change its scope type.", - ) - } else { - resp.Diagnostics.AddError( - "Cannot convert account-scoped service account to namespace-scoped", - "This service account is currently account-scoped and cannot be converted to a namespace-scoped service account. You must delete and recreate the service account to change its scope type.", - ) - } - return - } - spec, d := buildServiceAccountSpec(ctx, &plan) resp.Diagnostics.Append(d...) if resp.Diagnostics.HasError() { diff --git a/internal/provider/service_account_resource_test.go b/internal/provider/service_account_resource_test.go index 1aad39dc..a0b25266 100644 --- a/internal/provider/service_account_resource_test.go +++ b/internal/provider/service_account_resource_test.go @@ -537,76 +537,6 @@ resource "temporalcloud_service_account" "terraform" { }) } -func TestAccNamespaceScopedServiceAccountConversionBlocked(t *testing.T) { - type configArgs struct { - Name string - NamespaceName string - ConfigStr string - } - - name := createRandomName() - namespaceName := randomString(10) - - tmpl := template.Must(template.New("config").Parse(` -provider "temporalcloud" { - -} - -resource "temporalcloud_namespace" "test" { - name = "{{ .NamespaceName }}" - regions = ["aws-us-east-1"] - api_key_auth = true - retention_days = 7 -} - -resource "temporalcloud_service_account" "terraform" { - name = "{{ .Name }}" - {{ .ConfigStr }} - depends_on = [temporalcloud_namespace.test] -}`)) - - config := func(args configArgs) string { - var buf bytes.Buffer - writer := bufio.NewWriter(&buf) - if err := tmpl.Execute(writer, args); err != nil { - t.Errorf("failed to execute template: %v", err) - t.FailNow() - } - - writer.Flush() - return buf.String() - } - - resource.ParallelTest(t, resource.TestCase{ - PreCheck: func() { - testAccPreCheck(t) - }, - ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, - Steps: []resource.TestStep{ - { - // Create account-scoped service account - Config: config(configArgs{ - Name: name, - NamespaceName: namespaceName, - ConfigStr: `account_access = "read"`, - }), - }, - { - // Try to convert to namespace-scoped (should fail) - Config: config(configArgs{ - Name: name, - NamespaceName: namespaceName, - ConfigStr: `namespace_scoped_access = { - namespace_id = temporalcloud_namespace.test.id - permission = "write" - }`, - }), - ExpectError: regexp.MustCompile("(Cannot convert account-scoped service account to namespace-scoped|Invalid Attribute Combination)"), - }, - }, - }) -} - func TestAccServiceAccountMissingAccessConfiguration(t *testing.T) { name := createRandomName() @@ -634,76 +564,6 @@ resource "temporalcloud_service_account" "terraform" { }) } -func TestAccAccountScopedServiceAccountConversionBlocked(t *testing.T) { - type configArgs struct { - Name string - NamespaceName string - ConfigStr string - } - - name := createRandomName() - namespaceName := randomString(10) - - tmpl := template.Must(template.New("config").Parse(` -provider "temporalcloud" { - -} - -resource "temporalcloud_namespace" "test" { - name = "{{ .NamespaceName }}" - regions = ["aws-us-east-1"] - api_key_auth = true - retention_days = 7 -} - -resource "temporalcloud_service_account" "terraform" { - name = "{{ .Name }}" - {{ .ConfigStr }} - depends_on = [temporalcloud_namespace.test] -}`)) - - config := func(args configArgs) string { - var buf bytes.Buffer - writer := bufio.NewWriter(&buf) - if err := tmpl.Execute(writer, args); err != nil { - t.Errorf("failed to execute template: %v", err) - t.FailNow() - } - - writer.Flush() - return buf.String() - } - - resource.ParallelTest(t, resource.TestCase{ - PreCheck: func() { - testAccPreCheck(t) - }, - ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, - Steps: []resource.TestStep{ - { - // Create namespace-scoped service account - Config: config(configArgs{ - Name: name, - NamespaceName: namespaceName, - ConfigStr: `namespace_scoped_access = { - namespace_id = temporalcloud_namespace.test.id - permission = "write" - }`, - }), - }, - { - // Try to convert to account-scoped (should fail) - Config: config(configArgs{ - Name: name, - NamespaceName: namespaceName, - ConfigStr: `account_access = "read"`, - }), - ExpectError: regexp.MustCompile("(Cannot convert namespace-scoped service account to account-scoped|Invalid Attribute Combination)"), - }, - }, - }) -} - func TestAccBasicServiceAccountOrderingNamespaceAccesses(t *testing.T) { type configArgs struct { Name string