diff --git a/api/client/client.go b/api/client/client.go
index 8c6bdf0031e40..b560c4c2eefab 100644
--- a/api/client/client.go
+++ b/api/client/client.go
@@ -91,6 +91,7 @@ import (
userspb "github.com/gravitational/teleport/api/gen/proto/go/teleport/users/v1"
usertaskv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/usertasks/v1"
"github.com/gravitational/teleport/api/gen/proto/go/teleport/vnet/v1"
+ workloadidentityv1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/workloadidentity/v1"
userpreferencespb "github.com/gravitational/teleport/api/gen/proto/go/userpreferences/v1"
"github.com/gravitational/teleport/api/internalutils/stream"
"github.com/gravitational/teleport/api/metadata"
@@ -865,6 +866,12 @@ func (c *Client) SPIFFEFederationServiceClient() machineidv1pb.SPIFFEFederationS
return machineidv1pb.NewSPIFFEFederationServiceClient(c.conn)
}
+// WorkloadIdentityResourceServiceClient returns an unadorned client for the
+// workload identity resource service.
+func (c *Client) WorkloadIdentityResourceServiceClient() workloadidentityv1pb.WorkloadIdentityResourceServiceClient {
+ return workloadidentityv1pb.NewWorkloadIdentityResourceServiceClient(c.conn)
+}
+
// PresenceServiceClient returns an unadorned client for the presence service.
func (c *Client) PresenceServiceClient() presencepb.PresenceServiceClient {
return presencepb.NewPresenceServiceClient(c.conn)
diff --git a/lib/services/resource.go b/lib/services/resource.go
index 90ab55628025f..4af60c93ce67b 100644
--- a/lib/services/resource.go
+++ b/lib/services/resource.go
@@ -243,6 +243,8 @@ func ParseShortcut(in string) (string, error) {
return types.KindAccessGraphSettings, nil
case types.KindSPIFFEFederation, types.KindSPIFFEFederation + "s":
return types.KindSPIFFEFederation, nil
+ case types.KindWorkloadIdentity, types.KindWorkloadIdentity + "s", "workload_identities", "workloadidentity", "workloadidentities", "workloadidentitys":
+ return types.KindWorkloadIdentity, nil
case types.KindStaticHostUser, types.KindStaticHostUser + "s", "host_user", "host_users":
return types.KindStaticHostUser, nil
case types.KindUserTask, types.KindUserTask + "s":
diff --git a/tool/tctl/common/cmds.go b/tool/tctl/common/cmds.go
index 1c727eba01e25..c233761e044b9 100644
--- a/tool/tctl/common/cmds.go
+++ b/tool/tctl/common/cmds.go
@@ -42,6 +42,7 @@ func Commands() []CLICommand {
&DesktopCommand{},
&LockCommand{},
&BotsCommand{},
+ &WorkloadIdentityCommand{},
&InventoryCommand{},
&RecordingsCommand{},
&AlertCommand{},
diff --git a/tool/tctl/common/collection.go b/tool/tctl/common/collection.go
index 0d10de79c610b..64ef0b10a69fb 100644
--- a/tool/tctl/common/collection.go
+++ b/tool/tctl/common/collection.go
@@ -42,6 +42,7 @@ import (
userprovisioningpb "github.com/gravitational/teleport/api/gen/proto/go/teleport/userprovisioning/v2"
usertasksv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/usertasks/v1"
"github.com/gravitational/teleport/api/gen/proto/go/teleport/vnet/v1"
+ workloadidentityv1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/workloadidentity/v1"
"github.com/gravitational/teleport/api/types"
"github.com/gravitational/teleport/api/types/accesslist"
"github.com/gravitational/teleport/api/types/discoveryconfig"
@@ -1743,6 +1744,37 @@ func (c *spiffeFederationCollection) writeText(w io.Writer, verbose bool) error
return trace.Wrap(err)
}
+type workloadIdentityCollection struct {
+ items []*workloadidentityv1pb.WorkloadIdentity
+}
+
+func (c *workloadIdentityCollection) resources() []types.Resource {
+ r := make([]types.Resource, 0, len(c.items))
+ for _, resource := range c.items {
+ r = append(r, types.Resource153ToLegacy(resource))
+ }
+ return r
+}
+
+func (c *workloadIdentityCollection) writeText(w io.Writer, verbose bool) error {
+ headers := []string{"Name", "SPIFFE ID"}
+
+ var rows [][]string
+ for _, item := range c.items {
+ rows = append(rows, []string{
+ item.Metadata.Name,
+ item.GetSpec().GetSpiffe().GetId(),
+ })
+ }
+
+ t := asciitable.MakeTable(headers, rows...)
+
+ // stable sort by name.
+ t.SortRowsBy([]int{0}, true)
+ _, err := t.AsBuffer().WriteTo(w)
+ return trace.Wrap(err)
+}
+
type staticHostUserCollection struct {
items []*userprovisioningpb.StaticHostUser
}
diff --git a/tool/tctl/common/resource_command.go b/tool/tctl/common/resource_command.go
index 53afa6b324c7a..1cc029a0a3b52 100644
--- a/tool/tctl/common/resource_command.go
+++ b/tool/tctl/common/resource_command.go
@@ -55,6 +55,7 @@ import (
userprovisioningpb "github.com/gravitational/teleport/api/gen/proto/go/teleport/userprovisioning/v2"
usertasksv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/usertasks/v1"
"github.com/gravitational/teleport/api/gen/proto/go/teleport/vnet/v1"
+ workloadidentityv1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/workloadidentity/v1"
"github.com/gravitational/teleport/api/internalutils/stream"
"github.com/gravitational/teleport/api/mfa"
"github.com/gravitational/teleport/api/types"
@@ -170,6 +171,7 @@ func (rc *ResourceCommand) Initialize(app *kingpin.Application, config *servicec
types.KindAccessGraphSettings: rc.upsertAccessGraphSettings,
types.KindPlugin: rc.createPlugin,
types.KindSPIFFEFederation: rc.createSPIFFEFederation,
+ types.KindWorkloadIdentity: rc.createWorkloadIdentity,
types.KindStaticHostUser: rc.createStaticHostUser,
types.KindUserTask: rc.createUserTask,
types.KindAutoUpdateConfig: rc.createAutoUpdateConfig,
@@ -1035,6 +1037,32 @@ func (rc *ResourceCommand) createSPIFFEFederation(ctx context.Context, client *a
return nil
}
+func (rc *ResourceCommand) createWorkloadIdentity(ctx context.Context, client *authclient.Client, raw services.UnknownResource) error {
+ in, err := services.UnmarshalWorkloadIdentity(raw.Raw)
+ if err != nil {
+ return trace.Wrap(err)
+ }
+
+ c := client.WorkloadIdentityResourceServiceClient()
+ if rc.force {
+ if _, err := c.UpsertWorkloadIdentity(ctx, &workloadidentityv1pb.UpsertWorkloadIdentityRequest{
+ WorkloadIdentity: in,
+ }); err != nil {
+ return trace.Wrap(err)
+ }
+ } else {
+ if _, err := c.CreateWorkloadIdentity(ctx, &workloadidentityv1pb.CreateWorkloadIdentityRequest{
+ WorkloadIdentity: in,
+ }); err != nil {
+ return trace.Wrap(err)
+ }
+ }
+
+ fmt.Printf("Workload identity %q has been created\n", in.GetMetadata().GetName())
+
+ return nil
+}
+
func (rc *ResourceCommand) updateCrownJewel(ctx context.Context, client *authclient.Client, resource services.UnknownResource) error {
in, err := services.UnmarshalCrownJewel(resource.Raw)
if err != nil {
@@ -1923,6 +1951,14 @@ func (rc *ResourceCommand) Delete(ctx context.Context, client *authclient.Client
return trace.Wrap(err)
}
fmt.Printf("SPIFFE federation %q has been deleted\n", rc.ref.Name)
+ case types.KindWorkloadIdentity:
+ if _, err := client.WorkloadIdentityResourceServiceClient().DeleteWorkloadIdentity(
+ ctx, &workloadidentityv1pb.DeleteWorkloadIdentityRequest{
+ Name: rc.ref.Name,
+ }); err != nil {
+ return trace.Wrap(err)
+ }
+ fmt.Printf("Workload identity %q has been deleted\n", rc.ref.Name)
case types.KindStaticHostUser:
if err := client.StaticHostUserClient().DeleteStaticHostUser(ctx, rc.ref.Name); err != nil {
return trace.Wrap(err)
@@ -3080,6 +3116,36 @@ func (rc *ResourceCommand) getCollection(ctx context.Context, client *authclient
}
return &spiffeFederationCollection{items: resources}, nil
+ case types.KindWorkloadIdentity:
+ if rc.ref.Name != "" {
+ resource, err := client.WorkloadIdentityResourceServiceClient().GetWorkloadIdentity(ctx, &workloadidentityv1pb.GetWorkloadIdentityRequest{
+ Name: rc.ref.Name,
+ })
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+ return &workloadIdentityCollection{items: []*workloadidentityv1pb.WorkloadIdentity{resource}}, nil
+ }
+
+ var resources []*workloadidentityv1pb.WorkloadIdentity
+ pageToken := ""
+ for {
+ resp, err := client.WorkloadIdentityResourceServiceClient().ListWorkloadIdentities(ctx, &workloadidentityv1pb.ListWorkloadIdentitiesRequest{
+ PageToken: pageToken,
+ })
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+
+ resources = append(resources, resp.WorkloadIdentities...)
+
+ if resp.NextPageToken == "" {
+ break
+ }
+ pageToken = resp.NextPageToken
+ }
+
+ return &workloadIdentityCollection{items: resources}, nil
case types.KindStaticHostUser:
hostUserClient := client.StaticHostUserClient()
if rc.ref.Name != "" {
diff --git a/tool/tctl/common/testdata/TestWorkloadIdentity/workload-identity_ls.golden b/tool/tctl/common/testdata/TestWorkloadIdentity/workload-identity_ls.golden
new file mode 100644
index 0000000000000..80f6254aa91dc
--- /dev/null
+++ b/tool/tctl/common/testdata/TestWorkloadIdentity/workload-identity_ls.golden
@@ -0,0 +1,4 @@
+Name SPIFFE ID
+---- ---------
+test /test
+
diff --git a/tool/tctl/common/testdata/TestWorkloadIdentity/workload-identity_ls_empty.golden b/tool/tctl/common/testdata/TestWorkloadIdentity/workload-identity_ls_empty.golden
new file mode 100644
index 0000000000000..1e9ddb9aaa645
--- /dev/null
+++ b/tool/tctl/common/testdata/TestWorkloadIdentity/workload-identity_ls_empty.golden
@@ -0,0 +1 @@
+No workload identities configured
diff --git a/tool/tctl/common/testdata/TestWorkloadIdentity/workload-identity_rm.golden b/tool/tctl/common/testdata/TestWorkloadIdentity/workload-identity_rm.golden
new file mode 100644
index 0000000000000..3195b79b30d87
--- /dev/null
+++ b/tool/tctl/common/testdata/TestWorkloadIdentity/workload-identity_rm.golden
@@ -0,0 +1 @@
+Workload Identity "test" deleted successfully.
diff --git a/tool/tctl/common/workload_identity_command.go b/tool/tctl/common/workload_identity_command.go
new file mode 100644
index 0000000000000..54ceff23dfdaa
--- /dev/null
+++ b/tool/tctl/common/workload_identity_command.go
@@ -0,0 +1,164 @@
+// 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 .
+
+package common
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "os"
+
+ "github.com/alecthomas/kingpin/v2"
+ "github.com/gravitational/trace"
+
+ "github.com/gravitational/teleport"
+ workloadidentityv1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/workloadidentity/v1"
+ "github.com/gravitational/teleport/lib/asciitable"
+ "github.com/gravitational/teleport/lib/auth/authclient"
+ "github.com/gravitational/teleport/lib/service/servicecfg"
+ "github.com/gravitational/teleport/lib/utils"
+)
+
+// WorkloadIdentityCommand is a group of commands pertaining to Teleport
+// Workload Identity.
+type WorkloadIdentityCommand struct {
+ format string
+ workloadIdentityName string
+
+ listCmd *kingpin.CmdClause
+ rmCmd *kingpin.CmdClause
+
+ stdout io.Writer
+}
+
+// Initialize sets up the "tctl workload-identity" command.
+func (c *WorkloadIdentityCommand) Initialize(
+ app *kingpin.Application, config *servicecfg.Config,
+) {
+ // TODO(noah): Remove the hidden flag once base functionality is released.
+ cmd := app.Command(
+ "workload-identity",
+ "Manage Teleport Workload Identity.",
+ ).Hidden()
+
+ c.listCmd = cmd.Command(
+ "ls",
+ "List workload identity configurations.",
+ )
+ c.listCmd.
+ Flag(
+ "format",
+ "Output format, 'text' or 'json'",
+ ).
+ Hidden().
+ Default(teleport.Text).
+ EnumVar(&c.format, teleport.Text, teleport.JSON)
+
+ c.rmCmd = cmd.Command(
+ "rm",
+ "Delete a workload identity configuration.",
+ )
+ c.rmCmd.
+ Arg("name", "Name of the workload identity configuration to delete.").
+ Required().
+ StringVar(&c.workloadIdentityName)
+
+ if c.stdout == nil {
+ c.stdout = os.Stdout
+ }
+}
+
+// TryRun attempts to run subcommands.
+func (c *WorkloadIdentityCommand) TryRun(
+ ctx context.Context, cmd string, client *authclient.Client,
+) (match bool, err error) {
+ switch cmd {
+ case c.listCmd.FullCommand():
+ err = c.ListWorkloadIdentities(ctx, client)
+ case c.rmCmd.FullCommand():
+ err = c.DeleteWorkloadIdentity(ctx, client)
+ default:
+ return false, nil
+ }
+
+ return true, trace.Wrap(err)
+}
+
+func (c *WorkloadIdentityCommand) DeleteWorkloadIdentity(
+ ctx context.Context,
+ client *authclient.Client,
+) error {
+ workloadIdentityClient := client.WorkloadIdentityResourceServiceClient()
+ _, err := workloadIdentityClient.DeleteWorkloadIdentity(
+ ctx, &workloadidentityv1pb.DeleteWorkloadIdentityRequest{
+ Name: c.workloadIdentityName,
+ })
+ if err != nil {
+ return trace.Wrap(err)
+ }
+
+ fmt.Fprintf(
+ c.stdout,
+ "Workload Identity %q deleted successfully.\n",
+ c.workloadIdentityName,
+ )
+
+ return nil
+}
+
+// ListWorkloadIdentities writes a listing of the WorkloadIdentity resources
+func (c *WorkloadIdentityCommand) ListWorkloadIdentities(
+ ctx context.Context, client *authclient.Client,
+) error {
+ workloadIdentityClient := client.WorkloadIdentityResourceServiceClient()
+ var workloadIdentities []*workloadidentityv1pb.WorkloadIdentity
+ req := &workloadidentityv1pb.ListWorkloadIdentitiesRequest{}
+ for {
+ resp, err := workloadIdentityClient.ListWorkloadIdentities(ctx, req)
+ if err != nil {
+ return trace.Wrap(err)
+ }
+
+ workloadIdentities = append(
+ workloadIdentities, resp.WorkloadIdentities...,
+ )
+ if resp.NextPageToken == "" {
+ break
+ }
+ req.PageToken = resp.NextPageToken
+ }
+
+ if c.format == teleport.Text {
+ if len(workloadIdentities) == 0 {
+ fmt.Fprintln(c.stdout, "No workload identities configured")
+ return nil
+ }
+ t := asciitable.MakeTable([]string{"Name", "SPIFFE ID"})
+ for _, u := range workloadIdentities {
+ t.AddRow([]string{
+ u.GetMetadata().GetName(), u.GetSpec().GetSpiffe().GetId(),
+ })
+ }
+ fmt.Fprintln(c.stdout, t.AsBuffer().String())
+ } else {
+ err := utils.WriteJSONArray(c.stdout, workloadIdentities)
+ if err != nil {
+ return trace.Wrap(err, "failed to marshal workload identities")
+ }
+ }
+ return nil
+}
diff --git a/tool/tctl/common/workload_identity_test.go b/tool/tctl/common/workload_identity_test.go
new file mode 100644
index 0000000000000..818ec2cb1d8f4
--- /dev/null
+++ b/tool/tctl/common/workload_identity_test.go
@@ -0,0 +1,163 @@
+/*
+ * 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 .
+ */
+
+package common
+
+import (
+ "bytes"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/google/go-cmp/cmp"
+ "github.com/stretchr/testify/require"
+ "google.golang.org/protobuf/testing/protocmp"
+ "gopkg.in/yaml.v3"
+
+ 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/auth/authclient"
+ "github.com/gravitational/teleport/lib/utils"
+ "github.com/gravitational/teleport/lib/utils/golden"
+ "github.com/gravitational/teleport/tool/teleport/testenv"
+)
+
+func runWorkloadIdentityCommand(
+ t *testing.T, clt *authclient.Client, args []string,
+) (*bytes.Buffer, error) {
+ var stdoutBuf bytes.Buffer
+ cmd := &WorkloadIdentityCommand{
+ stdout: &stdoutBuf,
+ }
+ return &stdoutBuf, runCommand(t, clt, cmd, args)
+}
+
+func TestWorkloadIdentity(t *testing.T) {
+ t.Parallel()
+
+ process := testenv.MakeTestServer(t, testenv.WithLogger(utils.NewSlogLoggerForTests()))
+ rootClient := testenv.MakeDefaultAuthClient(t, process)
+
+ yamlData := `kind: workload_identity
+version: v1
+metadata:
+ name: test
+spec:
+ spiffe:
+ id: /test
+`
+ var expected workloadidentityv1pb.WorkloadIdentity
+ require.NoError(t, yaml.Unmarshal([]byte(yamlData), &expected))
+
+ t.Run("workload-identity ls empty", func(t *testing.T) {
+ buf, err := runWorkloadIdentityCommand(
+ t, rootClient, []string{
+ "workload-identity", "ls",
+ },
+ )
+ require.NoError(t, err)
+ if golden.ShouldSet() {
+ golden.Set(t, buf.Bytes())
+ }
+ require.Equal(t, string(golden.Get(t)), buf.String())
+ })
+
+ t.Run("resource list empty", func(t *testing.T) {
+ buf, err := runResourceCommand(
+ t, rootClient, []string{
+ "get",
+ types.KindWorkloadIdentity,
+ "--format=json",
+ },
+ )
+ require.NoError(t, err)
+
+ resources := mustDecodeJSON[[]*workloadidentityv1pb.WorkloadIdentity](t, buf)
+ require.Empty(t, resources)
+ })
+
+ t.Run("create", func(t *testing.T) {
+
+ yamlPath := filepath.Join(t.TempDir(), "workload_identity.yaml")
+ require.NoError(t, os.WriteFile(yamlPath, []byte(yamlData), 0644))
+ _, err := runResourceCommand(t, rootClient, []string{"create", yamlPath})
+ require.NoError(t, err)
+ })
+
+ t.Run("workload-identity ls", func(t *testing.T) {
+ buf, err := runWorkloadIdentityCommand(
+ t, rootClient, []string{
+ "workload-identity", "ls",
+ },
+ )
+ require.NoError(t, err)
+ if golden.ShouldSet() {
+ golden.Set(t, buf.Bytes())
+ }
+ require.Equal(t, string(golden.Get(t)), buf.String())
+ })
+
+ t.Run("resource list", func(t *testing.T) {
+ buf, err := runResourceCommand(
+ t, rootClient, []string{
+ "get",
+ types.KindWorkloadIdentity,
+ "--format=json",
+ },
+ )
+ require.NoError(t, err)
+
+ resources := mustDecodeJSON[[]*workloadidentityv1pb.WorkloadIdentity](t, buf)
+ require.NotEmpty(t, resources)
+ require.Empty(t, cmp.Diff(
+ []*workloadidentityv1pb.WorkloadIdentity{&expected},
+ resources,
+ protocmp.Transform(),
+ protocmp.IgnoreFields(&headerv1.Metadata{}, "revision"),
+ ))
+ })
+
+ t.Run("workload-identity rm", func(t *testing.T) {
+ buf, err := runWorkloadIdentityCommand(
+ t, rootClient, []string{
+ "workload-identity", "rm",
+ expected.GetMetadata().GetName(),
+ },
+ )
+ require.NoError(t, err)
+ if golden.ShouldSet() {
+ golden.Set(t, buf.Bytes())
+ }
+ require.Equal(t, string(golden.Get(t)), buf.String())
+ })
+
+ t.Run("resource list empty after delete", func(t *testing.T) {
+ buf, err := runResourceCommand(
+ t, rootClient, []string{
+ "get",
+ types.KindWorkloadIdentity,
+ "--format=json",
+ },
+ )
+ require.NoError(t, err)
+
+ resources := mustDecodeJSON[[]*workloadidentityv1pb.WorkloadIdentity](t, buf)
+ require.Empty(t, resources)
+ })
+}