diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index ca14abe5..94f741ab 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -24,6 +24,9 @@ on: [workflow_dispatch, push] env: PKG_NAME: "terraform-provider-boundary" +permissions: + contents: read + jobs: # Detects the Go version to use for builds. get-go-version: diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f873551..a281b937 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ Canonical reference for changes, improvements, and bugfixes for the Boundary Terraform provider. ## Next +## 1.6.1 (Jul 24th, 2026) + +### New and Improved + +* Adds support to enable set/remove alias suffix and aliases at project scope + ([PR](https://github.com/hashicorp/terraform-provider-boundary/pull/824)) ## 1.6.0 (Jul 8th, 2026) diff --git a/docs/resources/alias_target.md b/docs/resources/alias_target.md index 8ef1d89e..e4af37ad 100644 --- a/docs/resources/alias_target.md +++ b/docs/resources/alias_target.md @@ -3,12 +3,12 @@ page_title: "boundary_alias_target Resource - terraform-provider-boundary" subcategory: "" description: |- - The target alias resource allows you to configure a Boundary target alias. + The target alias resource allows you to configure a Boundary target alias at project and global scopes. --- # boundary_alias_target (Resource) -The target alias resource allows you to configure a Boundary target alias. +The target alias resource allows you to configure a Boundary target alias at project and global scopes. ## Example Usage @@ -28,6 +28,16 @@ resource "boundary_scope" "project" { auto_create_admin_role = true } +resource "boundary_scope_alias_suffix" "org" { + scope_id = boundary_scope.org.id + alias_suffix = "org" +} + +resource "boundary_scope_alias_suffix" "project" { + scope_id = boundary_scope.project.id + alias_suffix = "projectone" +} + resource "boundary_host_catalog_static" "foo" { name = "test" description = "test catalog" @@ -75,6 +85,19 @@ resource "boundary_alias_target" "example_alias_target" { destination_id = boundary_target.foo.id authorize_session_host_id = boundary_host_static.bar.id } + +resource "boundary_alias_target" "example_alias_target_project" { + name = "example_alias_target_project" + description = "Example project-scoped alias to target foo" + scope_id = boundary_scope.project.id + value = "prodops.projectone.org" + destination_id = boundary_target.foo.id + authorize_session_host_id = boundary_host_static.bar.id + depends_on = [ + boundary_scope_alias_suffix.org, + boundary_scope_alias_suffix.project, + ] +} ``` @@ -82,7 +105,7 @@ resource "boundary_alias_target" "example_alias_target" { ### Required -- `scope_id` (String) The scope ID. +- `scope_id` (String) The scope ID. Org scopes are not supported for aliases. - `value` (String) The value of the alias. ### Optional @@ -105,4 +128,5 @@ The [`terraform import` command](https://developer.hashicorp.com/terraform/cli/c ```shell terraform import boundary_alias_target.example_alias_target +terraform import boundary_alias_target.example_alias_target_project ``` diff --git a/docs/resources/scope_alias_suffix.md b/docs/resources/scope_alias_suffix.md new file mode 100644 index 00000000..44b6602b --- /dev/null +++ b/docs/resources/scope_alias_suffix.md @@ -0,0 +1,67 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "boundary_scope_alias_suffix Resource - terraform-provider-boundary" +subcategory: "" +description: |- + The scope alias suffix resource allows you to set an alias suffix for an org or project scope. Global scopes are not valid for alias suffix operations. +--- + +# boundary_scope_alias_suffix (Resource) + +The scope alias suffix resource allows you to set an alias suffix for an org or project scope. Global scopes are not valid for alias suffix operations. + +## Example Usage + +```terraform +resource "boundary_scope" "global" { + global_scope = true + scope_id = "global" +} + +resource "boundary_scope" "org" { + name = "organization_one" + description = "My first scope!" + scope_id = boundary_scope.global.id + auto_create_admin_role = true + auto_create_default_role = true +} + +resource "boundary_scope" "project" { + name = "project_one" + description = "My first project" + scope_id = boundary_scope.org.id + auto_create_admin_role = true +} + +resource "boundary_scope_alias_suffix" "org_suffix" { + scope_id = boundary_scope.org.id + alias_suffix = "org" +} + +resource "boundary_scope_alias_suffix" "project_suffix" { + scope_id = boundary_scope.project.id + alias_suffix = "projectone" +} +``` + + +## Schema + +### Required + +- `alias_suffix` (String) The alias suffix value for this scope. +- `scope_id` (String) The scope ID. Alias suffixes are supported for org and project scopes. + +### Read-Only + +- `id` (String) The ID of this resource. + +## Import + +Import is supported using the following syntax: + +The [`terraform import` command](https://developer.hashicorp.com/terraform/cli/commands/import) can be used, for example: + +```shell +terraform import boundary_scope_alias_suffix.org_suffix +``` diff --git a/examples/resources/boundary_alias_target/import.sh b/examples/resources/boundary_alias_target/import.sh index a092f2a9..7e16aaa1 100644 --- a/examples/resources/boundary_alias_target/import.sh +++ b/examples/resources/boundary_alias_target/import.sh @@ -1 +1,2 @@ terraform import boundary_alias_target.example_alias_target +terraform import boundary_alias_target.example_alias_target_project diff --git a/examples/resources/boundary_alias_target/resource.tf b/examples/resources/boundary_alias_target/resource.tf index 41cea036..dc128604 100644 --- a/examples/resources/boundary_alias_target/resource.tf +++ b/examples/resources/boundary_alias_target/resource.tf @@ -13,6 +13,16 @@ resource "boundary_scope" "project" { auto_create_admin_role = true } +resource "boundary_scope_alias_suffix" "org" { + scope_id = boundary_scope.org.id + alias_suffix = "org" +} + +resource "boundary_scope_alias_suffix" "project" { + scope_id = boundary_scope.project.id + alias_suffix = "projectone" +} + resource "boundary_host_catalog_static" "foo" { name = "test" description = "test catalog" @@ -59,4 +69,17 @@ resource "boundary_alias_target" "example_alias_target" { value = "example.bar.foo.boundary" destination_id = boundary_target.foo.id authorize_session_host_id = boundary_host_static.bar.id -} \ No newline at end of file +} + +resource "boundary_alias_target" "example_alias_target_project" { + name = "example_alias_target_project" + description = "Example project-scoped alias to target foo" + scope_id = boundary_scope.project.id + value = "prodops.projectone.org" + destination_id = boundary_target.foo.id + authorize_session_host_id = boundary_host_static.bar.id + depends_on = [ + boundary_scope_alias_suffix.org, + boundary_scope_alias_suffix.project, + ] +} diff --git a/examples/resources/boundary_scope_alias_suffix/import.sh b/examples/resources/boundary_scope_alias_suffix/import.sh new file mode 100644 index 00000000..5e5cbe2f --- /dev/null +++ b/examples/resources/boundary_scope_alias_suffix/import.sh @@ -0,0 +1 @@ +terraform import boundary_scope_alias_suffix.org_suffix diff --git a/examples/resources/boundary_scope_alias_suffix/resource.tf b/examples/resources/boundary_scope_alias_suffix/resource.tf new file mode 100644 index 00000000..a7443c1f --- /dev/null +++ b/examples/resources/boundary_scope_alias_suffix/resource.tf @@ -0,0 +1,29 @@ +resource "boundary_scope" "global" { + global_scope = true + scope_id = "global" +} + +resource "boundary_scope" "org" { + name = "organization_one" + description = "My first scope!" + scope_id = boundary_scope.global.id + auto_create_admin_role = true + auto_create_default_role = true +} + +resource "boundary_scope" "project" { + name = "project_one" + description = "My first project" + scope_id = boundary_scope.org.id + auto_create_admin_role = true +} + +resource "boundary_scope_alias_suffix" "org_suffix" { + scope_id = boundary_scope.org.id + alias_suffix = "org" +} + +resource "boundary_scope_alias_suffix" "project_suffix" { + scope_id = boundary_scope.project.id + alias_suffix = "projectone" +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 72a69467..b93a1fad 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -125,6 +125,7 @@ func New() *schema.Provider { "boundary_host_set_static": resourceHostSetStatic(), "boundary_host_set_plugin": resourceHostSetPlugin(), "boundary_policy_storage": resourcePolicyStorage(), + "boundary_scope_alias_suffix": resourceScopeAliasSuffix(), "boundary_scope_policy_attachment": resourceScopePolicyAttachment(), "boundary_role": resourceRole(), "boundary_scope": resourceScope(), diff --git a/internal/provider/resource_alias_target.go b/internal/provider/resource_alias_target.go index 50c7079b..87e9ba8c 100644 --- a/internal/provider/resource_alias_target.go +++ b/internal/provider/resource_alias_target.go @@ -5,7 +5,9 @@ package provider import ( "context" + "fmt" "net/http" + "strings" "github.com/hashicorp/boundary/api" "github.com/hashicorp/boundary/api/aliases" @@ -17,11 +19,15 @@ const ( aliasTypeTarget = "target" aliasTargetAuthorizeSessionHostIdKey = "authorize_session_host_id" + + globalScopeId = "global" + orgScopePrefix = "o_" + projectScopePrefix = "p_" ) func resourceAliasTarget() *schema.Resource { return &schema.Resource{ - Description: "The target alias resource allows you to configure a Boundary target alias.", + Description: "The target alias resource allows you to configure a Boundary target alias at project and global scopes.", CreateContext: resourceTargetAliasCreate, ReadContext: resourceTargetAliasRead, @@ -48,7 +54,7 @@ func resourceAliasTarget() *schema.Resource { Optional: true, }, ScopeIdKey: { - Description: "The scope ID.", + Description: "The scope ID. Org scopes are not supported for aliases.", Type: schema.TypeString, Required: true, ForceNew: true, @@ -144,6 +150,10 @@ func resourceTargetAliasCreate(ctx context.Context, d *schema.ResourceData, meta return diag.Errorf("no alias value provided") } + if err := validateTargetAliasScope(scopeId); err != nil { + return diag.FromErr(err) + } + if nameVal, ok := d.GetOk(NameKey); ok { opts = append(opts, aliases.WithName(nameVal.(string))) } @@ -221,7 +231,12 @@ func resourceTargetAliasUpdate(ctx context.Context, d *schema.ResourceData, meta if d.HasChange(ValueKey) { if valVal, ok := d.GetOk(ValueKey); ok { - opts = append(opts, aliases.WithValue(valVal.(string))) + aliasValue := valVal.(string) + scopeId := d.Get(ScopeIdKey).(string) + if err := validateTargetAliasScope(scopeId); err != nil { + return diag.FromErr(err) + } + opts = append(opts, aliases.WithValue(aliasValue)) } else { return diag.Errorf("no value provided") } @@ -267,3 +282,16 @@ func resourceTargetAliasDelete(ctx context.Context, d *schema.ResourceData, meta return nil } + +func validateTargetAliasScope(scopeId string) error { + switch { + case scopeId == globalScopeId: + return nil + case strings.HasPrefix(scopeId, projectScopePrefix): + return nil + case strings.HasPrefix(scopeId, orgScopePrefix): + return fmt.Errorf("target aliases are not supported for org scopes; use a project or global scope ID") + default: + return fmt.Errorf("target aliases are supported only for project and global scopes") + } +} diff --git a/internal/provider/resource_alias_target_validation_test.go b/internal/provider/resource_alias_target_validation_test.go new file mode 100644 index 00000000..bfe7a20a --- /dev/null +++ b/internal/provider/resource_alias_target_validation_test.go @@ -0,0 +1,52 @@ +// Copyright IBM Corp. 2020, 2026 +// SPDX-License-Identifier: MPL-2.0 + +package provider + +import "testing" + +func TestValidateTargetAliasScope(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + scopeId string + wantError bool + }{ + { + name: "org scope rejected", + scopeId: "o_1234567890", + wantError: true, + }, + { + name: "global scope allowed", + scopeId: globalScopeId, + wantError: false, + }, + { + name: "project scope allowed", + scopeId: "p_1234567890", + wantError: false, + }, + { + name: "unknown scope type rejected", + scopeId: "s_1234567890", + wantError: true, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := validateTargetAliasScope(tt.scopeId) + if tt.wantError && err == nil { + t.Fatal("expected error but got nil") + } + if !tt.wantError && err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + } +} diff --git a/internal/provider/resource_scope_alias_suffix.go b/internal/provider/resource_scope_alias_suffix.go new file mode 100644 index 00000000..476e3dff --- /dev/null +++ b/internal/provider/resource_scope_alias_suffix.go @@ -0,0 +1,244 @@ +// Copyright IBM Corp. 2020, 2026 +// SPDX-License-Identifier: MPL-2.0 + +package provider + +import ( + "context" + "fmt" + "net/http" + "strings" + + "github.com/hashicorp/boundary/api" + "github.com/hashicorp/boundary/api/scopes" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +const ( + aliasSuffixKey = "alias_suffix" +) + +func resourceScopeAliasSuffix() *schema.Resource { + return &schema.Resource{ + Description: "The scope alias suffix resource allows you to set an alias suffix for an org or project scope. " + + "Global scopes are not valid for alias suffix operations.", + + CreateContext: resourceScopeAliasSuffixCreate, + UpdateWithoutTimeout: resourceScopeAliasSuffixUpdate, + ReadWithoutTimeout: resourceScopeAliasSuffixRead, + DeleteWithoutTimeout: resourceScopeAliasSuffixDelete, + + Importer: &schema.ResourceImporter{ + StateContext: schema.ImportStatePassthroughContext, + }, + + Schema: map[string]*schema.Schema{ + ScopeIdKey: { + Description: "The scope ID. Alias suffixes are supported for org and project scopes.", + Type: schema.TypeString, + Required: true, + ForceNew: true, + }, + aliasSuffixKey: { + Description: "The alias suffix value for this scope.", + Type: schema.TypeString, + Required: true, + }, + }, + } +} + +func resourceScopeAliasSuffixCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + md := meta.(*metaData) + scp := scopes.NewClient(md.client) + opts := []scopes.Option{scopes.WithAutomaticVersioning(true)} + + scopeId, err := scopeIdFromAliasSuffixResourceData(d) + if err != nil { + return diag.FromErr(err) + } + + if err := validateAliasSuffixScope(scopeId); err != nil { + return diag.FromErr(err) + } + + var aliasSuffix string + if aliasSuffixVal, ok := d.GetOk(aliasSuffixKey); ok { + aliasSuffix = aliasSuffixVal.(string) + } else { + return diag.Errorf("no alias suffix provided") + } + + if _, err := scp.SetAliasSuffix(ctx, scopeId, 0, aliasSuffix, opts...); err != nil { + return diag.FromErr(err) + } + + if err := setScopeAliasSuffixState(d, scopeId, aliasSuffix); err != nil { + return diag.FromErr(err) + } + + return nil +} + +func resourceScopeAliasSuffixRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + md := meta.(*metaData) + scp := scopes.NewClient(md.client) + + scopeId, err := scopeIdFromAliasSuffixResourceData(d) + if err != nil { + return diag.FromErr(err) + } + + s, err := scp.Read(ctx, scopeId) + if err != nil { + if apiErr := api.AsServerError(err); apiErr != nil && apiErr.Response().StatusCode() == http.StatusNotFound { + // the scope is gone, destroy this resource + d.SetId("") + return nil + } + return diag.Errorf("error reading scope: %v", err) + } + if s == nil { + return diag.Errorf("scope nil after read") + } + if s.GetResponse() == nil { + return diag.Errorf("scope response nil after read") + } + + serverAliasSuffix, err := aliasSuffixFromScopeResponseMap(s.GetResponse().Map) + if err != nil { + return diag.FromErr(err) + } + + if serverAliasSuffix == "" { + // no alias suffix is currently set on this scope, destroy this resource + d.SetId("") + return nil + } + + if err := setScopeAliasSuffixState(d, scopeId, serverAliasSuffix); err != nil { + return diag.FromErr(err) + } + + return nil +} + +func resourceScopeAliasSuffixUpdate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + md := meta.(*metaData) + scp := scopes.NewClient(md.client) + opts := []scopes.Option{scopes.WithAutomaticVersioning(true)} + + scopeId, err := scopeIdFromAliasSuffixResourceData(d) + if err != nil { + return diag.FromErr(err) + } + + if err := validateAliasSuffixScope(scopeId); err != nil { + return diag.FromErr(err) + } + + var aliasSuffix string + if aliasSuffixVal, ok := d.GetOk(aliasSuffixKey); ok { + aliasSuffix = aliasSuffixVal.(string) + } else { + return diag.Errorf("no alias suffix provided") + } + + if _, err := scp.SetAliasSuffix(ctx, scopeId, 0, aliasSuffix, opts...); err != nil { + return diag.FromErr(err) + } + + if err := setScopeAliasSuffixState(d, scopeId, aliasSuffix); err != nil { + return diag.FromErr(err) + } + + return nil +} + +func resourceScopeAliasSuffixDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + md := meta.(*metaData) + scp := scopes.NewClient(md.client) + opts := []scopes.Option{scopes.WithAutomaticVersioning(true)} + + scopeId, err := scopeIdFromAliasSuffixResourceData(d) + if err != nil { + return diag.FromErr(err) + } + + if err := validateAliasSuffixScope(scopeId); err != nil { + return diag.FromErr(err) + } + + if _, err := scp.RemoveAliasSuffix(ctx, scopeId, 0, opts...); err != nil { + return diag.FromErr(err) + } + + return nil +} + +func aliasSuffixFromScopeResponseMap(raw map[string]interface{}) (string, error) { + if raw == nil { + return "", fmt.Errorf("scope response map nil after read") + } + + aliasSuffixRaw, ok := raw[aliasSuffixKey] + if !ok || aliasSuffixRaw == nil { + return "", nil + } + + aliasSuffix, ok := aliasSuffixRaw.(string) + if !ok { + return "", fmt.Errorf("alias suffix in scope response has unexpected type %T", aliasSuffixRaw) + } + + return aliasSuffix, nil +} + +func setScopeAliasSuffixState(d *schema.ResourceData, scopeId, aliasSuffix string) error { + if err := d.Set(ScopeIdKey, scopeId); err != nil { + return err + } + if err := d.Set(aliasSuffixKey, aliasSuffix); err != nil { + return err + } + + // One alias suffix may be set per scope, so scope ID is the stable TF ID. + d.SetId(scopeId) + + return nil +} + +func validateAliasSuffixScope(scopeId string) error { + switch { + case strings.HasPrefix(scopeId, orgScopePrefix): + return nil + case strings.HasPrefix(scopeId, projectScopePrefix): + return nil + case scopeId == globalScopeId: + return fmt.Errorf("alias suffixes are not supported for the global scope; use an org or project scope ID") + default: + return fmt.Errorf("alias suffixes are supported only for org and project scopes") + } +} + +func scopeIdFromAliasSuffixResourceData(d *schema.ResourceData) (string, error) { + if scopeIdVal, ok := d.GetOk(ScopeIdKey); ok { + scopeId := scopeIdVal.(string) + if scopeId != "" { + return scopeId, nil + } + } + + id := d.Id() + if id == "" { + return "", fmt.Errorf("no scope ID provided") + } + + // Backward-compatibility for any prior state that used alias_suffix:scope_id. + if index := strings.LastIndex(id, ":"); index >= 0 && index < len(id)-1 { + return id[index+1:], nil + } + + return id, nil +} diff --git a/internal/provider/resource_scope_alias_suffix_test.go b/internal/provider/resource_scope_alias_suffix_test.go new file mode 100644 index 00000000..9ef58b2d --- /dev/null +++ b/internal/provider/resource_scope_alias_suffix_test.go @@ -0,0 +1,110 @@ +// Copyright IBM Corp. 2020, 2026 +// SPDX-License-Identifier: MPL-2.0 + +package provider + +import "testing" + +func TestValidateAliasSuffixScope(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + scopeId string + wantError bool + }{ + { + name: "global scope rejected", + scopeId: globalScopeId, + wantError: true, + }, + { + name: "org scope allowed", + scopeId: "o_1234567890", + wantError: false, + }, + { + name: "project scope allowed", + scopeId: "p_1234567890", + wantError: false, + }, + { + name: "unknown scope type rejected", + scopeId: "s_1234567890", + wantError: true, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := validateAliasSuffixScope(tt.scopeId) + if tt.wantError && err == nil { + t.Fatal("expected error but got nil") + } + if !tt.wantError && err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + } +} + +func TestAliasSuffixFromScopeResponseMap(t *testing.T) { + t.Parallel() + + t.Run("nil map", func(t *testing.T) { + t.Parallel() + + _, err := aliasSuffixFromScopeResponseMap(nil) + if err == nil { + t.Fatal("expected error for nil map") + } + }) + + t.Run("missing key", func(t *testing.T) { + t.Parallel() + + got, err := aliasSuffixFromScopeResponseMap(map[string]interface{}{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "" { + t.Fatalf("expected empty alias suffix, got %q", got) + } + }) + + t.Run("nil value", func(t *testing.T) { + t.Parallel() + + got, err := aliasSuffixFromScopeResponseMap(map[string]interface{}{aliasSuffixKey: nil}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "" { + t.Fatalf("expected empty alias suffix, got %q", got) + } + }) + + t.Run("wrong type", func(t *testing.T) { + t.Parallel() + + _, err := aliasSuffixFromScopeResponseMap(map[string]interface{}{aliasSuffixKey: 123}) + if err == nil { + t.Fatal("expected error for non-string alias suffix") + } + }) + + t.Run("string value", func(t *testing.T) { + t.Parallel() + + got, err := aliasSuffixFromScopeResponseMap(map[string]interface{}{aliasSuffixKey: "example.boundary"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "example.boundary" { + t.Fatalf("unexpected alias suffix: %q", got) + } + }) +} diff --git a/version/VERSION b/version/VERSION index dc1e644a..9c6d6293 100644 --- a/version/VERSION +++ b/version/VERSION @@ -1 +1 @@ -1.6.0 +1.6.1