From 6fc31cb2db56451c16aee34de3e42768e5d3f24f Mon Sep 17 00:00:00 2001 From: Tiago Silva Date: Tue, 5 May 2026 13:09:02 +0100 Subject: [PATCH] sessionsearch[28]: add resource property filters to tctl recordings search (#66428) Expose SSH, Kubernetes, and database-specific resource property filters on `tctl recordings search`, and map them into the session search ResourceProperties oneof. Reject mixed resource property variants in a single search request, since the API accepts only one ResourceProperties variant at a time. Signed-off-by: Tiago Silva --- tool/tctl/common/recordings_command.go | 79 +++++++++++++++ tool/tctl/common/recordings_command_test.go | 102 ++++++++++++++++++++ 2 files changed, 181 insertions(+) create mode 100644 tool/tctl/common/recordings_command_test.go diff --git a/tool/tctl/common/recordings_command.go b/tool/tctl/common/recordings_command.go index b57fb6145ca..1875c348d28 100644 --- a/tool/tctl/common/recordings_command.go +++ b/tool/tctl/common/recordings_command.go @@ -109,6 +109,16 @@ type RecordingsCommand struct { searchResourceKind string // searchResourceName filters results by the resource name. searchResourceName string + // searchServerHostname filters SSH results by server hostname. + searchServerHostname string + // searchServerAddr filters SSH results by server address. + searchServerAddr string + // searchPodNamespace filters Kubernetes results by pod namespace. + searchPodNamespace string + // searchPodName filters Kubernetes results by pod name. + searchPodName string + // searchDatabaseName filters database results by database name. + searchDatabaseName string // searchSeverity filters results by minimum severity level (low/medium/high/critical). searchSeverity string // searchMode controls which search strategy to use: hybrid (default), keyword, or embedding. @@ -143,6 +153,11 @@ func (c *RecordingsCommand) Initialize(app *kingpin.Application, t *tctlcfg.Glob c.recordingsSearch.Flag("role", "Filter by role held during the session. Can be specified multiple times.").StringsVar(&c.searchRoles) c.recordingsSearch.Flag("resource-kind", "Filter by Teleport resource type (node, kube_cluster, db).").StringVar(&c.searchResourceKind) c.recordingsSearch.Flag("resource-name", "Filter by resource name.").StringVar(&c.searchResourceName) + c.recordingsSearch.Flag("server-hostname", "Filter SSH sessions by server hostname.").StringVar(&c.searchServerHostname) + c.recordingsSearch.Flag("server-addr", "Filter SSH sessions by server address.").StringVar(&c.searchServerAddr) + c.recordingsSearch.Flag("pod-namespace", "Filter Kubernetes sessions by pod namespace.").StringVar(&c.searchPodNamespace) + c.recordingsSearch.Flag("pod-name", "Filter Kubernetes sessions by pod name.").StringVar(&c.searchPodName) + c.recordingsSearch.Flag("database-name", "Filter database sessions by database name.").StringVar(&c.searchDatabaseName) c.recordingsSearch.Flag("severity", "Minimum severity level to include (low, medium, high, critical).").StringVar(&c.searchSeverity) c.recordingsSearch.Flag("search-mode", "Search strategy to use when search queries are provided.").Default(searchModeHybrid).EnumVar(&c.searchMode, searchModeHybrid, searchModeKeyword, searchModeEmbedding) c.recordingsSearch.Flag("limit", "Maximum number of results to return.").Default(defaults.TshTctlSessionListLimit).Uint32Var(&c.searchLimit) @@ -290,6 +305,11 @@ func (c *RecordingsCommand) SearchRecordings(ctx context.Context, tc *authclient UserRoles: c.searchRoles, MaxResults: c.searchLimit, } + resourceProperties, err := c.buildSearchResourceProperties() + if err != nil { + return trace.Wrap(err) + } + req.ResourceProperties = resourceProperties if c.searchUsername != "" { req.Username = &c.searchUsername } @@ -354,6 +374,65 @@ func showSessionSummaries(ctx context.Context, sessions []*sessionsearchv1pb.Ses } } +func (c *RecordingsCommand) buildSearchResourceProperties() (*sessionsearchv1pb.ResourceProperties, error) { + sshSet := c.searchServerHostname != "" || c.searchServerAddr != "" + kubernetesSet := c.searchPodNamespace != "" || c.searchPodName != "" + databaseSet := c.searchDatabaseName != "" + + var variants []string + if sshSet { + variants = append(variants, "SSH") + } + if kubernetesSet { + variants = append(variants, "Kubernetes") + } + if databaseSet { + variants = append(variants, "Database") + } + if len(variants) > 1 { + return nil, trace.BadParameter("resource property filters can only target one session kind at a time, got %s", strings.Join(variants, ", ")) + } + + switch { + case sshSet: + props := &sessionsearchv1pb.SSHProperties{} + if c.searchServerHostname != "" { + props.ServerHostname = &c.searchServerHostname + } + if c.searchServerAddr != "" { + props.ServerAddr = &c.searchServerAddr + } + return &sessionsearchv1pb.ResourceProperties{ + Type: &sessionsearchv1pb.ResourceProperties_Ssh{ + Ssh: props, + }, + }, nil + case kubernetesSet: + props := &sessionsearchv1pb.KubernetesProperties{} + if c.searchPodNamespace != "" { + props.PodNamespace = &c.searchPodNamespace + } + if c.searchPodName != "" { + props.PodName = &c.searchPodName + } + return &sessionsearchv1pb.ResourceProperties{ + Type: &sessionsearchv1pb.ResourceProperties_Kubernetes{ + Kubernetes: props, + }, + }, nil + case databaseSet: + return &sessionsearchv1pb.ResourceProperties{ + Type: &sessionsearchv1pb.ResourceProperties_Database{ + Database: &sessionsearchv1pb.DatabaseProperties{ + DatabaseName: &c.searchDatabaseName, + }, + }, + }, nil + default: + return nil, nil + } +} + // checkSessionSearchEnabled returns an error if session search is not active on this cluster. func checkSessionSearchEnabled(ctx context.Context, sc sessionsearchv1pb.SessionSearchServiceClient) error { resp, err := sc.IsEnabled(ctx, &sessionsearchv1pb.IsEnabledRequest{}) diff --git a/tool/tctl/common/recordings_command_test.go b/tool/tctl/common/recordings_command_test.go new file mode 100644 index 00000000000..677405b90a3 --- /dev/null +++ b/tool/tctl/common/recordings_command_test.go @@ -0,0 +1,102 @@ +/* + * Teleport + * Copyright (C) 2026 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 ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/gravitational/teleport/lib/service/servicecfg" + "github.com/gravitational/teleport/lib/utils" + tctlcfg "github.com/gravitational/teleport/tool/tctl/common/config" +) + +func TestRecordingsSearchResourcePropertyFlags(t *testing.T) { + var c RecordingsCommand + app := utils.InitCLIParser("tctl", GlobalHelpString) + c.Initialize(app, &tctlcfg.GlobalCLIFlags{}, servicecfg.MakeDefaultConfig()) + + selectedCmd, err := app.Parse([]string{ + "recordings", "search", + "--server-hostname=host-1", + "--server-addr=10.0.0.1:3022", + "--pod-namespace=prod", + "--pod-name=api-7fd", + "--database-name=postgres", + }) + require.NoError(t, err) + require.Equal(t, c.recordingsSearch.FullCommand(), selectedCmd) + require.Equal(t, "host-1", c.searchServerHostname) + require.Equal(t, "10.0.0.1:3022", c.searchServerAddr) + require.Equal(t, "prod", c.searchPodNamespace) + require.Equal(t, "api-7fd", c.searchPodName) + require.Equal(t, "postgres", c.searchDatabaseName) +} + +func TestBuildSearchResourceProperties(t *testing.T) { + t.Run("none", func(t *testing.T) { + got, err := (&RecordingsCommand{}).buildSearchResourceProperties() + require.NoError(t, err) + require.Nil(t, got) + }) + + t.Run("ssh", func(t *testing.T) { + got, err := (&RecordingsCommand{ + searchServerHostname: "host-1", + searchServerAddr: "10.0.0.1:3022", + }).buildSearchResourceProperties() + require.NoError(t, err) + ssh := got.GetSsh() + require.NotNil(t, ssh) + require.Equal(t, "host-1", ssh.GetServerHostname()) + require.Equal(t, "10.0.0.1:3022", ssh.GetServerAddr()) + }) + + t.Run("kubernetes", func(t *testing.T) { + got, err := (&RecordingsCommand{ + searchPodNamespace: "prod", + searchPodName: "api-7fd", + }).buildSearchResourceProperties() + require.NoError(t, err) + kubernetes := got.GetKubernetes() + require.NotNil(t, kubernetes) + require.Equal(t, "prod", kubernetes.GetPodNamespace()) + require.Equal(t, "api-7fd", kubernetes.GetPodName()) + }) + + t.Run("database", func(t *testing.T) { + got, err := (&RecordingsCommand{ + searchDatabaseName: "postgres", + }).buildSearchResourceProperties() + require.NoError(t, err) + database := got.GetDatabase() + require.NotNil(t, database) + require.Equal(t, "postgres", database.GetDatabaseName()) + }) + + t.Run("mixed variants", func(t *testing.T) { + got, err := (&RecordingsCommand{ + searchServerHostname: "host-1", + searchPodName: "api-7fd", + }).buildSearchResourceProperties() + require.ErrorContains(t, err, "resource property filters can only target one session kind") + require.Nil(t, got) + }) +}