diff --git a/docs/data-sources/service_account.md b/docs/data-sources/service_account.md
index e57f7e4..e8aa7ac 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 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.
@@ -65,4 +66,13 @@ 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).
+
+
+
+### Nested Schema for `namespace_scoped_access`
+
+Read-Only:
+
+- `namespace_id` (String) The namespace this service account is scoped to.
+- `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 a88213e..bbd42a1 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 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.
@@ -43,4 +44,13 @@ 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).
+
+
+
+### 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 to assign. Must be one of admin, write, or read (case-insensitive).
diff --git a/docs/resources/service_account.md b/docs/resources/service_account.md
index cb7fee0..ae285b0 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 to assign. Must be one of admin, write, or read (case-insensitive). This field is mutable.
+
+
### Nested Schema for `timeouts`
diff --git a/internal/provider/service_account_datasource_test.go b/internal/provider/service_account_datasource_test.go
index af7ec22..ea69b37 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 872afc5..61ea267 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 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()...),
+ },
+ },
+ },
+ Validators: []validator.Object{
+ objectvalidator.ConflictsWith(path.Expressions{
+ path.MatchRoot("account_access"),
+ path.MatchRoot("namespace_accesses"),
+ }...),
},
},
},
@@ -181,33 +216,14 @@ func (r *serviceAccountResource) Create(ctx context.Context, req resource.Create
ctx, cancel := context.WithTimeout(ctx, createTimeout)
defer cancel()
- namespaceAccesses, d := getNamespaceAccessesFromServiceAccountModel(ctx, &plan)
+ spec, d := buildServiceAccountSpec(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
- }
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,
- },
- Description: description,
- },
+ Spec: spec,
AsyncOperationId: uuid.New().String(),
})
if err != nil {
@@ -273,12 +289,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 +297,15 @@ func (r *serviceAccountResource) Update(ctx context.Context, req resource.Update
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())
+ spec, d := buildServiceAccountSpec(ctx, &plan)
+ resp.Diagnostics.Append(d...)
+ if resp.Diagnostics.HasError() {
return
}
+
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 +427,157 @@ func getNamespaceAccessesFromServiceAccountModel(ctx context.Context, model *ser
return namespaceAccesses, diags
}
-func updateServiceAccountModelFromSpec(ctx context.Context, state *serviceAccountResourceModel, serviceAccount *identityv1.ServiceAccount) diag.Diagnostics {
+func buildServiceAccountSpec(ctx context.Context, plan *serviceAccountResourceModel) (*identityv1.ServiceAccountSpec, diag.Diagnostics) {
var diags diag.Diagnostics
- stateStr, err := enums.FromResourceState(serviceAccount.GetState())
- if err != nil {
- diags.AddError("Failed to convert resource state", err.Error())
+
+ description := ""
+ if !plan.Description.IsNull() {
+ description = plan.Description.ValueString()
}
- role, err := enums.FromAccountAccessRole(serviceAccount.GetSpec().GetAccess().GetAccountAccess().GetRole())
- if err != nil {
- diags.AddError("Failed to convert account access role", err.Error())
+
+ spec := &identityv1.ServiceAccountSpec{
+ Name: plan.Name.ValueString(),
+ Description: description,
}
- 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)
+ // 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
}
- accesses, d := types.SetValueFrom(ctx, types.ObjectType{AttrTypes: serviceAccountNamespaceAccessAttrs}, namespaceAccessObjects)
+ namespaceAccesses, d := getNamespaceAccessesFromServiceAccountModel(ctx, plan)
diags.Append(d...)
- if !diags.HasError() {
- namespaceAccesses = accesses
+ 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
+ diags.Append(model.NamespaceScopedAccess.As(ctx, &namespaceScopedAccessModel, basetypes.ObjectAsOptions{})...)
if diags.HasError() {
- return diags
+ 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())
}
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
+
+ // 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)
+ }
+
+ accesses, d := types.SetValueFrom(ctx, types.ObjectType{AttrTypes: serviceAccountNamespaceAccessAttrs}, namespaceAccessObjects)
+ diags.Append(d...)
+ if !diags.HasError() {
+ namespaceAccesses = accesses
+ }
+ }
+
+ if diags.HasError() {
+ return diags
+ }
+
+ state.AccountAccess = internaltypes.CaseInsensitiveString(role)
+ state.NamespaceAccesses = namespaceAccesses
+ state.NamespaceScopedAccess = types.ObjectNull(serviceAccountNamespaceAccessAttrs)
+ }
return nil
}
diff --git a/internal/provider/service_account_resource_test.go b/internal/provider/service_account_resource_test.go
index 2ec2eb9..a0b2526 100644
--- a/internal/provider/service_account_resource_test.go
+++ b/internal/provider/service_account_resource_test.go
@@ -344,6 +344,226 @@ 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("Invalid Attribute Combination"),
+ },
+ },
+ })
+}
+
+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("Invalid Attribute Combination"),
+ },
+ },
+ })
+}
+
+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 TestAccBasicServiceAccountOrderingNamespaceAccesses(t *testing.T) {
type configArgs struct {
Name string
diff --git a/internal/provider/service_accounts_datasource.go b/internal/provider/service_accounts_datasource.go
index 6dfdb14..f889720 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"
@@ -26,14 +27,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 {
@@ -115,12 +117,27 @@ 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 for this service account.",
+ 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 to assign. Must be one of admin, write, or read (case-insensitive).",
+ Computed: true,
+ },
+ },
+ },
"created_at": schema.StringAttribute{
Description: "The creation time of the Service Account.",
Computed: true,
@@ -210,46 +227,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),
+ }
- namespaceAccesses := types.SetNull(types.ObjectType{AttrTypes: serviceAccountNamespaceAccessAttrs})
+ obj, d := types.ObjectValueFrom(ctx, serviceAccountNamespaceAccessAttrs, model)
+ diags.Append(d...)
+ if diags.HasError() {
+ 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.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
+ }
- model := serviceAccountNSAccessModel{
- NamespaceID: types.StringValue(ns),
- Permission: types.StringValue(permission),
+ 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)
}
- 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
}
diff --git a/internal/provider/service_accounts_datasource_test.go b/internal/provider/service_accounts_datasource_test.go
index d32f5c9..82e0a92 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
+ },
+ },
+ },
+ })
+}