From d3fdf1d6015c7b3f3c43ae0343a61bedaa760031 Mon Sep 17 00:00:00 2001 From: Noah Stride Date: Wed, 29 Jan 2025 14:11:29 +0000 Subject: [PATCH] Introduce `tsh workload-identity issue-x509` (#51492) * Start hacking on tsh support for new RPCs * Wire up to Run * Add test over new CLI command --- lib/auth/authclient/clt.go | 7 + lib/tbot/workloadidentity/issue.go | 7 +- tool/tsh/common/tsh.go | 9 +- tool/tsh/common/workload_identity.go | 191 ++++++++++++++++++++++ tool/tsh/common/workload_identity_test.go | 93 +++++++++++ 5 files changed, 303 insertions(+), 4 deletions(-) diff --git a/lib/auth/authclient/clt.go b/lib/auth/authclient/clt.go index d0a0bded016a9..41bcaabc4762e 100644 --- a/lib/auth/authclient/clt.go +++ b/lib/auth/authclient/clt.go @@ -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" @@ -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 diff --git a/lib/tbot/workloadidentity/issue.go b/lib/tbot/workloadidentity/issue.go index 5c3e46116b1b4..4f4710db22c74 100644 --- a/lib/tbot/workloadidentity/issue.go +++ b/lib/tbot/workloadidentity/issue.go @@ -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, diff --git a/tool/tsh/common/tsh.go b/tool/tsh/common/tsh.go index 32ddd00258412..da5a43dbe086d 100644 --- a/tool/tsh/common/tsh.go +++ b/tool/tsh/common/tsh.go @@ -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) @@ -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(): diff --git a/tool/tsh/common/workload_identity.go b/tool/tsh/common/workload_identity.go index d46ce7cf63412..8a30b3f5c2033 100644 --- a/tool/tsh/common/workload_identity.go +++ b/tool/tsh/common/workload_identity.go @@ -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 } diff --git a/tool/tsh/common/workload_identity_test.go b/tool/tsh/common/workload_identity_test.go index 04e73ce18f7e1..4cfa176c1ce5d 100644 --- a/tool/tsh/common/workload_identity_test.go +++ b/tool/tsh/common/workload_identity_test.go @@ -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) { @@ -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) +}