mirror of
https://github.com/gravitational/teleport.git
synced 2026-10-30 18:29:21 +08:00
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:
@@ -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
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`
|
||||
|
||||
|
||||
+8
@@ -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) {
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user