Workload Identity: workload-identity-jwt (#51027)

* Add config.WorkloadIdentityJWTService

* Add tsests for WorkloadIdentityJWTService

* Add CLI setup for `workload-identity-jwt` svc

* Start work on svc implementation

* Add TestBotWorkloadIdentityJWT

* Correct log message

* Firm up TestBotWorkloadIdentityX509 test

* Fix param names in errors
This commit is contained in:
Noah Stride
2025-01-17 11:35:58 +00:00
committed by GitHub
parent e7bd5c2a65
commit d887f90f91
13 changed files with 936 additions and 0 deletions
+113
View File
@@ -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 <http://www.gnu.org/licenses/>.
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
}
@@ -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 <http://www.gnu.org/licenses/>.
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)
},
},
})
}
+6
View File
@@ -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)
}
+9
View File
@@ -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"},
},
},
},
},
@@ -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 <http://www.gnu.org/licenses/>.
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
}
@@ -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 <http://www.gnu.org/licenses/>.
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)
}
@@ -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
@@ -0,0 +1,8 @@
type: workload-identity-jwt
selector:
name: my-workload-identity
destination:
type: memory
audiences:
- audience1
- audience2
+250
View File
@@ -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 <http://www.gnu.org/licenses/>.
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
}
@@ -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 <http://www.gnu.org/licenses/>.
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)
})
}
@@ -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)
})
}
+19
View File
@@ -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{
+3
View File
@@ -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