diff --git a/lib/tbot/cli/start_workload_identity_jwt.go b/lib/tbot/cli/start_workload_identity_jwt.go new file mode 100644 index 00000000000..9e8a318a23d --- /dev/null +++ b/lib/tbot/cli/start_workload_identity_jwt.go @@ -0,0 +1,113 @@ +// 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" + + "github.com/alecthomas/kingpin/v2" + "github.com/gravitational/trace" + + "github.com/gravitational/teleport/lib/client" + "github.com/gravitational/teleport/lib/tbot/config" +) + +// WorkloadIdentityJWTCommand implements `tbot start workload-identity-jwt` and +// `tbot configure workload-identity-jwt`. +type WorkloadIdentityJWTCommand 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 + + // Audiences is the list of audiences to include in the JWT. + Audiences []string +} + +// NewWorkloadIdentityJWTCommand initializes the command and flags for the +// `workload-identity-jwt` output and returns a struct that will contain the parse +// result. +func NewWorkloadIdentityJWTCommand(parentCmd *kingpin.CmdClause, action MutatorAction, mode CommandMode) *WorkloadIdentityJWTCommand { + // TODO(noah): Unhide this command when feature flag removed + cmd := parentCmd.Command("workload-identity-jwt", fmt.Sprintf("%s tbot with a SPIFFE-compatible JWT SVID output.", mode)).Hidden() + + c := &WorkloadIdentityJWTCommand{} + 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( + "audience", + "Specify the audiences to include in the JWT. At least one audience must be specified.", + ).Required().StringsVar(&c.Audiences) + + return c +} + +// ApplyConfig applies the parsed flags to the bot configuration. +func (c *WorkloadIdentityJWTCommand) 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.WorkloadIdentityJWTService{ + Destination: dest, + Audiences: c.Audiences, + } + + 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_jwt_test.go b/lib/tbot/cli/start_workload_identity_jwt_test.go new file mode 100644 index 00000000000..f00b1bff2af --- /dev/null +++ b/lib/tbot/cli/start_workload_identity_jwt_test.go @@ -0,0 +1,90 @@ +// 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" + + "github.com/stretchr/testify/require" + + "github.com/gravitational/teleport/lib/tbot/config" +) + +func TestWorkloadIdentityJWTCommand(t *testing.T) { + testStartConfigureCommand(t, NewWorkloadIdentityJWTCommand, []startConfigureTestCase{ + { + name: "success", + args: []string{ + "start", + "workload-identity-jwt", + "--destination=/bar", + "--token=foo", + "--join-method=github", + "--proxy-server=example.com:443", + "--label-selector=*=*,foo=bar", + "--audience=foo", + }, + assertConfig: func(t *testing.T, cfg *config.BotConfig) { + require.Len(t, cfg.Services, 1) + + svc := cfg.Services[0] + wis, ok := svc.(*config.WorkloadIdentityJWTService) + 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, []string{"foo"}, wis.Audiences) + }, + }, + { + name: "success name selector", + args: []string{ + "start", + "workload-identity-jwt", + "--destination=/bar", + "--token=foo", + "--join-method=github", + "--proxy-server=example.com:443", + "--name-selector=jim", + "--audience=foo", + "--audience=bar", + }, + assertConfig: func(t *testing.T, cfg *config.BotConfig) { + require.Len(t, cfg.Services, 1) + + svc := cfg.Services[0] + wis, ok := svc.(*config.WorkloadIdentityJWTService) + 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, []string{"foo", "bar"}, wis.Audiences) + + }, + }, + }) +} diff --git a/lib/tbot/config/config.go b/lib/tbot/config/config.go index b23101fc4bc..79eed0db680 100644 --- a/lib/tbot/config/config.go +++ b/lib/tbot/config/config.go @@ -411,6 +411,12 @@ func (o *ServiceConfigs) UnmarshalYAML(node *yaml.Node) error { return trace.Wrap(err) } out = append(out, v) + case WorkloadIdentityJWTOutputType: + v := &WorkloadIdentityJWTService{} + 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/config_test.go b/lib/tbot/config/config_test.go index d19abe08599..455f9233e6b 100644 --- a/lib/tbot/config/config_test.go +++ b/lib/tbot/config/config_test.go @@ -278,6 +278,15 @@ func TestBotConfig_YAML(t *testing.T) { Name: "my-workload-identity", }, }, + &WorkloadIdentityJWTService{ + Destination: &DestinationDirectory{ + Path: "/an/output/path", + }, + Selector: WorkloadIdentitySelector{ + Name: "my-workload-identity", + }, + Audiences: []string{"audience1", "audience2"}, + }, }, }, }, diff --git a/lib/tbot/config/service_workload_identity_jwt.go b/lib/tbot/config/service_workload_identity_jwt.go new file mode 100644 index 00000000000..4c71dace4bc --- /dev/null +++ b/lib/tbot/config/service_workload_identity_jwt.go @@ -0,0 +1,106 @@ +// 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" + + "github.com/gravitational/trace" + "gopkg.in/yaml.v3" + + "github.com/gravitational/teleport/lib/tbot/bot" +) + +const WorkloadIdentityJWTOutputType = "workload-identity-jwt" + +var ( + _ ServiceConfig = &WorkloadIdentityJWTService{} + _ Initable = &WorkloadIdentityJWTService{} +) + +// WorkloadIdentityJWTService is the configuration for the WorkloadIdentityJWTService +type WorkloadIdentityJWTService 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"` + // Audiences is the list of audiences that the JWT should be valid for. + Audiences []string +} + +// Init initializes the destination. +func (o *WorkloadIdentityJWTService) Init(ctx context.Context) error { + return trace.Wrap(o.Destination.Init(ctx, []string{})) +} + +// CheckAndSetDefaults checks the WorkloadIdentityJWTService values and sets any defaults. +func (o *WorkloadIdentityJWTService) 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") + } + if len(o.Audiences) == 0 { + return trace.BadParameter("audiences: must have at least one value") + } + return nil +} + +// JWTSVIDPath is the name of the artifact that a JWT SVID will be written to. +const JWTSVIDPath = "jwt_svid" + +// Describe returns the file descriptions for the WorkloadIdentityJWTService. +func (o *WorkloadIdentityJWTService) Describe() []FileDescription { + fds := []FileDescription{ + { + Name: JWTSVIDPath, + }, + } + return fds +} + +func (o *WorkloadIdentityJWTService) Type() string { + return WorkloadIdentityJWTOutputType +} + +// MarshalYAML marshals the WorkloadIdentityJWTService into YAML. +func (o *WorkloadIdentityJWTService) MarshalYAML() (interface{}, error) { + type raw WorkloadIdentityJWTService + return withTypeHeader((*raw)(o), WorkloadIdentityJWTOutputType) +} + +// UnmarshalYAML unmarshals the WorkloadIdentityJWTService from YAML. +func (o *WorkloadIdentityJWTService) 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 WorkloadIdentityJWTService + if err := node.Decode((*raw)(o)); err != nil { + return trace.Wrap(err) + } + o.Destination = dest + return nil +} + +// GetDestination returns the destination. +func (o *WorkloadIdentityJWTService) GetDestination() bot.Destination { + return o.Destination +} diff --git a/lib/tbot/config/service_workload_identity_jwt_test.go b/lib/tbot/config/service_workload_identity_jwt_test.go new file mode 100644 index 00000000000..6f4f2571df0 --- /dev/null +++ b/lib/tbot/config/service_workload_identity_jwt_test.go @@ -0,0 +1,148 @@ +// 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" + + "github.com/gravitational/teleport/lib/tbot/botfs" +) + +func TestWorkloadIdentityJWTService_YAML(t *testing.T) { + t.Parallel() + + dest := &DestinationMemory{} + tests := []testYAMLCase[WorkloadIdentityJWTService]{ + { + name: "full", + in: WorkloadIdentityJWTService{ + Destination: dest, + Selector: WorkloadIdentitySelector{ + Name: "my-workload-identity", + }, + Audiences: []string{"audience1", "audience2"}, + }, + }, + } + testYAML(t, tests) +} + +func TestWorkloadIdentityJWTService_CheckAndSetDefaults(t *testing.T) { + t.Parallel() + + tests := []testCheckAndSetDefaultsCase[*WorkloadIdentityJWTService]{ + { + name: "valid", + in: func() *WorkloadIdentityJWTService { + return &WorkloadIdentityJWTService{ + Selector: WorkloadIdentitySelector{ + Name: "my-workload-identity", + }, + Destination: &DestinationDirectory{ + Path: "/opt/machine-id", + ACLs: botfs.ACLOff, + Symlinks: botfs.SymlinksInsecure, + }, + Audiences: []string{"audience1", "audience2"}, + } + }, + }, + { + name: "valid with labels", + in: func() *WorkloadIdentityJWTService { + return &WorkloadIdentityJWTService{ + Selector: WorkloadIdentitySelector{ + Labels: map[string][]string{ + "key": {"value"}, + }, + }, + Destination: &DestinationDirectory{ + Path: "/opt/machine-id", + ACLs: botfs.ACLOff, + Symlinks: botfs.SymlinksInsecure, + }, + Audiences: []string{"audience1", "audience2"}, + } + }, + }, + { + name: "missing audience", + in: func() *WorkloadIdentityJWTService { + return &WorkloadIdentityJWTService{ + Selector: WorkloadIdentitySelector{ + Name: "my-workload-identity", + }, + Destination: &DestinationDirectory{ + Path: "/opt/machine-id", + ACLs: botfs.ACLOff, + Symlinks: botfs.SymlinksInsecure, + }, + } + }, + wantErr: "audiences: must have at least one value", + }, + { + name: "missing selectors", + in: func() *WorkloadIdentityJWTService { + return &WorkloadIdentityJWTService{ + Selector: WorkloadIdentitySelector{}, + Destination: &DestinationDirectory{ + Path: "/opt/machine-id", + ACLs: botfs.ACLOff, + Symlinks: botfs.SymlinksInsecure, + }, + Audiences: []string{"audience1", "audience2"}, + } + }, + wantErr: "one of ['name', 'labels'] must be set", + }, + { + name: "too many selectors", + in: func() *WorkloadIdentityJWTService { + return &WorkloadIdentityJWTService{ + Selector: WorkloadIdentitySelector{ + Name: "my-workload-identity", + Labels: map[string][]string{ + "key": {"value"}, + }, + }, + Destination: &DestinationDirectory{ + Path: "/opt/machine-id", + ACLs: botfs.ACLOff, + Symlinks: botfs.SymlinksInsecure, + }, + Audiences: []string{"audience1", "audience2"}, + } + }, + wantErr: "at most one of ['name', 'labels'] can be set", + }, + { + name: "missing destination", + in: func() *WorkloadIdentityJWTService { + return &WorkloadIdentityJWTService{ + Destination: nil, + Selector: WorkloadIdentitySelector{ + Name: "my-workload-identity", + }, + Audiences: []string{"audience1", "audience2"}, + } + }, + wantErr: "no destination configured for output", + }, + } + testCheckAndSetDefaults(t, tests) +} diff --git a/lib/tbot/config/testdata/TestBotConfig_YAML/standard_config.golden b/lib/tbot/config/testdata/TestBotConfig_YAML/standard_config.golden index 302209284ad..79bfdb3b422 100644 --- a/lib/tbot/config/testdata/TestBotConfig_YAML/standard_config.golden +++ b/lib/tbot/config/testdata/TestBotConfig_YAML/standard_config.golden @@ -68,6 +68,15 @@ services: enabled: false selector: name: my-workload-identity + - type: workload-identity-jwt + selector: + name: my-workload-identity + destination: + type: directory + path: /an/output/path + audiences: + - audience1 + - audience2 debug: true auth_server: example.teleport.sh:443 certificate_ttl: 1m0s diff --git a/lib/tbot/config/testdata/TestWorkloadIdentityJWTService_YAML/full.golden b/lib/tbot/config/testdata/TestWorkloadIdentityJWTService_YAML/full.golden new file mode 100644 index 00000000000..09c561e4cde --- /dev/null +++ b/lib/tbot/config/testdata/TestWorkloadIdentityJWTService_YAML/full.golden @@ -0,0 +1,8 @@ +type: workload-identity-jwt +selector: + name: my-workload-identity +destination: + type: memory +audiences: + - audience1 + - audience2 diff --git a/lib/tbot/service_workload_identity_jwt.go b/lib/tbot/service_workload_identity_jwt.go new file mode 100644 index 00000000000..0f3093ea8ca --- /dev/null +++ b/lib/tbot/service_workload_identity_jwt.go @@ -0,0 +1,250 @@ +// 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" + "fmt" + "log/slog" + "math" + "time" + + "github.com/gravitational/trace" + + workloadidentityv1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/workloadidentity/v1" + "github.com/gravitational/teleport/api/utils/retryutils" + "github.com/gravitational/teleport/lib/auth/authclient" + "github.com/gravitational/teleport/lib/reversetunnelclient" + "github.com/gravitational/teleport/lib/tbot/config" + "github.com/gravitational/teleport/lib/tbot/identity" + "github.com/gravitational/teleport/lib/tbot/workloadidentity" +) + +// WorkloadIdentityJWTService is a service that retrieves JWT workload identity +// credentials for WorkloadIdentity resources. +type WorkloadIdentityJWTService struct { + botAuthClient *authclient.Client + botCfg *config.BotConfig + cfg *config.WorkloadIdentityJWTService + getBotIdentity getBotIdentityFn + log *slog.Logger + resolver reversetunnelclient.Resolver + // trustBundleCache is the cache of trust bundles. It only needs to be + // provided when running in daemon mode. + trustBundleCache *workloadidentity.TrustBundleCache +} + +// String returns a human-readable description of the service. +func (s *WorkloadIdentityJWTService) String() string { + return fmt.Sprintf("workload-identity-jwt (%s)", s.cfg.Destination.String()) +} + +// OneShot runs the service once, generating the output and writing it to the +// destination, before exiting. +func (s *WorkloadIdentityJWTService) OneShot(ctx context.Context) error { + res, err := s.requestJWTSVID(ctx) + if err != nil { + return trace.Wrap(err, "requesting JWT SVID") + } + return s.render(ctx, res) +} + +// Run runs the service in daemon mode, periodically generating the output and +// writing it to the destination. +func (s *WorkloadIdentityJWTService) Run(ctx context.Context) error { + bundleSet, err := s.trustBundleCache.GetBundleSet(ctx) + if err != nil { + return trace.Wrap(err, "getting trust bundle set") + } + + jitter := retryutils.DefaultJitter + var cred *workloadidentityv1pb.Credential + var failures int + firstRun := make(chan struct{}, 1) + firstRun <- struct{}{} + for { + var retryAfter <-chan time.Time + if failures > 0 { + backoffTime := time.Second * time.Duration(math.Pow(2, float64(failures-1))) + if backoffTime > time.Minute { + backoffTime = time.Minute + } + backoffTime = jitter(backoffTime) + s.log.WarnContext( + ctx, + "Last attempt to generate output failed, will retry", + "retry_after", backoffTime, + "failures", failures, + ) + retryAfter = time.After(time.Duration(failures) * time.Second) + } + select { + case <-ctx.Done(): + return nil + case <-retryAfter: + s.log.InfoContext(ctx, "Retrying") + case <-bundleSet.Stale(): + // We don't actually write this bundle out at the moment, but, we + // still track it so we know when to reissue the JWT SVID. + newBundleSet, err := s.trustBundleCache.GetBundleSet(ctx) + if err != nil { + return trace.Wrap(err, "getting trust bundle set") + } + s.log.InfoContext(ctx, "Trust bundle set has been updated") + if !newBundleSet.Local.Equal(bundleSet.Local) { + // If the local trust domain CA has changed, we need to reissue + // the SVID. + cred = nil + } + bundleSet = newBundleSet + case <-time.After(s.botCfg.RenewalInterval): + s.log.InfoContext(ctx, "Renewal interval reached, renewing SVIDs") + cred = nil + case <-firstRun: + } + + if cred == nil { + var err error + cred, err = s.requestJWTSVID(ctx) + if err != nil { + s.log.ErrorContext(ctx, "Failed to request JWT SVID", "error", err) + failures++ + continue + } + } + if err := s.render(ctx, cred); err != nil { + s.log.ErrorContext(ctx, "Failed to render output", "error", err) + failures++ + continue + } + failures = 0 + } +} + +func (s *WorkloadIdentityJWTService) requestJWTSVID( + ctx context.Context, +) ( + *workloadidentityv1pb.Credential, + error, +) { + ctx, span := tracer.Start( + ctx, + "WorkloadIdentityJWTService/requestJWTSVID", + ) + defer span.End() + + roles, err := fetchDefaultRoles(ctx, s.botAuthClient, s.getBotIdentity()) + if err != nil { + return nil, trace.Wrap(err, "fetching roles") + } + + id, err := generateIdentity( + ctx, + s.botAuthClient, + s.getBotIdentity(), + roles, + s.botCfg.CertificateTTL, + nil, + ) + if err != nil { + return 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, trace.Wrap(err) + } + defer impersonatedClient.Close() + + credentials, err := workloadidentity.IssueJWTWorkloadIdentity( + ctx, + s.log, + impersonatedClient, + s.cfg.Selector, + s.cfg.Audiences, + s.botCfg.CertificateTTL, + nil, + ) + if err != nil { + return nil, trace.Wrap(err, "generating JWT SVID") + } + var credential *workloadidentityv1pb.Credential + switch len(credentials) { + case 0: + return nil, trace.BadParameter("no JWT SVIDs returned") + case 1: + credential = credentials[0] + default: + // We could eventually implement some kind of hint selection mechanism + // to pick the "right" one. + received := make([]string, 0, len(credentials)) + for _, cred := range credentials { + received = append(received, + fmt.Sprintf( + "%s:%s", + cred.WorkloadIdentityName, + cred.SpiffeId, + ), + ) + } + return nil, trace.BadParameter( + "multiple JWT SVIDs received: %v", received, + ) + } + + return credential, nil +} + +func (s *WorkloadIdentityJWTService) render( + ctx context.Context, + cred *workloadidentityv1pb.Credential, +) error { + ctx, span := tracer.Start( + ctx, + "WorkloadIdentityJWTService/render", + ) + defer span.End() + s.log.InfoContext(ctx, "Rendering output") + + // Check the ACLs. We can't fix them, but we can warn if they're + // misconfigured. We'll need to precompute a list of keys to check. + // Note: This may only log a warning, depending on configuration. + if err := s.cfg.Destination.Verify(identity.ListKeys(identity.DestinationKinds()...)); err != nil { + return trace.Wrap(err) + } + // Ensure this destination is also writable. This is a hard fail if + // ACLs are misconfigured, regardless of configuration. + if err := identity.VerifyWrite(ctx, s.cfg.Destination); err != nil { + return trace.Wrap(err, "verifying destination") + } + + if err := s.cfg.Destination.Write( + ctx, config.JWTSVIDPath, []byte(cred.GetJwtSvid().GetJwt()), + ); err != nil { + return trace.Wrap(err, "writing jwt svid") + } + + s.log.InfoContext( + ctx, + "Successfully wrote JWT workload identity credential to destination", + "workload_identity", workloadidentity.WorkloadIdentityLogValue(cred), + "destination", s.cfg.Destination.String(), + ) + return nil +} diff --git a/lib/tbot/service_workload_identity_jwt_test.go b/lib/tbot/service_workload_identity_jwt_test.go new file mode 100644 index 00000000000..dacd495ee69 --- /dev/null +++ b/lib/tbot/service_workload_identity_jwt_test.go @@ -0,0 +1,152 @@ +// 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" + "os" + "path/filepath" + "testing" + "time" + + "github.com/spiffe/go-spiffe/v2/svid/jwtsvid" + "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/auth/machineid/workloadidentityv1/experiment" + "github.com/gravitational/teleport/lib/tbot/config" + "github.com/gravitational/teleport/lib/utils" + "github.com/gravitational/teleport/tool/teleport/testenv" +) + +func TestBotWorkloadIdentityJWT(t *testing.T) { + experimentStatus := experiment.Enabled() + defer experiment.SetEnabled(experimentStatus) + experiment.SetEnabled(true) + + ctx := context.Background() + log := utils.NewSlogLoggerForTests() + + process := testenv.MakeTestServer(t, defaultTestServerOpts(t, log)) + rootClient := testenv.MakeDefaultAuthClient(t, process) + + 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: "/valid/{{ user.bot_name }}", + }, + }, + } + workloadIdentity, err = rootClient.WorkloadIdentityResourceServiceClient(). + CreateWorkloadIdentity(ctx, &workloadidentityv1pb.CreateWorkloadIdentityRequest{ + WorkloadIdentity: workloadIdentity, + }) + require.NoError(t, err) + + t.Run("By Name", func(t *testing.T) { + tmpDir := t.TempDir() + onboarding, _ := makeBot(t, rootClient, "by-name", role.GetName()) + botConfig := defaultBotConfig(t, process, onboarding, config.ServiceConfigs{ + &config.WorkloadIdentityJWTService{ + Selector: config.WorkloadIdentitySelector{ + Name: workloadIdentity.GetMetadata().GetName(), + }, + Destination: &config.DestinationDirectory{ + Path: tmpDir, + }, + Audiences: []string{"example", "foo"}, + }, + }, 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) + defer cancel() + require.NoError(t, b.Run(ctx)) + + jwtBytes, err := os.ReadFile(filepath.Join(tmpDir, config.JWTSVIDPath)) + require.NoError(t, err) + jwt, err := jwtsvid.ParseInsecure(string(jwtBytes), []string{"example"}) + require.NoError(t, err) + require.Equal(t, "spiffe://root/valid/by-name", jwt.ID.String()) + require.Equal(t, []string{"example", "foo"}, jwt.Audience) + }) + t.Run("By Labels", func(t *testing.T) { + tmpDir := t.TempDir() + onboarding, _ := makeBot(t, rootClient, "by-labels", role.GetName()) + botConfig := defaultBotConfig(t, process, onboarding, config.ServiceConfigs{ + &config.WorkloadIdentityJWTService{ + Selector: config.WorkloadIdentitySelector{ + Labels: map[string][]string{ + "foo": {"bar"}, + }, + }, + Destination: &config.DestinationDirectory{ + Path: tmpDir, + }, + Audiences: []string{"example"}, + }, + }, 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) + defer cancel() + require.NoError(t, b.Run(ctx)) + + jwtBytes, err := os.ReadFile(filepath.Join(tmpDir, config.JWTSVIDPath)) + require.NoError(t, err) + jwt, err := jwtsvid.ParseInsecure(string(jwtBytes), []string{"example"}) + require.NoError(t, err) + require.Equal(t, "spiffe://root/valid/by-labels", jwt.ID.String()) + require.Equal(t, []string{"example"}, jwt.Audience) + }) +} diff --git a/lib/tbot/service_workload_identity_x509_test.go b/lib/tbot/service_workload_identity_x509_test.go index 392670f5ce6..81d0823cc7f 100644 --- a/lib/tbot/service_workload_identity_x509_test.go +++ b/lib/tbot/service_workload_identity_x509_test.go @@ -19,9 +19,12 @@ package tbot import ( "context" "path" + "path/filepath" "testing" "time" + "github.com/spiffe/go-spiffe/v2/bundle/x509bundle" + "github.com/spiffe/go-spiffe/v2/spiffeid" "github.com/spiffe/go-spiffe/v2/svid/x509svid" "github.com/stretchr/testify/require" @@ -113,6 +116,16 @@ func TestBotWorkloadIdentityX509(t *testing.T) { ) require.NoError(t, err) require.Equal(t, "spiffe://root/valid/by-name", svid.ID.String()) + + // Validate the trust bundle was written to disk, and, that our SVID + // appears valid according to the trust bundle. + td := spiffeid.RequireTrustDomainFromString("root") + bundle, err := x509bundle.Load( + td, filepath.Join(tmpDir, config.SVIDTrustBundlePEMPath), + ) + require.NoError(t, err) + _, _, err = x509svid.Verify(svid.Certificates, bundle) + require.NoError(t, err) }) t.Run("By Labels", func(t *testing.T) { tmpDir := t.TempDir() @@ -145,5 +158,15 @@ func TestBotWorkloadIdentityX509(t *testing.T) { ) require.NoError(t, err) require.Equal(t, "spiffe://root/valid/by-labels", svid.ID.String()) + + // Validate the trust bundle was written to disk, and, that our SVID + // appears valid according to the trust bundle. + td := spiffeid.RequireTrustDomainFromString("root") + bundle, err := x509bundle.Load( + td, filepath.Join(tmpDir, config.SVIDTrustBundlePEMPath), + ) + require.NoError(t, err) + _, _, err = x509svid.Verify(svid.Certificates, bundle) + require.NoError(t, err) }) } diff --git a/lib/tbot/tbot.go b/lib/tbot/tbot.go index cf0e38b0b2e..6afcfabc13c 100644 --- a/lib/tbot/tbot.go +++ b/lib/tbot/tbot.go @@ -505,6 +505,25 @@ func (b *Bot) Run(ctx context.Context) (err error) { svc.trustBundleCache = tbCache } services = append(services, svc) + case *config.WorkloadIdentityJWTService: + svc := &WorkloadIdentityJWTService{ + botAuthClient: b.botIdentitySvc.GetClient(), + botCfg: b.cfg, + cfg: svcCfg, + getBotIdentity: b.botIdentitySvc.GetIdentity, + resolver: resolver, + } + svc.log = b.log.With( + teleport.ComponentKey, teleport.Component(componentTBot, "svc", svc.String()), + ) + if !b.cfg.Oneshot { + tbCache, err := setupTrustBundleCache() + if err != nil { + return trace.Wrap(err) + } + svc.trustBundleCache = tbCache + } + services = append(services, svc) case *config.WorkloadIdentityAPIService: clientCredential := &config.UnstableClientCredentialOutput{} svcIdentity := &ClientCredentialOutputService{ diff --git a/tool/tbot/main.go b/tool/tbot/main.go index fac8d35747a..45278e193c5 100644 --- a/tool/tbot/main.go +++ b/tool/tbot/main.go @@ -146,6 +146,9 @@ func Run(args []string, stdout io.Writer) error { cli.NewWorkloadIdentityAPICommand(startCmd, buildConfigAndStart(ctx, globalCfg), cli.CommandModeStart), cli.NewWorkloadIdentityAPICommand(configureCmd, buildConfigAndConfigure(ctx, globalCfg, &configureOutPath, stdout), cli.CommandModeConfigure), + + cli.NewWorkloadIdentityJWTCommand(startCmd, buildConfigAndStart(ctx, globalCfg), cli.CommandModeStart), + cli.NewWorkloadIdentityJWTCommand(configureCmd, buildConfigAndConfigure(ctx, globalCfg, &configureOutPath, stdout), cli.CommandModeConfigure), ) // Initialize legacy-style commands. These are simple enough to not really