Skip to content

Commit

Permalink
integration configure commands prompt for changes (#47224)
Browse files Browse the repository at this point in the history
* integration configure commands prompt for changes

This applies to the following "teleport integration configure" subcommands:
* deployservice-iam
* eice-iam
* ec2-ssm-iam
* aws-app-access-iam
* eks-iam
* access-graph
* listdatabases-iam

These commands will now describe the actions they will take, the
resources that will be created/configured, and will by default prompt
the user to confirm the changes.

Confirmation prompt can be skipped by passing the new --confirm flag.

The confirmation prompt is enabled by default because the most common
case, if not the only case, where a user runs these commands is via an
opaque "bash -c $(curl ...)" script generated by a web UI enrollment
wizard.

* address feedback

* fix test

* pluralize only when there are multiple actions

* remove leading newline from func body

* remove extra newline in ec2 iam configure request

* improve --confirm flag description

* fix test

* remove extraneous else

* dont display escaped ssm doc for op plan
  • Loading branch information
GavinFrazar authored Oct 7, 2024
1 parent ca91ed8 commit 07e2c06
Show file tree
Hide file tree
Showing 30 changed files with 1,098 additions and 268 deletions.
33 changes: 28 additions & 5 deletions lib/cloud/aws/ssm_documents.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,34 @@ import (
"github.com/google/uuid"
)

// EC2DiscoverySSMDocumentOptions are options for generating the EC2 SSM discovery document.
type EC2DiscoverySSMDocumentOptions struct {
// InsecureSkipInstallPathRandomization skips randomizing the Teleport installation script file path.
InsecureSkipInstallPathRandomization bool
}

// WithInsecureSkipInstallPathRandomization returns an option func that
// sets the InsecureSkipInstallPathRandomization option.
func WithInsecureSkipInstallPathRandomization(setting bool) func(*EC2DiscoverySSMDocumentOptions) {
return func(options *EC2DiscoverySSMDocumentOptions) {
options.InsecureSkipInstallPathRandomization = setting
}
}

// EC2DiscoverySSMDocument receives the proxy address and returns an SSM Document.
// This document downloads and runs a Teleport installer.
// Requires the proxy endpoint URL, example: https://tenant.teleport.sh
func EC2DiscoverySSMDocument(proxy string) string {
randString := uuid.NewString() // Secure random so the filename can not be guessed to avoid possible script injection
func EC2DiscoverySSMDocument(proxy string, opts ...func(*EC2DiscoverySSMDocumentOptions)) string {
var options EC2DiscoverySSMDocumentOptions
for _, optFn := range opts {
optFn(&options)
}

installTeleportPath := "/tmp/installTeleport.sh"
if !options.InsecureSkipInstallPathRandomization {
// Randomize the install path so the filename can not be guessed to avoid possible script injection
installTeleportPath = fmt.Sprintf("/tmp/installTeleport-%s.sh", uuid.NewString())
}

return fmt.Sprintf(`
schemaVersion: '2.2'
Expand All @@ -45,16 +68,16 @@ mainSteps:
name: downloadContent
inputs:
sourceType: "HTTP"
destinationPath: "/tmp/installTeleport-%s.sh"
destinationPath: %q
sourceInfo:
url: "%s/webapi/scripts/installer/{{ scriptName }}"
- action: aws:runShellScript
name: runShellScript
inputs:
timeoutSeconds: '300'
runCommand:
- /bin/sh /tmp/installTeleport-%s.sh "{{ token }}"
`, randString, proxy, randString)
- /bin/sh %s "{{ token }}"
`, installTeleportPath, proxy, installTeleportPath)
}

const EC2DiscoveryPolicyName = "TeleportEC2Discovery"
Expand Down
117 changes: 117 additions & 0 deletions lib/cloud/provisioning/awsactions/create_document.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/*
* Teleport
* Copyright (C) 2024 Gravitational, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

package awsactions

import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"

"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/ssm"
ssmtypes "github.com/aws/aws-sdk-go-v2/service/ssm/types"
"github.com/gravitational/trace"
"gopkg.in/yaml.v3"

"github.com/gravitational/teleport/lib/cloud/provisioning"
"github.com/gravitational/teleport/lib/integrations/awsoidc/tags"
)

// DocumentCreator can create an AWS SSM document.
type DocumentCreator interface {
// CreateDocument creates an AWS SSM document.
CreateDocument(ctx context.Context, params *ssm.CreateDocumentInput, optFns ...func(*ssm.Options)) (*ssm.CreateDocumentOutput, error)
}

// CreateDocument wraps a [DocumentCreator] in a [provisioning.Action] that
// creates an SSM document when invoked.
func CreateDocument(
clt DocumentCreator,
name string,
content string,
docType ssmtypes.DocumentType,
docFormat ssmtypes.DocumentFormat,
tags tags.AWSTags,
) (*provisioning.Action, error) {
input := &ssm.CreateDocumentInput{
Name: aws.String(name),
DocumentType: docType,
DocumentFormat: docFormat,
Content: aws.String(content),
Tags: tags.ToSSMTags(),
}
type createDocumentInput struct {
// PolicyDocument shadows the input's field of the same name
// to marshal the doc content as unescaped JSON or text.
Content any
*ssm.CreateDocumentInput
}
unmarshaledContent, err := unmarshalDocumentContent(content, docFormat)
if err != nil {
return nil, trace.Wrap(err)
}
details, err := formatDetails(createDocumentInput{
Content: unmarshaledContent,
CreateDocumentInput: input,
})
if err != nil {
return nil, trace.Wrap(err)
}

config := provisioning.ActionConfig{
Name: "CreateDocument",
Summary: fmt.Sprintf("Create an AWS Systems Manager (SSM) %s document %q", docType, name),
Details: details,
RunnerFn: func(ctx context.Context) error {
_, err = clt.CreateDocument(ctx, input)
if err != nil {
var docAlreadyExistsError *ssmtypes.DocumentAlreadyExists
if errors.As(err, &docAlreadyExistsError) {
slog.InfoContext(ctx, "SSM document already exists", "name", name)
return nil
}

return trace.Wrap(err)
}

slog.InfoContext(ctx, "SSM document created", "name", name)
return nil
},
}
action, err := provisioning.NewAction(config)
return action, trace.Wrap(err)
}

func unmarshalDocumentContent(content string, docFormat ssmtypes.DocumentFormat) (any, error) {
var structuredOutput map[string]any
switch docFormat {
case ssmtypes.DocumentFormatJson:
json.Unmarshal([]byte(content), &structuredOutput)
case ssmtypes.DocumentFormatYaml:
yaml.Unmarshal([]byte(content), &structuredOutput)
case ssmtypes.DocumentFormatText:
return content, nil
default:
return nil, trace.BadParameter("unknown document format %q", docFormat)
}

return structuredOutput, nil
}
2 changes: 1 addition & 1 deletion lib/cloud/provisioning/awsactions/create_role.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ func CreateRole(
}
type createRoleInput struct {
// AssumeRolePolicyDocument shadows the input's field of the same name
// to marshal the trust policy doc as unescpaed JSON.
// to marshal the trust policy doc as unescaped JSON.
AssumeRolePolicyDocument *awslib.PolicyDocument
*iam.CreateRoleInput
}
Expand Down
95 changes: 95 additions & 0 deletions lib/cloud/provisioning/awsactions/put_role_policy.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/*
* Teleport
* Copyright (C) 2024 Gravitational, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

package awsactions

import (
"context"
"fmt"
"log/slog"

"github.com/aws/aws-sdk-go-v2/service/iam"
"github.com/gravitational/trace"

awslib "github.com/gravitational/teleport/lib/cloud/aws"
"github.com/gravitational/teleport/lib/cloud/provisioning"
)

// RolePolicyPutter can upsert an IAM inline role policy.
type RolePolicyPutter interface {
// PutRolePolicy creates or replaces a Policy by its name in a IAM Role.
PutRolePolicy(context.Context, *iam.PutRolePolicyInput, ...func(*iam.Options)) (*iam.PutRolePolicyOutput, error)
}

// PutRolePolicy wraps a [RolePolicyPutter] in a [provisioning.Action] that
// upserts an inline IAM policy when invoked.
func PutRolePolicy(
clt RolePolicyPutter,
policyName string,
roleName string,
policy *awslib.PolicyDocument,
) (*provisioning.Action, error) {
policyJSON, err := policy.Marshal()
if err != nil {
return nil, trace.Wrap(err)
}
input := &iam.PutRolePolicyInput{
PolicyName: &policyName,
RoleName: &roleName,
PolicyDocument: &policyJSON,
}
type putRolePolicyInput struct {
// PolicyDocument shadows the input's field of the same name
// to marshal the trust policy doc as unescaped JSON.
PolicyDocument *awslib.PolicyDocument
*iam.PutRolePolicyInput
}
details, err := formatDetails(putRolePolicyInput{
PolicyDocument: policy,
PutRolePolicyInput: input,
})
if err != nil {
return nil, trace.Wrap(err)
}

config := provisioning.ActionConfig{
Name: "PutRolePolicy",
Summary: fmt.Sprintf("Attach an inline IAM policy named %q to IAM role %q",
policyName,
roleName,
),
Details: details,
RunnerFn: func(ctx context.Context) error {
_, err = clt.PutRolePolicy(ctx, input)
if err != nil {
if trace.IsNotFound(awslib.ConvertIAMv2Error(err)) {
return trace.NotFound("role %q not found.", roleName)
}
return trace.Wrap(err)
}

slog.InfoContext(ctx, "Added inline policy to IAM role",
"policy", policyName,
"role", roleName,
)
return nil
},
}
action, err := provisioning.NewAction(config)
return action, trace.Wrap(err)
}
14 changes: 10 additions & 4 deletions lib/cloud/provisioning/operations.go
Original file line number Diff line number Diff line change
Expand Up @@ -163,8 +163,7 @@ func Run(ctx context.Context, config OperationConfig) error {
}

if !config.AutoConfirm {
question := fmt.Sprintf("Do you want %q to perform these actions?", config.Name)
ok, err := prompt.Confirmation(ctx, config.Output, prompt.Stdin(), question)
ok, err := prompt.Confirmation(ctx, config.Output, prompt.Stdin(), getPromptQuestion(config))
if err != nil {
return trace.Wrap(err)
}
Expand Down Expand Up @@ -208,12 +207,19 @@ var operationPlanTemplate = template.Must(template.New("plan").
"addOne": func(x int) int { return x + 1 },
}).
Parse(`
{{- printf "%q" .config.Name }} will perform the following actions:
{{- printf "%q" .config.Name }} will perform the following {{ if .showStepNumbers }}actions{{ else }}action{{ end }}:
{{ $global := . }}
{{- range $index, $action := .config.Actions }}
{{- if $global.showStepNumbers }}{{ $index | addOne }}. {{ end -}}{{$action.GetSummary}}.
{{- if $global.showStepNumbers }}{{ addOne $index }}. {{ end -}}{{$action.GetSummary}}.
{{$action.GetName}}: {{$action.GetDetails}}
{{end -}}
`))

func getPromptQuestion(config OperationConfig) string {
if len(config.Actions) > 1 {
return fmt.Sprintf("Do you want %q to perform these actions?", config.Name)
}
return fmt.Sprintf("Do you want %q to perform this action?", config.Name)
}
2 changes: 1 addition & 1 deletion lib/cloud/provisioning/operations_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,7 @@ func TestWriteOperationPlan(t *testing.T) {
AutoConfirm: true,
},
want: strings.TrimLeft(`
"op-name" will perform the following actions:
"op-name" will perform the following action:
<actionA summary>.
actionA: <actionA details>
Expand Down
14 changes: 14 additions & 0 deletions lib/config/configuration.go
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,8 @@ type IntegrationConfAccessGraphAWSSync struct {
Role string
// AccountID is the AWS account ID.
AccountID string
// AutoConfirm skips user confirmation of the operation plan if true.
AutoConfirm bool
}

// IntegrationConfAzureOIDC contains the arguments of
Expand Down Expand Up @@ -312,6 +314,8 @@ type IntegrationConfDeployServiceIAM struct {
TaskRole string
// AccountID is the AWS account ID.
AccountID string
// AutoConfirm skips user confirmation of the operation plan if true.
AutoConfirm bool
}

// IntegrationConfEICEIAM contains the arguments of
Expand All @@ -323,6 +327,8 @@ type IntegrationConfEICEIAM struct {
Role string
// AccountID is the AWS account ID.
AccountID string
// AutoConfirm skips user confirmation of the operation plan if true.
AutoConfirm bool
}

// IntegrationConfAWSAppAccessIAM contains the arguments of
Expand All @@ -332,6 +338,8 @@ type IntegrationConfAWSAppAccessIAM struct {
RoleName string
// AccountID is the AWS account ID.
AccountID string
// AutoConfirm skips user confirmation of the operation plan if true.
AutoConfirm bool
}

// IntegrationConfEC2SSMIAM contains the arguments of
Expand All @@ -356,6 +364,8 @@ type IntegrationConfEC2SSMIAM struct {
IntegrationName string
// AccountID is the AWS account ID.
AccountID string
// AutoConfirm skips user confirmation of the operation plan if true.
AutoConfirm bool
}

// IntegrationConfEKSIAM contains the arguments of
Expand All @@ -367,6 +377,8 @@ type IntegrationConfEKSIAM struct {
Role string
// AccountID is the AWS account ID.
AccountID string
// AutoConfirm skips user confirmation of the operation plan if true.
AutoConfirm bool
}

// IntegrationConfAWSOIDCIdP contains the arguments of
Expand Down Expand Up @@ -394,6 +406,8 @@ type IntegrationConfListDatabasesIAM struct {
Role string
// AccountID is the AWS account ID.
AccountID string
// AutoConfirm skips user confirmation of the operation plan if true.
AutoConfirm bool
}

// ReadConfigFile reads /etc/teleport.yaml (or whatever is passed via --config flag)
Expand Down
Loading

0 comments on commit 07e2c06

Please sign in to comment.