Add new resource SPIFFE config (#2620)

* WIP: Add support for SPIFFE auth configuration

* use api model technique

* Address import issues

* Rename spiffe object to SpiffeAuthConfigModel

* Import without ID

* Leverage API model again with StringNull setting

* Rename and implement ResourceWithImportState

* Fix comment

* Remove support for parsing namespaces from import ID

* WIP Add SPIFFE role resource

* Add new generic token model for auth roles and new framework

* godocs and various small tweaks

* Rename resource name to match existing pattern and add docs

* Add cl

* Add ability to filter tests by Vault version

* Revert "Add ability to filter tests by Vault version"

This reverts commit 0a8c445a199230113ffac763171730fdaa8dfd9c.

* Review feedback

* Filter tests by Vault version - take 2

* Apply suggestions from code review

Co-authored-by: vinay-gopalan <86625824+vinay-gopalan@users.noreply.github.com>

* PR feedback, remove text pre checks and a bad comment

* PR feedback: Rename helper methods names to match old names

---------

Co-authored-by: vinay-gopalan <86625824+vinay-gopalan@users.noreply.github.com>
This commit is contained in:
Steven Clark 2025-10-24 12:41:14 -04:00 committed by GitHub
parent 44df8b3a78
commit 08925e862f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 1843 additions and 1 deletions

View file

@ -14,6 +14,8 @@ FEATURES:
* Add support for `max_retries` parameter in `vault_aws_secret_backend` resource. ([#2623](https://github.com/hashicorp/terraform-provider-vault/pull/2623))
* Add retry configuration fields (`max_retries`, `retry_delay`, `max_retry_delay`) to `vault_azure_auth_backend_config` resource for Azure API request resilience ([#2629](https://github.com/hashicorp/terraform-provider-vault/pull/2629))
* Add new resources `vault_spiffe_auth_backend_config` and `vault_spiffe_auth_backend_role` ([#2620](https://github.com/hashicorp/terraform-provider-vault/pull/2620))
BUGS:

View file

@ -0,0 +1,75 @@
package acctestutil
import (
"os"
"sync"
"testing"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
"github.com/hashicorp/terraform-plugin-testing/helper/resource"
"github.com/hashicorp/terraform-provider-vault/internal/provider"
vaultSchema "github.com/hashicorp/terraform-provider-vault/schema"
"github.com/hashicorp/terraform-provider-vault/testutil"
"github.com/hashicorp/vault/api"
)
var (
TestProvider *schema.Provider
)
// testAccProviderConfigure ensures Provider is only configured once
//
// The PreCheck(t) function is invoked for every test and this prevents
// extraneous reconfiguration to the same values each time. However, this does
// not prevent reconfiguration that may happen should the address of
// Provider be errantly reused in ProviderFactories.
var testAccProviderConfigure sync.Once
func TestAccPreCheck(t *testing.T) {
t.Helper()
PreCheck(t)
testutil.FatalTestEnvUnset(t, api.EnvVaultAddress, api.EnvVaultToken)
}
func TestEntPreCheck(t *testing.T) {
t.Helper()
PreCheck(t)
SkipTestAccEnt(t)
TestAccPreCheck(t)
}
func PreCheck(t *testing.T) {
t.Helper()
// only required when running acceptance tests
if os.Getenv(resource.EnvTfAcc) == "" && os.Getenv(testutil.EnvVarTfAccEnt) == "" {
return
}
testAccProviderConfigure.Do(func() {
// TODO: Are the registries needed here?
p := vaultSchema.NewProvider(provider.NewProvider(map[string]*provider.Description{}, map[string]*provider.Description{}))
TestProvider = p.SchemaProvider()
rootProviderResource := &schema.Resource{
Schema: p.SchemaProvider().Schema,
}
rootProviderData := rootProviderResource.TestResourceData()
m, err := provider.NewProviderMeta(rootProviderData)
if err != nil {
panic(err)
}
TestProvider.SetMeta(m)
})
}
func SkipTestAccEnt(t *testing.T) {
t.Helper()
testutil.SkipTestEnvUnset(t, testutil.EnvVarTfAccEnt)
}
func SkipTestAcc(t *testing.T) {
t.Helper()
testutil.SkipTestEnvUnset(t, resource.EnvTfAcc)
}

View file

@ -0,0 +1,76 @@
package acctestutil
import (
"testing"
"github.com/hashicorp/go-version"
"github.com/hashicorp/terraform-provider-vault/internal/provider"
)
type CompareVaultVersionFunc func(*version.Version) bool
// SkipIfAPIVersionLT skips of the running vault version is less-than ver.
func SkipIfAPIVersionLT(t *testing.T, ver *version.Version) {
t.Helper()
SkipIfAPIVersion(t, func(curVer *version.Version) bool {
return curVer.LessThan(ver)
}, "Vault version < %q", ver)
}
// SkipIfAPIVersionLTE skips if the running vault version is less-than-or-equal to ver.
func SkipIfAPIVersionLTE(t *testing.T, ver *version.Version) {
t.Helper()
SkipIfAPIVersion(t, func(curVer *version.Version) bool {
return curVer.LessThanOrEqual(ver)
}, "Vault version <= %q", ver)
}
// SkipIfAPIVersionEQ skips if the running vault version is equal to ver.
func SkipIfAPIVersionEQ(t *testing.T, ver *version.Version) {
t.Helper()
f := func(curVer *version.Version) bool {
return curVer.Equal(ver)
}
SkipIfAPIVersion(t, f, "Vault version == %q", ver)
}
// SkipIfAPIVersionGT skips if the running vault version is greater-than ver.
func SkipIfAPIVersionGT(t *testing.T, ver *version.Version) {
t.Helper()
f := func(curVer *version.Version) bool {
return curVer.GreaterThan(ver)
}
SkipIfAPIVersion(t, f, "Vault version > %q", ver)
}
// SkipIfAPIVersionGTE skips if the running vault version is greater-than-or-equal to ver.
func SkipIfAPIVersionGTE(t *testing.T, ver *version.Version) {
t.Helper()
f := func(curVer *version.Version) bool {
return curVer.GreaterThanOrEqual(ver)
}
SkipIfAPIVersion(t, f, "Vault version >= %q", ver)
}
func SkipIfAPIVersion(t *testing.T, cmp CompareVaultVersionFunc, format string, args ...interface{}) {
t.Helper()
if TestProvider == nil {
t.Fatalf("Provider is nil")
}
pm, ok := TestProvider.Meta().(*provider.ProviderMeta)
if !ok {
t.Fatalf("expected provider meta to be of type *provider.ProviderMeta, got %T", TestProvider.Meta())
}
curVersion := pm.GetVaultVersion()
if curVersion == nil {
t.Fatalf("vault version not set on %T", pm)
}
t.Logf("Vault server version %q", curVersion)
if cmp(curVersion) {
t.Skipf(format, args...)
}
}

1
go.mod
View file

@ -14,6 +14,7 @@ require (
github.com/denisenkom/go-mssqldb v0.12.3
github.com/go-sql-driver/mysql v1.9.3
github.com/go-test/deep v1.1.1
github.com/go-viper/mapstructure/v2 v2.1.0
github.com/google/uuid v1.6.0
github.com/gosimple/slug v1.15.0
github.com/hashicorp/consul/api v1.32.1

View file

@ -645,6 +645,7 @@ const (
VaultVersion1185 = "1.18.5"
VaultVersion119 = "1.19.0"
VaultVersion120 = "1.20.0"
VaultVersion121 = "1.21.0"
/*
Vault auth methods

View file

@ -0,0 +1,234 @@
package token
import (
"context"
"fmt"
"github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-provider-vault/internal/framework/base"
)
// TokenModel provides a base struct for auth backend roles that contain the common token
// fields. Note that this model does not include any of the deprecated token fields.
type TokenModel struct {
base.BaseModel
TokenTTL types.Int64 `tfsdk:"token_ttl"`
TokenMaxTTL types.Int64 `tfsdk:"token_max_ttl"`
TokenPolicies types.Set `tfsdk:"token_policies"`
TokenBoundCIDRs types.Set `tfsdk:"token_bound_cidrs"`
TokenExplicitMaxTTL types.Int64 `tfsdk:"token_explicit_max_ttl"`
TokenNoDefaultPolicy types.Bool `tfsdk:"token_no_default_policy"`
TokenNumUses types.Int64 `tfsdk:"token_num_uses"`
TokenPeriod types.Int64 `tfsdk:"token_period"`
TokenType types.String `tfsdk:"token_type"`
AliasMetadata types.Map `tfsdk:"alias_metadata"`
}
// TokenAPIModel represents all the common auth token fields from an API perspective. Note none
// of the deprecated token fields are included.
type TokenAPIModel struct {
TokenTTL int64 `json:"token_ttl" mapstructure:"token_ttl"`
TokenMaxTTL int64 `json:"token_max_ttl" mapstructure:"token_max_ttl"`
TokenPolicies []string `json:"token_policies" mapstructure:"token_policies"`
TokenBoundCIDRs []string `json:"token_bound_cidrs" mapstructure:"token_bound_cidrs"`
TokenExplicitMaxTTL int64 `json:"token_explicit_max_ttl" mapstructure:"token_explicit_max_ttl"`
TokenNoDefaultPolicy bool `json:"token_no_default_policy" mapstructure:"token_no_default_policy"`
TokenNumUses int64 `json:"token_num_uses" mapstructure:"token_num_uses"`
TokenPeriod int64 `json:"token_period" mapstructure:"token_period"`
TokenType string `json:"token_type" mapstructure:"token_type"`
AliasMetadata map[string]string `json:"alias_metadata" mapstructure:"alias_metadata"`
}
// MustAddBaseAndTokenSchemas adds the schema fields that are required for all net new
// resources and data sources built with the TF Plugin Framework extending the
// TokenModel base model.
//
// This should be called from a resources or data source's Schema() method.
func MustAddBaseAndTokenSchemas(s *schema.Schema) {
base.MustAddBaseSchema(s)
for k, v := range tokenSchema() {
if _, ok := s.Attributes[k]; ok {
panic(fmt.Sprintf("cannot add schema field %q, already exists in the Schema map", k))
}
s.Attributes[k] = v
}
}
// PopulateTokenAPIFromModel copies the data from a TokenModel into a TokenAPIModel, useful for
// translating TF data into the Vault API data model.
func PopulateTokenAPIFromModel(ctx context.Context, model *TokenModel, apiModel *TokenAPIModel) diag.Diagnostics {
if apiModel == nil {
return diag.Diagnostics{
diag.NewErrorDiagnostic("cannot populate nil api model", ""),
}
}
if model == nil {
return diag.Diagnostics{
diag.NewErrorDiagnostic("cannot populate api model from nil token model", ""),
}
}
apiModel.TokenTTL = model.TokenTTL.ValueInt64()
apiModel.TokenMaxTTL = model.TokenMaxTTL.ValueInt64()
var tokenPolicies []string
if err := model.TokenPolicies.ElementsAs(ctx, &tokenPolicies, false); err.HasError() {
return err
}
apiModel.TokenPolicies = tokenPolicies
var tokenBoundCIDRs []string
if err := model.TokenBoundCIDRs.ElementsAs(ctx, &tokenBoundCIDRs, false); err.HasError() {
return err
}
apiModel.TokenBoundCIDRs = tokenBoundCIDRs
apiModel.TokenExplicitMaxTTL = model.TokenExplicitMaxTTL.ValueInt64()
apiModel.TokenNoDefaultPolicy = model.TokenNoDefaultPolicy.ValueBool()
apiModel.TokenNumUses = model.TokenNumUses.ValueInt64()
apiModel.TokenPeriod = model.TokenPeriod.ValueInt64()
apiModel.TokenType = model.TokenType.ValueString()
var aliasMetadata map[string]string
if err := model.AliasMetadata.ElementsAs(ctx, &aliasMetadata, false); err.HasError() {
return err
}
apiModel.AliasMetadata = aliasMetadata
return diag.Diagnostics{}
}
// PopulateTokenModelFromAPI copies the data from a TokenAPIModel into a TokenModel, useful for
// translating Vault API data into the TF data model.
func PopulateTokenModelFromAPI(ctx context.Context, model *TokenModel, apiModel *TokenAPIModel) diag.Diagnostics {
if apiModel == nil {
return diag.Diagnostics{
diag.NewErrorDiagnostic("cannot populate token model from nil api model", ""),
}
}
if model == nil {
return diag.Diagnostics{
diag.NewErrorDiagnostic("cannot populate nil model", ""),
}
}
model.TokenTTL = types.Int64Null()
if apiModel.TokenTTL != 0 {
model.TokenTTL = types.Int64Value(apiModel.TokenTTL)
}
model.TokenMaxTTL = types.Int64Null()
if apiModel.TokenMaxTTL != 0 {
model.TokenMaxTTL = types.Int64Value(apiModel.TokenMaxTTL)
}
model.TokenPolicies = types.SetNull(types.StringType)
if len(apiModel.TokenPolicies) > 0 {
policies, err := types.SetValueFrom(ctx, types.StringType, apiModel.TokenPolicies)
if err.HasError() {
return err
}
model.TokenPolicies = policies
}
model.TokenBoundCIDRs = types.SetNull(types.StringType)
if len(apiModel.TokenBoundCIDRs) > 0 {
cidrs, err := types.SetValueFrom(ctx, types.StringType, apiModel.TokenBoundCIDRs)
if err.HasError() {
return err
}
model.TokenBoundCIDRs = cidrs
}
model.TokenExplicitMaxTTL = types.Int64Null()
if apiModel.TokenExplicitMaxTTL != 0 {
model.TokenExplicitMaxTTL = types.Int64Value(apiModel.TokenExplicitMaxTTL)
}
model.TokenNoDefaultPolicy = types.BoolNull()
if apiModel.TokenNoDefaultPolicy {
model.TokenNoDefaultPolicy = types.BoolValue(apiModel.TokenNoDefaultPolicy)
}
model.TokenNumUses = types.Int64Null()
if apiModel.TokenNumUses != 0 {
model.TokenNumUses = types.Int64Value(apiModel.TokenNumUses)
}
model.TokenPeriod = types.Int64Null()
if apiModel.TokenPeriod != 0 {
model.TokenPeriod = types.Int64Value(apiModel.TokenPeriod)
}
model.TokenType = types.StringNull()
if apiModel.TokenType != "" {
model.TokenType = types.StringValue(apiModel.TokenType)
}
model.AliasMetadata = types.MapNull(types.StringType)
if len(apiModel.AliasMetadata) > 0 {
metadata, err := types.MapValueFrom(ctx, types.StringType, apiModel.AliasMetadata)
if err.HasError() {
return err
}
model.AliasMetadata = metadata
}
return diag.Diagnostics{}
}
func tokenSchema() map[string]schema.Attribute {
return map[string]schema.Attribute{
"token_ttl": schema.Int64Attribute{
Optional: true,
Description: "The initial ttl of the token to generate in seconds",
},
"token_max_ttl": schema.Int64Attribute{
Optional: true,
Description: "The maximum lifetime of the generated token",
},
"token_policies": schema.SetAttribute{
ElementType: types.StringType,
Optional: true,
Description: "Generated Token's Policies",
},
"token_bound_cidrs": schema.SetAttribute{
ElementType: types.StringType,
Optional: true,
Description: "Specifies the blocks of IP addresses which are allowed to use the generated token",
},
"token_explicit_max_ttl": schema.Int64Attribute{
Optional: true,
Description: "Generated Token's Explicit Maximum TTL in seconds",
},
"token_no_default_policy": schema.BoolAttribute{
Optional: true,
Description: "If true, the 'default' policy will not automatically be added to generated tokens",
},
"token_num_uses": schema.Int64Attribute{
Optional: true,
Description: "The maximum number of times a token may be used, a value of zero means unlimited",
},
"token_period": schema.Int64Attribute{
Optional: true,
Description: "Generated Token's Period",
},
"token_type": schema.StringAttribute{
Optional: true,
Computed: true,
Description: "The type of token to generate, service or batch",
Default: stringdefault.StaticString("default"),
},
"alias_metadata": schema.MapAttribute{
ElementType: types.StringType,
Optional: true,
Description: "A map of string to string that will be set as metadata on the identity alias",
},
}
}

View file

@ -6,10 +6,12 @@ package fwprovider
import (
"context"
"fmt"
"regexp"
"github.com/hashicorp/terraform-plugin-framework-validators/listvalidator"
"github.com/hashicorp/terraform-plugin-framework/ephemeral"
"github.com/hashicorp/terraform-provider-vault/internal/vault/auth/spiffe"
ephemeralsecrets "github.com/hashicorp/terraform-provider-vault/internal/vault/secrets/ephemeral"
"regexp"
"github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
"github.com/hashicorp/terraform-plugin-framework/datasource"
@ -223,6 +225,8 @@ func (p *fwprovider) Configure(ctx context.Context, req provider.ConfigureReques
// the Metadata method. All resources must have unique names.
func (p *fwprovider) Resources(ctx context.Context) []func() resource.Resource {
return []func() resource.Resource{
spiffe.NewSpiffeAuthConfigResource,
spiffe.NewSpiffeAuthRoleResource,
sys.NewPasswordPolicyResource,
}
}

View file

@ -49,6 +49,7 @@ var (
VaultVersion1185 = version.Must(version.NewSemver(consts.VaultVersion1185))
VaultVersion119 = version.Must(version.NewSemver(consts.VaultVersion119))
VaultVersion120 = version.Must(version.NewSemver(consts.VaultVersion120))
VaultVersion121 = version.Must(version.NewSemver(consts.VaultVersion121))
TokenTTLMinRecommended = time.Minute * 15
)

View file

@ -0,0 +1,400 @@
package spiffe
import (
"context"
"fmt"
"os"
"regexp"
"strings"
"github.com/go-viper/mapstructure/v2"
"github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/tfsdk"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-log/tflog"
"github.com/hashicorp/terraform-provider-vault/internal/consts"
"github.com/hashicorp/terraform-provider-vault/internal/framework/base"
"github.com/hashicorp/terraform-provider-vault/internal/framework/client"
"github.com/hashicorp/terraform-provider-vault/internal/framework/errutil"
"github.com/hashicorp/terraform-provider-vault/internal/framework/model"
"github.com/hashicorp/vault/api"
)
const (
spiffeConfigPath = "config"
)
var backendNameRegexp = regexp.MustCompile("^auth/(.+)/config$")
// Ensure the implementation satisfies the resource.ResourceWithImportState interface
var _ resource.ResourceWithImportState = &SpiffeAuthConfigResource{}
// NewSpiffeAuthConfigResource returns the implementation for this resource to be
// imported by the Terraform Plugin Framework provider
func NewSpiffeAuthConfigResource() resource.Resource {
return &SpiffeAuthConfigResource{}
}
// SpiffeAuthConfigResource implements the methods that define this resource
type SpiffeAuthConfigResource struct {
base.ResourceWithConfigure
}
type SpiffeAuthConfigModel struct {
base.BaseModel
Mount types.String `tfsdk:"mount"`
TrustDomain types.String `tfsdk:"trust_domain"`
Profile types.String `tfsdk:"profile"`
EndPointUrl types.String `tfsdk:"endpoint_url"`
EndpointSpiffeId types.String `tfsdk:"endpoint_spiffe_id"`
EndpointRootCaTrustStorePem types.String `tfsdk:"endpoint_root_ca_truststore_pem"`
Bundle types.String `tfsdk:"bundle"`
DeferBundleFetch types.Bool `tfsdk:"defer_bundle_fetch"`
Audience types.List `tfsdk:"audience"`
}
type SpiffeConfigAPIModel struct {
TrustDomain string `json:"trust_domain" mapstructure:"trust_domain"`
Profile string `json:"profile" mapstructure:"profile"`
EndPointUrl string `json:"endpoint_url" mapstructure:"endpoint_url"`
EndpointSpiffeId string `json:"endpoint_spiffe_id" mapstructure:"endpoint_spiffe_id"`
EndpointRootCaTrustStorePem string `json:"endpoint_root_ca_truststore_pem" mapstructure:"endpoint_root_ca_truststore_pem"`
Bundle string `json:"bundle" mapstructure:"bundle"`
DeferBundleFetch bool `json:"defer_bundle_fetch" mapstructure:"defer_bundle_fetch"`
Audience []string `json:"audience" mapstructure:"audience"`
}
func (s *SpiffeAuthConfigResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_spiffe_auth_backend_config"
}
func (s *SpiffeAuthConfigResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
Attributes: map[string]schema.Attribute{
consts.FieldMount: schema.StringAttribute{
MarkdownDescription: "Mount path for the SPIFFE auth engine in Vault.",
Required: true,
},
"trust_domain": schema.StringAttribute{
MarkdownDescription: "The SPIFFE trust domain for this backend.",
Required: true,
},
"profile": schema.StringAttribute{
MarkdownDescription: "The mechanism to fetch or embed the trust bundle to use.",
Required: true,
},
"endpoint_url": schema.StringAttribute{
MarkdownDescription: `The URI to be used when profile is 'https_web_bundle' or 'https_spiffe_bundle'`,
Optional: true,
},
"endpoint_spiffe_id": schema.StringAttribute{
MarkdownDescription: `The server's SPIFFE ID to validate when profile is 'https_spiffe_bundle'`,
Optional: true,
},
"endpoint_root_ca_truststore_pem": schema.StringAttribute{
MarkdownDescription: `PEM-encoded CA certificate(s) to validate the server reached by 'endpoint_url', if set this will override the default TLS trust store`,
Optional: true,
},
"bundle": schema.StringAttribute{
MarkdownDescription: `When profile is 'https_spiffe_bundle', the bootstrapping bundle in SPIFFE format; when profile is 'static', either a bundle in SPIFFE format or PEM-encoded CA certificate(s)`,
Optional: true,
},
"defer_bundle_fetch": schema.BoolAttribute{
MarkdownDescription: `Don't attempt to fetch a bundle immediately; only applies when profile != static`,
Optional: true,
WriteOnly: true,
},
"audience": schema.ListAttribute{
ElementType: types.StringType,
MarkdownDescription: `A list of audience values allowed to match claims in JWT-SVIDs`,
Optional: true,
},
},
}
base.MustAddBaseSchema(&resp.Schema)
}
func (s *SpiffeAuthConfigResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var data SpiffeAuthConfigModel
// Read Terraform plan data into the model
resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
vaultClient, err := client.GetClient(ctx, s.Meta(), data.Namespace.ValueString())
if err != nil {
resp.Diagnostics.AddError(errutil.ClientConfigureErr(err))
return
}
deferBundleFetch, diagErr := s.readDeferBundleFetchConfig(ctx, req.Config)
if diagErr.HasError() {
resp.Diagnostics.Append(diagErr...)
return
}
vaultRequest, diagErr := s.getApiModel(ctx, &data, deferBundleFetch)
if diagErr != nil {
resp.Diagnostics.Append(diagErr...)
return
}
// vault returns a nil response on success
mountPath := s.path(data.Mount.ValueString())
confResp, err := vaultClient.Logical().WriteWithContext(ctx, mountPath, vaultRequest)
if err != nil {
resp.Diagnostics.AddError(errutil.VaultCreateErr(err))
return
}
if confResp == nil {
resp.Diagnostics.AddError(errutil.VaultReadResponseNil())
return
}
if diagErr := s.populateDataModelFromApi(ctx, &data, confResp); diagErr.HasError() {
resp.Diagnostics.Append(diagErr...)
return
}
// Save data into Terraform state
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
}
func (s *SpiffeAuthConfigResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
var data SpiffeAuthConfigModel
// Read Terraform prior state data into the model
resp.Diagnostics.Append(req.State.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
vaultClient, err := client.GetClient(ctx, s.Meta(), data.Namespace.ValueString())
if err != nil {
resp.Diagnostics.AddError(errutil.ClientConfigureErr(err))
return
}
policyResp, err := vaultClient.Logical().ReadWithContext(ctx, s.path(data.Mount.ValueString()))
if err != nil {
resp.Diagnostics.AddError(errutil.VaultReadErr(err))
return
}
if policyResp == nil {
resp.Diagnostics.AddError(errutil.VaultReadResponseNil())
return
}
if diagErr := s.populateDataModelFromApi(ctx, &data, policyResp); diagErr.HasError() {
resp.Diagnostics.Append(diagErr...)
return
}
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
}
func (s *SpiffeAuthConfigResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
var data SpiffeAuthConfigModel
// Read Terraform plan data into the model
resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
vaultClient, err := client.GetClient(ctx, s.Meta(), data.Namespace.ValueString())
if err != nil {
resp.Diagnostics.AddError(errutil.ClientConfigureErr(err))
return
}
deferBundleFetch, diagErr := s.readDeferBundleFetchConfig(ctx, req.Config)
if diagErr.HasError() {
resp.Diagnostics.Append(diagErr...)
return
}
vaultRequest, diagErr := s.getApiModel(ctx, &data, deferBundleFetch)
if diagErr.HasError() {
resp.Diagnostics.Append(diagErr...)
return
}
mountPath := s.path(data.Mount.ValueString())
confResp, err := vaultClient.Logical().WriteWithContext(ctx, mountPath, vaultRequest)
if err != nil {
resp.Diagnostics.AddError(errutil.VaultCreateErr(err))
return
}
if confResp == nil {
resp.Diagnostics.AddError(errutil.VaultReadResponseNil())
return
}
if diagErr := s.populateDataModelFromApi(ctx, &data, confResp); diagErr.HasError() {
resp.Diagnostics.Append(diagErr...)
return
}
// Save data into Terraform state
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
}
func (s *SpiffeAuthConfigResource) Delete(_ context.Context, _ resource.DeleteRequest, _ *resource.DeleteResponse) {
// API does not support delete, so just remove from state
}
func (s *SpiffeAuthConfigResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
resource.ImportStatePassthroughID(ctx, path.Root(consts.FieldMount), req, resp)
mount, err := extractSpiffeConfigMountFromID(req.ID)
if err != nil {
resp.Diagnostics.AddError(
"Error parsing import identifier",
fmt.Sprintf("The import identifier '%s' is not valid: %s", req.ID, err.Error()),
)
return
}
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root(consts.FieldMount), mount)...)
ns := os.Getenv(consts.EnvVarVaultNamespaceImport)
if ns != "" {
tflog.Info(
ctx,
fmt.Sprintf("Environment variable %s set, attempting TF state import", consts.EnvVarVaultNamespaceImport),
map[string]any{consts.FieldNamespace: ns},
)
resp.Diagnostics.Append(
resp.State.SetAttribute(ctx, path.Root(consts.FieldNamespace), ns)...,
)
}
}
func (s *SpiffeAuthConfigResource) path(mount string) string {
return fmt.Sprintf("auth/%s/%s", mount, spiffeConfigPath)
}
func (s *SpiffeAuthConfigResource) readDeferBundleFetchConfig(ctx context.Context, config tfsdk.Config) (bool, diag.Diagnostics) {
var deferBundleFetch *bool
if diagErr := config.GetAttribute(ctx, path.Root("defer_bundle_fetch"), &deferBundleFetch); diagErr.HasError() {
return false, diagErr
}
if deferBundleFetch == nil {
return false, diag.Diagnostics{}
}
return *deferBundleFetch, diag.Diagnostics{}
}
func (s *SpiffeAuthConfigResource) getApiModel(ctx context.Context, data *SpiffeAuthConfigModel, deferBundleFetch bool) (map[string]any, diag.Diagnostics) {
// Note: defer bundle fetch is marked as write-only so it is never
// part of the plan which the data model is built from
apiModel := SpiffeConfigAPIModel{
TrustDomain: data.TrustDomain.ValueString(),
Profile: data.Profile.ValueString(),
EndPointUrl: data.EndPointUrl.ValueString(),
EndpointSpiffeId: data.EndpointSpiffeId.ValueString(),
EndpointRootCaTrustStorePem: data.EndpointRootCaTrustStorePem.ValueString(),
Bundle: data.Bundle.ValueString(),
DeferBundleFetch: deferBundleFetch,
}
var audienceVals []string
if diagErr := data.Audience.ElementsAs(ctx, &audienceVals, false); diagErr.HasError() {
return nil, diagErr
}
apiModel.Audience = audienceVals
var vaultRequest map[string]any
if err := mapstructure.Decode(apiModel, &vaultRequest); err != nil {
return nil, diag.Diagnostics{
diag.NewErrorDiagnostic("Failed to decode SPIFFE config API model to map", err.Error()),
}
}
return vaultRequest, nil
}
func (s *SpiffeAuthConfigResource) populateDataModelFromApi(ctx context.Context, data *SpiffeAuthConfigModel, resp *api.Secret) diag.Diagnostics {
if resp == nil || resp.Data == nil {
return diag.Diagnostics{
diag.NewErrorDiagnostic("Missing data in API response", "The API response or response data was nil."),
}
}
var readResp SpiffeConfigAPIModel
if err := model.ToAPIModel(resp.Data, &readResp); err != nil {
return diag.Diagnostics{
diag.NewErrorDiagnostic("Unable to translate Vault response data", err.Error()),
}
}
data.Profile = types.StringValue(readResp.Profile)
data.TrustDomain = types.StringValue(readResp.TrustDomain)
data.EndpointSpiffeId = types.StringNull()
if readResp.EndpointSpiffeId != "" {
data.EndpointSpiffeId = types.StringValue(readResp.EndpointSpiffeId)
}
data.EndPointUrl = types.StringNull()
if readResp.EndPointUrl != "" {
data.EndPointUrl = types.StringValue(readResp.EndPointUrl)
}
data.EndpointRootCaTrustStorePem = types.StringNull()
if readResp.EndpointRootCaTrustStorePem != "" {
data.EndpointRootCaTrustStorePem = types.StringValue(readResp.EndpointRootCaTrustStorePem)
}
data.Bundle = types.StringNull()
if readResp.Bundle != "" {
data.Bundle = types.StringValue(readResp.Bundle)
}
// Note that DeferBundleFetch influences how the API is run, and is not returned from the API endpoint
if len(readResp.Audience) > 0 {
aud, listErr := types.ListValueFrom(ctx, types.StringType, readResp.Audience)
if listErr != nil {
return listErr
}
data.Audience = aud
}
return diag.Diagnostics{}
}
// extractSpiffeConfigMountFromID extracts the mount path from the given import ID provided
// by the terraform import CLI command.
func extractSpiffeConfigMountFromID(id string) (string, error) {
if id == "" {
return "", fmt.Errorf("import identifier cannot be empty")
}
// Trim leading slash if present
id = strings.Trim(id, "/")
if !backendNameRegexp.MatchString(id) {
return "", fmt.Errorf("import identifier must be of the form 'auth/<mount>/config', "+
"namespace can be specified using the env var %s", consts.EnvVarVaultNamespaceImport)
}
matches := backendNameRegexp.FindStringSubmatch(id)
if len(matches) != 2 {
return "", fmt.Errorf("import identifier must be of the form 'auth/<mount>/config', "+
"namespace can be specified using the env var %s", consts.EnvVarVaultNamespaceImport)
}
mount := strings.TrimSpace(matches[1])
if mount == "" {
return "", fmt.Errorf("mount cannot be empty")
}
return mount, nil
}

View file

@ -0,0 +1,248 @@
package spiffe_test
import (
"fmt"
"strings"
"testing"
"github.com/hashicorp/terraform-plugin-testing/helper/acctest"
"github.com/hashicorp/terraform-plugin-testing/helper/resource"
"github.com/hashicorp/terraform-plugin-testing/terraform"
"github.com/hashicorp/terraform-provider-vault/acctestutil"
"github.com/hashicorp/terraform-provider-vault/internal/provider"
"github.com/hashicorp/terraform-provider-vault/internal/providertest"
"github.com/hashicorp/terraform-provider-vault/testutil"
)
// TestAccSpiffeAuthConfig tests the spiffe auth config resource
func TestAccSpiffeAuthConfig(t *testing.T) {
mount := acctest.RandomWithPrefix("spiffe-mount")
caBytes, _, _ := testutil.GenerateCA()
ca := strings.Trim(string(caBytes), "\n")
resourceAddress := "vault_spiffe_auth_backend_config.spiffe_config"
spiffeBundle := `
{
"keys": [
{
"use": "jwt-svid",
"kty": "EC",
"kid": "ZxKvdYWv1ZcSAUOQ0zxNmyvgm8eKKgIb",
"crv": "P-256",
"x": "UU_Z5vjB272LtPsRxemPskh8fVhEvfy7xzg3tsIyas0",
"y": "0B8DIXslvTqYTVSxzuGyGzVVKTUOHcJMzjOfmmR3kaE"
}
],
"spiffe_sequence": 1
}
`
resource.Test(t, resource.TestCase{
PreCheck: func() {
acctestutil.TestEntPreCheck(t)
acctestutil.SkipIfAPIVersionLT(t, provider.VaultVersion121)
},
ProtoV5ProviderFactories: providertest.ProtoV5ProviderFactories,
Steps: []resource.TestStep{
// Test the simplest form of config
{
Config: staticBundleSpiffeConfig(mount, ca),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr(resourceAddress, "trust_domain", "example.org"),
resource.TestCheckResourceAttr(resourceAddress, "profile", "static"),
resource.TestCheckResourceAttr(resourceAddress, "bundle", ca+"\n"),
),
},
// Test we can set the audience list
{
Config: staticBundleSpiffeConfigWithAudience(mount, ca, []string{"vault", "vault-core"}),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr(resourceAddress, "trust_domain", "example.org"),
resource.TestCheckResourceAttr(resourceAddress, "profile", "static"),
resource.TestCheckResourceAttr(resourceAddress, "bundle", ca+"\n"),
resource.TestCheckResourceAttr(resourceAddress, "audience"+".#", "2"),
resource.TestCheckResourceAttr(resourceAddress, "audience"+".0", "vault"),
resource.TestCheckResourceAttr(resourceAddress, "audience"+".1", "vault-core"),
),
},
// Test we can clear the audience list
{
Config: staticBundleSpiffeConfigWithAudience(mount, ca, []string{}),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr(resourceAddress, "trust_domain", "example.org"),
resource.TestCheckResourceAttr(resourceAddress, "profile", "static"),
resource.TestCheckResourceAttr(resourceAddress, "bundle", ca+"\n"),
resource.TestCheckResourceAttr(resourceAddress, "audience"+".#", "0"),
),
},
{
Config: httpsWebPemSpiffeConfig(mount, ca),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr(resourceAddress, "trust_domain", "example.org"),
resource.TestCheckResourceAttr(resourceAddress, "profile", "https_web_pem"),
resource.TestCheckResourceAttr(resourceAddress, "endpoint_url", "https://dadgarcorp.com/spiffe-ca"),
resource.TestCheckResourceAttr(resourceAddress, "endpoint_root_ca_truststore_pem", ca+"\n"),
resource.TestCheckNoResourceAttr(resourceAddress, "bundle"),
),
},
{
Config: webBundleSpiffeConfig(mount),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr(resourceAddress, "trust_domain", "example.org"),
resource.TestCheckResourceAttr(resourceAddress, "profile", "https_web_bundle"),
resource.TestCheckResourceAttr(resourceAddress, "endpoint_url", "https://dadgarcorp.com/spiffe-ca"),
resource.TestCheckNoResourceAttr(resourceAddress, "bundle"),
),
},
{
Config: spiffeBundleSpiffeConfig(mount, spiffeBundle),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr(resourceAddress, "trust_domain", "example.org"),
resource.TestCheckResourceAttr(resourceAddress, "profile", "https_spiffe_bundle"),
resource.TestCheckResourceAttr(resourceAddress, "endpoint_url", "https://dadgarcorp.com/spiffe-ca"),
resource.TestCheckResourceAttr(resourceAddress, "endpoint_spiffe_id", "spiffe://dadgarcorp.com/spire"),
resource.TestCheckResourceAttr(resourceAddress, "bundle", spiffeBundle+"\n"),
),
},
// Test importing
{
ResourceName: resourceAddress,
ImportState: true,
ImportStateIdFunc: testAccSpiffeAuthConfigImportStateIdFunc(resourceAddress),
ImportStateVerify: true,
ImportStateVerifyIdentifierAttribute: "mount",
},
},
})
}
func testAccSpiffeAuthConfigImportStateIdFunc(resourceName string) resource.ImportStateIdFunc {
return func(s *terraform.State) (string, error) {
rs, ok := s.RootModule().Resources[resourceName]
if !ok {
return "", fmt.Errorf("not found: %s", resourceName)
}
return fmt.Sprintf("auth/%s/config", rs.Primary.Attributes["mount"]), nil
}
}
func staticBundleSpiffeConfig(mount string, ca string) string {
return fmt.Sprintf(`
resource "vault_auth_backend" "spiffe_mount" {
type = "spiffe"
path = "%s"
tune {
passthrough_request_headers = ["Authorization"]
}
}
resource "vault_spiffe_auth_backend_config" "spiffe_config" {
mount = vault_auth_backend.spiffe_mount.path
trust_domain = "example.org"
profile = "static"
bundle = <<EOC
%s
EOC
}
`, mount, ca)
}
func staticBundleSpiffeConfigWithAudience(mount string, ca string, audiences []string) string {
var formattedAudiences string
if len(audiences) > 0 {
formattedAudiences = "\"" + strings.Join(audiences, "\", \"") + "\""
}
return fmt.Sprintf(`
resource "vault_auth_backend" "spiffe_mount" {
type = "spiffe"
path = "%s"
tune {
passthrough_request_headers = ["Authorization"]
}
}
resource "vault_spiffe_auth_backend_config" "spiffe_config" {
mount = vault_auth_backend.spiffe_mount.path
trust_domain = "example.org"
profile = "static"
bundle = <<EOC
%s
EOC
audience = [%s]
}
`, mount, ca, formattedAudiences)
}
func httpsWebPemSpiffeConfig(mount string, trustCa string) string {
return fmt.Sprintf(`
resource "vault_auth_backend" "spiffe_mount" {
type = "spiffe"
path = "%s"
tune {
passthrough_request_headers = ["Authorization"]
}
}
resource "vault_spiffe_auth_backend_config" "spiffe_config" {
mount = vault_auth_backend.spiffe_mount.path
trust_domain = "example.org"
profile = "https_web_pem"
endpoint_url = "https://dadgarcorp.com/spiffe-ca"
defer_bundle_fetch = true
endpoint_root_ca_truststore_pem = <<EOC
%s
EOC
}
`, mount, trustCa)
}
func webBundleSpiffeConfig(mount string) string {
return fmt.Sprintf(`
resource "vault_auth_backend" "spiffe_mount" {
type = "spiffe"
path = "%s"
tune {
passthrough_request_headers = ["Authorization"]
}
}
resource "vault_spiffe_auth_backend_config" "spiffe_config" {
mount = vault_auth_backend.spiffe_mount.path
trust_domain = "example.org"
profile = "https_web_bundle"
endpoint_url = "https://dadgarcorp.com/spiffe-ca"
defer_bundle_fetch = true
}
`, mount)
}
func spiffeBundleSpiffeConfig(mount string, bundle string) string {
return fmt.Sprintf(`
resource "vault_auth_backend" "spiffe_mount" {
type = "spiffe"
path = "%s"
tune {
passthrough_request_headers = ["Authorization"]
}
}
resource "vault_spiffe_auth_backend_config" "spiffe_config" {
mount = vault_auth_backend.spiffe_mount.path
trust_domain = "example.org"
profile = "https_spiffe_bundle"
endpoint_url = "https://dadgarcorp.com/spiffe-ca"
endpoint_spiffe_id = "spiffe://dadgarcorp.com/spire"
defer_bundle_fetch = true
bundle = <<EOB
%s
EOB
}
`, mount, bundle)
}

View file

@ -0,0 +1,368 @@
package spiffe
import (
"context"
"fmt"
"os"
"regexp"
"strings"
"github.com/go-viper/mapstructure/v2"
"github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-log/tflog"
"github.com/hashicorp/terraform-provider-vault/internal/consts"
"github.com/hashicorp/terraform-provider-vault/internal/framework/base"
"github.com/hashicorp/terraform-provider-vault/internal/framework/client"
"github.com/hashicorp/terraform-provider-vault/internal/framework/errutil"
"github.com/hashicorp/terraform-provider-vault/internal/framework/model"
"github.com/hashicorp/terraform-provider-vault/internal/framework/token"
"github.com/hashicorp/vault/api"
)
var roleNameRegexp = regexp.MustCompile("^auth/(.+)/role/(.+)$")
// Ensure the implementation satisfies the resource.ResourceWithImportState interface
var _ resource.ResourceWithImportState = &SpiffeAuthRoleResource{}
// NewSpiffeAuthRoleResource returns the implementation for this resource to be
// imported by the Terraform Plugin Framework provider
func NewSpiffeAuthRoleResource() resource.Resource {
return &SpiffeAuthRoleResource{}
}
// SpiffeAuthRoleResource implements the methods that define this resource
type SpiffeAuthRoleResource struct {
base.ResourceWithConfigure
}
type SpiffeAuthRoleModel struct {
token.TokenModel
Mount types.String `tfsdk:"mount"`
Name types.String `tfsdk:"name"`
DisplayName types.String `tfsdk:"display_name"`
WorkloadIDPatterns types.List `tfsdk:"workload_id_patterns"`
}
type SpiffeRoleAPIModel struct {
token.TokenAPIModel `mapstructure:",squash"`
DisplayName string `json:"display_name" mapstructure:"display_name"`
WorkloadIDPatterns []string `json:"workload_id_patterns" mapstructure:"workload_id_patterns"`
}
func (s *SpiffeAuthRoleResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_spiffe_auth_backend_role"
}
func (s *SpiffeAuthRoleResource) Schema(ctx context.Context, request resource.SchemaRequest, response *resource.SchemaResponse) {
response.Schema = schema.Schema{
Attributes: map[string]schema.Attribute{
consts.FieldMount: schema.StringAttribute{
Description: "Mount path for the SPIFFE auth engine in Vault.",
Required: true,
},
consts.FieldName: schema.StringAttribute{
Description: "Name of the SPIFFE auth role.",
Required: true,
},
consts.FieldDisplayName: schema.StringAttribute{
Description: "A display name for the role. This is only used for display " +
"purposes in Vault, if not provided it will default to the role name.",
Optional: true,
Computed: true,
},
"workload_id_patterns": schema.ListAttribute{
ElementType: types.StringType,
Description: "A comma separated list of patterns that match an incoming workload " +
"id to this role. A workload id is the part that remains after stripping the trust domain prefix " +
"and the slash separator from a spiffe id.",
Optional: true,
},
},
}
token.MustAddBaseAndTokenSchemas(&response.Schema)
}
func (s *SpiffeAuthRoleResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var data SpiffeAuthRoleModel
// Read Terraform plan data into the model
resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
vaultClient, err := client.GetClient(ctx, s.Meta(), data.Namespace.ValueString())
if err != nil {
resp.Diagnostics.AddError(errutil.ClientConfigureErr(err))
return
}
vaultRequest, diagErr := s.getApiModel(ctx, &data)
if diagErr.HasError() {
resp.Diagnostics.Append(diagErr...)
return
}
mountPath, err := s.path(&data)
if err != nil {
resp.Diagnostics.AddError("Error determining role path", err.Error())
return
}
roleResp, err := vaultClient.Logical().WriteWithContext(ctx, mountPath, vaultRequest)
if err != nil {
resp.Diagnostics.AddError(errutil.VaultCreateErr(err))
return
}
if roleResp == nil {
resp.Diagnostics.AddError(errutil.VaultReadResponseNil())
return
}
if diagErr := s.populateDataModelFromApi(ctx, &data, roleResp); diagErr.HasError() {
resp.Diagnostics.Append(diagErr...)
return
}
// Save data into Terraform state
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
}
func (s *SpiffeAuthRoleResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
var data SpiffeAuthRoleModel
// Read Terraform prior state data into the model
resp.Diagnostics.Append(req.State.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
vaultClient, err := client.GetClient(ctx, s.Meta(), data.Namespace.ValueString())
if err != nil {
resp.Diagnostics.AddError(errutil.ClientConfigureErr(err))
return
}
mountPath, err := s.path(&data)
if err != nil {
resp.Diagnostics.AddError("Error determining role path", err.Error())
return
}
roleResp, err := vaultClient.Logical().ReadWithContext(ctx, mountPath)
if err != nil {
resp.Diagnostics.AddError(errutil.VaultReadErr(err))
return
}
if roleResp == nil {
resp.Diagnostics.AddError(errutil.VaultReadResponseNil())
return
}
if diagErr := s.populateDataModelFromApi(ctx, &data, roleResp); diagErr.HasError() {
resp.Diagnostics.Append(diagErr...)
return
}
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
}
func (s *SpiffeAuthRoleResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
var data SpiffeAuthRoleModel
// Read Terraform plan data into the model
resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
vaultClient, err := client.GetClient(ctx, s.Meta(), data.Namespace.ValueString())
if err != nil {
resp.Diagnostics.AddError(errutil.ClientConfigureErr(err))
return
}
vaultRequest, diagErr := s.getApiModel(ctx, &data)
if diagErr.HasError() {
resp.Diagnostics.Append(diagErr...)
return
}
mountPath, err := s.path(&data)
if err != nil {
resp.Diagnostics.AddError("Error determining role path", err.Error())
return
}
roleResp, err := vaultClient.Logical().WriteWithContext(ctx, mountPath, vaultRequest)
if err != nil {
resp.Diagnostics.AddError(errutil.VaultCreateErr(err))
return
}
if roleResp == nil {
resp.Diagnostics.AddError(errutil.VaultReadResponseNil())
return
}
if diagErr := s.populateDataModelFromApi(ctx, &data, roleResp); diagErr.HasError() {
resp.Diagnostics.Append(diagErr...)
return
}
// Save data into Terraform state
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
}
func (s *SpiffeAuthRoleResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
var data SpiffeAuthRoleModel
// Read Terraform state data into the model
resp.Diagnostics.Append(req.State.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
vaultClient, err := client.GetClient(ctx, s.Meta(), data.Namespace.ValueString())
if err != nil {
resp.Diagnostics.AddError(errutil.ClientConfigureErr(err))
return
}
mountPath, err := s.path(&data)
if err != nil {
resp.Diagnostics.AddError("Error determining role path", err.Error())
return
}
if _, err = vaultClient.Logical().Delete(mountPath); err != nil {
resp.Diagnostics.AddError("Error deleting role", err.Error())
}
}
func (s *SpiffeAuthRoleResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
mount, roleName, err := s.extractSpiffeRoleIdentifiers(req.ID)
if err != nil {
resp.Diagnostics.AddError(
"Error parsing import identifier",
fmt.Sprintf("The import identifier '%s' is not valid: %s", req.ID, err.Error()),
)
return
}
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root(consts.FieldMount), mount)...)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root(consts.FieldName), roleName)...)
ns := os.Getenv(consts.EnvVarVaultNamespaceImport)
if ns != "" {
tflog.Info(
ctx,
fmt.Sprintf("Environment variable %s set, attempting TF state import", consts.EnvVarVaultNamespaceImport),
map[string]any{consts.FieldNamespace: ns},
)
resp.Diagnostics.Append(
resp.State.SetAttribute(ctx, path.Root(consts.FieldNamespace), ns)...,
)
}
}
func (s *SpiffeAuthRoleResource) path(data *SpiffeAuthRoleModel) (string, error) {
mount := data.Mount.ValueString()
name := data.Name.ValueString()
if mount == "" || name == "" {
return "", fmt.Errorf("mount and name are required fields got mount: %q name: %q", mount, name)
}
return fmt.Sprintf("auth/%s/role/%s", mount, name), nil
}
func (s *SpiffeAuthRoleResource) getApiModel(ctx context.Context, data *SpiffeAuthRoleModel) (map[string]any, diag.Diagnostics) {
apiModel := SpiffeRoleAPIModel{}
var workloadIdPatterns []string
if err := data.WorkloadIDPatterns.ElementsAs(ctx, &workloadIdPatterns, false); err != nil {
return nil, err
}
apiModel.WorkloadIDPatterns = workloadIdPatterns
apiModel.DisplayName = data.DisplayName.ValueString()
if diagErr := token.PopulateTokenAPIFromModel(ctx, &data.TokenModel, &apiModel.TokenAPIModel); diagErr.HasError() {
return nil, diagErr
}
var vaultRequest map[string]any
if err := mapstructure.Decode(apiModel, &vaultRequest); err != nil {
return nil, diag.Diagnostics{
diag.NewErrorDiagnostic("Failed to decode SPIFFE role API model to map", err.Error()),
}
}
return vaultRequest, nil
}
func (s *SpiffeAuthRoleResource) populateDataModelFromApi(ctx context.Context, role *SpiffeAuthRoleModel, resp *api.Secret) diag.Diagnostics {
if resp == nil || resp.Data == nil {
return diag.Diagnostics{
diag.NewErrorDiagnostic("Missing data in API response", "The API response or response data was nil."),
}
}
var readResp SpiffeRoleAPIModel
if err := model.ToAPIModel(resp.Data, &readResp); err != nil {
return diag.Diagnostics{
diag.NewErrorDiagnostic("Unable to translate Vault response data", err.Error()),
}
}
if len(readResp.WorkloadIDPatterns) > 0 {
wkldIdPatterns, listErr := types.ListValueFrom(ctx, types.StringType, readResp.WorkloadIDPatterns)
if listErr != nil {
return listErr
}
role.WorkloadIDPatterns = wkldIdPatterns
}
role.DisplayName = types.StringValue(readResp.DisplayName)
return token.PopulateTokenModelFromAPI(ctx, &role.TokenModel, &readResp.TokenAPIModel)
}
func (s *SpiffeAuthRoleResource) extractSpiffeRoleIdentifiers(id string) (string, string, error) {
if id == "" {
return "", "", fmt.Errorf("import identifier cannot be empty")
}
// Trim leading slash if present
id = strings.Trim(id, "/")
if !roleNameRegexp.MatchString(id) {
return "", "", fmt.Errorf("import identifier must be of the form 'auth/<mount>/role/<rolename>', "+
"namespace can be specified using the env var %s", consts.EnvVarVaultNamespaceImport)
}
matches := roleNameRegexp.FindStringSubmatch(id)
if len(matches) != 3 {
return "", "", fmt.Errorf("import identifier must be of the form 'auth/<mount>/role/<rolename>', "+
"namespace can be specified using the env var %s", consts.EnvVarVaultNamespaceImport)
}
mount := strings.TrimSpace(matches[1])
if mount == "" {
return "", "", fmt.Errorf("mount cannot be empty")
}
roleName := strings.TrimSpace(matches[2])
if roleName == "" {
return "", "", fmt.Errorf("role name cannot be empty")
}
return mount, roleName, nil
}

View file

@ -0,0 +1,202 @@
package spiffe_test
import (
"fmt"
"strings"
"testing"
"github.com/hashicorp/terraform-plugin-testing/helper/acctest"
"github.com/hashicorp/terraform-plugin-testing/helper/resource"
"github.com/hashicorp/terraform-plugin-testing/plancheck"
"github.com/hashicorp/terraform-plugin-testing/terraform"
"github.com/hashicorp/terraform-provider-vault/acctestutil"
"github.com/hashicorp/terraform-provider-vault/internal/provider"
"github.com/hashicorp/terraform-provider-vault/internal/providertest"
)
// TestAccSpiffeAuthRole tests the spiffe auth role resource
func TestAccSpiffeAuthRole(t *testing.T) {
mount := acctest.RandomWithPrefix("spiffe-mount")
resourceAddress := "vault_spiffe_auth_backend_role.spiffe_role"
workloadIds := []string{"/+/test/*", "/example/*"}
resource.Test(t, resource.TestCase{
PreCheck: func() {
acctestutil.TestEntPreCheck(t)
acctestutil.SkipIfAPIVersionLT(t, provider.VaultVersion121)
},
ProtoV5ProviderFactories: providertest.ProtoV5ProviderFactories,
Steps: []resource.TestStep{
// Test the simplest form of a role
{
Config: spiffeRoleConfig(mount, workloadIds),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttrSet(resourceAddress, "mount"),
resource.TestCheckResourceAttr(resourceAddress, "name", "example-role"),
resource.TestCheckResourceAttr(resourceAddress, "workload_id_patterns.#", "2"),
resource.TestCheckResourceAttr(resourceAddress, "workload_id_patterns.0", workloadIds[0]),
resource.TestCheckResourceAttr(resourceAddress, "workload_id_patterns.1", workloadIds[1]),
),
},
// Test updating the role to have a different workload id patterns (workload id can't be empty)
{
Config: spiffeRoleConfig(mount, []string{workloadIds[1]}),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttrSet(resourceAddress, "mount"),
resource.TestCheckResourceAttr(resourceAddress, "name", "example-role"),
resource.TestCheckResourceAttr(resourceAddress, "workload_id_patterns.#", "1"),
resource.TestCheckResourceAttr(resourceAddress, "workload_id_patterns.0", workloadIds[1]),
resource.TestCheckNoResourceAttr(resourceAddress, "token_ttl"),
resource.TestCheckNoResourceAttr(resourceAddress, "token_max_ttl"),
resource.TestCheckNoResourceAttr(resourceAddress, "token_policies"),
resource.TestCheckNoResourceAttr(resourceAddress, "token_bound_cidrs"),
resource.TestCheckNoResourceAttr(resourceAddress, "token_explicit_max_ttl"),
resource.TestCheckNoResourceAttr(resourceAddress, "token_no_default_policy"),
resource.TestCheckNoResourceAttr(resourceAddress, "token_num_uses"),
resource.TestCheckNoResourceAttr(resourceAddress, "token_period"),
resource.TestCheckResourceAttr(resourceAddress, "token_type", "default"),
resource.TestCheckNoResourceAttr(resourceAddress, "alias_metadata"),
),
},
// Test updating all the token fields
{
Config: spiffeRoleWithTokenConfig(mount, []string{workloadIds[1]}),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttrSet(resourceAddress, "mount"),
resource.TestCheckResourceAttr(resourceAddress, "name", "example-role"),
resource.TestCheckResourceAttr(resourceAddress, "workload_id_patterns.#", "1"),
resource.TestCheckResourceAttr(resourceAddress, "workload_id_patterns.0", workloadIds[1]),
resource.TestCheckResourceAttr(resourceAddress, "token_ttl", "3600"),
resource.TestCheckResourceAttr(resourceAddress, "token_max_ttl", "7200"),
resource.TestCheckResourceAttr(resourceAddress, "token_policies.#", "1"),
resource.TestCheckResourceAttr(resourceAddress, "token_policies.0", "example"),
resource.TestCheckResourceAttr(resourceAddress, "token_bound_cidrs.#", "1"),
resource.TestCheckResourceAttr(resourceAddress, "token_bound_cidrs.0", "127.0.0.1"),
resource.TestCheckResourceAttr(resourceAddress, "token_explicit_max_ttl", "10800"),
resource.TestCheckResourceAttr(resourceAddress, "token_no_default_policy", "true"),
resource.TestCheckResourceAttr(resourceAddress, "token_num_uses", "3"),
resource.TestCheckResourceAttr(resourceAddress, "token_period", "60"),
resource.TestCheckResourceAttr(resourceAddress, "token_type", "service"),
resource.TestCheckResourceAttr(resourceAddress, "alias_metadata.%", "2"),
resource.TestCheckResourceAttr(resourceAddress, "alias_metadata.spiffe_workload", "my-workload-id"),
resource.TestCheckResourceAttr(resourceAddress, "alias_metadata.metadata-key", "metadata-value"),
),
},
// Test that we flush back to a simpler role
{
Config: spiffeRoleConfig(mount, workloadIds),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttrSet(resourceAddress, "mount"),
resource.TestCheckResourceAttr(resourceAddress, "name", "example-role"),
resource.TestCheckResourceAttr(resourceAddress, "workload_id_patterns.#", "2"),
resource.TestCheckResourceAttr(resourceAddress, "workload_id_patterns.0", workloadIds[0]),
resource.TestCheckResourceAttr(resourceAddress, "workload_id_patterns.1", workloadIds[1]),
resource.TestCheckNoResourceAttr(resourceAddress, "token_ttl"),
resource.TestCheckNoResourceAttr(resourceAddress, "token_max_ttl"),
resource.TestCheckNoResourceAttr(resourceAddress, "token_policies"),
resource.TestCheckNoResourceAttr(resourceAddress, "token_bound_cidrs"),
resource.TestCheckNoResourceAttr(resourceAddress, "token_explicit_max_ttl"),
resource.TestCheckNoResourceAttr(resourceAddress, "token_no_default_policy"),
resource.TestCheckNoResourceAttr(resourceAddress, "token_num_uses"),
resource.TestCheckNoResourceAttr(resourceAddress, "token_period"),
resource.TestCheckResourceAttr(resourceAddress, "token_type", "default"),
resource.TestCheckNoResourceAttr(resourceAddress, "alias_metadata"),
),
},
// Test importing
{
ResourceName: resourceAddress,
ImportState: true,
ImportStateIdFunc: testAccSpiffeAuthRoleImportStateIdFunc(resourceAddress),
ImportStateVerify: true,
ImportStateVerifyIdentifierAttribute: "mount",
},
// Test deleting the role
{
Config: spiffeRoleConfigNoRole(mount),
ConfigPlanChecks: resource.ConfigPlanChecks{
PreApply: []plancheck.PlanCheck{
plancheck.ExpectNonEmptyPlan(),
plancheck.ExpectResourceAction(resourceAddress, plancheck.ResourceActionDestroy),
},
},
},
},
})
}
func testAccSpiffeAuthRoleImportStateIdFunc(resourceName string) resource.ImportStateIdFunc {
return func(s *terraform.State) (string, error) {
rs, ok := s.RootModule().Resources[resourceName]
if !ok {
return "", fmt.Errorf("not found: %s", resourceName)
}
return fmt.Sprintf("auth/%s/role/%s", rs.Primary.Attributes["mount"], rs.Primary.Attributes["name"]), nil
}
}
func spiffeRoleConfigNoRole(mount string) string {
return fmt.Sprintf(`
resource "vault_auth_backend" "spiffe_mount" {
type = "spiffe"
path = "%s"
tune {
passthrough_request_headers = ["Authorization"]
}
}
`, mount)
}
func spiffeRoleConfig(mount string, workloadIds []string) string {
var formattedWorkload string
if len(workloadIds) > 0 {
formattedWorkload = "\"" + strings.Join(workloadIds, "\", \"") + "\""
}
baseMountTf := spiffeRoleConfigNoRole(mount)
return fmt.Sprintf(`
%s
resource "vault_spiffe_auth_backend_role" "spiffe_role" {
mount = vault_auth_backend.spiffe_mount.path
name = "example-role"
workload_id_patterns = [%s]
}
`, baseMountTf, formattedWorkload)
}
func spiffeRoleWithTokenConfig(mount string, workloadIds []string) string {
var formattedWorkload string
if len(workloadIds) > 0 {
formattedWorkload = "\"" + strings.Join(workloadIds, "\", \"") + "\""
}
baseMountTf := spiffeRoleConfigNoRole(mount)
return fmt.Sprintf(`
%s
resource "vault_spiffe_auth_backend_role" "spiffe_role" {
mount = vault_auth_backend.spiffe_mount.path
name = "example-role"
workload_id_patterns = [%s]
token_ttl = 3600
token_max_ttl = 7200
token_policies = ["example"]
token_bound_cidrs = ["127.0.0.1"]
token_explicit_max_ttl = 10800
token_no_default_policy = true
token_num_uses = 3
token_period = 60
token_type = "service"
alias_metadata = {
"spiffe_workload" = "my-workload-id",
"metadata-key" = "metadata-value"
}
}
`, baseMountTf, formattedWorkload)
}

View file

@ -45,22 +45,30 @@ const (
EnvVarTfAccEnt = "TF_ACC_ENTERPRISE"
)
// Deprecated: use acctestutil.TestAccPreCheck instead, this is here for
// backwards compatibility.
func TestAccPreCheck(t *testing.T) {
t.Helper()
FatalTestEnvUnset(t, api.EnvVaultAddress, api.EnvVaultToken)
}
// Deprecated: use acctestutil.TestEntPreCheck instead, this is here for
// backwards compatibility.
func TestEntPreCheck(t *testing.T) {
t.Helper()
SkipTestAccEnt(t)
TestAccPreCheck(t)
}
// Deprecated: use acctestutil.SkipTestAcc instead, this is here for
// backwards compatibility.
func SkipTestAcc(t *testing.T) {
t.Helper()
SkipTestEnvUnset(t, resource.EnvTfAcc)
}
// Deprecated: use acctestutil.SkipTestAccEnt instead, this is here for
// backwards compatibility.
func SkipTestAccEnt(t *testing.T) {
t.Helper()
SkipTestEnvUnset(t, EnvVarTfAccEnt)

View file

@ -0,0 +1,91 @@
---
layout: "vault"
page_title: "Vault: vault_spiffe_auth_backend_config resource"
sidebar_current: "docs-vault-resource-vault_spiffe_auth_backend_config"
description: |-
Update the main configuration of the SPIFFE auth backend in Vault.
---
# vault\_spiffe\_auth\_backend\_config
Configure the SPIFFE trust domain and associated trust bundle for the backend
~> **Important** All data provided in the resource configuration will be
written in cleartext to state and plan files generated by Terraform, and
will appear in the console output when Terraform runs. Protect these
artifacts accordingly. See
[the main provider documentation](../index.html)
for more details.
## Example Usage
```hcl
resource "vault_auth_backend" "spiffe_mount" {
type = "spiffe"
path = "spiffe"
tune {
passthrough_request_headers = ["Authorization"]
}
}
resource "vault_spiffe_auth_backend_config" "spiffe_config" {
mount = vault_auth_backend.spiffe_mount.path
trust_domain = "example.org"
profile = "https_web_bundle"
endpoint_url = "https://example.org:8443/"
}
```
## Argument Reference
The following arguments are supported:
* `namespace` - (Optional) The namespace to provision the resource in.
The value should not contain leading or trailing forward slashes.
The `namespace` is always relative to the provider's configured [namespace](/docs/providers/vault/index.html#namespace).
*Available only for Vault Enterprise*.
* `mount` - (Required) The PKI secret backend the resource belongs to.
* `trust_domain` - (Required) The SPIFFE trust domain used by the backend.
* `profile` - (Required) Sets the profile type and fetch mechanism for the profile
used to fetch the trust bundle. Must be one of:
* https_spiffe_bundle - fetch the trust bundle in JWKS format from a SPIFFE endpoint.
* https_web_bundle - fetch the trust bundle in JWKS format from an HTTPS endpoint.
* https_web_pem - fetch a valid X.509 certificate as the trust bundle from an HTTPS endpoint.
* static - use the trust bundle explicitly configured in the profile definition.
* `audience` - (Optional) A list of allowed audience values for JWT based SVIDs.
* `defer_bundle_fetch` - (Optional) If true, tells Vault not to fetch the remote trust
bundle to validate the configuration
* `bundle` - (Optional) A PEM encoded X.509 certificate or a JWKS document based on what
the `profile` argument is set.
* `endpoint_url` - (Optional) The URL to fetch the trust bundle from. Required if the
`profile` argument is set to `https_spiffe_bundle`, `https_web_bundle`, or
`https_web_pem`.
* `endpoint_root_ca_truststore_pem` - (Optional) A PEM encoded CA certificate to use
to validate the TLS connection to the `endpoint_url` when the `profile` argument
is set to `https_web_bundle` or `https_web_pem`.
* `endpoint_spiffe_id` - (Optional) The SPIFFE ID to expect in the TLS certificate when
the `profile` argument is set to `https_spiffe_bundle`.
## Attributes Reference
No additional attributes are exported by this resource.
## Import
The SPIFFE config can be imported using the resource's `id`.
In the case of the example above the `id` would be `auth/spiffe/config`,
where the `spiffe` component is the resource's `mount`, e.g.
```
$ terraform import vault_spiffe_auth_backend_config.example auth/spiffe/config
```

View file

@ -0,0 +1,125 @@
---
layout: "vault"
page_title: "Vault: vault_spiffe_auth_backend_role resource"
sidebar_current: "docs-vault-resource-vault_spiffe_auth_backend_role"
description: |-
Manage a named role within a SPIFFE auth backend that maps SPIFFE IDs to Vault policies.
---
# vault\_spiffe\_auth\_backend\_role
Manage a named role within a SPIFFE auth backend. The role defines a mapping
from SPIFFE IDs to Vault policies along with other parameters that influence
the token that gets created upon successful authentication.
~> **Important** All data provided in the resource configuration will be
written in cleartext to state and plan files generated by Terraform, and
will appear in the console output when Terraform runs. Protect these
artifacts accordingly. See
[the main provider documentation](../index.html)
for more details.
## Example Usage
```hcl
resource "vault_auth_backend" "spiffe_mount" {
type = "spiffe"
path = "spiffe"
tune {
passthrough_request_headers = ["Authorization"]
}
}
resource "vault_spiffe_auth_backend_role" "spiffe_role" {
mount = vault_auth_backend.spiffe_mount.path
name = "example-role"
workload_id_patterns = ["/env/+/svc/web", "/env/+/svc/db"]
token_ttl = 3600
token_max_ttl = 7200
token_policies = ["example"]
token_bound_cidrs = ["127.0.0.1"]
token_explicit_max_ttl = 10800
token_no_default_policy = true
token_num_uses = 1
token_period = 60
token_type = "service"
alias_metadata = {
"metadata-key" = "metadata-value"
}
}
```
## Argument Reference
The following arguments are supported:
* `namespace` - (Optional) The namespace to provision the resource in.
The value should not contain leading or trailing forward slashes.
The `namespace` is always relative to the provider's configured [namespace](/docs/providers/vault/index.html#namespace).
*Available only for Vault Enterprise*.
* `mount` - (Required) The PKI secret backend the resource belongs to.
* `workload_id_patterns` - (Required) A comma separated list of patterns that match an
incoming workload ID within the SVID document presented by the client.
* `display_name` - (Optional) The human-readable name for tokens issued when
authenticating against the role. Defaults to the value provided for `mount`.
### Common Token Arguments
These arguments are common across several Authentication Token resources since Vault 1.2.
* `token_ttl` - (Optional) The incremental lifetime for generated tokens in number of seconds.
Its current value will be referenced at renewal time.
* `token_max_ttl` - (Optional) The maximum lifetime for generated tokens in number of seconds.
Its current value will be referenced at renewal time.
* `token_period` - (Optional) If set, indicates that the
token generated using this role should never expire. The token should be renewed within the
duration specified by this value. At each renewal, the token's TTL will be set to the
value of this field. Specified in seconds.
* `token_policies` - (Optional) List of policies to encode onto generated tokens. Depending
on the auth method, this list may be supplemented by user/group/other values.
* `token_bound_cidrs` - (Optional) List of CIDR blocks; if set, specifies blocks of IP
addresses which can authenticate successfully, and ties the resulting token to these blocks
as well.
* `token_explicit_max_ttl` - (Optional) If set, will encode an
[explicit max TTL](https://www.vaultproject.io/docs/concepts/tokens.html#token-time-to-live-periodic-tokens-and-explicit-max-ttls)
onto the token in number of seconds. This is a hard cap even if `token_ttl` and
`token_max_ttl` would otherwise allow a renewal.
* `token_no_default_policy` - (Optional) If set, the default policy will not be set on
generated tokens; otherwise it will be added to the policies set in token_policies.
* `token_num_uses` - (Optional) The [maximum number](https://developer.hashicorp.com/vault/api-docs/auth/saml#token_num_uses)
of times a generated token may be used (within its lifetime); 0 means unlimited.
* `token_type` - (Optional) The type of token that should be generated. Can be `service`,
`batch`, or `default` to use the mount's tuned default (which unless changed will be
`service` tokens). For token store roles, there are two additional possibilities:
`default-service` and `default-batch` which specify the type to return unless the client
requests a different type at generation time.
* `alias_metadata` - (Optional) A mapping of string key-value pairs that will be set as
metadata on the token's alias. This can be used to store information about the
authenticated entity.
## Attributes Reference
No additional attributes are exported by this resource.
## Import
The SPIFFE role can be imported using the resource's `id`.
In the case of the example above the `id` would be `auth/spiffe/role/example-role`,
where the `spiffe` component is the resource's `mount`, e.g.
```
$ terraform import vault_spiffe_auth_backend_role.example-role auth/spiffe/role/example-role
```

View file

@ -775,7 +775,13 @@
<a href="/docs/providers/vault/r/secrets_sync_association.html">vault_secrets_sync_association</a>
</li>
<li<%= sidebar_current("docs-vault-resource-vault_spiffe_auth_backend_config") %>>
<a href="/docs/providers/vault/r/spiffe_auth_backend_config.html">vault_spiffe_auth_backend_config</a>
</li>
<li<%= sidebar_current("docs-vault-resource-vault_spiffe_auth_backend_role") %>>
<a href="/docs/providers/vault/r/spiffe_auth_backend_role.html">vault_spiffe_auth_backend_role</a>
</li>
</ul>
</li>