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) + }) +}