diff --git a/docs/pages/reference/cli/tsh.mdx b/docs/pages/reference/cli/tsh.mdx index fdd61333571f2..61d59afde5450 100644 --- a/docs/pages/reference/cli/tsh.mdx +++ b/docs/pages/reference/cli/tsh.mdx @@ -1078,7 +1078,7 @@ $ tsh request review [] ## tsh request search -Search for resources to request access to +Search for resources to request access to, or list requestable roles. ```code $ tsh request search [] @@ -1088,7 +1088,8 @@ $ tsh request search [] | Name | Default Value(s) | Allowed Value(s) | Description | | - | - | - | - | -| `--kind` | none | `node`, `kube_cluster`, `kube_resource`, `db`, `app`, `windows_desktop` | Resource kind to search for (required) | +| `--roles` | false | boolean | List requestable roles instead of searching for resources. Mutually exclusive with --kind. | +| `--kind` | none | `node`, `kube_cluster`, `kube_resource`, `db`, `app`, `windows_desktop` | Resource kind to search for. Mutually exclusive with --roles. | | `--kube-kind` | none | string | Plural name of the kube resource (required when --kind is `kube_resource`) | | `--kube-api-group` | none | string | API group of the kube resource (required when --kind is `kube_resource` depending on the resource) | | `--search` | none | Comma-separated strings | List of comma-separated search keywords or phrases enclosed in single quotes | diff --git a/lib/services/access_request.go b/lib/services/access_request.go index c26cf710d5408..31f2a6599e357 100644 --- a/lib/services/access_request.go +++ b/lib/services/access_request.go @@ -68,6 +68,11 @@ const ( // `request.kubernetes_resources` config. It's also used to determine if a returned error // contains this string (in tests and tsh) to customize error messages shown to user. InvalidKubernetesKindAccessRequest = `your Teleport role's "request.kubernetes_resources" field` + + // CannotRequestRole is used in error messages when a user attempts to request + // a role they are not allowed to use. It is also checked in returned errors + // (in tests and tsh) to customize the error message shown to the user. + CannotRequestRole = "can not request role" ) // ValidateAccessRequest validates the AccessRequest and sets default values @@ -1272,11 +1277,11 @@ func (m *RequestValidator) validate(ctx context.Context, req types.AccessRequest // Roles are normally determined automatically for resource // access requests, this role must have been explicitly // requested, or a new deny rule has since been added. - return trace.BadParameter("user %q can not request role %q", req.GetUser(), roleName) + return trace.BadParameter("user %q %s %q", req.GetUser(), CannotRequestRole, roleName) } } else { if !m.CanRequestRole(roleName) { - return trace.BadParameter("user %q can not request role %q", req.GetUser(), roleName) + return trace.BadParameter("user %q %s %q", req.GetUser(), CannotRequestRole, roleName) } } } diff --git a/tool/tsh/common/access_request.go b/tool/tsh/common/access_request.go index 4ca65b51377fe..32d4f606d4f48 100644 --- a/tool/tsh/common/access_request.go +++ b/tool/tsh/common/access_request.go @@ -19,6 +19,7 @@ package common import ( + "context" "fmt" "path" "sort" @@ -34,8 +35,10 @@ import ( "github.com/gravitational/teleport/api/client/proto" kubeproto "github.com/gravitational/teleport/api/gen/proto/go/teleport/kube/v1" "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/api/utils/clientutils" "github.com/gravitational/teleport/lib/asciitable" "github.com/gravitational/teleport/lib/auth/authclient" + "github.com/gravitational/teleport/lib/itertools/stream" "github.com/gravitational/teleport/lib/kube/kubeconfig" "github.com/gravitational/teleport/lib/services" "github.com/gravitational/teleport/lib/utils" @@ -406,6 +409,69 @@ func showRequestTable(cf *CLIConf, reqs []types.AccessRequest) error { } func onRequestSearch(cf *CLIConf) error { + if cf.RequestableRoles && cf.ResourceKind != "" { + return trace.BadParameter("only one of --kind and --roles may be specified") + } + if !cf.RequestableRoles && cf.ResourceKind == "" { + return trace.BadParameter("one of --kind and --roles is required") + } + + if cf.RequestableRoles { + return searchRequestableRoles(cf) + } else { + return searchRequestableResources(cf) + } +} + +func searchRequestableRoles(cf *CLIConf) error { + tc, err := makeClient(cf) + if err != nil { + return trace.Wrap(err) + } + + var allRoles []*proto.ListRequestableRolesResponse_RequestableRole + err = tc.WithRootClusterClient(cf.Context, func(clt authclient.ClientI) error { + pageFunc := func(ctx context.Context, pageSize int, pageToken string) ([]*proto.ListRequestableRolesResponse_RequestableRole, string, error) { + req := &proto.ListRequestableRolesRequest{ + PageSize: int32(pageSize), + PageToken: pageToken, + } + + resp, err := clt.ListRequestableRoles(ctx, req) + return resp.GetRoles(), resp.GetNextPageToken(), trace.Wrap(err) + } + + var err error + allRoles, err = stream.Collect(clientutils.Resources(cf.Context, pageFunc)) + return err + }) + if err != nil { + return trace.Wrap(err) + } + + if len(allRoles) == 0 { + fmt.Fprintln(cf.Stdout(), "No requestable roles found.") + return nil + } + + columns := []string{"Role", "Description"} + var rows [][]string + for _, r := range allRoles { + rows = append(rows, []string{r.Name, r.Description}) + } + + var table asciitable.Table + if cf.Verbose { + table = asciitable.MakeTable(columns, rows...) + } else { + table = asciitable.MakeTableWithTruncatedColumn(columns, rows, "Description") + } + + _, err = table.AsBuffer().WriteTo(cf.Stdout()) + return trace.Wrap(err) +} + +func searchRequestableResources(cf *CLIConf) error { tc, err := makeClient(cf) if err != nil { return trace.Wrap(err) diff --git a/tool/tsh/common/access_request_test.go b/tool/tsh/common/access_request_test.go index a19ec52646f9b..1016308123949 100644 --- a/tool/tsh/common/access_request_test.go +++ b/tool/tsh/common/access_request_test.go @@ -292,3 +292,124 @@ func TestShowRequestTable(t *testing.T) { }) } } + +func TestRequestSearchRequestableRoles(t *testing.T) { + ctx := t.Context() + tmpHomePath := t.TempDir() + connector := mockConnector(t) + + accessRole, err := types.NewRole("access", types.RoleSpecV6{}) + require.NoError(t, err) + accessRole.SetMetadata(types.Metadata{ + Name: "access", + Description: "base access role", + }) + + dbAdminRole, err := types.NewRole("db-admin", types.RoleSpecV6{}) + require.NoError(t, err) + dbAdminRole.SetMetadata(types.Metadata{ + Name: "db-admin", + Description: "database administrator role", + }) + + unrequestableRole, err := types.NewRole("unrequestable", types.RoleSpecV6{}) + require.NoError(t, err) + unrequestableRole.SetMetadata(types.Metadata{ + Name: "unrequestable", + Description: "role that exists but is not requestable for this user", + }) + + requesterRole, err := types.NewRole("requester", types.RoleSpecV6{ + Allow: types.RoleConditions{ + Request: &types.AccessRequestConditions{ + Roles: []string{"access", "db-admin"}, + }, + }, + }) + require.NoError(t, err) + + user, err := types.NewUser("alice@example.com") + require.NoError(t, err) + user.SetRoles([]string{"requester"}) + + auth, proxy := makeTestServers(t, + withBootstrap( + connector, + accessRole, + dbAdminRole, + unrequestableRole, + requesterRole, + user, + ), + ) + authServer := auth.GetAuthServer() + require.NotNil(t, authServer) + + proxyAddr, err := proxy.ProxyWebAddr() + require.NoError(t, err) + + err = Run(ctx, []string{ + "login", + "--insecure", + "--debug", + "--proxy", proxyAddr.String(), + }, setHomePath(tmpHomePath), setMockSSOLogin(authServer, user, connector.GetName())) + require.NoError(t, err) + + wantRolesTable := func() string { + table := asciitable.MakeTable([]string{"Role", "Description"}) + table.AddRow([]string{"access", "base access role"}) + table.AddRow([]string{"db-admin", "database administrator role"}) + return table.AsBuffer().String() + } + + tests := []struct { + name string + args []string + // If empty, we expect the command to succeed. + errMessage string + want func() string + }{ + { + name: "list requestable roles", + args: []string{"request", "search", "--roles"}, + errMessage: "", + want: wantRolesTable, + }, + { + name: "both kind and roles set", + args: []string{"request", "search", "--kind=node", "--roles"}, + errMessage: "only one of --kind and --roles may be specified", + want: nil, + }, + { + name: "neither kind nor roles set", + args: []string{"request", "search"}, + errMessage: "one of --kind and --roles is required", + want: nil, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var stdout bytes.Buffer + + err := Run(ctx, + append([]string{"--insecure"}, tc.args...), + setHomePath(tmpHomePath), + setCopyStdout(&stdout), + ) + + if tc.errMessage != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tc.errMessage) + return + } + + require.NoError(t, err) + if tc.want != nil { + require.Equal(t, tc.want(), stdout.String()) + } + }) + } +} diff --git a/tool/tsh/common/tsh.go b/tool/tsh/common/tsh.go index d0cb9b281fccb..fc63cf1a7a736 100644 --- a/tool/tsh/common/tsh.go +++ b/tool/tsh/common/tsh.go @@ -190,6 +190,8 @@ type CLIConf struct { AssumeStartTimeRaw string // ResourceKind is the resource kind to search for ResourceKind string + // RequestableRoles allows users to search for requestable roles. + RequestableRoles bool // Username is the Teleport user's username (to login into proxies) Username string // ExplicitUsername is true if Username was initially set by the end-user @@ -1358,10 +1360,23 @@ func Run(ctx context.Context, args []string, opts ...CliOption) error { reqReview.Flag("assume-start-time", "Sets time roles can be assumed by requestor (RFC3339 e.g 2023-12-12T23:20:50.52Z).").StringVar(&cf.AssumeStartTimeRaw) reqSearch := req.Command("search", "Search for resources to request access to.") - reqSearch.Flag("kind", fmt.Sprintf("Resource kind to search for (%s).", strings.Join(types.RequestableResourceKinds, ", "))).Required().StringVar(&cf.ResourceKind) + reqSearch.Flag("kind", fmt.Sprintf("Resource kind to search for (%s). Mutually exclusive with --roles.", strings.Join(types.RequestableResourceKinds, ", "))).StringVar(&cf.ResourceKind) + reqSearch.Flag("roles", "List requestable roles instead of searching for resources. Mutually exclusive with --kind.").BoolVar(&cf.RequestableRoles) reqSearch.Flag("kube-kind", fmt.Sprintf("Kubernetes resource kind name (plural) to search for. Required with --kind=%q Ex: pods, deployments, namespaces, etc.", types.KindKubernetesResource)).StringVar(&cf.kubeResourceKind) reqSearch.Flag("kube-api-group", "Kubernetes API group to search for resources.").StringVar(&cf.kubeAPIGroup) reqSearch.PreAction(func(*kingpin.ParseContext) error { + if cf.RequestableRoles && cf.ResourceKind != "" { + return trace.BadParameter("only one of --kind and --roles may be specified") + } + if !cf.RequestableRoles && cf.ResourceKind == "" { + return trace.BadParameter("one of --kind and --roles is required") + } + + // in --roles mode we don't care about resource kinds or kube flags. + if cf.RequestableRoles { + return nil + } + // TODO(@creack): DELETE IN v20.0.0. Allow legacy kinds with a warning for now. if slices.Contains(types.LegacyRequestableKubeResourceKinds, cf.ResourceKind) { cf.kubeAPIGroup = types.KubernetesResourcesV7KindGroups[cf.ResourceKind] @@ -3083,6 +3098,9 @@ func executeAccessRequest(cf *CLIConf, tc *client.TeleportClient) error { if strings.Contains(err.Error(), services.InvalidKubernetesKindAccessRequest) { return trace.BadParameter("%s\nTry searching for specific kinds with:\n> tsh request search --kube-cluster=KUBE_CLUSTER_NAME --kind=KIND", err.Error()) } + if strings.Contains(err.Error(), services.CannotRequestRole) { + return trace.BadParameter("%s\nHint: run \"tsh request search --roles\" to list requestable roles.", err.Error()) + } return trace.Wrap(err) } cf.RequestID = req.GetName()