Skip to content

Commit

Permalink
Introduce tsh workload-identity issue-x509 (#51492)
Browse files Browse the repository at this point in the history
* Start hacking on tsh support for new RPCs

* Wire up to Run

* Add test over new CLI command
  • Loading branch information
strideynet authored Jan 29, 2025
1 parent c907235 commit d3fdf1d
Show file tree
Hide file tree
Showing 5 changed files with 303 additions and 4 deletions.
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 @@ -1850,6 +1851,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 @@ -1257,7 +1257,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 @@ -1638,8 +1639,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)
}

0 comments on commit d3fdf1d

Please sign in to comment.