diff --git a/lib/services/presets.go b/lib/services/presets.go index 631bcaa2ca80..493d84693960 100644 --- a/lib/services/presets.go +++ b/lib/services/presets.go @@ -180,6 +180,7 @@ func NewPresetEditorRole() types.Role { types.NewRule(types.KindAccessGraphSettings, RW()), types.NewRule(types.KindSPIFFEFederation, RW()), types.NewRule(types.KindNotification, RW()), + types.NewRule(types.KindStaticHostUser, RW()), }, }, }, diff --git a/lib/services/resource.go b/lib/services/resource.go index 4b2f52adb9ec..7449aef189c8 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.KindStaticHostUser, types.KindStaticHostUser + "s", "host_user", "host_users": + return types.KindStaticHostUser, nil } return "", trace.BadParameter("unsupported resource: %q - resources should be expressed as 'type/name', for example 'connector/github'", in) } diff --git a/tool/tctl/common/collection.go b/tool/tctl/common/collection.go index d74fcf21cd41..3126f565e663 100644 --- a/tool/tctl/common/collection.go +++ b/tool/tctl/common/collection.go @@ -22,6 +22,7 @@ import ( "encoding/json" "fmt" "io" + "slices" "sort" "strconv" "strings" @@ -36,11 +37,13 @@ import ( devicepb "github.com/gravitational/teleport/api/gen/proto/go/teleport/devicetrust/v1" loginrulepb "github.com/gravitational/teleport/api/gen/proto/go/teleport/loginrule/v1" machineidv1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/machineid/v1" + userprovisioningpb "github.com/gravitational/teleport/api/gen/proto/go/teleport/userprovisioning/v2" "github.com/gravitational/teleport/api/gen/proto/go/teleport/vnet/v1" "github.com/gravitational/teleport/api/types" "github.com/gravitational/teleport/api/types/accesslist" "github.com/gravitational/teleport/api/types/discoveryconfig" "github.com/gravitational/teleport/api/types/externalauditstorage" + "github.com/gravitational/teleport/api/types/label" "github.com/gravitational/teleport/api/types/secreports" apiutils "github.com/gravitational/teleport/api/utils" "github.com/gravitational/teleport/lib/asciitable" @@ -1733,3 +1736,61 @@ func (c *spiffeFederationCollection) writeText(w io.Writer, verbose bool) error _, err := t.AsBuffer().WriteTo(w) return trace.Wrap(err) } + +type staticHostUserCollection struct { + items []*userprovisioningpb.StaticHostUser +} + +func (c *staticHostUserCollection) 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 *staticHostUserCollection) writeText(w io.Writer, verbose bool) error { + var rows [][]string + for _, item := range c.items { + + for _, matcher := range item.Spec.Matchers { + labelMap := label.ToMap(matcher.NodeLabels) + labelStringMap := make(map[string]string, len(labelMap)) + for k, vals := range labelMap { + labelStringMap[k] = fmt.Sprintf("[%s]", printSortedStringSlice(vals)) + } + var uid string + if matcher.Uid != 0 { + uid = strconv.Itoa(int(matcher.Uid)) + } + var gid string + if matcher.Gid != 0 { + gid = strconv.Itoa(int(matcher.Gid)) + } + rows = append(rows, []string{ + item.GetMetadata().Name, + common.FormatLabels(labelStringMap, verbose), + matcher.NodeLabelsExpression, + printSortedStringSlice(matcher.Groups), + uid, + gid, + }) + } + } + headers := []string{"Login", "Node Labels", "Node Expression", "Groups", "Uid", "Gid"} + var t asciitable.Table + if verbose { + t = asciitable.MakeTable(headers, rows...) + } else { + t = asciitable.MakeTableWithTruncatedColumn(headers, rows, "Node Expression") + } + t.SortRowsBy([]int{0}, true) + _, err := t.AsBuffer().WriteTo(w) + return trace.Wrap(err) +} + +func printSortedStringSlice(s []string) string { + s = slices.Clone(s) + slices.Sort(s) + return strings.Join(s, ",") +} diff --git a/tool/tctl/common/edit_command_test.go b/tool/tctl/common/edit_command_test.go index b42517ac7038..5d717ed091b8 100644 --- a/tool/tctl/common/edit_command_test.go +++ b/tool/tctl/common/edit_command_test.go @@ -28,9 +28,14 @@ import ( "github.com/gravitational/trace" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "google.golang.org/protobuf/testing/protocmp" "github.com/gravitational/teleport/api/constants" + headerv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/header/v1" + labelv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/label/v1" + userprovisioningpb "github.com/gravitational/teleport/api/gen/proto/go/teleport/userprovisioning/v2" "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/api/types/userprovisioning" "github.com/gravitational/teleport/entitlements" "github.com/gravitational/teleport/lib/auth/authclient" "github.com/gravitational/teleport/lib/backend" @@ -73,6 +78,10 @@ func TestEditResources(t *testing.T) { kind: types.KindSessionRecordingConfig, edit: testEditSessionRecordingConfig, }, + { + kind: types.KindStaticHostUser, + edit: testEditStaticHostUser, + }, } for _, test := range tests { @@ -485,3 +494,50 @@ func testEditSAMLConnector(t *testing.T, clt *authclient.Client) { assert.Error(t, err, "stale connector was allowed to be updated") require.ErrorIs(t, err, backend.ErrIncorrectRevision, "expected an incorrect revision error, got %T", err) } + +func testEditStaticHostUser(t *testing.T, clt *authclient.Client) { + ctx := context.Background() + + expected := userprovisioning.NewStaticHostUser("alice", &userprovisioningpb.StaticHostUserSpec{ + Matchers: []*userprovisioningpb.Matcher{ + { + NodeLabels: []*labelv1.Label{ + { + Name: "foo", + Values: []string{"bar"}, + }, + }, + Groups: []string{"foo", "bar"}, + }, + }, + }) + created, err := clt.StaticHostUserClient().CreateStaticHostUser(ctx, expected) + require.NoError(t, err) + + editor := func(name string) error { + f, err := os.Create(name) + if err != nil { + return trace.Wrap(err, "opening file to edit") + } + + expected.GetMetadata().Revision = created.GetMetadata().Revision + expected.Spec.Matchers[0].Groups = []string{"baz", "quux"} + + collection := &staticHostUserCollection{items: []*userprovisioningpb.StaticHostUser{expected}} + return trace.NewAggregate(writeYAML(collection, f), f.Close()) + } + + _, err = runEditCommand(t, clt, []string{"edit", "host_user/alice"}, withEditor(editor)) + require.NoError(t, err) + + actual, err := clt.StaticHostUserClient().GetStaticHostUser(ctx, expected.GetMetadata().Name) + require.NoError(t, err) + require.Empty(t, cmp.Diff(expected, actual, + protocmp.IgnoreFields(&headerv1.Metadata{}, "revision"), + protocmp.Transform(), + )) + + _, err = runEditCommand(t, clt, []string{"edit", "host_user/alice"}, withEditor(editor)) + require.Error(t, err) + require.True(t, trace.IsCompareFailed(err), "unexpected error: %v", err) +} diff --git a/tool/tctl/common/resource_command.go b/tool/tctl/common/resource_command.go index 3193cc28902a..bb90404387d8 100644 --- a/tool/tctl/common/resource_command.go +++ b/tool/tctl/common/resource_command.go @@ -50,6 +50,7 @@ import ( loginrulepb "github.com/gravitational/teleport/api/gen/proto/go/teleport/loginrule/v1" machineidv1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/machineid/v1" pluginsv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/plugins/v1" + userprovisioningpb "github.com/gravitational/teleport/api/gen/proto/go/teleport/userprovisioning/v2" "github.com/gravitational/teleport/api/gen/proto/go/teleport/vnet/v1" "github.com/gravitational/teleport/api/internalutils/stream" "github.com/gravitational/teleport/api/mfa" @@ -166,6 +167,7 @@ func (rc *ResourceCommand) Initialize(app *kingpin.Application, config *servicec types.KindAccessGraphSettings: rc.upsertAccessGraphSettings, types.KindPlugin: rc.createPlugin, types.KindSPIFFEFederation: rc.createSPIFFEFederation, + types.KindStaticHostUser: rc.createStaticHostUser, } rc.UpdateHandlers = map[ResourceKind]ResourceCreateHandler{ types.KindUser: rc.updateUser, @@ -181,6 +183,7 @@ func (rc *ResourceCommand) Initialize(app *kingpin.Application, config *servicec types.KindVnetConfig: rc.updateVnetConfig, types.KindAccessGraphSettings: rc.updateAccessGraphSettings, types.KindPlugin: rc.updatePlugin, + types.KindStaticHostUser: rc.updateStaticHostUser, } rc.config = config @@ -1419,6 +1422,39 @@ func (rc *ResourceCommand) createServerInfo(ctx context.Context, client *authcli return nil } +func (rc *ResourceCommand) createStaticHostUser(ctx context.Context, client *authclient.Client, resource services.UnknownResource) error { + hostUser, err := services.UnmarshalProtoResource[*userprovisioningpb.StaticHostUser](resource.Raw) + if err != nil { + return trace.Wrap(err) + } + c := client.StaticHostUserClient() + if rc.force { + if _, err := c.UpsertStaticHostUser(ctx, hostUser); err != nil { + return trace.Wrap(err) + } + fmt.Printf("static host user %q has been updated\n", hostUser.GetMetadata().Name) + } else { + if _, err := c.CreateStaticHostUser(ctx, hostUser); err != nil { + return trace.Wrap(err) + } + fmt.Printf("static host user %q has been created\n", hostUser.GetMetadata().Name) + } + + return nil +} + +func (rc *ResourceCommand) updateStaticHostUser(ctx context.Context, client *authclient.Client, resource services.UnknownResource) error { + hostUser, err := services.UnmarshalProtoResource[*userprovisioningpb.StaticHostUser](resource.Raw) + if err != nil { + return trace.Wrap(err) + } + if _, err := client.StaticHostUserClient().UpdateStaticHostUser(ctx, hostUser); err != nil { + return trace.Wrap(err) + } + fmt.Printf("static host user %q has been updated\n", hostUser.GetMetadata().Name) + return nil +} + // Delete deletes resource by name func (rc *ResourceCommand) Delete(ctx context.Context, client *authclient.Client) (err error) { singletonResources := []string{ @@ -1812,6 +1848,11 @@ 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.KindStaticHostUser: + if err := client.StaticHostUserClient().DeleteStaticHostUser(ctx, rc.ref.Name); err != nil { + return trace.Wrap(err) + } + fmt.Printf("static host user %q has been deleted\n", rc.ref.Name) default: return trace.BadParameter("deleting resources of type %q is not supported", rc.ref.Kind) } @@ -2929,6 +2970,31 @@ func (rc *ResourceCommand) getCollection(ctx context.Context, client *authclient } return &botInstanceCollection{items: instances}, nil + case types.KindStaticHostUser: + hostUserClient := client.StaticHostUserClient() + if rc.ref.Name != "" { + hostUser, err := hostUserClient.GetStaticHostUser(ctx, rc.ref.Name) + if err != nil { + return nil, trace.Wrap(err) + } + + return &staticHostUserCollection{items: []*userprovisioningpb.StaticHostUser{hostUser}}, nil + } + + var hostUsers []*userprovisioningpb.StaticHostUser + var nextToken string + for { + resp, token, err := hostUserClient.ListStaticHostUsers(ctx, 0, nextToken) + if err != nil { + return nil, trace.Wrap(err) + } + hostUsers = append(hostUsers, resp...) + if token == "" { + break + } + nextToken = token + } + return &staticHostUserCollection{items: hostUsers}, nil } return nil, trace.BadParameter("getting %q is not supported", rc.ref.String()) } diff --git a/tool/tctl/common/resource_command_test.go b/tool/tctl/common/resource_command_test.go index 43002008f28d..ff5c5fcae176 100644 --- a/tool/tctl/common/resource_command_test.go +++ b/tool/tctl/common/resource_command_test.go @@ -42,6 +42,7 @@ import ( "github.com/gravitational/teleport/api/constants" apidefaults "github.com/gravitational/teleport/api/defaults" headerv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/header/v1" + userprovisioningpb "github.com/gravitational/teleport/api/gen/proto/go/teleport/userprovisioning/v2" "github.com/gravitational/teleport/api/types" "github.com/gravitational/teleport/api/types/discoveryconfig" "github.com/gravitational/teleport/api/types/header" @@ -1405,6 +1406,10 @@ func TestCreateResources(t *testing.T) { kind: types.KindAppServer, create: testCreateAppServer, }, + { + kind: types.KindStaticHostUser, + create: testCreateStaticHostUser, + }, } for _, test := range tests { @@ -2205,6 +2210,72 @@ spec: require.NoError(t, err) } +func testCreateStaticHostUser(t *testing.T, clt *authclient.Client) { + // Ensure that our test user does not exist + resourceName := "alice" + resourceKey := types.KindStaticHostUser + "/" + resourceName + _, err := runResourceCommand(t, clt, []string{"get", resourceKey, "--format=json"}) + require.Error(t, err) + require.True(t, trace.IsNotFound(err), "unexpected error: %v", err) + + const userYAML = `kind: static_host_user +version: v2 +metadata: + name: alice +spec: + matchers: + - node_labels: + - name: foo + values: ["bar"] + groups: + - foo + - bar + uid: 1234 + gid: 5678 + - node_labels_expression: 'labels["foo"] == labels["bar"]' + groups: + - baz + - quux + sudoers: ["abc1234"] +` + + // Create the host user + userYAMLPath := filepath.Join(t.TempDir(), "host_user.yaml") + require.NoError(t, os.WriteFile(userYAMLPath, []byte(userYAML), 0644)) + _, err = runResourceCommand(t, clt, []string{"create", userYAMLPath}) + require.NoError(t, err) + + // Fetch the user + buf, err := runResourceCommand(t, clt, []string{"get", resourceKey, "--format=json"}) + require.NoError(t, err) + hostUsers := mustDecodeJSON[[]*userprovisioningpb.StaticHostUser](t, buf) + require.Len(t, hostUsers, 1) + + var expected userprovisioningpb.StaticHostUser + require.NoError(t, yaml.Unmarshal([]byte(userYAML), &expected)) + + require.Empty(t, cmp.Diff( + []*userprovisioningpb.StaticHostUser{&expected}, + hostUsers, + protocmp.IgnoreFields(&headerv1.Metadata{}, "revision"), + protocmp.Transform(), + )) + + // Explicitly change the revision and try creating the user with and without + // the force flag. + expected.GetMetadata().Revision = uuid.NewString() + hostUserBytes, err := services.MarshalProtoResource(&expected, services.PreserveRevision()) + require.NoError(t, err) + require.NoError(t, os.WriteFile(userYAMLPath, hostUserBytes, 0644)) + + _, err = runResourceCommand(t, clt, []string{"create", userYAMLPath}) + require.Error(t, err) + require.True(t, trace.IsAlreadyExists(err), "unexpected error: %v", err) + + _, err = runResourceCommand(t, clt, []string{"create", "-f", userYAMLPath}) + require.NoError(t, err) +} + func TestPluginResourceWrapper(t *testing.T) { tests := []struct { name string