diff --git a/lib/cloud/aws/policy_statements.go b/lib/cloud/aws/policy_statements.go index 1b1602fcc3fc2..42c2ac3c61d1e 100644 --- a/lib/cloud/aws/policy_statements.go +++ b/lib/cloud/aws/policy_statements.go @@ -98,3 +98,27 @@ func StatementForRDSDBConnect() *Statement { Resources: allResources, } } + +// StatementForEC2InstanceConnectEndpoint returns the statement that allows the flow for accessing +// an EC2 instance using its private IP, using EC2 Instance Connect Endpoint. +func StatementForEC2InstanceConnectEndpoint() *Statement { + return &Statement{ + Effect: EffectAllow, + Actions: []string{ + "ec2:DescribeInstances", + "ec2:DescribeInstanceConnectEndpoints", + "ec2:DescribeSecurityGroups", + + // Create ICE requires the following actions: + // https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/permissions-for-ec2-instance-connect-endpoint.html + "ec2:CreateInstanceConnectEndpoint", + "ec2:CreateTags", + "ec2:CreateNetworkInterface", + "iam:CreateServiceLinkedRole", + + "ec2-instance-connect:SendSSHPublicKey", + "ec2-instance-connect:OpenTunnel", + }, + Resources: allResources, + } +} diff --git a/lib/config/configuration.go b/lib/config/configuration.go index a04cc62647bf3..ac7e1bb382022 100644 --- a/lib/config/configuration.go +++ b/lib/config/configuration.go @@ -198,6 +198,10 @@ type CommandLineFlags struct { // IntegrationConfDeployServiceIAMArguments contains the arguments of // `teleport integration configure deployservice-iam` command IntegrationConfDeployServiceIAMArguments IntegrationConfDeployServiceIAM + + // IntegrationConfEICEIAMArguments contains the arguments of + // `teleport integration configure eice-iam` command + IntegrationConfEICEIAMArguments IntegrationConfEICEIAM } // IntegrationConfDeployServiceIAM contains the arguments of @@ -215,6 +219,15 @@ type IntegrationConfDeployServiceIAM struct { TaskRole string } +// IntegrationConfEICEIAM contains the arguments of +// `teleport integration configure eice-iam` command +type IntegrationConfEICEIAM struct { + // Region is the AWS Region used to set up the client. + Region string + // Role is the AWS Role associated with the Integration + Role string +} + // ReadConfigFile reads /etc/teleport.yaml (or whatever is passed via --config flag) // and overrides values in 'cfg' structure func ReadConfigFile(cliConfigPath string) (*FileConfig, error) { diff --git a/lib/integrations/awsoidc/deployservice_iam_config.go b/lib/integrations/awsoidc/deployservice_iam_config.go index 2c994c746928e..76db47a915e35 100644 --- a/lib/integrations/awsoidc/deployservice_iam_config.go +++ b/lib/integrations/awsoidc/deployservice_iam_config.go @@ -324,7 +324,7 @@ func addPolicyToIntegrationRole(ctx context.Context, clt DeployServiceIAMConfigu }) if err != nil { if trace.IsNotFound(awslib.ConvertIAMv2Error(err)) { - return trace.NotFound("Role %q not found.", req.IntegrationRole) + return trace.NotFound("role %q not found.", req.IntegrationRole) } return trace.Wrap(err) } diff --git a/lib/integrations/awsoidc/eice_iam_config.go b/lib/integrations/awsoidc/eice_iam_config.go new file mode 100644 index 0000000000000..3e9712068dc56 --- /dev/null +++ b/lib/integrations/awsoidc/eice_iam_config.go @@ -0,0 +1,145 @@ +/* +Copyright 2023 Gravitational, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package awsoidc + +import ( + "context" + "log" + + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/iam" + "github.com/gravitational/trace" + + awslib "github.com/gravitational/teleport/lib/cloud/aws" +) + +const ( + // defaultPolicyNameForEICE is the default name for the Inline Policy added to the IntegrationRole. + defaultPolicyNameForEICE = "EC2InstanceConnectEndpoint" +) + +// EICEIAMConfigureRequest is a request to configure the required Policies to use the EC2 Instance Connect Endpoint feature. +type EICEIAMConfigureRequest struct { + // Region is the AWS Region. + // Used to set up the AWS SDK Client. + Region string + + // IntegrationRole is the Integration's AWS Role used to set up Teleport as an OIDC IdP. + IntegrationRole string + + // IntegrationRoleEICEPolicy is the Policy Name that is created to allow access to call AWS APIs. + // Defaults to EC2InstanceConnectEndpoint + IntegrationRoleEICEPolicy string +} + +// CheckAndSetDefaults ensures the required fields are present. +func (r *EICEIAMConfigureRequest) CheckAndSetDefaults() error { + if r.Region == "" { + return trace.BadParameter("region is required") + } + + if r.IntegrationRole == "" { + return trace.BadParameter("integration role is required") + } + + if r.IntegrationRoleEICEPolicy == "" { + r.IntegrationRoleEICEPolicy = defaultPolicyNameForEICE + } + + return nil +} + +// EICEIAMConfigureClient describes the required methods to create the IAM Policies required for accessing EC2 instances usine EICE. +type EICEIAMConfigureClient interface { + // PutRolePolicy creates or replaces a Policy by its name in a IAM Role. + PutRolePolicy(ctx context.Context, params *iam.PutRolePolicyInput, optFns ...func(*iam.Options)) (*iam.PutRolePolicyOutput, error) +} + +type defaultEICEIAMConfigureClient struct { + *iam.Client +} + +// NewEICEIAMConfigureClient creates a new EICEIAMConfigureClient. +func NewEICEIAMConfigureClient(ctx context.Context, region string) (EICEIAMConfigureClient, error) { + if region == "" { + return nil, trace.BadParameter("region is required") + } + + cfg, err := config.LoadDefaultConfig(ctx, config.WithRegion(region)) + if err != nil { + return nil, trace.Wrap(err) + } + + return &defaultEICEIAMConfigureClient{ + Client: iam.NewFromConfig(cfg), + }, nil +} + +// ConfigureEICEIAM set ups the roles required for accessing an EC2 Instance using EICE. +// It creates an embedded policy with the following permissions: +// +// Action: List EC2 instances to add them as Teleport Nodes +// - ec2:DescribeInstances +// +// Action: List EC2 Instance Connect Endpoints so that knows if they must create one Endpoint. +// - ec2:DescribeInstanceConnectEndpoints +// +// Action: Select one or more SecurityGroups to apply to the EC2 Instance Connect Endpoints (the VPC's default SG is applied if no SG is provided). +// - ec2:DescribeSecurityGroups +// +// Action: Create EC2 Instance Connect Endpoint so the user can open a tunnel to the EC2 instance. +// More info: https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/permissions-for-ec2-instance-connect-endpoint.html +// - ec2:CreateInstanceConnectEndpoint +// - ec2:CreateTags +// - ec2:CreateNetworkInterface +// - iam:CreateServiceLinkedRole +// +// Action: Send a temporary SSH Key to the target host. +// - ec2-instance-connect:SendSSHPublicKey +// +// Action: Open a Tunnel to the EC2 using the Endpoint +// - ec2-instance-connect:OpenTunnel +// +// The following actions must be allowed by the IAM Role assigned in the Client. +// - iam:PutRolePolicy +func ConfigureEICEIAM(ctx context.Context, clt EICEIAMConfigureClient, req EICEIAMConfigureRequest) error { + if err := req.CheckAndSetDefaults(); err != nil { + return trace.Wrap(err) + } + + ec2ICEPolicyDocument, err := awslib.NewPolicyDocument( + awslib.StatementForEC2InstanceConnectEndpoint(), + ).Marshal() + if err != nil { + return trace.Wrap(err) + } + + _, err = clt.PutRolePolicy(ctx, &iam.PutRolePolicyInput{ + PolicyName: &req.IntegrationRoleEICEPolicy, + RoleName: &req.IntegrationRole, + PolicyDocument: &ec2ICEPolicyDocument, + }) + if err != nil { + if trace.IsNotFound(awslib.ConvertIAMv2Error(err)) { + return trace.NotFound("role %q not found.", req.IntegrationRole) + } + return trace.Wrap(err) + } + + log.Printf("IntegrationRole: IAM Policy %q added to Role %q\n", req.IntegrationRoleEICEPolicy, req.IntegrationRole) + return nil +} diff --git a/lib/integrations/awsoidc/eice_iam_config_test.go b/lib/integrations/awsoidc/eice_iam_config_test.go new file mode 100644 index 0000000000000..fc81ac551649e --- /dev/null +++ b/lib/integrations/awsoidc/eice_iam_config_test.go @@ -0,0 +1,138 @@ +/* +Copyright 2023 Gravitational, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package awsoidc + +import ( + "context" + "fmt" + "testing" + + "github.com/aws/aws-sdk-go-v2/service/iam" + iamTypes "github.com/aws/aws-sdk-go-v2/service/iam/types" + "github.com/stretchr/testify/require" + "golang.org/x/exp/slices" +) + +func TestEICEIAMConfigReqDefaults(t *testing.T) { + baseReq := func() EICEIAMConfigureRequest { + return EICEIAMConfigureRequest{ + Region: "us-east-1", + IntegrationRole: "integrationrole", + } + } + + for _, tt := range []struct { + name string + req func() EICEIAMConfigureRequest + errCheck require.ErrorAssertionFunc + expected EICEIAMConfigureRequest + }{ + { + name: "set defaults", + req: baseReq, + errCheck: require.NoError, + expected: EICEIAMConfigureRequest{ + Region: "us-east-1", + IntegrationRole: "integrationrole", + IntegrationRoleEICEPolicy: "EC2InstanceConnectEndpoint", + }, + }, + { + name: "missing region", + req: func() EICEIAMConfigureRequest { + req := baseReq() + req.Region = "" + return req + }, + errCheck: badParameterCheck, + }, + { + name: "missing integration role", + req: func() EICEIAMConfigureRequest { + req := baseReq() + req.IntegrationRole = "" + return req + }, + errCheck: badParameterCheck, + }, + } { + t.Run(tt.name, func(t *testing.T) { + req := tt.req() + err := req.CheckAndSetDefaults() + tt.errCheck(t, err) + if err != nil { + return + } + + require.Equal(t, tt.expected, req) + }) + } +} + +func TestEICEIAMConfig(t *testing.T) { + ctx := context.Background() + baseReq := func() EICEIAMConfigureRequest { + return EICEIAMConfigureRequest{ + Region: "us-east-1", + IntegrationRole: "integrationrole", + } + } + + for _, tt := range []struct { + name string + mockExistingRoles []string + req func() EICEIAMConfigureRequest + errCheck require.ErrorAssertionFunc + }{ + { + name: "valid", + req: baseReq, + mockExistingRoles: []string{"integrationrole"}, + errCheck: require.NoError, + }, + { + name: "integration role does not exist", + mockExistingRoles: []string{}, + req: baseReq, + errCheck: notFounCheck, + }, + } { + t.Run(tt.name, func(t *testing.T) { + clt := mockEICEIAMConfigClient{ + existingRoles: tt.mockExistingRoles, + } + + err := ConfigureEICEIAM(ctx, &clt, tt.req()) + tt.errCheck(t, err) + }) + } +} + +type mockEICEIAMConfigClient struct { + existingRoles []string +} + +// PutRolePolicy creates or replaces a Policy by its name in a IAM Role. +func (m *mockEICEIAMConfigClient) PutRolePolicy(ctx context.Context, params *iam.PutRolePolicyInput, optFns ...func(*iam.Options)) (*iam.PutRolePolicyOutput, error) { + noSuchEntityMessage := fmt.Sprintf("role %q does not exist.", *params.RoleName) + if !slices.Contains(m.existingRoles, *params.RoleName) { + return nil, &iamTypes.NoSuchEntityException{ + Message: &noSuchEntityMessage, + } + } + return nil, nil +} diff --git a/lib/web/apiserver.go b/lib/web/apiserver.go index 6ae224a51950a..bbdf7f8db5257 100644 --- a/lib/web/apiserver.go +++ b/lib/web/apiserver.go @@ -779,6 +779,7 @@ func (h *Handler) bindDefaultEndpoints() { h.GET("/webapi/scripts/integrations/configure/deployservice-iam.sh", h.WithLimiter(h.awsOIDCConfigureDeployServiceIAM)) h.POST("/webapi/sites/:site/integrations/aws-oidc/:name/ec2", h.WithClusterAuth(h.awsOIDCListEC2)) h.POST("/webapi/sites/:site/integrations/aws-oidc/:name/ec2ice", h.WithClusterAuth(h.awsOIDCListEC2ICE)) + h.GET("/webapi/scripts/integrations/configure/eice-iam.sh", h.WithLimiter(h.awsOIDCConfigureEICEIAM)) // AWS OIDC Integration specific endpoints: // Unauthenticated access to OpenID Configuration - used for AWS OIDC IdP integration diff --git a/lib/web/integrations_awsoidc.go b/lib/web/integrations_awsoidc.go index bcdf19e163d3d..7625e468f45db 100644 --- a/lib/web/integrations_awsoidc.go +++ b/lib/web/integrations_awsoidc.go @@ -247,6 +247,42 @@ func (h *Handler) awsOIDCConfigureDeployServiceIAM(w http.ResponseWriter, r *htt return nil, trace.Wrap(err) } +// awsOIDCConfigureEICEIAM returns a script that configures the required IAM permissions to enable the usage of EC2 Instance Connect Endpoint +// to access EC2 instances. +func (h *Handler) awsOIDCConfigureEICEIAM(w http.ResponseWriter, r *http.Request, p httprouter.Params) (any, error) { + queryParams := r.URL.Query() + + awsRegion := queryParams.Get("awsRegion") + if err := aws.IsValidRegion(awsRegion); err != nil { + return nil, trace.BadParameter("invalid awsRegion") + } + + role := queryParams.Get("role") + if err := aws.IsValidIAMRoleName(role); err != nil { + return nil, trace.BadParameter("invalid role") + } + + // The script must execute the following command: + // teleport integration configure eice-iam + argsList := []string{ + "integration", "configure", "eice-iam", + fmt.Sprintf("--aws-region=%s", awsRegion), + fmt.Sprintf("--role=%s", role), + } + script, err := oneoff.BuildScript(oneoff.OneOffScriptParams{ + TeleportArgs: strings.Join(argsList, " "), + SuccessMessage: "Success! You can now go back to the browser to complete the EC2 enrollment.", + }) + if err != nil { + return nil, trace.Wrap(err) + } + + httplib.SetScriptHeaders(w.Header()) + fmt.Fprint(w, script) + + return nil, trace.Wrap(err) +} + // awsOIDCListEC2 returns a list of EC2 Instances using the ListEC2 action of the AWS OIDC Integration. func (h *Handler) awsOIDCListEC2(w http.ResponseWriter, r *http.Request, p httprouter.Params, sctx *SessionContext, site reversetunnelclient.RemoteSite) (any, error) { ctx := r.Context() diff --git a/lib/web/integrations_awsoidc_test.go b/lib/web/integrations_awsoidc_test.go index b5ebd020f70c8..09673c1056bee 100644 --- a/lib/web/integrations_awsoidc_test.go +++ b/lib/web/integrations_awsoidc_test.go @@ -144,3 +144,91 @@ func TestBuildDeployServiceConfigureIAMScript(t *testing.T) { }) } } + +func TestBuildEICEConfigureIAMScript(t *testing.T) { + isBadParamErrFn := func(tt require.TestingT, err error, i ...any) { + require.True(tt, trace.IsBadParameter(err), "expected bad parameter, got %v", err) + } + + ctx := context.Background() + env := newWebPack(t, 1) + + // Unauthenticated client for script downloading. + publicClt := env.proxies[0].newClient(t) + pathVars := []string{ + "webapi", + "scripts", + "integrations", + "configure", + "eice-iam.sh", + } + endpoint := publicClt.Endpoint(pathVars...) + + tests := []struct { + name string + reqRelativeURL string + reqQuery url.Values + errCheck require.ErrorAssertionFunc + expectedTeleportArgs string + }{ + { + name: "valid", + reqQuery: url.Values{ + "awsRegion": []string{"us-east-1"}, + "role": []string{"myRole"}, + }, + errCheck: require.NoError, + expectedTeleportArgs: "integration configure eice-iam " + + "--aws-region=us-east-1 " + + "--role=myRole", + }, + { + name: "valid with symbols in role", + reqQuery: url.Values{ + "awsRegion": []string{"us-east-1"}, + "role": []string{"Test+1=2,3.4@5-6_7"}, + }, + errCheck: require.NoError, + expectedTeleportArgs: "integration configure eice-iam " + + "--aws-region=us-east-1 " + + "--role=Test+1=2,3.4@5-6_7", + }, + { + name: "missing aws-region", + reqQuery: url.Values{ + "role": []string{"myRole"}, + }, + errCheck: isBadParamErrFn, + }, + { + name: "missing role", + reqQuery: url.Values{ + "awsRegion": []string{"us-east-1"}, + }, + errCheck: isBadParamErrFn, + }, + { + name: "trying to inject escape sequence into query params", + reqQuery: url.Values{ + "awsRegion": []string{"'; rm -rf /tmp/dir; echo '"}, + "role": []string{"role"}, + }, + errCheck: isBadParamErrFn, + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + resp, err := publicClt.Get(ctx, endpoint, tc.reqQuery) + tc.errCheck(t, err) + if err != nil { + return + } + + require.Contains(t, string(resp.Bytes()), + fmt.Sprintf("teleportArgs='%s'\n", tc.expectedTeleportArgs), + ) + }) + } +} diff --git a/tool/teleport/common/teleport.go b/tool/teleport/common/teleport.go index bd5475ffca53a..433cb4fd6983e 100644 --- a/tool/teleport/common/teleport.go +++ b/tool/teleport/common/teleport.go @@ -450,6 +450,10 @@ func Run(options Options) (app *kingpin.Application, executedCommand string, con integrationConfDeployServiceCmd.Flag("role", "The AWS Role used by the AWS OIDC Integration.").Required().StringVar(&ccf.IntegrationConfDeployServiceIAMArguments.Role) integrationConfDeployServiceCmd.Flag("task-role", "The AWS Role to be used by the deployed service.").Required().StringVar(&ccf.IntegrationConfDeployServiceIAMArguments.TaskRole) + integrationConfEICECmd := integrationConfigureCmd.Command("eice-iam", "Adds required IAM permissions to connect to EC2 Instances using EC2 Instance Connect Endpoint") + integrationConfEICECmd.Flag("aws-region", "AWS Region.").Required().StringVar(&ccf.IntegrationConfEICEIAMArguments.Region) + integrationConfEICECmd.Flag("role", "The AWS Role used by the AWS OIDC Integration.").Required().StringVar(&ccf.IntegrationConfEICEIAMArguments.Role) + // parse CLI commands+flags: utils.UpdateAppUsageTemplate(app, options.Args) command, err := app.Parse(options.Args) @@ -537,6 +541,8 @@ func Run(options Options) (app *kingpin.Application, executedCommand string, con err = onJoinOpenSSH(ccf, conf) case integrationConfDeployServiceCmd.FullCommand(): err = onIntegrationConfDeployService(ccf.IntegrationConfDeployServiceIAMArguments) + case integrationConfEICECmd.FullCommand(): + err = onIntegrationConfEICEIAM(ccf.IntegrationConfEICEIAMArguments) } if err != nil { utils.FatalError(err) @@ -892,3 +898,22 @@ func onIntegrationConfDeployService(params config.IntegrationConfDeployServiceIA return nil } + +func onIntegrationConfEICEIAM(params config.IntegrationConfEICEIAM) error { + ctx := context.Background() + + iamClient, err := awsoidc.NewEICEIAMConfigureClient(ctx, params.Region) + if err != nil { + return trace.Wrap(err) + } + + err = awsoidc.ConfigureEICEIAM(ctx, iamClient, awsoidc.EICEIAMConfigureRequest{ + Region: params.Region, + IntegrationRole: params.Role, + }) + if err != nil { + return trace.Wrap(err) + } + + return nil +}