Skip to content

Commit

Permalink
[v15] tsh svid issue for issuing SPIFFE SVIDs (#39115)
Browse files Browse the repository at this point in the history
* TSH Support for issuing SPIFFE SVIDs

* Remove admin MFA from SVID RPC

* Add headless support

* Nicer output message and fix ttl

* Add test case for `tsh workloadid issue`

* Change requests

* Remove unnecessary conditional for headless

* `workloadid` -> `svid`

* Adjust for no experiment

* Fix test by reenabling experiment
  • Loading branch information
strideynet committed Mar 8, 2024
1 parent a50d74b commit 390d43e
Show file tree
Hide file tree
Showing 4 changed files with 329 additions and 3 deletions.
3 changes: 0 additions & 3 deletions lib/auth/machineid/machineidv1/workload_identity_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -313,9 +313,6 @@ func (wis *WorkloadIdentityService) SignX509SVIDs(ctx context.Context, req *pb.S
if err != nil {
return nil, trace.Wrap(err)
}
if err := authCtx.AuthorizeAdminAction(); err != nil {
return nil, trace.Wrap(err)
}

// Fetch info that will be needed for all SPIFFE SVIDs requested
clusterName, err := wis.cache.GetClusterName()
Expand Down
4 changes: 4 additions & 0 deletions tool/tsh/common/tsh.go
Original file line number Diff line number Diff line change
Expand Up @@ -1142,6 +1142,8 @@ func Run(ctx context.Context, args []string, opts ...CliOption) error {
// Device Trust commands.
deviceCmd := newDeviceCommand(app)

workloadIdentityCmd := newSVIDCommands(app)

if runtime.GOOS == constants.WindowsOS {
bench.Hidden()
}
Expand Down Expand Up @@ -1498,6 +1500,8 @@ 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)
default:
// Handle commands that might not be available.
switch {
Expand Down
217 changes: 217 additions & 0 deletions tool/tsh/common/workload_identity.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
/*
* Teleport
* Copyright (C) 2024 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 common

import (
"bytes"
"crypto/x509"
"encoding/pem"
"fmt"
"os"
"path"
"time"

"github.com/alecthomas/kingpin/v2"
"github.com/gravitational/trace"
"google.golang.org/protobuf/types/known/durationpb"

"github.com/gravitational/teleport"
machineidv1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/machineid/v1"
"github.com/gravitational/teleport/api/types"
"github.com/gravitational/teleport/lib/auth/native"
"github.com/gravitational/teleport/lib/client"
"github.com/gravitational/teleport/lib/services"
)

type svidCommands struct {
issue *svidIssueCommand
}

func newSVIDCommands(app *kingpin.Application) svidCommands {
cmd := app.Command("svid", "Manage Teleport Workload Identity SVIDs.")
cmds := svidCommands{
issue: newSVIDIssueCommand(cmd),
}
return cmds
}

const (
// Based on the default paths listed in
// https://github.com/spiffe/spiffe-helper/blob/v0.7.0/README.md
svidPEMPath = "svid.pem"
svidKeyPEMPath = "svid_key.pem"
svidTrustBundlePEMPath = "svid_bundle.pem"

svidTypeX509 = "x509"
)

type svidIssueCommand struct {
*kingpin.CmdClause
svidSPIFFEIDPath string
svidType string
svidDNSSANs []string
svidIPSANs []string
svidTTL time.Duration
outputDirectory string
}

func newSVIDIssueCommand(parent *kingpin.CmdClause) *svidIssueCommand {
cmd := &svidIssueCommand{
CmdClause: parent.Command("issue", "Issues a SPIFFE SVID using Teleport Workload Identity and writes it to a local directory."),
}
cmd.Arg("path", "Path to use for the SVID SPIFFE ID. Must have a preceding '/'.").
Required().
StringVar(&cmd.svidSPIFFEIDPath)
cmd.Flag("type", "Type of the SVID to issue (x509). Defaults to x509.").
Default(svidTypeX509).
EnumVar(&cmd.svidType, svidTypeX509)
cmd.Flag("output", "Path to the directory to write the SVID into.").
Required().
StringVar(&cmd.outputDirectory)
cmd.Flag("dns-san", "DNS SANs to include in the SVID. By default, none are included.").
StringsVar(&cmd.svidDNSSANs)
cmd.Flag("ip-san", "IP SANs to include in the SVID. By default, none are included.").
StringsVar(&cmd.svidIPSANs)
cmd.Flag("svid-ttl", "Sets the time to live for the SVID.").
Default("1h").
DurationVar(&cmd.svidTTL)
return cmd
}

func (c *svidIssueCommand) run(cf *CLIConf) error {
ctx := cf.Context
// Validate flags
if c.svidType != svidTypeX509 {
return trace.BadParameter("unsupported SVID type: %v", c.svidType)
}

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

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

// Generate keypair to use in SVID
privateKey, err := native.GenerateRSAPrivateKey()
if err != nil {
return trace.Wrap(err)
}
pubBytes, err := x509.MarshalPKIXPublicKey(privateKey.Public())
if err != nil {
return trace.Wrap(err)
}

res, err := clusterClient.AuthClient.WorkloadIdentityServiceClient().
SignX509SVIDs(ctx,
&machineidv1pb.SignX509SVIDsRequest{
Svids: []*machineidv1pb.SVIDRequest{
{
SpiffeIdPath: c.svidSPIFFEIDPath,
PublicKey: pubBytes,
DnsSans: c.svidDNSSANs,
IpSans: c.svidIPSANs,
Ttl: durationpb.New(c.svidTTL),
},
},
},
)
if err != nil {
return trace.Wrap(err)
}
if len(res.Svids) != 1 {
return trace.BadParameter("expected 1 SVID, got %v", len(res.Svids))
}

// Write private key
privBytes, err := x509.MarshalPKCS8PrivateKey(privateKey)
if err != nil {
return trace.Wrap(err)
}
keyPath := path.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 := path.Join(c.outputDirectory, svidPEMPath)
err = os.WriteFile(
svidPath,
pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE",
Bytes: res.Svids[0].Certificate,
}),
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 := path.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",
res.Svids[0].SpiffeId,
keyPath,
svidPath,
trustBundlePath,
)

return nil
})
}
108 changes: 108 additions & 0 deletions tool/tsh/common/workload_identity_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/*
* Teleport
* Copyright (C) 2024 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 common

import (
"context"
"crypto/x509"
"encoding/pem"
"net"
"os"
"path"
"testing"

"github.com/stretchr/testify/require"

"github.com/gravitational/teleport/api/types"
"github.com/gravitational/teleport/lib/auth/machineid/machineidv1/experiment"
"github.com/gravitational/teleport/lib/service/servicecfg"
)

func TestWorkloadIdentityIssue(t *testing.T) {
experiment.SetEnabled(true)
t.Cleanup(func() {
experiment.SetEnabled(false)
})
ctx := context.Background()

role, err := types.NewRole("spiffe-issuer", types.RoleSpecV6{
Allow: types.RoleConditions{
SPIFFE: []*types.SPIFFERoleCondition{
{
Path: "/*",
IPSANs: []string{"0.0.0.0/0"},
DNSSANs: []string{"*"},
},
},
},
})
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)
}),
)

homeDir, _ := mustLogin(t, s)
temp := t.TempDir()
err = Run(
ctx,
[]string{
"svid",
"issue",
"--output", temp,
"--svid-ttl", "10m",
"--dns-san", "example.com",
"--dns-san", "foo.example.com",
"--ip-san", "10.0.0.1",
"--ip-san", "10.1.0.1",
"/foo/bar",
},
setHomePath(homeDir),
)
require.NoError(t, err)

certPEM, err := os.ReadFile(path.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, "example.com", cert.DNSNames[0])
require.Equal(t, "foo.example.com", cert.DNSNames[1])
require.Equal(t, net.IP{10, 0, 0, 1}, cert.IPAddresses[0])
require.Equal(t, net.IP{10, 1, 0, 1}, cert.IPAddresses[1])
require.Equal(t, "spiffe://root/foo/bar", cert.URIs[0].String())

keyPEM, err := os.ReadFile(path.Join(temp, "svid_key.pem"))
require.NoError(t, err)
keyBlock, _ := pem.Decode(keyPEM)
_, err = x509.ParsePKCS8PrivateKey(keyBlock.Bytes)
require.NoError(t, err)

bundlePEM, err := os.ReadFile(path.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 390d43e

Please sign in to comment.