feat(proj-alias): support project scoped alias via suffixes (#824)

* feat(proj-alias): support project scoped alias via suffixes

* fix: generate resouce docs using make gen

* fix: address review comments

* chore: make gen changes

* fix(proja-alias): validate scope id before calling remove alias suffix

* fix: filter unknown scopes in validation
This commit is contained in:
Amit Kotagi authored and GitHub committed 2026-07-17 18:33:02 +05:30
1 parent b3df57c2eb
commit f059618167
11 files changed
+587 -7

No files matched your search

+27 -3
View File
@@ -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,
]
}
```
<!-- schema generated by tfplugindocs -->
@@ -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 <my-id>
terraform import boundary_alias_target.example_alias_target_project <my-id>
```
+67
View File
@@ -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 generated by tfplugindocs -->
## 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 <scope-id>
```
@@ -1 +1,2 @@
terraform import boundary_alias_target.example_alias_target <my-id>
terraform import boundary_alias_target.example_alias_target_project <my-id>
@@ -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
}
}
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,
]
}
@@ -0,0 +1 @@
terraform import boundary_scope_alias_suffix.org_suffix <scope-id>
@@ -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"
}
+1
View File
@@ -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(),
+31 -3
View File
@@ -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")
}
}
@@ -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)
}
})
}
}
@@ -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
}
@@ -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)
}
})
}