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 + }, + }, + }, + }) +}