Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions lib/auth/authclient/clt.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import (
trustpb "github.com/gravitational/teleport/api/gen/proto/go/teleport/trust/v1"
userspb "github.com/gravitational/teleport/api/gen/proto/go/teleport/users/v1"
"github.com/gravitational/teleport/api/gen/proto/go/teleport/vnet/v1"
workloadidentityv1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/workloadidentity/v1"
userpreferencesv1 "github.com/gravitational/teleport/api/gen/proto/go/userpreferences/v1"
"github.com/gravitational/teleport/api/mfa"
"github.com/gravitational/teleport/api/types"
Expand Down Expand Up @@ -1847,6 +1848,12 @@ type ClientI interface {
// (as per the default gRPC behavior).
WorkloadIdentityServiceClient() machineidv1pb.WorkloadIdentityServiceClient

// WorkloadIdentityIssuanceClient returns a workload identity issuance service client.
// Clients connecting to older Teleport versions, still get a client
// when calling this method, but all RPCs will return "not implemented" errors
// (as per the default gRPC behavior).
WorkloadIdentityIssuanceClient() workloadidentityv1pb.WorkloadIdentityIssuanceServiceClient

// NotificationServiceClient returns a notification service client.
// Clients connecting to older Teleport versions, still get a client
// when calling this method, but all RPCs will return "not implemented" errors
Expand Down
7 changes: 6 additions & 1 deletion lib/tbot/workloadidentity/issue.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,17 @@ func WorkloadIdentitiesLogValue(credentials []*workloadidentityv1pb.Credential)
return values
}

type authClient interface {
WorkloadIdentityIssuanceClient() workloadidentityv1pb.WorkloadIdentityIssuanceServiceClient
cryptosuites.AuthPreferenceGetter
}

// IssueX509WorkloadIdentity uses a given client and selector to issue a single
// or multiple X509-SVID workload identity credentials.
func IssueX509WorkloadIdentity(
ctx context.Context,
log *slog.Logger,
clt *authclient.Client,
clt authClient,
workloadIdentity config.WorkloadIdentitySelector,
ttl time.Duration,
attest *workloadidentityv1pb.WorkloadAttrs,
Expand Down
9 changes: 6 additions & 3 deletions tool/tsh/common/tsh.go
Original file line number Diff line number Diff line change
Expand Up @@ -1261,7 +1261,8 @@ func Run(ctx context.Context, args []string, opts ...CliOption) error {
// Device Trust commands.
deviceCmd := newDeviceCommand(app)

workloadIdentityCmd := newSVIDCommands(app)
svidCmd := newSVIDCommands(app)
workloadIdentityCmd := newWorkloadIdentityCommands(app)

vnetCommand := newVnetCommand(app)
vnetAdminSetupCommand := newVnetAdminSetupCommand(app)
Expand Down Expand Up @@ -1639,8 +1640,10 @@ func Run(ctx context.Context, args []string, opts ...CliOption) error {
err = onKubectlCommand(&cf, args, args[idx:])
case headlessApprove.FullCommand():
err = onHeadlessApprove(&cf)
case workloadIdentityCmd.issue.FullCommand():
err = workloadIdentityCmd.issue.run(&cf)
case svidCmd.issue.FullCommand():
err = svidCmd.issue.run(&cf)
case workloadIdentityCmd.issueX509.FullCommand():
err = workloadIdentityCmd.issueX509.run(&cf)
case vnetCommand.FullCommand():
err = vnetCommand.run(&cf)
case vnetAdminSetupCommand.FullCommand():
Expand Down
191 changes: 191 additions & 0 deletions tool/tsh/common/workload_identity.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,203 @@ import (

"github.com/gravitational/teleport"
machineidv1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/machineid/v1"
workloadidentityv1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/workloadidentity/v1"
"github.com/gravitational/teleport/api/types"
"github.com/gravitational/teleport/lib/client"
"github.com/gravitational/teleport/lib/cryptosuites"
"github.com/gravitational/teleport/lib/services"
"github.com/gravitational/teleport/lib/tbot/config"
"github.com/gravitational/teleport/lib/tbot/workloadidentity"
)

type workloadIdentityCommands struct {
issueX509 *issueX509Command
}

func newWorkloadIdentityCommands(
app *kingpin.Application,
) workloadIdentityCommands {
cmd := app.Command("workload-identity", "Issue Workload Identity credentials")
cmds := workloadIdentityCommands{
issueX509: newIssueX509Command(cmd),
}
return cmds
}

type issueX509Command struct {
*kingpin.CmdClause
nameSelector string
labelSelector string
ttl time.Duration
outputDirectory string
}

func newIssueX509Command(parent *kingpin.CmdClause) *issueX509Command {
cmd := &issueX509Command{
CmdClause: parent.Command("issue-x509", "Use Teleport Workload Identity to issue an X509 credential write it to a local directory."),
}

cmd.Flag(
"name-selector",
"The name of the workload identity to issue",
).StringVar(&cmd.nameSelector)
cmd.Flag(
"label-selector",
"A label-based selector for which workload identities to issue. Multiple labels can be provided using ','.",
).StringVar(&cmd.labelSelector)
cmd.Flag("credential-ttl", "Sets the time to live for the credential.").
Default("1h").
DurationVar(&cmd.ttl)
cmd.Flag("output", "Path to the directory to write the SVID into.").
Required().
StringVar(&cmd.outputDirectory)

return cmd
}

func (c *issueX509Command) run(cf *CLIConf) error {
ctx := cf.Context

tc, err := makeClient(cf)
if err != nil {
return trace.Wrap(err)
}
tc.AllowHeadless = true

selector := config.WorkloadIdentitySelector{}
switch {
case c.nameSelector != "" && c.labelSelector != "":
return trace.BadParameter("cannot specify both name and label selectors")
case c.nameSelector != "":
selector.Name = c.nameSelector
case c.labelSelector != "":
labels, err := client.ParseLabelSpec(c.labelSelector)
if err != nil {
return trace.Wrap(err)
}
selector.Labels = map[string][]string{}
for k, v := range labels {
selector.Labels[k] = []string{v}
}
default:
return trace.BadParameter("name-selector or label-selector must be specified")
}

return client.RetryWithRelogin(ctx, tc, func() error {
clusterClient, err := tc.ConnectToCluster(ctx)
if err != nil {
return trace.Wrap(err)
}
defer clusterClient.Close()

credentials, privateKey, err := workloadidentity.IssueX509WorkloadIdentity(
ctx,
logger,
clusterClient.AuthClient,
selector,
c.ttl,
nil,
)
if err != nil {
return trace.Wrap(err)
}
var x509Credential *workloadidentityv1pb.Credential
switch len(credentials) {
case 0:
return trace.BadParameter("no X509 SVIDs returned")
case 1:
x509Credential = 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 trace.BadParameter(
"multiple X509 SVIDs received: %v", received,
)
}

// Write private key
privBytes, err := x509.MarshalPKCS8PrivateKey(privateKey)
if err != nil {
return trace.Wrap(err)
}
keyPath := filepath.Join(c.outputDirectory, svidKeyPEMPath)
err = os.WriteFile(
keyPath,
pem.EncodeToMemory(&pem.Block{
Type: "PRIVATE KEY",
Bytes: privBytes,
}),
teleport.FileMaskOwnerOnly,
)
if err != nil {
return trace.Wrap(err)
}

// Write SVID
svidPath := filepath.Join(c.outputDirectory, svidPEMPath)
err = os.WriteFile(
svidPath,
pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE",
Bytes: x509Credential.GetX509Svid().GetCert(),
}),
teleport.FileMaskOwnerOnly,
)
if err != nil {
return trace.Wrap(err)
}

// Write trust bundle
caRes, err := clusterClient.AuthClient.GetCertAuthorities(
ctx, types.SPIFFECA, false,
)
if err != nil {
return trace.Wrap(err)
}
trustBundleBytes := &bytes.Buffer{}
for _, ca := range caRes {
for _, cert := range services.GetTLSCerts(ca) {
// Values are already PEM encoded, so we just append to the buffer
if _, err := trustBundleBytes.Write(cert); err != nil {
return trace.Wrap(err, "writing trust bundle to buffer")
}
}
}
trustBundlePath := filepath.Join(c.outputDirectory, svidTrustBundlePEMPath)
err = os.WriteFile(
trustBundlePath,
trustBundleBytes.Bytes(),
teleport.FileMaskOwnerOnly,
)
if err != nil {
return trace.Wrap(err)
}

fmt.Fprintf(
cf.Stdout(),
"SVID %q issued. Files written to: \n - %s\n - %s\n - %s\n",
x509Credential.SpiffeId,
keyPath,
svidPath,
trustBundlePath,
)

return nil
})
}

// svidCommands manages the SVID commands.
// Deprecated and being replaced by workloadIdentityCommands
type svidCommands struct {
issue *svidIssueCommand
}
Expand Down
93 changes: 93 additions & 0 deletions tool/tsh/common/workload_identity_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,16 @@ import (
"os"
"path/filepath"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

headerv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/header/v1"
workloadidentityv1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/workloadidentity/v1"
"github.com/gravitational/teleport/api/types"
"github.com/gravitational/teleport/lib/service/servicecfg"
"github.com/gravitational/teleport/lib/services"
)

func TestWorkloadIdentityIssue(t *testing.T) {
Expand Down Expand Up @@ -110,3 +115,91 @@ func TestWorkloadIdentityIssue(t *testing.T) {
_, err = x509.ParseCertificate(bundleBlock.Bytes)
require.NoError(t, err)
}

func TestWorkloadIdentityIssueX509(t *testing.T) {
ctx := context.Background()

role, err := types.NewRole("workload-identity-issuer", types.RoleSpecV6{
Allow: types.RoleConditions{
WorkloadIdentityLabels: types.Labels{
types.Wildcard: []string{types.Wildcard},
},
Rules: []types.Rule{
types.NewRule(types.KindWorkloadIdentity, services.RO()),
},
},
})
require.NoError(t, err)
s := newTestSuite(t, withRootConfigFunc(func(cfg *servicecfg.Config) {
// reconfig the user to use the new role instead of the default ones
// User is the second bootstrap resource.
user, ok := cfg.Auth.BootstrapResources[1].(types.User)
require.True(t, ok)
user.AddRole(role.GetName())
cfg.Auth.BootstrapResources[1] = user
cfg.Auth.BootstrapResources = append(cfg.Auth.BootstrapResources, role)
}))

_, err = s.root.GetAuthServer().Services.UpsertWorkloadIdentity(
ctx,
&workloadidentityv1pb.WorkloadIdentity{
Kind: types.KindWorkloadIdentity,
Version: types.V1,
Metadata: &headerv1.Metadata{
Name: "my-workload-identity",
Labels: map[string]string{},
},
Spec: &workloadidentityv1pb.WorkloadIdentitySpec{
Spiffe: &workloadidentityv1pb.WorkloadIdentitySPIFFE{
Id: "/test",
},
},
},
)
require.NoError(t, err)
require.EventuallyWithT(t, func(collect *assert.CollectT) {
_, err := s.root.GetAuthServer().Cache.GetWorkloadIdentity(ctx, "my-workload-identity")
require.NoError(collect, err)
}, time.Second*5, 100*time.Millisecond)

homeDir, _ := mustLoginLegacy(t, s)
temp := t.TempDir()
err = Run(
ctx,
[]string{
"workload-identity",
"issue-x509",
"--insecure",
"--output", temp,
"--credential-ttl", "10m",
"--name-selector", "my-workload-identity",
},
setHomePath(homeDir),
)
require.NoError(t, err)

certPEM, err := os.ReadFile(filepath.Join(temp, "svid.pem"))
require.NoError(t, err)
certBlock, _ := pem.Decode(certPEM)
cert, err := x509.ParseCertificate(certBlock.Bytes)
require.NoError(t, err)
require.Equal(t, "spiffe://root/test", cert.URIs[0].String())
// Sanity check we generated an ECDSA public key (test suite uses
// balanced-v1 algorithm suite).
require.IsType(t, &ecdsa.PublicKey{}, cert.PublicKey)

keyPEM, err := os.ReadFile(filepath.Join(temp, "svid_key.pem"))
require.NoError(t, err)
keyBlock, _ := pem.Decode(keyPEM)
privateKey, err := x509.ParsePKCS8PrivateKey(keyBlock.Bytes)
require.NoError(t, err)
// Sanity check private key matches x509 cert subject.
require.Implements(t, (*crypto.Signer)(nil), privateKey)
require.Equal(t, cert.PublicKey, privateKey.(crypto.Signer).Public())

bundlePEM, err := os.ReadFile(filepath.Join(temp, "svid_bundle.pem"))
require.NoError(t, err)
bundleBlock, _ := pem.Decode(bundlePEM)
_, err = x509.ParseCertificate(bundleBlock.Bytes)
require.NoError(t, err)
}