mirror of
https://github.com/gravitational/teleport.git
synced 2026-10-30 18:29:21 +08:00
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:
@@ -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)
|
||||
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user