Skip to content

Commit

Permalink
[v16] Machine ID: Terraform Cloud joining (#45574) (#46049)
Browse files Browse the repository at this point in the history
* Machine ID: Terraform Cloud joining (#45574)

Backport of #45574 for branch/v16

---

* Machine ID: Terraform Cloud joining

This adds a new `terraform` join method, which uses Terraform Cloud's
Workload Identity OIDC provider to provide delegated joining support
for Machine ID bots. As the Terraform provider now embeds tbot, this
means - with some light modifications - that the Terraform provider
can now join using this join method, and without any secrets.

* Address some review feedback, add first batch of tests

This addresses a few feedback items, defaulting the audience to the
Teleport cluster name and requiring allow rules specify an
organization + at least one other parameter.

Also, adds a first batch of tests.

* Update autogenerated CRD docs

* Add tests for RegisterUsingToken and provisioning token init

* Fix imports

* Fix CRD docs

* Update tfschema

* Update tf docs

* Update docstring to make it clear that `Audience` is optional

* Update manifests

* Update CRD docs

* Code review feedback; docstring fixes

* Rename lib/terraform to lib/terraformcloud

* Rename terraform -> terraform_cloud

Renames the new join method to terraform_cloud to make it clear that
it won't work for TF running elsewhere (where other join methods
should be used)

* Fix tests and docs lints

* Update api/proto/teleport/legacy/types/types.proto

Co-authored-by: Tiago Silva <tiago.silva@goteleport.com>

* Update proto artifacts

---------

Co-authored-by: Tiago Silva <tiago.silva@goteleport.com>

* 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.

* Enable Terraform Cloud joining for Teleport Community Edition (#46419)

* Terraform Cloud joining: Support Terraform Enterprise issuers

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.

* Enable Terraform Cloud joining for Teleport Community Edition

This enables Terraform Cloud joining for Community Edition when
using the public HCP Terraform SaaS. Teleport Enterprise is still
required for use with self-hosted Terraform Enterprise.

changelog: Enable Terraform Cloud joining for Teleport Community Edition when using HCP Terraform

* Fix unit tests

* Update lib/auth/join_terraformcloud.go

Co-authored-by: Tiago Silva <tiago.silva@goteleport.com>

* Fix linter

---------

Co-authored-by: Tiago Silva <tiago.silva@goteleport.com>

---------

Co-authored-by: Tiago Silva <tiago.silva@goteleport.com>
  • Loading branch information
timothyb89 and tigrato authored Sep 18, 2024
1 parent 51c6573 commit 5dad937
Show file tree
Hide file tree
Showing 29 changed files with 5,344 additions and 2,525 deletions.
4 changes: 4 additions & 0 deletions api/constants/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -507,4 +507,8 @@ const (
EnvVarTerraformJoinMethod = "TF_TELEPORT_JOIN_METHOD"
// EnvVarTerraformJoinToken is the environment variable configuring the Terraform provider native MachineID join token.
EnvVarTerraformJoinToken = "TF_TELEPORT_JOIN_TOKEN"
// EnvVarTerraformCloudJoinAudienceTag is the environment variable configuring the Terraform provider's native Machine ID
// joining. The audience tag specifies the optional suffix for the TF_WORKLOAD_IDENTITY_AUDIENCE variable when
// specifically using the `terraform` join method.
EnvVarTerraformCloudJoinAudienceTag = "TF_TELEPORT_JOIN_AUDIENCE_TAG"
)
58 changes: 58 additions & 0 deletions api/proto/teleport/legacy/types/types.proto
Original file line number Diff line number Diff line change
Expand Up @@ -1295,6 +1295,8 @@ message ProvisionTokenSpecV2 {
ProvisionTokenSpecV2Spacelift Spacelift = 14 [(gogoproto.jsontag) = "spacelift,omitempty"];
// TPM allows the configuration of options specific to the "tpm" join method.
ProvisionTokenSpecV2TPM TPM = 15 [(gogoproto.jsontag) = "tpm,omitempty"];
// TerraformCloud allows the configuration of options specific to the "terraform_cloud" join method.
ProvisionTokenSpecV2TerraformCloud TerraformCloud = 16 [(gogoproto.jsontag) = "terraform_cloud,omitempty"];
}

// ProvisionTokenSpecV2TPM contains the TPM-specific part of the
Expand Down Expand Up @@ -1579,6 +1581,62 @@ message ProvisionTokenSpecV2GCP {
repeated Rule Allow = 1 [(gogoproto.jsontag) = "allow,omitempty"];
}

// ProvisionTokenSpecV2Terraform contains Terraform-specific parts of the
// ProvisionTokenSpecV2.
message ProvisionTokenSpecV2TerraformCloud {
// Rule is a set of properties the Terraform-issued token might have to be
// allowed to use this ProvisionToken.
message Rule {
// OrganizationID is the ID of the HCP Terraform organization. At least
// one organization value is required, either ID or name.
string OrganizationID = 1 [(gogoproto.jsontag) = "organization_id,omitempty"];

// OrganizationName is the human-readable name of the HCP Terraform
// organization. At least one organization value is required, either ID or
// name.
string OrganizationName = 2 [(gogoproto.jsontag) = "organization_name,omitempty"];

// ProjectID is the ID of the HCP Terraform project. At least one project or
// workspace value is required, either ID or name.
string ProjectID = 3 [(gogoproto.jsontag) = "project_id,omitempty"];

// ProjectName is the human-readable name for the HCP Terraform project. At
// least one project or workspace value is required, either ID or name.
string ProjectName = 4 [(gogoproto.jsontag) = "project_name,omitempty"];

// WorkspaceID is the ID of the HCP Terraform workspace. At least one
// project or workspace value is required, either ID or name.
string WorkspaceID = 5 [(gogoproto.jsontag) = "workspace_id,omitempty"];

// WorkspaceName is the human-readable name of the HCP Terraform workspace.
// At least one project or workspace value is required, either ID or name.
string WorkspaceName = 6 [(gogoproto.jsontag) = "workspace_name,omitempty"];

// RunPhase is the phase of the run the token was issued for, e.g. `plan` or
// `apply`
string RunPhase = 7 [(gogoproto.jsontag) = "run_phase,omitempty"];
}

// Allow is a list of Rules, nodes using this token must match one
// allow rule to use this token.
repeated Rule Allow = 1 [(gogoproto.jsontag) = "allow,omitempty"];

// 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.
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.
message StaticTokensV2 {
option (gogoproto.goproto_stringer) = false;
Expand Down
45 changes: 45 additions & 0 deletions api/types/provisioning.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ const (
// JoinMethodTPM indicates that the node will join with the TPM join method.
// The core implementation of this join method can be found in lib/tpm.
JoinMethodTPM JoinMethod = "tpm"
// JoinMethodTerraformCloud indicates that the node will join using the Terraform
// join method. See lib/terraformcloud for more.
JoinMethodTerraformCloud JoinMethod = "terraform_cloud"
)

var JoinMethods = []JoinMethod{
Expand All @@ -85,6 +88,7 @@ var JoinMethods = []JoinMethod{
JoinMethodSpacelift,
JoinMethodToken,
JoinMethodTPM,
JoinMethodTerraformCloud,
}

func ValidateJoinMethod(method JoinMethod) error {
Expand Down Expand Up @@ -348,6 +352,17 @@ func (p *ProvisionTokenV2) CheckAndSetDefaults() error {
if err := providerCfg.validate(); err != nil {
return trace.Wrap(err, "spec.tpm: failed validation")
}
case JoinMethodTerraformCloud:
providerCfg := p.Spec.TerraformCloud
if providerCfg == nil {
return trace.BadParameter(
"spec.terraform_cloud: must be configured for the join method %q",
JoinMethodTerraformCloud,
)
}
if err := providerCfg.checkAndSetDefaults(); err != nil {
return trace.Wrap(err, "spec.terraform_cloud: failed validation")
}
default:
return trace.BadParameter("unknown join method %q", p.Spec.JoinMethod)
}
Expand Down Expand Up @@ -817,3 +832,33 @@ func (a *ProvisionTokenSpecV2TPM) validate() error {
}
return nil
}

func (a *ProvisionTokenSpecV2TerraformCloud) checkAndSetDefaults() error {
if len(a.Allow) == 0 {
return trace.BadParameter("the %q join method requires at least one token allow rule", JoinMethodTerraformCloud)
}

// Note: an empty audience will fall back to the cluster name.

for i, allowRule := range a.Allow {
orgSet := allowRule.OrganizationID != "" || allowRule.OrganizationName != ""
projectSet := allowRule.ProjectID != "" || allowRule.ProjectName != ""
workspaceSet := allowRule.WorkspaceID != "" || allowRule.WorkspaceName != ""

if !orgSet {
return trace.BadParameter(
"allow[%d]: one of ['organization_id', 'organization_name'] must be set",
i,
)
}

if !projectSet && !workspaceSet {
return trace.BadParameter(
"allow[%d]: at least one of ['project_id', 'project_name', 'workspace_id', 'workspace_name'] must be set",
i,
)
}
}

return nil
}
150 changes: 150 additions & 0 deletions api/types/provisioning_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1019,6 +1019,156 @@ func TestProvisionTokenV2_CheckAndSetDefaults(t *testing.T) {
},
wantErr: true,
},
{
desc: "terraform",
token: &ProvisionTokenV2{
Metadata: Metadata{
Name: "test",
},
Spec: ProvisionTokenSpecV2{
Roles: []SystemRole{RoleNode},
JoinMethod: JoinMethodTerraformCloud,
TerraformCloud: &ProvisionTokenSpecV2TerraformCloud{
Allow: []*ProvisionTokenSpecV2TerraformCloud_Rule{
{
OrganizationName: "foo",
OrganizationID: "foo-id",
ProjectName: "bar",
ProjectID: "bar-id",
WorkspaceName: "baz",
WorkspaceID: "baz-id",
RunPhase: "apply",
},
},
},
},
},
expected: &ProvisionTokenV2{
Kind: "token",
Version: "v2",
Metadata: Metadata{
Name: "test",
Namespace: "default",
},
Spec: ProvisionTokenSpecV2{
Roles: []SystemRole{RoleNode},
JoinMethod: JoinMethodTerraformCloud,
TerraformCloud: &ProvisionTokenSpecV2TerraformCloud{
Allow: []*ProvisionTokenSpecV2TerraformCloud_Rule{
{
OrganizationName: "foo",
OrganizationID: "foo-id",
ProjectName: "bar",
ProjectID: "bar-id",
WorkspaceName: "baz",
WorkspaceID: "baz-id",
RunPhase: "apply",
},
},
},
},
},
},
{
desc: "terraform missing organization (id)",
token: &ProvisionTokenV2{
Metadata: Metadata{
Name: "test",
},
Spec: ProvisionTokenSpecV2{
Roles: []SystemRole{RoleNode},
JoinMethod: JoinMethodTerraformCloud,
TerraformCloud: &ProvisionTokenSpecV2TerraformCloud{
Allow: []*ProvisionTokenSpecV2TerraformCloud_Rule{
{
WorkspaceName: "foo",
},
},
},
},
},
wantErr: true,
},
{
desc: "terraform missing specific resource",
token: &ProvisionTokenV2{
Metadata: Metadata{
Name: "test",
},
Spec: ProvisionTokenSpecV2{
Roles: []SystemRole{RoleNode},
JoinMethod: JoinMethodTerraformCloud,
TerraformCloud: &ProvisionTokenSpecV2TerraformCloud{
Allow: []*ProvisionTokenSpecV2TerraformCloud_Rule{
{
OrganizationName: "foo",
},
},
},
},
},
wantErr: true,
},
{
desc: "terraform only names",
token: &ProvisionTokenV2{
Metadata: Metadata{
Name: "test",
},
Spec: ProvisionTokenSpecV2{
Roles: []SystemRole{RoleNode},
JoinMethod: JoinMethodTerraformCloud,
TerraformCloud: &ProvisionTokenSpecV2TerraformCloud{
Allow: []*ProvisionTokenSpecV2TerraformCloud_Rule{
{
OrganizationName: "foo",
ProjectName: "bar",
WorkspaceName: "baz",
},
},
},
},
},
wantErr: false,
},
{
desc: "terraform only ids",
token: &ProvisionTokenV2{
Metadata: Metadata{
Name: "test",
},
Spec: ProvisionTokenSpecV2{
Roles: []SystemRole{RoleNode},
JoinMethod: JoinMethodTerraformCloud,
TerraformCloud: &ProvisionTokenSpecV2TerraformCloud{
Allow: []*ProvisionTokenSpecV2TerraformCloud_Rule{
{
OrganizationID: "foo",
ProjectID: "bar",
WorkspaceID: "baz",
},
},
},
},
},
wantErr: false,
},
{
desc: "terraform missing rules",
token: &ProvisionTokenV2{
Metadata: Metadata{
Name: "test",
},
Spec: ProvisionTokenSpecV2{
Roles: []SystemRole{RoleNode},
JoinMethod: JoinMethodTerraformCloud,
TerraformCloud: &ProvisionTokenSpecV2TerraformCloud{
Allow: []*ProvisionTokenSpecV2TerraformCloud_Rule{},
},
},
},
wantErr: true,
},
}

for _, tc := range testcases {
Expand Down
Loading

0 comments on commit 5dad937

Please sign in to comment.