Terraform Cloud joining: Support Terraform Enterprise issuers (#46051)

This adds support for hostname/issuer overrides, needed to support
on-prem Terraform Enterprise installs. When the new `hostname` field
is unset, behavior is changed, but when set, the JWT is validated
against it instead of `app.terraform.io`.

Additionally, this renames `join_terraform.go` to
`join_terraformcloud.go`, since that was missed during the rename
in #45574.
This commit is contained in:
Tim Buckley
2024-09-10 01:33:49 +00:00
committed by GitHub
parent 71a4efc871
commit db689c76ef
12 changed files with 1944 additions and 1758 deletions
@@ -1650,6 +1650,13 @@ message ProvisionTokenSpecV2TerraformCloud {
// Terraform Cloud, this value should be `foo`. If the variable is set to
// match the cluster name, it does not need to be set here.
string Audience = 2 [(gogoproto.jsontag) = "audience,omitempty"];
// Hostname is the hostname of the Terraform Enterprise instance expected to
// issue JWTs allowed by this token. This may be unset for regular Terraform
// Cloud use, in which case it will be assumed to be `app.terraform.io`.
// Otherwise, it must both match the `iss` (issuer) field included in JWTs,
// and provide standard JWKS endpoints.
string Hostname = 3 [(gogoproto.jsontag) = "hostname,omitempty"];
}
// StaticTokensV2 implements the StaticTokens interface.
+1796 -1746
View File
File diff suppressed because it is too large Load Diff
@@ -184,6 +184,7 @@ resource, which you can apply after installing the Teleport Kubernetes operator.
|---|---|---|
|allow|[][object](#specterraform_cloudallow-items)|Allow is a list of Rules, nodes using this token must match one allow rule to use this token.|
|audience|string|Audience is the JWT audience as configured in the TFC_WORKLOAD_IDENTITY_AUDIENCE(_$TAG) variable in Terraform Cloud. If unset, defaults to the Teleport cluster name. For example, if `TFC_WORKLOAD_IDENTITY_AUDIENCE_TELEPORT=foo` is set in Terraform Cloud, this value should be `foo`. If the variable is set to match the cluster name, it does not need to be set here.|
|hostname|string|Hostname is the hostname of the Terraform Enterprise instance expected to issue JWTs allowed by this token. This may be unset for regular Terraform Cloud use, in which case it will be assumed to be `app.terraform.io`. Otherwise, it must both match the `iss` (issuer) field included in JWTs, and provide standard JWKS endpoints.|
### spec.terraform_cloud.allow items
@@ -204,6 +204,7 @@ Optional:
- `allow` (Attributes List) Allow is a list of Rules, nodes using this token must match one allow rule to use this token. (see [below for nested schema](#nested-schema-for-specterraform_cloudallow))
- `audience` (String) Audience is the JWT audience as configured in the TFC_WORKLOAD_IDENTITY_AUDIENCE(_$TAG) variable in Terraform Cloud. If unset, defaults to the Teleport cluster name. For example, if `TFC_WORKLOAD_IDENTITY_AUDIENCE_TELEPORT=foo` is set in Terraform Cloud, this value should be `foo`. If the variable is set to match the cluster name, it does not need to be set here.
- `hostname` (String) Hostname is the hostname of the Terraform Enterprise instance expected to issue JWTs allowed by this token. This may be unset for regular Terraform Cloud use, in which case it will be assumed to be `app.terraform.io`. Otherwise, it must both match the `iss` (issuer) field included in JWTs, and provide standard JWKS endpoints.
### Nested Schema for `spec.terraform_cloud.allow`
@@ -238,6 +238,7 @@ Optional:
- `allow` (Attributes List) Allow is a list of Rules, nodes using this token must match one allow rule to use this token. (see [below for nested schema](#nested-schema-for-specterraform_cloudallow))
- `audience` (String) Audience is the JWT audience as configured in the TFC_WORKLOAD_IDENTITY_AUDIENCE(_$TAG) variable in Terraform Cloud. If unset, defaults to the Teleport cluster name. For example, if `TFC_WORKLOAD_IDENTITY_AUDIENCE_TELEPORT=foo` is set in Terraform Cloud, this value should be `foo`. If the variable is set to match the cluster name, it does not need to be set here.
- `hostname` (String) Hostname is the hostname of the Terraform Enterprise instance expected to issue JWTs allowed by this token. This may be unset for regular Terraform Cloud use, in which case it will be assumed to be `app.terraform.io`. Otherwise, it must both match the `iss` (issuer) field included in JWTs, and provide standard JWKS endpoints.
### Nested Schema for `spec.terraform_cloud.allow`
@@ -377,6 +377,14 @@ spec:
is set to match the cluster name, it does not need to be set
here.
type: string
hostname:
description: Hostname is the hostname of the Terraform Enterprise
instance expected to issue JWTs allowed by this token. This
may be unset for regular Terraform Cloud use, in which case
it will be assumed to be `app.terraform.io`. Otherwise, it must
both match the `iss` (issuer) field included in JWTs, and provide
standard JWKS endpoints.
type: string
type: object
tpm:
description: TPM allows the configuration of options specific to the
@@ -377,6 +377,14 @@ spec:
is set to match the cluster name, it does not need to be set
here.
type: string
hostname:
description: Hostname is the hostname of the Terraform Enterprise
instance expected to issue JWTs allowed by this token. This
may be unset for regular Terraform Cloud use, in which case
it will be assumed to be `app.terraform.io`. Otherwise, it must
both match the `iss` (issuer) field included in JWTs, and provide
standard JWKS endpoints.
type: string
type: object
tpm:
description: TPM allows the configuration of options specific to the
@@ -493,6 +493,11 @@ func GenSchemaProvisionTokenV2(ctx context.Context) (github_com_hashicorp_terraf
Optional: true,
Type: github_com_hashicorp_terraform_plugin_framework_types.StringType,
},
"hostname": {
Description: "Hostname is the hostname of the Terraform Enterprise instance expected to issue JWTs allowed by this token. This may be unset for regular Terraform Cloud use, in which case it will be assumed to be `app.terraform.io`. Otherwise, it must both match the `iss` (issuer) field included in JWTs, and provide standard JWKS endpoints.",
Optional: true,
Type: github_com_hashicorp_terraform_plugin_framework_types.StringType,
},
}),
Description: "TerraformCloud allows the configuration of options specific to the \"terraform_cloud\" join method.",
Optional: true,
@@ -2345,6 +2350,23 @@ func CopyProvisionTokenV2FromTerraform(_ context.Context, tf github_com_hashicor
}
}
}
{
a, ok := tf.Attrs["hostname"]
if !ok {
diags.Append(attrReadMissingDiag{"ProvisionTokenV2.Spec.TerraformCloud.Hostname"})
} else {
v, ok := a.(github_com_hashicorp_terraform_plugin_framework_types.String)
if !ok {
diags.Append(attrReadConversionFailureDiag{"ProvisionTokenV2.Spec.TerraformCloud.Hostname", "github.com/hashicorp/terraform-plugin-framework/types.String"})
} else {
var t string
if !v.Null && !v.Unknown {
t = string(v.Value)
}
obj.Hostname = t
}
}
}
}
}
}
@@ -5138,6 +5160,28 @@ func CopyProvisionTokenV2ToTerraform(ctx context.Context, obj *github_com_gravit
tf.Attrs["audience"] = v
}
}
{
t, ok := tf.AttrTypes["hostname"]
if !ok {
diags.Append(attrWriteMissingDiag{"ProvisionTokenV2.Spec.TerraformCloud.Hostname"})
} else {
v, ok := tf.Attrs["hostname"].(github_com_hashicorp_terraform_plugin_framework_types.String)
if !ok {
i, err := t.ValueFromTerraform(ctx, github_com_hashicorp_terraform_plugin_go_tftypes.NewValue(t.TerraformType(ctx), nil))
if err != nil {
diags.Append(attrWriteGeneralError{"ProvisionTokenV2.Spec.TerraformCloud.Hostname", err})
}
v, ok = i.(github_com_hashicorp_terraform_plugin_framework_types.String)
if !ok {
diags.Append(attrWriteConversionFailureDiag{"ProvisionTokenV2.Spec.TerraformCloud.Hostname", "github.com/hashicorp/terraform-plugin-framework/types.String"})
}
v.Null = string(obj.Hostname) == ""
}
v.Value = string(obj.Hostname)
v.Unknown = false
tf.Attrs["hostname"] = v
}
}
}
v.Unknown = false
tf.Attrs["terraform_cloud"] = v
@@ -32,7 +32,7 @@ import (
type terraformCloudIDTokenValidator interface {
Validate(
ctx context.Context, audience string, token string,
ctx context.Context, audience, hostname, token string,
) (*terraformcloud.IDTokenClaims, error)
}
@@ -68,7 +68,7 @@ func (a *Server) checkTerraformCloudJoinRequest(ctx context.Context, req *types.
}
claims, err := a.terraformIDTokenValidator.Validate(
ctx, aud, req.IDToken,
ctx, aud, token.Spec.TerraformCloud.Hostname, req.IDToken,
)
if err != nil {
return nil, trace.Wrap(err)
@@ -38,12 +38,18 @@ type mockTerraformTokenValidator struct {
}
func (m *mockTerraformTokenValidator) Validate(
_ context.Context, audience string, token string,
_ context.Context, audience, hostname, token string,
) (*terraformcloud.IDTokenClaims, error) {
if audience != "test.localhost" {
return nil, fmt.Errorf("bad audience: %s", audience)
}
// Hostname override always fails, but that's okay: we're just making sure
// the value gets plumbed through to here.
if hostname != "" {
return nil, fmt.Errorf("bad issuer: %s", hostname)
}
claims, ok := m.tokens[token]
if !ok {
return nil, errMockInvalidToken
@@ -363,6 +369,27 @@ func TestAuth_RegisterUsingToken_Terraform(t *testing.T) {
require.ErrorContains(t, err, "bad audience")
},
},
{
name: "overridden hostname is honored",
setEnterprise: true,
tokenSpec: types.ProvisionTokenSpecV2{
JoinMethod: types.JoinMethodTerraformCloud,
Roles: []types.SystemRole{types.RoleNode},
TerraformCloud: &types.ProvisionTokenSpecV2TerraformCloud{
Allow: []*types.ProvisionTokenSpecV2TerraformCloud_Rule{
{
OrganizationID: "example-organization-id",
WorkspaceID: "example-workspace-id",
},
},
Hostname: "example.com",
},
},
request: newRequest(validIDToken),
assertError: func(t require.TestingT, err error, i ...interface{}) {
require.ErrorContains(t, err, "bad issuer: example.com")
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
+17 -8
View File
@@ -30,8 +30,8 @@ import (
"github.com/gravitational/teleport/lib/jwt"
)
// IssuerURL is the issuer URL for Terraform Cloud
const IssuerURL = "https://app.terraform.io"
// DefaultIssuerURL is the issuer URL for Terraform Cloud
const DefaultIssuerURL = "https://app.terraform.io"
// IDTokenValidatorConfig contains the configuration options needed to control
// the behavior of IDTokenValidator.
@@ -65,9 +65,18 @@ func NewIDTokenValidator(
}
}
func (id *IDTokenValidator) issuerURL() string {
if id.issuerHostnameOverride == "" {
return IssuerURL
// issuerURL returns an issuer URL. If overridden by tests with
// `issuerHostnameOverride`, returns that value. Otherwise, if an issuer is
// provided via the token (for TFE), returns that, else returns the default
// issuer for Terraform Cloud.
func (id *IDTokenValidator) issuerURL(tfeHostname string) string {
if id.issuerHostnameOverride == "" && tfeHostname == "" {
return DefaultIssuerURL
}
hostname := tfeHostname
if id.issuerHostnameOverride != "" {
hostname = id.issuerHostnameOverride
}
scheme := "https"
@@ -75,16 +84,16 @@ func (id *IDTokenValidator) issuerURL() string {
scheme = "http"
}
return fmt.Sprintf("%s://%s", scheme, id.issuerHostnameOverride)
return fmt.Sprintf("%s://%s", scheme, hostname)
}
// Validate validates a Terraform issued ID token.
func (id *IDTokenValidator) Validate(
ctx context.Context, audience string, token string,
ctx context.Context, audience, hostname, token string,
) (*IDTokenClaims, error) {
p, err := oidc.NewProvider(
ctx,
id.issuerURL(),
id.issuerURL(hostname),
)
if err != nil {
return nil, trace.Wrap(err, "creating oidc provider")
+31 -1
View File
@@ -171,6 +171,7 @@ func TestIDTokenValidator_Validate(t *testing.T) {
assertError require.ErrorAssertionFunc
want *IDTokenClaims
token string
hostname string
}{
{
name: "success",
@@ -253,6 +254,27 @@ func TestIDTokenValidator_Validate(t *testing.T) {
time.Now().Add(5*time.Minute),
),
},
{
// A bit weird since we won't be able to test a successful case. We
// can't specify a port (only hostname), so won't be able to point
// the validator at our fake idp. However, we can make sure the
// overridden issuer value is honored by making sure that a request
// that would otherwise succeed, fails.
name: "invalid issuer, hostname override",
assertError: require.Error,
hostname: "invalid",
token: idp.issueToken(
t,
idp.issuer(),
idp.audience,
"example-organization",
"example-project",
"example-workspace",
"organization:example-organization:project:example-project:workspace:example-workspace:run_phase:apply",
time.Now().Add(-5*time.Minute),
time.Now().Add(5*time.Minute),
),
},
}
for _, tt := range tests {
@@ -261,15 +283,23 @@ func TestIDTokenValidator_Validate(t *testing.T) {
issuerAddr := idp.server.Listener.Addr().String()
// If no hostname is configured, assume we want to validate against
// our fake idp
hostnameOverride := ""
if tt.hostname == "" {
hostnameOverride = issuerAddr
}
v := NewIDTokenValidator(IDTokenValidatorConfig{
Clock: clockwork.NewRealClock(),
insecure: true,
issuerHostnameOverride: issuerAddr,
issuerHostnameOverride: hostnameOverride,
})
claims, err := v.Validate(
ctx,
"test-audience",
tt.hostname,
tt.token,
)
tt.assertError(t, err)