diff --git a/go.mod b/go.mod index d252395f334ce..13cfea23c3a88 100644 --- a/go.mod +++ b/go.mod @@ -192,6 +192,7 @@ require ( github.com/sijms/go-ora/v2 v2.8.24 github.com/snowflakedb/gosnowflake v1.13.0 github.com/spf13/cobra v1.9.1 + github.com/spiffe/aws-spiffe-workload-helper v0.0.1-rc.8 github.com/spiffe/go-spiffe/v2 v2.5.0 github.com/stretchr/testify v1.10.0 github.com/ucarion/urlpath v0.0.0-20200424170820-7ccc79b76bbb @@ -296,6 +297,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.25.0 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.29.0 // indirect + github.com/aws/rolesanywhere-credential-helper v1.2.0 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver v3.5.1+incompatible // indirect diff --git a/go.sum b/go.sum index 87c760f17aec6..60bf6bbab20d0 100644 --- a/go.sum +++ b/go.sum @@ -963,6 +963,8 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.33.16 h1:BHEK2Q/7CMRMCb3nySi/w8UbIcP github.com/aws/aws-sdk-go-v2/service/sts v1.33.16/go.mod h1:cQnB8CUnxbMU82JvlqjKR2HBOm3fe9pWorWBza6MBJ4= github.com/aws/aws-sigv4-auth-cassandra-gocql-driver-plugin v1.1.0 h1:EJsHUYgFBV7/N1YtL73lsfZODAOU+CnNSZfEAlqqQaA= github.com/aws/aws-sigv4-auth-cassandra-gocql-driver-plugin v1.1.0/go.mod h1:AxKuXHc0zv2yYaeueUG7R3ONbcnQIuDj0bkdFmPVRzU= +github.com/aws/rolesanywhere-credential-helper v1.2.0 h1:eLqJvSznH8nJk48dwFc0raWOpbTGgBeNYH3Q8UQFVx4= +github.com/aws/rolesanywhere-credential-helper v1.2.0/go.mod h1:YRxmRrAaqbVVXPNH1gHT76nWaMGvpAziHAHw8UwKrpU= github.com/aws/smithy-go v1.13.5/go.mod h1:Tg+OJXh4MB2R/uN61Ko2f6hTZwB/ZYGOtib8J3gBHzA= github.com/aws/smithy-go v1.22.3 h1:Z//5NuZCSW6R4PhQ93hShNbyBbn8BWCmCVCt+Q8Io5k= github.com/aws/smithy-go v1.22.3/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= @@ -2182,6 +2184,8 @@ github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= +github.com/spiffe/aws-spiffe-workload-helper v0.0.1-rc.8 h1:KFHLQuNqUG4YZXo+QwMurqFxU5qWchFDCxYbl+uJz9w= +github.com/spiffe/aws-spiffe-workload-helper v0.0.1-rc.8/go.mod h1:xhhvkBenvvbuEEw9YR2HwGzUPv0sQiHd3wv8avhra+U= github.com/spiffe/go-spiffe/v2 v2.5.0 h1:N2I01KCUkv1FAjZXJMwh95KK1ZIQLYbPfhaxw8WS0hE= github.com/spiffe/go-spiffe/v2 v2.5.0/go.mod h1:P+NxobPc6wXhVtINNtFjNWGBTreew1GBUCwT2wPmb7g= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= diff --git a/integrations/terraform/go.mod b/integrations/terraform/go.mod index 853b9e5eae99f..20b217aad7721 100644 --- a/integrations/terraform/go.mod +++ b/integrations/terraform/go.mod @@ -106,6 +106,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/ssoadmin v1.30.0 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.29.0 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.33.16 // indirect + github.com/aws/rolesanywhere-credential-helper v1.2.0 // indirect github.com/aws/smithy-go v1.22.3 // indirect github.com/aws/smithy-go/tracing/smithyoteltracing v1.0.4 // indirect github.com/beevik/etree v1.5.0 // indirect @@ -317,6 +318,7 @@ require ( github.com/spf13/cast v1.7.0 // indirect github.com/spf13/cobra v1.9.1 // indirect github.com/spf13/pflag v1.0.6 // indirect + github.com/spiffe/aws-spiffe-workload-helper v0.0.1-rc.8 // indirect github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/thales-e-security/pool v0.0.2 // indirect @@ -375,6 +377,7 @@ require ( gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect helm.sh/helm/v3 v3.17.1 // indirect diff --git a/integrations/terraform/go.sum b/integrations/terraform/go.sum index c24a583c10015..b263b56ac3807 100644 --- a/integrations/terraform/go.sum +++ b/integrations/terraform/go.sum @@ -306,6 +306,8 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.33.16 h1:BHEK2Q/7CMRMCb3nySi/w8UbIcP github.com/aws/aws-sdk-go-v2/service/sts v1.33.16/go.mod h1:cQnB8CUnxbMU82JvlqjKR2HBOm3fe9pWorWBza6MBJ4= github.com/aws/aws-sigv4-auth-cassandra-gocql-driver-plugin v1.1.0 h1:EJsHUYgFBV7/N1YtL73lsfZODAOU+CnNSZfEAlqqQaA= github.com/aws/aws-sigv4-auth-cassandra-gocql-driver-plugin v1.1.0/go.mod h1:AxKuXHc0zv2yYaeueUG7R3ONbcnQIuDj0bkdFmPVRzU= +github.com/aws/rolesanywhere-credential-helper v1.2.0 h1:eLqJvSznH8nJk48dwFc0raWOpbTGgBeNYH3Q8UQFVx4= +github.com/aws/rolesanywhere-credential-helper v1.2.0/go.mod h1:YRxmRrAaqbVVXPNH1gHT76nWaMGvpAziHAHw8UwKrpU= github.com/aws/smithy-go v1.22.3 h1:Z//5NuZCSW6R4PhQ93hShNbyBbn8BWCmCVCt+Q8Io5k= github.com/aws/smithy-go v1.22.3/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= github.com/aws/smithy-go/tracing/smithyoteltracing v1.0.4 h1:Gx4ipHtKfaABSHAVo4Zjo2E4ClKzYqZ2NrPO0gy6qvY= @@ -1158,6 +1160,8 @@ github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wx github.com/spf13/pflag v1.0.2/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spiffe/aws-spiffe-workload-helper v0.0.1-rc.8 h1:KFHLQuNqUG4YZXo+QwMurqFxU5qWchFDCxYbl+uJz9w= +github.com/spiffe/aws-spiffe-workload-helper v0.0.1-rc.8/go.mod h1:xhhvkBenvvbuEEw9YR2HwGzUPv0sQiHd3wv8avhra+U= github.com/spiffe/go-spiffe/v2 v2.5.0 h1:N2I01KCUkv1FAjZXJMwh95KK1ZIQLYbPfhaxw8WS0hE= github.com/spiffe/go-spiffe/v2 v2.5.0/go.mod h1:P+NxobPc6wXhVtINNtFjNWGBTreew1GBUCwT2wPmb7g= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= diff --git a/lib/tbot/cli/start_workload_identity_aws_ra.go b/lib/tbot/cli/start_workload_identity_aws_ra.go new file mode 100644 index 0000000000000..db250831ccfb6 --- /dev/null +++ b/lib/tbot/cli/start_workload_identity_aws_ra.go @@ -0,0 +1,172 @@ +// Teleport +// Copyright (C) 2025 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 . + +package cli + +import ( + "fmt" + "log/slog" + "time" + + "github.com/alecthomas/kingpin/v2" + "github.com/gravitational/trace" + + "github.com/gravitational/teleport/lib/client" + "github.com/gravitational/teleport/lib/tbot/config" +) + +// WorkloadIdentityAWSRACommand implements `tbot start workload-identity-aws-ra` +// and `tbot configure workload-identity-aws-ra`. +type WorkloadIdentityAWSRACommand struct { + *sharedStartArgs + *sharedDestinationArgs + *genericMutatorHandler + + // NameSelector is the name of the workload identity to use. + // --workload-identity-name foo + NameSelector string + // LabelSelector is the labels of the workload identity to use. + // --workload-identity-labels x=y,z=a + LabelSelector string + + // RoleARN is the ARN of the role to assume. + // Example: `arn:aws:iam::123456789012:role/example-role` + // Required. + RoleARN string + // ProfileARN is the ARN of the Roles Anywhere profile to use. + // Example: `arn:aws:rolesanywhere:us-east-1:123456789012:profile/0000000-0000-0000-0000-00000000000` + // Required. + ProfileARN string + // TrustAnchorARN is the ARN of the Roles Anywhere trust anchor to use. + // Example: `arn:aws:rolesanywhere:us-east-1:123456789012:trust-anchor/0000000-0000-0000-0000-000000000000` + // Required. + TrustAnchorARN string + // Region is the AWS region to use. + // Example: `us-east-1` + // Must be set here or in the environment or AWS config using the + // `AWS_REGION` environment variable. If set here, this will override the + // environment or AWS config. + Region string + + // SessionDuration is the duration of the resulting AWS session and + // credentials. This may be up to 12 hours. When unset, this defaults to + // 6 hours. + SessionDuration time.Duration + // SessionRenewalInterval is the interval at which the session should be + // renewed. This should be less than the session duration. When unset, this + // defaults to 1 hour. + SessionRenewalInterval time.Duration +} + +// NewWorkloadIdentityAWSRACommand initializes the command and flags for the +// `workload-identity-aws-ra` output and returns a struct that will contain the parse +// result. +func NewWorkloadIdentityAWSRACommand( + parentCmd *kingpin.CmdClause, action MutatorAction, mode CommandMode, +) *WorkloadIdentityAWSRACommand { + cmd := parentCmd.Command( + "workload-identity-aws-roles-anywhere", + fmt.Sprintf( + "%s tbot with an output containing AWS credentials generated via AWS Roles Anywhere.", + mode, + ), + ) + + c := &WorkloadIdentityAWSRACommand{} + c.sharedStartArgs = newSharedStartArgs(cmd) + c.sharedDestinationArgs = newSharedDestinationArgs(cmd) + c.genericMutatorHandler = newGenericMutatorHandler(cmd, c, action) + + cmd.Flag( + "name-selector", + "The name of the workload identity to issue", + ).StringVar(&c.NameSelector) + cmd.Flag( + "label-selector", + "A label-based selector for which workload identities to issue. Multiple labels can be provided using ','.", + ).StringVar(&c.LabelSelector) + cmd.Flag( + "role-arn", + "The ARN of the role to assume.", + ).Required().StringVar(&c.RoleARN) + cmd.Flag( + "profile-arn", + "The ARN of the Roles Anywhere profile to use.", + ).Required().StringVar(&c.ProfileARN) + cmd.Flag( + "trust-anchor-arn", + "The ARN of the Roles Anywhere trust anchor to use.", + ).Required().StringVar(&c.TrustAnchorARN) + cmd.Flag( + "region", + "The AWS region to use. If unset, value will be used from the AWS config or the AWS_REGION environment variable.", + ).StringVar(&c.Region) + + cmd.Flag( + "session-duration", + "The duration of the resulting AWS session and credentials. This may be up to 12 hours. When unset, this defaults to 6 hours.", + ).DurationVar(&c.SessionDuration) + cmd.Flag( + "session-renewal-interval", + "How often the session should be renewed. This should be less than the session duration. When unset, this defaults to 1 hour.", + ).DurationVar(&c.SessionRenewalInterval) + + return c +} + +// ApplyConfig applies the parsed flags to the bot configuration. +func (c *WorkloadIdentityAWSRACommand) ApplyConfig(cfg *config.BotConfig, l *slog.Logger) error { + if err := c.sharedStartArgs.ApplyConfig(cfg, l); err != nil { + return trace.Wrap(err) + } + + dest, err := c.BuildDestination() + if err != nil { + return trace.Wrap(err) + } + + svc := &config.WorkloadIdentityAWSRAService{ + Destination: dest, + RoleARN: c.RoleARN, + ProfileARN: c.ProfileARN, + TrustAnchorARN: c.TrustAnchorARN, + Region: c.Region, + SessionDuration: c.SessionDuration, + SessionRenewalInterval: c.SessionRenewalInterval, + } + + switch { + case c.NameSelector != "" && c.LabelSelector != "": + return trace.BadParameter("name-selector and label-selector flags are mutually exclusive") + case c.NameSelector != "": + svc.Selector.Name = c.NameSelector + case c.LabelSelector != "": + labels, err := client.ParseLabelSpec(c.LabelSelector) + if err != nil { + return trace.Wrap(err, "parsing --label-selector") + } + svc.Selector.Labels = map[string][]string{} + for k, v := range labels { + svc.Selector.Labels[k] = []string{v} + } + default: + return trace.BadParameter("name-selector or label-selector must be specified") + } + + cfg.Services = append(cfg.Services, svc) + + return nil +} diff --git a/lib/tbot/cli/start_workload_identity_aws_ra_test.go b/lib/tbot/cli/start_workload_identity_aws_ra_test.go new file mode 100644 index 0000000000000..0b4e974b88091 --- /dev/null +++ b/lib/tbot/cli/start_workload_identity_aws_ra_test.go @@ -0,0 +1,121 @@ +// Teleport +// Copyright (C) 2025 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 . + +package cli + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/gravitational/teleport/lib/tbot/config" +) + +func TestWorkloadIdentityAWSRACommand(t *testing.T) { + testStartConfigureCommand(t, NewWorkloadIdentityAWSRACommand, []startConfigureTestCase{ + { + name: "success", + args: []string{ + "start", + "workload-identity-aws-roles-anywhere", + "--destination=/bar", + "--token=foo", + "--join-method=github", + "--proxy-server=example.com:443", + "--label-selector=*=*,foo=bar", + "--role-arn=arn:aws:iam::123456789012:role/example-role", + "--trust-anchor-arn=arn:aws:rolesanywhere:us-east-1:123456789012:trust-anchor/0000000-0000-0000-0000-000000000000", + "--profile-arn=arn:aws:rolesanywhere:us-east-1:123456789012:profile/0000000-0000-0000-0000-00000000000", + "--region=us-east-1", + "--session-duration=2h", + "--session-renewal-interval=30m", + }, + assertConfig: func(t *testing.T, cfg *config.BotConfig) { + require.Len(t, cfg.Services, 1) + + svc := cfg.Services[0] + wis, ok := svc.(*config.WorkloadIdentityAWSRAService) + require.True(t, ok) + + dir, ok := wis.Destination.(*config.DestinationDirectory) + require.True(t, ok) + require.Equal(t, "/bar", dir.Path) + + require.Equal(t, map[string][]string{ + "*": {"*"}, + "foo": {"bar"}, + }, wis.Selector.Labels) + require.Equal(t, "arn:aws:iam::123456789012:role/example-role", wis.RoleARN) + require.Equal( + t, + "arn:aws:rolesanywhere:us-east-1:123456789012:trust-anchor/0000000-0000-0000-0000-000000000000", + wis.TrustAnchorARN, + ) + require.Equal( + t, + "arn:aws:rolesanywhere:us-east-1:123456789012:profile/0000000-0000-0000-0000-00000000000", + wis.ProfileARN, + ) + require.Equal(t, "us-east-1", wis.Region) + + require.Equal(t, 2*time.Hour, wis.SessionDuration) + require.Equal(t, 30*time.Minute, wis.SessionRenewalInterval) + }, + }, + { + name: "success name selector", + args: []string{ + "start", + "workload-identity-aws-roles-anywhere", + "--destination=/bar", + "--token=foo", + "--join-method=github", + "--proxy-server=example.com:443", + "--name-selector=jim", + "--role-arn=arn:aws:iam::123456789012:role/example-role", + "--trust-anchor-arn=arn:aws:rolesanywhere:us-east-1:123456789012:trust-anchor/0000000-0000-0000-0000-000000000000", + "--profile-arn=arn:aws:rolesanywhere:us-east-1:123456789012:profile/0000000-0000-0000-0000-00000000000", + "--region=us-east-1", + }, + assertConfig: func(t *testing.T, cfg *config.BotConfig) { + require.Len(t, cfg.Services, 1) + + svc := cfg.Services[0] + wis, ok := svc.(*config.WorkloadIdentityAWSRAService) + require.True(t, ok) + + dir, ok := wis.Destination.(*config.DestinationDirectory) + require.True(t, ok) + require.Equal(t, "/bar", dir.Path) + + require.Equal(t, "jim", wis.Selector.Name) + require.Equal(t, "arn:aws:iam::123456789012:role/example-role", wis.RoleARN) + require.Equal( + t, + "arn:aws:rolesanywhere:us-east-1:123456789012:trust-anchor/0000000-0000-0000-0000-000000000000", + wis.TrustAnchorARN, + ) + require.Equal( + t, + "arn:aws:rolesanywhere:us-east-1:123456789012:profile/0000000-0000-0000-0000-00000000000", + wis.ProfileARN, + ) + require.Equal(t, "us-east-1", wis.Region) + }, + }, + }) +} diff --git a/lib/tbot/config/config.go b/lib/tbot/config/config.go index 7c56766ce7ccd..6df5330b75549 100644 --- a/lib/tbot/config/config.go +++ b/lib/tbot/config/config.go @@ -456,6 +456,12 @@ func (o *ServiceConfigs) UnmarshalYAML(node *yaml.Node) error { return trace.Wrap(err) } out = append(out, v) + case WorkloadIdentityAWSRAType: + v := &WorkloadIdentityAWSRAService{} + if err := node.Decode(v); err != nil { + return trace.Wrap(err) + } + out = append(out, v) default: return trace.BadParameter("unrecognized service type (%s)", header.Type) } diff --git a/lib/tbot/config/service_workload_identity_aws_ra.go b/lib/tbot/config/service_workload_identity_aws_ra.go new file mode 100644 index 0000000000000..eb81a3c07beda --- /dev/null +++ b/lib/tbot/config/service_workload_identity_aws_ra.go @@ -0,0 +1,181 @@ +// Teleport +// Copyright (C) 2025 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 . + +package config + +import ( + "context" + "time" + + "github.com/aws/aws-sdk-go-v2/aws/arn" + "github.com/gravitational/trace" + "gopkg.in/yaml.v3" + + "github.com/gravitational/teleport/api/utils/aws" + "github.com/gravitational/teleport/lib/tbot/bot" +) + +const ( + WorkloadIdentityAWSRAType = "workload-identity-aws-roles-anywhere" + defaultAWSSessionDuration = 6 * time.Hour + maxAWSSessionDuration = 12 * time.Hour + defaultAWSSessionRenewalInterval = 1 * time.Hour +) + +var ( + _ ServiceConfig = &WorkloadIdentityAWSRAService{} + _ Initable = &WorkloadIdentityAWSRAService{} +) + +// WorkloadIdentityAWSRAService is the configuration for the +// WorkloadIdentityAWSRAService +type WorkloadIdentityAWSRAService struct { + // Selector is the selector for the WorkloadIdentity resource that will be + // used to issue WICs. + Selector WorkloadIdentitySelector `yaml:"selector"` + // Destination is where the credentials should be written to. + Destination bot.Destination `yaml:"destination"` + + // RoleARN is the ARN of the role to assume. + // Example: `arn:aws:iam::123456789012:role/example-role` + // Required. + RoleARN string `yaml:"role_arn"` + // ProfileARN is the ARN of the Roles Anywhere profile to use. + // Example: `arn:aws:rolesanywhere:us-east-1:123456789012:profile/0000000-0000-0000-0000-00000000000` + // Required. + ProfileARN string `yaml:"profile_arn"` + // TrustAnchorARN is the ARN of the Roles Anywhere trust anchor to use. + // Example: `arn:aws:rolesanywhere:us-east-1:123456789012:trust-anchor/0000000-0000-0000-0000-000000000000` + // Required. + TrustAnchorARN string `yaml:"trust_anchor_arn"` + // Region is the AWS region to use. + // Example: `us-east-1` + // Must be set here or in the environment or AWS config using the + // `AWS_REGION` environment variable. If set here, this will override the + // environment or AWS config. + Region string `yaml:"region"` + + // SessionDuration is the duration of the resulting AWS session and + // credentials. This may be up to 12 hours. When unset, this defaults to + // 6 hours. + SessionDuration time.Duration `yaml:"session_duration"` + // SessionRenewalInterval is the interval at which the session should be + // renewed. This should be less than the session duration. When unset, this + // defaults to 1 hour. + SessionRenewalInterval time.Duration `yaml:"session_renewal_interval"` + + // EndpointOverride is the endpoint to use for the AWS Roles Anywhere service. + // This is designed to be leveraged by tests and unset in production + // circumstances. + EndpointOverride string `yaml:"-"` +} + +// Init initializes the destination. +func (o *WorkloadIdentityAWSRAService) Init(ctx context.Context) error { + return trace.Wrap(o.Destination.Init(ctx, []string{})) +} + +// CheckAndSetDefaults checks the WorkloadIdentityAWSRAService values and sets any defaults. +func (o *WorkloadIdentityAWSRAService) CheckAndSetDefaults() error { + if err := validateOutputDestination(o.Destination); err != nil { + return trace.Wrap(err) + } + if err := o.Selector.CheckAndSetDefaults(); err != nil { + return trace.Wrap(err, "validating selector") + } + + switch { + case o.RoleARN == "": + return trace.BadParameter("role_arn: must be set") + case o.ProfileARN == "": + return trace.BadParameter("profile_arn: must be set") + case o.TrustAnchorARN == "": + return trace.BadParameter("trust_anchor_arn: must be set") + } + if _, err := arn.Parse(o.RoleARN); err != nil { + return trace.Wrap(err, "parsing role_arn") + } + if _, err := arn.Parse(o.ProfileARN); err != nil { + return trace.Wrap(err, "parsing profile_arn") + } + if _, err := arn.Parse(o.TrustAnchorARN); err != nil { + return trace.Wrap(err, "parsing trust_anchor_arn") + } + if o.Region != "" { + if err := aws.IsValidRegion(o.Region); err != nil { + return trace.Wrap(err, "validating region") + } + } + + if o.SessionDuration == 0 { + o.SessionDuration = defaultAWSSessionDuration + } + if o.SessionDuration > maxAWSSessionDuration { + return trace.BadParameter("session_duration: must be less than or equal to 12 hours") + } + if o.SessionRenewalInterval == 0 { + o.SessionRenewalInterval = defaultAWSSessionRenewalInterval + } + if o.SessionRenewalInterval >= o.SessionDuration { + return trace.BadParameter("session_renewal_interval: must be less than session_duration") + } + + return nil +} + +// Describe returns the file descriptions for the WorkloadIdentityJWTService. +func (o *WorkloadIdentityAWSRAService) Describe() []FileDescription { + fds := []FileDescription{ + { + Name: JWTSVIDPath, + }, + } + return fds +} + +func (o *WorkloadIdentityAWSRAService) Type() string { + return WorkloadIdentityAWSRAType +} + +// MarshalYAML marshals the WorkloadIdentityJWTService into YAML. +func (o *WorkloadIdentityAWSRAService) MarshalYAML() (interface{}, error) { + type raw WorkloadIdentityAWSRAService + return withTypeHeader((*raw)(o), WorkloadIdentityAWSRAType) +} + +// UnmarshalYAML unmarshals the WorkloadIdentityJWTService from YAML. +func (o *WorkloadIdentityAWSRAService) UnmarshalYAML(node *yaml.Node) error { + dest, err := extractOutputDestination(node) + if err != nil { + return trace.Wrap(err) + } + // Alias type to remove UnmarshalYAML to avoid recursion + type raw WorkloadIdentityAWSRAService + if err := node.Decode((*raw)(o)); err != nil { + return trace.Wrap(err) + } + o.Destination = dest + return nil +} + +// GetDestination returns the destination. +func (o *WorkloadIdentityAWSRAService) GetDestination() bot.Destination { + return o.Destination +} + +func (o *WorkloadIdentityAWSRAService) GetCredentialLifetime() CredentialLifetime { + return CredentialLifetime{} +} diff --git a/lib/tbot/config/service_workload_identity_aws_ra_test.go b/lib/tbot/config/service_workload_identity_aws_ra_test.go new file mode 100644 index 0000000000000..0ed20e1e6e87a --- /dev/null +++ b/lib/tbot/config/service_workload_identity_aws_ra_test.go @@ -0,0 +1,308 @@ +// Teleport +// Copyright (C) 2025 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 . + +package config + +import ( + "testing" + "time" + + "github.com/gravitational/teleport/lib/tbot/botfs" +) + +func TestWorkloadIdentityAWSRAService_YAML(t *testing.T) { + t.Parallel() + + dest := &DestinationMemory{} + tests := []testYAMLCase[WorkloadIdentityAWSRAService]{ + { + name: "full", + in: WorkloadIdentityAWSRAService{ + Destination: dest, + Selector: WorkloadIdentitySelector{ + Name: "my-workload-identity", + }, + SessionDuration: time.Minute * 59, + SessionRenewalInterval: time.Minute * 29, + RoleARN: "arn:aws:iam::123456789012:role/example-role", + TrustAnchorARN: "arn:aws:rolesanywhere:us-east-1:123456789012:trust-anchor/0000000-0000-0000-0000-000000000000", + ProfileARN: "arn:aws:rolesanywhere:us-east-1:123456789012:profile/0000000-0000-0000-0000-00000000000", + Region: "us-east-1", + }, + }, + } + testYAML(t, tests) +} + +func TestWorkloadIdentityAWSRAService_CheckAndSetDefaults(t *testing.T) { + t.Parallel() + + tests := []testCheckAndSetDefaultsCase[*WorkloadIdentityAWSRAService]{ + { + name: "valid", + in: func() *WorkloadIdentityAWSRAService { + return &WorkloadIdentityAWSRAService{ + Selector: WorkloadIdentitySelector{ + Name: "my-workload-identity", + }, + Destination: &DestinationDirectory{ + Path: "/opt/machine-id", + ACLs: botfs.ACLOff, + Symlinks: botfs.SymlinksInsecure, + }, + RoleARN: "arn:aws:iam::123456789012:role/example-role", + TrustAnchorARN: "arn:aws:rolesanywhere:us-east-1:123456789012:trust-anchor/0000000-0000-0000-0000-000000000000", + ProfileARN: "arn:aws:rolesanywhere:us-east-1:123456789012:profile/0000000-0000-0000-0000-00000000000", + Region: "us-east-1", + } + }, + want: &WorkloadIdentityAWSRAService{ + Selector: WorkloadIdentitySelector{ + Name: "my-workload-identity", + }, + Destination: &DestinationDirectory{ + Path: "/opt/machine-id", + ACLs: botfs.ACLOff, + Symlinks: botfs.SymlinksInsecure, + }, + SessionDuration: defaultAWSSessionDuration, + SessionRenewalInterval: defaultAWSSessionRenewalInterval, + RoleARN: "arn:aws:iam::123456789012:role/example-role", + TrustAnchorARN: "arn:aws:rolesanywhere:us-east-1:123456789012:trust-anchor/0000000-0000-0000-0000-000000000000", + ProfileARN: "arn:aws:rolesanywhere:us-east-1:123456789012:profile/0000000-0000-0000-0000-00000000000", + Region: "us-east-1", + }, + }, + { + name: "valid with labels", + in: func() *WorkloadIdentityAWSRAService { + return &WorkloadIdentityAWSRAService{ + Selector: WorkloadIdentitySelector{ + Labels: map[string][]string{ + "key": {"value"}, + }, + }, + Destination: &DestinationDirectory{ + Path: "/opt/machine-id", + ACLs: botfs.ACLOff, + Symlinks: botfs.SymlinksInsecure, + }, + SessionDuration: 1 * time.Hour, + SessionRenewalInterval: 30 * time.Minute, + RoleARN: "arn:aws:iam::123456789012:role/example-role", + TrustAnchorARN: "arn:aws:rolesanywhere:us-east-1:123456789012:trust-anchor/0000000-0000-0000-0000-000000000000", + ProfileARN: "arn:aws:rolesanywhere:us-east-1:123456789012:profile/0000000-0000-0000-0000-00000000000", + Region: "us-east-1", + } + }, + }, + { + name: "missing selectors", + in: func() *WorkloadIdentityAWSRAService { + return &WorkloadIdentityAWSRAService{ + Selector: WorkloadIdentitySelector{}, + Destination: &DestinationDirectory{ + Path: "/opt/machine-id", + ACLs: botfs.ACLOff, + Symlinks: botfs.SymlinksInsecure, + }, + RoleARN: "arn:aws:iam::123456789012:role/example-role", + TrustAnchorARN: "arn:aws:rolesanywhere:us-east-1:123456789012:trust-anchor/0000000-0000-0000-0000-000000000000", + ProfileARN: "arn:aws:rolesanywhere:us-east-1:123456789012:profile/0000000-0000-0000-0000-00000000000", + Region: "us-east-1", + } + }, + wantErr: "one of ['name', 'labels'] must be set", + }, + { + name: "too many selectors", + in: func() *WorkloadIdentityAWSRAService { + return &WorkloadIdentityAWSRAService{ + Selector: WorkloadIdentitySelector{ + Name: "my-workload-identity", + Labels: map[string][]string{ + "key": {"value"}, + }, + }, + Destination: &DestinationDirectory{ + Path: "/opt/machine-id", + ACLs: botfs.ACLOff, + Symlinks: botfs.SymlinksInsecure, + }, + RoleARN: "arn:aws:iam::123456789012:role/example-role", + TrustAnchorARN: "arn:aws:rolesanywhere:us-east-1:123456789012:trust-anchor/0000000-0000-0000-0000-000000000000", + ProfileARN: "arn:aws:rolesanywhere:us-east-1:123456789012:profile/0000000-0000-0000-0000-00000000000", + Region: "us-east-1", + } + }, + wantErr: "at most one of ['name', 'labels'] can be set", + }, + { + name: "missing destination", + in: func() *WorkloadIdentityAWSRAService { + return &WorkloadIdentityAWSRAService{ + Destination: nil, + Selector: WorkloadIdentitySelector{ + Name: "my-workload-identity", + }, + RoleARN: "arn:aws:iam::123456789012:role/example-role", + TrustAnchorARN: "arn:aws:rolesanywhere:us-east-1:123456789012:trust-anchor/0000000-0000-0000-0000-000000000000", + ProfileARN: "arn:aws:rolesanywhere:us-east-1:123456789012:profile/0000000-0000-0000-0000-00000000000", + Region: "us-east-1", + } + }, + wantErr: "no destination configured for output", + }, + { + name: "missing role_arn", + in: func() *WorkloadIdentityAWSRAService { + return &WorkloadIdentityAWSRAService{ + Selector: WorkloadIdentitySelector{ + Name: "my-workload-identity", + }, + Destination: &DestinationDirectory{ + Path: "/opt/machine-id", + ACLs: botfs.ACLOff, + Symlinks: botfs.SymlinksInsecure, + }, + TrustAnchorARN: "arn:aws:rolesanywhere:us-east-1:123456789012:trust-anchor/0000000-0000-0000-0000-000000000000", + ProfileARN: "arn:aws:rolesanywhere:us-east-1:123456789012:profile/0000000-0000-0000-0000-00000000000", + Region: "us-east-1", + } + }, + wantErr: "role_arn: must be set", + }, + { + name: "missing trust_anchor_arn", + in: func() *WorkloadIdentityAWSRAService { + return &WorkloadIdentityAWSRAService{ + Selector: WorkloadIdentitySelector{ + Name: "my-workload-identity", + }, + Destination: &DestinationDirectory{ + Path: "/opt/machine-id", + ACLs: botfs.ACLOff, + Symlinks: botfs.SymlinksInsecure, + }, + RoleARN: "arn:aws:iam::123456789012:role/example-role", + ProfileARN: "arn:aws:rolesanywhere:us-east-1:123456789012:profile/0000000-0000-0000-0000-00000000000", + Region: "us-east-1", + } + }, + wantErr: "trust_anchor_arn: must be set", + }, + { + name: "missing profile_arn", + in: func() *WorkloadIdentityAWSRAService { + return &WorkloadIdentityAWSRAService{ + Selector: WorkloadIdentitySelector{ + Name: "my-workload-identity", + }, + Destination: &DestinationDirectory{ + Path: "/opt/machine-id", + ACLs: botfs.ACLOff, + Symlinks: botfs.SymlinksInsecure, + }, + RoleARN: "arn:aws:iam::123456789012:role/example-role", + TrustAnchorARN: "arn:aws:rolesanywhere:us-east-1:123456789012:trust-anchor/0000000-0000-0000-0000-000000000000", + Region: "us-east-1", + } + }, + wantErr: "profile_arn: must be set", + }, + { + name: "invalid role arn", + in: func() *WorkloadIdentityAWSRAService { + return &WorkloadIdentityAWSRAService{ + Selector: WorkloadIdentitySelector{ + Name: "my-workload-identity", + }, + Destination: &DestinationDirectory{ + Path: "/opt/machine-id", + ACLs: botfs.ACLOff, + Symlinks: botfs.SymlinksInsecure, + }, + RoleARN: "foo", + TrustAnchorARN: "arn:aws:rolesanywhere:us-east-1:123456789012:trust-anchor/0000000-0000-0000-0000-000000000000", + ProfileARN: "arn:aws:rolesanywhere:us-east-1:123456789012:profile/0000000-0000-0000-0000-00000000000", + Region: "us-east-1", + } + }, + wantErr: "arn: invalid prefix", + }, + { + name: "invalid trust anchor arn", + in: func() *WorkloadIdentityAWSRAService { + return &WorkloadIdentityAWSRAService{ + Selector: WorkloadIdentitySelector{ + Name: "my-workload-identity", + }, + Destination: &DestinationDirectory{ + Path: "/opt/machine-id", + ACLs: botfs.ACLOff, + Symlinks: botfs.SymlinksInsecure, + }, + RoleARN: "arn:aws:iam::123456789012:role/example-role", + TrustAnchorARN: "foo", + ProfileARN: "arn:aws:rolesanywhere:us-east-1:123456789012:profile/0000000-0000-0000-0000-00000000000", + Region: "us-east-1", + } + }, + wantErr: "arn: invalid prefix", + }, + { + name: "invalid profile arn", + in: func() *WorkloadIdentityAWSRAService { + return &WorkloadIdentityAWSRAService{ + Selector: WorkloadIdentitySelector{ + Name: "my-workload-identity", + }, + Destination: &DestinationDirectory{ + Path: "/opt/machine-id", + ACLs: botfs.ACLOff, + Symlinks: botfs.SymlinksInsecure, + }, + RoleARN: "arn:aws:iam::123456789012:role/example-role", + TrustAnchorARN: "arn:aws:rolesanywhere:us-east-1:123456789012:trust-anchor/0000000-0000-0000-0000-000000000000", + ProfileARN: "foo", + Region: "us-east-1", + } + }, + wantErr: "arn: invalid prefix", + }, + { + name: "invalid region", + in: func() *WorkloadIdentityAWSRAService { + return &WorkloadIdentityAWSRAService{ + Selector: WorkloadIdentitySelector{ + Name: "my-workload-identity", + }, + Destination: &DestinationDirectory{ + Path: "/opt/machine-id", + ACLs: botfs.ACLOff, + Symlinks: botfs.SymlinksInsecure, + }, + RoleARN: "arn:aws:iam::123456789012:role/example-role", + TrustAnchorARN: "arn:aws:rolesanywhere:us-east-1:123456789012:trust-anchor/0000000-0000-0000-0000-000000000000", + ProfileARN: "arn:aws:rolesanywhere:us-east-1:123456789012:profile/0000000-0000-0000-0000-00000000000", + Region: "us-east-1!!!!", + } + }, + wantErr: "validating region", + }, + } + testCheckAndSetDefaults(t, tests) +} diff --git a/lib/tbot/config/testdata/TestWorkloadIdentityAWSRAService_YAML/full.golden b/lib/tbot/config/testdata/TestWorkloadIdentityAWSRAService_YAML/full.golden new file mode 100644 index 0000000000000..4f3a9812064b9 --- /dev/null +++ b/lib/tbot/config/testdata/TestWorkloadIdentityAWSRAService_YAML/full.golden @@ -0,0 +1,11 @@ +type: workload-identity-aws-roles-anywhere +selector: + name: my-workload-identity +destination: + type: memory +role_arn: arn:aws:iam::123456789012:role/example-role +profile_arn: arn:aws:rolesanywhere:us-east-1:123456789012:profile/0000000-0000-0000-0000-00000000000 +trust_anchor_arn: arn:aws:rolesanywhere:us-east-1:123456789012:trust-anchor/0000000-0000-0000-0000-000000000000 +region: us-east-1 +session_duration: 59m0s +session_renewal_interval: 29m0s diff --git a/lib/tbot/service_workload_identity_aws_ra.go b/lib/tbot/service_workload_identity_aws_ra.go new file mode 100644 index 0000000000000..1f3167fc04b30 --- /dev/null +++ b/lib/tbot/service_workload_identity_aws_ra.go @@ -0,0 +1,260 @@ +// Teleport +// Copyright (C) 2025 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 . + +package tbot + +import ( + "bytes" + "context" + "crypto" + "crypto/x509" + "fmt" + "log/slog" + "time" + + "github.com/gravitational/trace" + awsspiffe "github.com/spiffe/aws-spiffe-workload-helper" + "github.com/spiffe/aws-spiffe-workload-helper/vendoredaws" + "github.com/spiffe/go-spiffe/v2/svid/x509svid" + "gopkg.in/ini.v1" + + workloadidentityv1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/workloadidentity/v1" + "github.com/gravitational/teleport/lib/auth/authclient" + "github.com/gravitational/teleport/lib/reversetunnelclient" + "github.com/gravitational/teleport/lib/tbot/bot" + "github.com/gravitational/teleport/lib/tbot/config" + "github.com/gravitational/teleport/lib/tbot/identity" + "github.com/gravitational/teleport/lib/tbot/workloadidentity" +) + +// WorkloadIdentityAWSRAService is a service that retrieves X.509 certificates +// and exchanges them for AWS credentials using the AWS Roles Anywhere service. +type WorkloadIdentityAWSRAService struct { + botAuthClient *authclient.Client + botCfg *config.BotConfig + cfg *config.WorkloadIdentityAWSRAService + getBotIdentity getBotIdentityFn + log *slog.Logger + resolver reversetunnelclient.Resolver + reloadBroadcaster *channelBroadcaster +} + +// String returns a human-readable description of the service. +func (s *WorkloadIdentityAWSRAService) String() string { + return fmt.Sprintf("workload-identity-aws-roles-anywhere (%s)", s.cfg.Destination.String()) +} + +// OneShot runs the service once, generating the output and writing it to the +// destination, before exiting. +func (s *WorkloadIdentityAWSRAService) OneShot(ctx context.Context) error { + return s.generate(ctx) +} + +// Run runs the service in a loop, generating the output and writing it to the +// destination at regular intervals. +func (s *WorkloadIdentityAWSRAService) Run(ctx context.Context) error { + reloadCh, unsubscribe := s.reloadBroadcaster.subscribe() + defer unsubscribe() + + err := runOnInterval(ctx, runOnIntervalConfig{ + service: s.String(), + name: "output-renewal", + f: s.generate, + interval: s.cfg.SessionRenewalInterval, + retryLimit: renewalRetryLimit, + log: s.log, + reloadCh: reloadCh, + }) + return trace.Wrap(err) +} + +func (s *WorkloadIdentityAWSRAService) generate(ctx context.Context) error { + res, privateKey, err := s.requestSVID(ctx) + if err != nil { + return trace.Wrap(err, "requesting SVID") + } + pkcs8, err := x509.MarshalPKCS8PrivateKey(privateKey) + if err != nil { + return trace.Wrap(err, "marshaling private key") + } + svid, err := x509svid.ParseRaw(res.GetX509Svid().Cert, pkcs8) + if err != nil { + return trace.Wrap(err, "parsing x509 svid") + } + + s.log.InfoContext( + ctx, + "Exchanging SVID for AWS credentials", + "spiffe_id", svid.ID.String(), + "role_arn", s.cfg.RoleARN, + "profile_arn", s.cfg.ProfileARN, + "trust_anchor_arn", s.cfg.TrustAnchorARN, + ) + creds, err := s.exchangeSVID(svid) + if err != nil { + return trace.Wrap(err, "exchanging SVID via Roles Anywhere") + } + s.log.InfoContext( + ctx, + "Exchanged SVID for AWS credentials", + "aws_credentials_expiry", creds.Expiration, + ) + + return renderAWSCreds(ctx, creds, s.cfg.Destination) +} + +// exchangeSVID will exchange the X.509 SVID for AWS credentials using the +// AWS Roles Anywhere service. +func (s *WorkloadIdentityAWSRAService) exchangeSVID( + svid *x509svid.SVID, +) (*vendoredaws.CredentialProcessOutput, error) { + signer := &awsspiffe.X509SVIDSigner{ + SVID: svid, + } + algo, err := signer.SignatureAlgorithm() + if err != nil { + return nil, trace.Wrap(err, "getting signature algorithm") + } + + credentials, err := vendoredaws.GenerateCredentials(&vendoredaws.CredentialsOpts{ + RoleArn: s.cfg.RoleARN, + ProfileArnStr: s.cfg.ProfileARN, + Region: s.cfg.Region, + TrustAnchorArnStr: s.cfg.TrustAnchorARN, + SessionDuration: int(s.cfg.SessionDuration.Seconds()), + Endpoint: s.cfg.EndpointOverride, + }, signer, algo) + if err != nil { + return nil, trace.Wrap(err, "exchanging credentials") + } + + return &credentials, nil +} + +func (s *WorkloadIdentityAWSRAService) requestSVID( + ctx context.Context, +) ( + *workloadidentityv1pb.Credential, + crypto.Signer, + error, +) { + ctx, span := tracer.Start( + ctx, + "WorkloadIdentityAWSRAService/requestSVID", + ) + defer span.End() + + roles, err := fetchDefaultRoles(ctx, s.botAuthClient, s.getBotIdentity()) + if err != nil { + return nil, nil, trace.Wrap(err, "fetching roles") + } + + id, err := generateIdentity( + ctx, + s.botAuthClient, + s.getBotIdentity(), + roles, + // We only need this to issue the X509 SVID, so we don't need the full + // lifetime. + time.Minute*10, + nil, + ) + if err != nil { + return nil, nil, trace.Wrap(err, "generating identity") + } + // create a client that uses the impersonated identity, so that when we + // fetch information, we can ensure access rights are enforced. + facade := identity.NewFacade(s.botCfg.FIPS, s.botCfg.Insecure, id) + impersonatedClient, err := clientForFacade(ctx, s.log, s.botCfg, facade, s.resolver) + if err != nil { + return nil, nil, trace.Wrap(err) + } + defer impersonatedClient.Close() + + x509Credentials, privateKey, err := workloadidentity.IssueX509WorkloadIdentity( + ctx, + s.log, + impersonatedClient, + s.cfg.Selector, + // We only use this SVID to exchange for AWS credentials, so we don't + // need the full lifetime. + time.Minute*10, + nil, + ) + if err != nil { + return nil, nil, trace.Wrap(err, "generating X509 SVID") + } + var x509Credential *workloadidentityv1pb.Credential + switch len(x509Credentials) { + case 0: + return nil, nil, trace.BadParameter("no X509 SVIDs returned") + case 1: + x509Credential = x509Credentials[0] + default: + // We could eventually implement some kind of hint selection mechanism + // to pick the "right" one. + received := make([]string, 0, len(x509Credentials)) + for _, cred := range x509Credentials { + received = append(received, + fmt.Sprintf( + "%s:%s", + cred.WorkloadIdentityName, + cred.SpiffeId, + ), + ) + } + return nil, nil, trace.BadParameter( + "multiple X509 SVIDs received: %v", received, + ) + } + + return x509Credential, privateKey, nil +} + +// render will write the AWS credentials to the AWS CLI configuration file. +// See https://docs.aws.amazon.com/cli/v1/userguide/cli-configure-files.html +func renderAWSCreds( + ctx context.Context, creds *vendoredaws.CredentialProcessOutput, dest bot.Destination, +) error { + ctx, span := tracer.Start( + ctx, + "renderAWSCreds", + ) + defer span.End() + + // TODO(noah): At a later date, we can add a mode where we read in and + // modify an existing profile within the credentials file - without + // overwriting other profiles. + f := ini.Empty() + // "default" is the name of the section that the AWS CLI will use by + // default. + sec := f.Section("default") + sec.Key("aws_secret_access_key").SetValue(creds.SecretAccessKey) + sec.Key("aws_access_key_id").SetValue(creds.AccessKeyId) + sec.Key("aws_session_token").SetValue(creds.SessionToken) + + b := &bytes.Buffer{} + _, err := f.WriteTo(b) + if err != nil { + return trace.Wrap(err, "writing credentials to buffer") + } + + err = dest.Write(ctx, "aws_credentials", b.Bytes()) + if err != nil { + return trace.Wrap(err, "writing credentials to destination") + } + return nil +} diff --git a/lib/tbot/service_workload_identity_aws_ra_test.go b/lib/tbot/service_workload_identity_aws_ra_test.go new file mode 100644 index 0000000000000..41c509d0b6121 --- /dev/null +++ b/lib/tbot/service_workload_identity_aws_ra_test.go @@ -0,0 +1,215 @@ +// Teleport +// Copyright (C) 2025 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 . +package tbot + +import ( + "context" + "crypto/x509" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "github.com/spiffe/aws-spiffe-workload-helper/vendoredaws" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + headerv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/header/v1" + workloadidentityv1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/workloadidentity/v1" + "github.com/gravitational/teleport/api/types" + apiutils "github.com/gravitational/teleport/api/utils" + "github.com/gravitational/teleport/lib/tbot/config" + "github.com/gravitational/teleport/lib/utils" + "github.com/gravitational/teleport/lib/utils/testutils/golden" + "github.com/gravitational/teleport/tool/teleport/testenv" +) + +func Test_renderAWSCreds(t *testing.T) { + creds := &vendoredaws.CredentialProcessOutput{ + AccessKeyId: "AKIAIOSFODNN7EXAMPLEAKID", + SessionToken: "AQoDYXdzEJrtyWJ4NjK7PiEXAMPLEST", + SecretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYzEXAMPLESAK", + } + ctx := context.Background() + + dest := &config.DestinationMemory{} + require.NoError(t, dest.CheckAndSetDefaults()) + require.NoError(t, dest.Init(ctx, []string{})) + + err := renderAWSCreds(ctx, creds, dest) + require.NoError(t, err) + + got, err := dest.Read(ctx, "aws_credentials") + require.NoError(t, err) + + if golden.ShouldSet() { + golden.Set(t, got) + } + require.Equal(t, golden.Get(t), got) +} + +type mockCreateSessionInputBody struct { + DurationSeconds int `json:"durationSeconds"` +} + +func TestBotWorkloadIdentityAWSRA(t *testing.T) { + t.Parallel() + ctx := context.Background() + log := utils.NewSlogLoggerForTests() + + process := testenv.MakeTestServer(t, defaultTestServerOpts(t, log)) + rootClient := testenv.MakeDefaultAuthClient(t, process) + + roleArn := "arn:aws:iam::123456789012:role/example-role" + trustAnchorArn := "arn:aws:rolesanywhere:us-east-1:123456789012:trust-anchor/0000000-0000-0000-0000-000000000000" + profileArn := "arn:aws:rolesanywhere:us-east-1:123456789012:profile/0000000-0000-0000-0000-00000000000" + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/sessions", r.URL.Path) + assert.Equal(t, http.MethodPost, r.Method) + + // Check query parameter inputs + // The AWS documentation "lies" about these inputs using the JSON body + // - the rolesanywhere API client in + // `aws/rolesanywhere-credential-helper` uses query parameters for + // these. + assert.Equal(t, roleArn, r.URL.Query().Get("roleArn")) + assert.Equal(t, trustAnchorArn, r.URL.Query().Get("trustAnchorArn")) + assert.Equal(t, profileArn, r.URL.Query().Get("profileArn")) + + // Check JSON body inputs + body := &mockCreateSessionInputBody{} + assert.NoError(t, json.NewDecoder(r.Body).Decode(body)) + assert.Equal(t, int((2 * time.Hour).Seconds()), body.DurationSeconds) + + // Validate the X-Amz-X509 header contains the valid (and correct) SVID + derString := r.Header.Get("X-Amz-X509") + assert.NotEmpty(t, derString) + derBytes, err := base64.StdEncoding.DecodeString(derString) + assert.NoError(t, err) + cert, err := x509.ParseCertificate(derBytes) + assert.NoError(t, err) + assert.Len(t, cert.URIs, 1) + assert.Equal(t, "spiffe://root/ra-test", cert.URIs[0].String()) + + // Validate the authorization header exists. We rely on the AWS SDK to + // actually produce the signature, and, validating this signature would + // introduce significant complexity to this test - so this is omitted. + authz := r.Header.Get("Authorization") + assert.NotEmpty(t, authz) + + // Send mocked response + _, _ = w.Write([]byte(`{ + "credentialSet":[ + { + "assumedRoleUser": { + "arn": "arn:aws:iam::123456789012:role/example-role", + "assumedRoleId": "assumedRoleId" + }, + "credentials":{ + "accessKeyId": "accessKeyId", + "expiration": "2028-07-27T04:36:55Z", + "secretAccessKey": "secretAccessKey", + "sessionToken": "sessionToken" + }, + "packedPolicySize": 10, + "roleArn": "arn:aws:iam::123456789012:role/example-role", + "sourceIdentity": "sourceIdentity" + } + ], + "subjectArn": "arn:aws:rolesanywhere:us-east-1:000000000000:subject/41cl0bae-6783-40d4-ab20-65dc5d922e45" + }`)) + })) + t.Cleanup(srv.Close) + + role, err := types.NewRole("issue-foo", types.RoleSpecV6{ + Allow: types.RoleConditions{ + WorkloadIdentityLabels: map[string]apiutils.Strings{ + "foo": []string{"bar"}, + }, + Rules: []types.Rule{ + { + Resources: []string{types.KindWorkloadIdentity}, + Verbs: []string{types.VerbRead, types.VerbList}, + }, + }, + }, + }) + require.NoError(t, err) + role, err = rootClient.UpsertRole(ctx, role) + require.NoError(t, err) + + workloadIdentity := &workloadidentityv1pb.WorkloadIdentity{ + Kind: types.KindWorkloadIdentity, + Version: types.V1, + Metadata: &headerv1.Metadata{ + Name: "foo-bar-bizz", + Labels: map[string]string{ + "foo": "bar", + }, + }, + Spec: &workloadidentityv1pb.WorkloadIdentitySpec{ + Spiffe: &workloadidentityv1pb.WorkloadIdentitySPIFFE{ + Id: "/ra-test", + }, + }, + } + workloadIdentity, err = rootClient.WorkloadIdentityResourceServiceClient(). + CreateWorkloadIdentity(ctx, &workloadidentityv1pb.CreateWorkloadIdentityRequest{ + WorkloadIdentity: workloadIdentity, + }) + require.NoError(t, err) + + tmpDir := t.TempDir() + onboarding, _ := makeBot(t, rootClient, "ra-test", role.GetName()) + botConfig := defaultBotConfig(t, process, onboarding, config.ServiceConfigs{ + &config.WorkloadIdentityAWSRAService{ + Selector: config.WorkloadIdentitySelector{ + Name: workloadIdentity.GetMetadata().GetName(), + }, + Destination: &config.DestinationDirectory{ + Path: tmpDir, + }, + RoleARN: roleArn, + ProfileARN: profileArn, + TrustAnchorARN: trustAnchorArn, + Region: "us-east-1", + SessionDuration: 2 * time.Hour, + SessionRenewalInterval: 30 * time.Minute, + EndpointOverride: srv.URL, + }, + }, defaultBotConfigOpts{ + useAuthServer: true, + insecure: true, + }) + + botConfig.Oneshot = true + b := New(botConfig, log) + // Run Bot with 10 second timeout to catch hangs. + ctx, cancel := context.WithTimeout(ctx, time.Second*10) + t.Cleanup(cancel) + require.NoError(t, b.Run(ctx)) + + got, err := os.ReadFile(filepath.Join(tmpDir, "aws_credentials")) + require.NoError(t, err) + if golden.ShouldSet() { + golden.Set(t, got) + } + require.Equal(t, string(golden.Get(t)), string(got)) +} diff --git a/lib/tbot/tbot.go b/lib/tbot/tbot.go index 4964310fb30f8..a863a076cf8c0 100644 --- a/lib/tbot/tbot.go +++ b/lib/tbot/tbot.go @@ -616,6 +616,19 @@ func (b *Bot) Run(ctx context.Context) (err error) { teleport.ComponentKey, teleport.Component(componentTBot, "svc", svc.String()), ) services = append(services, svc) + case *config.WorkloadIdentityAWSRAService: + svc := &WorkloadIdentityAWSRAService{ + botCfg: b.cfg, + cfg: svcCfg, + resolver: resolver, + botAuthClient: b.botIdentitySvc.GetClient(), + getBotIdentity: b.botIdentitySvc.GetIdentity, + reloadBroadcaster: reloadBroadcaster, + } + svc.log = b.log.With( + teleport.ComponentKey, teleport.Component(componentTBot, "svc", svc.String()), + ) + services = append(services, svc) default: return trace.BadParameter("unknown service type: %T", svcCfg) } diff --git a/lib/tbot/testdata/TestBotWorkloadIdentityAWSRA.golden b/lib/tbot/testdata/TestBotWorkloadIdentityAWSRA.golden new file mode 100644 index 0000000000000..ae1150fb2ee6d --- /dev/null +++ b/lib/tbot/testdata/TestBotWorkloadIdentityAWSRA.golden @@ -0,0 +1,4 @@ +[default] +aws_secret_access_key=secretAccessKey +aws_access_key_id=accessKeyId +aws_session_token=sessionToken diff --git a/lib/tbot/testdata/Test_renderAWSCreds.golden b/lib/tbot/testdata/Test_renderAWSCreds.golden new file mode 100644 index 0000000000000..0a55d7dad24b5 --- /dev/null +++ b/lib/tbot/testdata/Test_renderAWSCreds.golden @@ -0,0 +1,4 @@ +[default] +aws_secret_access_key=wJalrXUtnFEMI/K7MDENG/bPxRfiCYzEXAMPLESAK +aws_access_key_id=AKIAIOSFODNN7EXAMPLEAKID +aws_session_token=AQoDYXdzEJrtyWJ4NjK7PiEXAMPLEST diff --git a/tool/tbot/main.go b/tool/tbot/main.go index e9e22d506934e..293d7c0a5d45e 100644 --- a/tool/tbot/main.go +++ b/tool/tbot/main.go @@ -152,6 +152,9 @@ func Run(args []string, stdout io.Writer) error { cli.NewWorkloadIdentityJWTCommand(startCmd, buildConfigAndStart(ctx, globalCfg), cli.CommandModeStart), cli.NewWorkloadIdentityJWTCommand(configureCmd, buildConfigAndConfigure(ctx, globalCfg, &configureOutPath, stdout), cli.CommandModeConfigure), + + cli.NewWorkloadIdentityAWSRACommand(startCmd, buildConfigAndStart(ctx, globalCfg), cli.CommandModeStart), + cli.NewWorkloadIdentityAWSRACommand(configureCmd, buildConfigAndConfigure(ctx, globalCfg, &configureOutPath, stdout), cli.CommandModeConfigure), ) // Initialize legacy-style commands. These are simple enough to not really