Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
44e8219
Machine ID: Terraform Cloud joining
timothyb89 Aug 17, 2024
ef74340
Address some review feedback, add first batch of tests
timothyb89 Aug 20, 2024
b4eb14c
Update autogenerated CRD docs
timothyb89 Aug 20, 2024
f2ff62d
Merge remote-tracking branch 'origin/master' into timothyb89/terrafor…
timothyb89 Aug 20, 2024
60be95b
Add tests for RegisterUsingToken and provisioning token init
timothyb89 Aug 21, 2024
8b1037f
Fix imports
timothyb89 Aug 21, 2024
92821b1
Fix CRD docs
timothyb89 Aug 21, 2024
577b77c
Update tfschema
timothyb89 Aug 21, 2024
dda5e83
Merge remote-tracking branch 'origin/master' into timothyb89/terrafor…
timothyb89 Aug 22, 2024
b51201f
Merge remote-tracking branch 'origin/master' into timothyb89/terrafor…
timothyb89 Aug 22, 2024
84bca00
Update tf docs
timothyb89 Aug 22, 2024
9c6572b
Update docstring to make it clear that `Audience` is optional
timothyb89 Aug 22, 2024
dd86ca2
Update manifests
timothyb89 Aug 22, 2024
813c482
Update CRD docs
timothyb89 Aug 23, 2024
9fd4135
Code review feedback; docstring fixes
timothyb89 Aug 24, 2024
d6f4b19
Rename lib/terraform to lib/terraformcloud
timothyb89 Aug 24, 2024
709029f
Rename terraform -> terraform_cloud
timothyb89 Aug 24, 2024
2114881
Fix tests and docs lints
timothyb89 Aug 24, 2024
edf14f2
Update api/proto/teleport/legacy/types/types.proto
timothyb89 Aug 26, 2024
99c0ab3
Update proto artifacts
timothyb89 Aug 26, 2024
6d3de39
Merge remote-tracking branch 'origin/master' into timothyb89/terrafor…
timothyb89 Aug 27, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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"
)
51 changes: 51 additions & 0 deletions api/proto/teleport/legacy/types/types.proto
Original file line number Diff line number Diff line change
Expand Up @@ -1296,6 +1296,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 @@ -1580,6 +1582,55 @@ 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 {
Comment thread
timothyb89 marked this conversation as resolved.
// 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"];
Comment thread
marcoandredinis marked this conversation as resolved.
}

// 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.
Comment thread
timothyb89 marked this conversation as resolved.
// 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"];
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we have it so that we always just expect the audience to equal the name of the Teleport cluster ? That sounds more semantically correct to me.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could, and I think that's the only answer we could bake in that makes sense. It might make the config/docs UX a bit weird (without a guided flow of some sort at least) but is otherwise fine.

The more confusing parameter is the $TAG, I suppose. For docs purposes we can just recommend TFC_WORKLOAD_IDENTITY_AUDIENCE_TELEPORT=<cluster name>.

Do you think that should be the default value if unset, or should we just always use the cluster name? Is there any sane usecase for allowing user override?

}

// 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 != ""
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are project names globally unique across TfCloud ? We should ensure we require at least one globally unique property here.

Copy link
Copy Markdown
Contributor Author

@timothyb89 timothyb89 Aug 19, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Organization names/ids are, at least. The others are only unique within their parent org/project. I think we could require that at least organization (name or ID) is set, then at least one of project name/project ID/workspace name/workspace ID?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 I think I've done similar on another one of the join methods (always required org id/org name). Without it, it's super easy to make a join token that would allow a bad actor to create their own org, create a project w/ the same name, and access your cluster.

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