From c159767bface50e0cece373ec98cd7b194b05333 Mon Sep 17 00:00:00 2001 From: "STeve (Xin) Huang" Date: Thu, 4 Jan 2024 11:24:36 -0500 Subject: [PATCH] [v14] Add database roles to `tsh db ls -v` (#36246) * Add database roles to `tsh db ls -v` (#35582) * refactor and add --exclude-column * add Database Roles column * add database_roles to json,yaml * remove --exclude-column and refactor databaseTableRow * change slices import * update copyright header --- tool/tsh/common/db.go | 6 +- tool/tsh/common/db_print.go | 133 +++++++++++++++++++++ tool/tsh/common/db_print_test.go | 196 +++++++++++++++++++++++++++++++ tool/tsh/common/tsh.go | 118 +++++++++---------- 4 files changed, 385 insertions(+), 68 deletions(-) create mode 100644 tool/tsh/common/db_print.go create mode 100644 tool/tsh/common/db_print_test.go diff --git a/tool/tsh/common/db.go b/tool/tsh/common/db.go index 91722620c035e..145d7a444f8cf 100644 --- a/tool/tsh/common/db.go +++ b/tool/tsh/common/db.go @@ -94,7 +94,7 @@ func onListDatabases(cf *CLIConf) error { } sort.Sort(types.Databases(databases)) - return trace.Wrap(showDatabases(cf.Stdout(), cf.SiteName, databases, activeDatabases, accessChecker, cf.Format, cf.Verbose)) + return trace.Wrap(showDatabases(cf, databases, activeDatabases, accessChecker)) } func accessCheckerForRemoteCluster(ctx context.Context, profile *client.ProfileStatus, proxy *client.ProxyClient, clusterName string) (services.AccessChecker, error) { @@ -230,7 +230,7 @@ func listDatabasesAllClusters(cf *CLIConf) error { format := strings.ToLower(cf.Format) switch format { case teleport.Text, "": - printDatabasesWithClusters(cf.SiteName, dbListings, active, cf.Verbose) + printDatabasesWithClusters(cf, dbListings, active) case teleport.JSON, teleport.YAML: out, err := serializeDatabasesAllClusters(dbListings, format) if err != nil { @@ -1647,7 +1647,7 @@ func formatAmbiguousDB(cf *CLIConf, selectors resourceSelectors, matchedDBs type var checker services.AccessChecker var sb strings.Builder verbose := true - showDatabasesAsText(&sb, cf.SiteName, matchedDBs, activeDBs, checker, verbose) + showDatabasesAsText(cf, &sb, matchedDBs, activeDBs, checker, verbose) listCommand := formatDatabaseListCommand(cf.SiteName) fullNameExample := matchedDBs[0].GetName() diff --git a/tool/tsh/common/db_print.go b/tool/tsh/common/db_print.go new file mode 100644 index 0000000000000..372e815d2076c --- /dev/null +++ b/tool/tsh/common/db_print.go @@ -0,0 +1,133 @@ +/* +Copyright 2023 Gravitational, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package common + +import ( + "fmt" + "io" + "reflect" + "regexp" + "slices" + + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/lib/asciitable" + "github.com/gravitational/teleport/lib/services" +) + +type databaseTableRow struct { + Proxy string + Cluster string + DisplayName string `title:"Name"` + Description string + Protocol string + Type string + URI string + AllowedUsers string + DatabaseRoles string + Labels string + Connect string +} + +func makeTableColumnTitles(row any) (out []string) { + // Regular expression to convert from "DatabaseRoles" to "Database Roles" etc. + re := regexp.MustCompile(`([a-z])([A-Z])`) + + t := reflect.TypeOf(row) + for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + title := field.Tag.Get("title") + if title == "" { + title = re.ReplaceAllString(field.Name, "${1} ${2}") + } + out = append(out, title) + } + return out +} + +func makeTableRows[T any](rows []T) [][]string { + out := make([][]string, 0, len(rows)) + for _, row := range rows { + var columnValues []string + v := reflect.ValueOf(row) + for i := 0; i < v.NumField(); i++ { + columnValues = append(columnValues, fmt.Sprintf("%v", v.Field(i))) + } + out = append(out, columnValues) + } + return out +} + +type printDatabaseTableConfig struct { + writer io.Writer + rows []databaseTableRow + showProxyAndCluster bool + verbose bool +} + +func (cfg printDatabaseTableConfig) excludeColumns() (out []string) { + if !cfg.showProxyAndCluster { + out = append(out, "Proxy", "Cluster") + } + if !cfg.verbose { + out = append(out, "Protocol", "Type", "URI", "Database Roles") + } + return out +} + +func printDatabaseTable(cfg printDatabaseTableConfig) { + allColumns := makeTableColumnTitles(databaseTableRow{}) + rowsWithAllColumns := makeTableRows(cfg.rows) + excludeColumns := cfg.excludeColumns() + + var printColumns []string + printRows := make([][]string, len(cfg.rows)) + for columnIndex, column := range allColumns { + if slices.Contains(excludeColumns, column) { + continue + } + + printColumns = append(printColumns, column) + for rowIndex, row := range rowsWithAllColumns { + printRows[rowIndex] = append(printRows[rowIndex], row[columnIndex]) + } + } + + var t asciitable.Table + if cfg.verbose { + t = asciitable.MakeTable(printColumns, printRows...) + } else { + t = asciitable.MakeTableWithTruncatedColumn(printColumns, printRows, "Labels") + } + fmt.Fprintln(cfg.writer, t.AsBuffer().String()) +} + +func formatDatabaseRolesForDB(database types.Database, accessChecker services.AccessChecker) string { + if database.SupportsAutoUsers() && database.GetAdminUser().Name != "" { + // may happen if fetching the role set failed for any reason. + if accessChecker == nil { + return "(unknown)" + } + + autoUser, roles, err := accessChecker.CheckDatabaseRoles(database) + if err != nil { + log.Warnf("Failed to CheckDatabaseRoles for database %v: %v.", database.GetName(), err) + } else if autoUser.IsEnabled() { + return fmt.Sprintf("%v", roles) + } + } + return "" +} diff --git a/tool/tsh/common/db_print_test.go b/tool/tsh/common/db_print_test.go new file mode 100644 index 0000000000000..efcb45947a6d5 --- /dev/null +++ b/tool/tsh/common/db_print_test.go @@ -0,0 +1,196 @@ +/* +Copyright 2023 Gravitational, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package common + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" + + apidefaults "github.com/gravitational/teleport/api/defaults" + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/lib/services" +) + +func Test_printDatabaseTable(t *testing.T) { + t.Parallel() + + rows := []databaseTableRow{ + databaseTableRow{ + Proxy: "proxy", + Cluster: "cluster1", + DisplayName: "db1", + Description: "describe db1", + Protocol: "postgres", + Type: "self-hosted", + URI: "localhost:5432", + AllowedUsers: "[*]", + Labels: "Env=dev", + Connect: "tsh db connect db1", + }, + databaseTableRow{ + Proxy: "proxy", + Cluster: "cluster1", + DisplayName: "db2", + Description: "describe db2", + Protocol: "mysql", + Type: "self-hosted", + URI: "localhost:3306", + AllowedUsers: "[alice]", + DatabaseRoles: "[readonly]", + Labels: "Env=prod", + }, + } + + tests := []struct { + name string + cfg printDatabaseTableConfig + expect string + }{ + { + name: "tsh db ls", + cfg: printDatabaseTableConfig{ + rows: rows, + showProxyAndCluster: false, + verbose: false, + }, + // os.Stdin.Fd() fails during go test, so width is defaulted to 80 for truncated table. + expect: `Name Description Allowed Users Labels Connect +---- ------------ ------------- -------- ------------------- +db1 describe db1 [*] Env=dev tsh db connect d... +db2 describe db2 [alice] Env=prod + +`, + }, + { + name: "tsh db ls --verbose", + cfg: printDatabaseTableConfig{ + rows: rows, + showProxyAndCluster: false, + verbose: true, + }, + expect: `Name Description Protocol Type URI Allowed Users Database Roles Labels Connect +---- ------------ -------- ----------- -------------- ------------- -------------- -------- ------------------ +db1 describe db1 postgres self-hosted localhost:5432 [*] Env=dev tsh db connect db1 +db2 describe db2 mysql self-hosted localhost:3306 [alice] [readonly] Env=prod + +`, + }, + { + name: "tsh db ls --verbose --all", + cfg: printDatabaseTableConfig{ + rows: rows, + showProxyAndCluster: true, + verbose: true, + }, + expect: `Proxy Cluster Name Description Protocol Type URI Allowed Users Database Roles Labels Connect +----- -------- ---- ------------ -------- ----------- -------------- ------------- -------------- -------- ------------------ +proxy cluster1 db1 describe db1 postgres self-hosted localhost:5432 [*] Env=dev tsh db connect db1 +proxy cluster1 db2 describe db2 mysql self-hosted localhost:3306 [alice] [readonly] Env=prod + +`, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var sb strings.Builder + + cfg := test.cfg + cfg.writer = &sb + + printDatabaseTable(cfg) + require.Equal(t, test.expect, sb.String()) + }) + } +} + +func Test_formatDatabaseRolesForDB(t *testing.T) { + t.Parallel() + + db, err := types.NewDatabaseV3(types.Metadata{ + Name: "db", + }, types.DatabaseSpecV3{ + Protocol: "postgres", + URI: "localhost:5432", + }) + require.NoError(t, err) + + dbWithAutoUser, err := types.NewDatabaseV3(types.Metadata{ + Name: "dbWithAutoUser", + Labels: map[string]string{"env": "prod"}, + }, types.DatabaseSpecV3{ + Protocol: "postgres", + URI: "localhost:5432", + AdminUser: &types.DatabaseAdminUser{ + Name: "teleport-admin", + }, + }) + require.NoError(t, err) + + roleAutoUser := &types.RoleV6{ + Metadata: types.Metadata{Name: "auto-user", Namespace: apidefaults.Namespace}, + Spec: types.RoleSpecV6{ + Options: types.RoleOptions{ + CreateDatabaseUserMode: types.CreateDatabaseUserMode_DB_USER_MODE_KEEP, + }, + Allow: types.RoleConditions{ + Namespaces: []string{apidefaults.Namespace}, + DatabaseLabels: types.Labels{"env": []string{"prod"}}, + DatabaseRoles: []string{"roleA", "roleB"}, + DatabaseNames: []string{"*"}, + DatabaseUsers: []string{types.Wildcard}, + }, + }, + } + + tests := []struct { + name string + database types.Database + accessChecker services.AccessChecker + expect string + }{ + { + name: "nil accessChecker", + database: dbWithAutoUser, + expect: "(unknown)", + }, + { + name: "roles", + database: dbWithAutoUser, + accessChecker: services.NewAccessCheckerWithRoleSet(&services.AccessInfo{ + Username: "alice", + }, "clustername", services.RoleSet{roleAutoUser}), + expect: "[roleA roleB]", + }, + { + name: "db without admin user", + database: db, + accessChecker: services.NewAccessCheckerWithRoleSet(&services.AccessInfo{ + Username: "alice", + }, "clustername", services.RoleSet{roleAutoUser}), + expect: "", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + require.Equal(t, test.expect, formatDatabaseRolesForDB(test.database, test.accessChecker)) + }) + } +} diff --git a/tool/tsh/common/tsh.go b/tool/tsh/common/tsh.go index 386e7dc9a6acb..834732b76bc65 100644 --- a/tool/tsh/common/tsh.go +++ b/tool/tsh/common/tsh.go @@ -2752,17 +2752,17 @@ func showAppsAsText(apps []types.Application, active []tlsca.RouteToApp, verbose fmt.Println(t.AsBuffer().String()) } -func showDatabases(w io.Writer, clusterFlag string, databases []types.Database, active []tlsca.RouteToDatabase, accessChecker services.AccessChecker, format string, verbose bool) error { - format = strings.ToLower(format) +func showDatabases(cf *CLIConf, databases []types.Database, active []tlsca.RouteToDatabase, accessChecker services.AccessChecker) error { + format := strings.ToLower(cf.Format) switch format { case teleport.Text, "": - showDatabasesAsText(w, clusterFlag, databases, active, accessChecker, verbose) + showDatabasesAsText(cf, cf.Stdout(), databases, active, accessChecker, cf.Verbose) case teleport.JSON, teleport.YAML: - out, err := serializeDatabases(databases, format, accessChecker) + out, err := serializeDatabases(databases, cf.Format, accessChecker) if err != nil { return trace.Wrap(err) } - fmt.Fprintln(w, out) + fmt.Fprintln(cf.Stdout(), out) default: return trace.BadParameter("unsupported format %q", format) } @@ -2809,7 +2809,8 @@ type databaseWithUsers struct { // *DatabaseV3 is used instead of types.Database because we want the db fields marshaled to JSON inline. // An embedded interface (like types.Database) does not inline when marshaled to JSON. *types.DatabaseV3 - Users *dbUsers `json:"users"` + Users *dbUsers `json:"users"` + DatabaseRoles []string `json:"database_roles,omitempty"` } func getDBUsers(db types.Database, accessChecker services.AccessChecker) *dbUsers { @@ -2842,6 +2843,15 @@ func newDatabaseWithUsers(db types.Database, accessChecker services.AccessChecke default: return nil, trace.BadParameter("unrecognized database type %T", db) } + + if db.SupportsAutoUsers() && db.GetAdminUser().Name != "" { + autoUser, roles, err := accessChecker.CheckDatabaseRoles(db) + if err != nil { + log.Warnf("Failed to CheckDatabaseRoles for database %v: %v.", db.GetName(), err) + } else if autoUser.IsEnabled() { + dbWithUsers.DatabaseRoles = roles + } + } return dbWithUsers, nil } @@ -2900,17 +2910,16 @@ func formatUsersForDB(database types.Database, accessChecker services.AccessChec return fmt.Sprintf("%v, except: %v", dbUsers.Allowed, dbUsers.Denied) } -func getDatabaseRow(proxy, cluster, clusterFlag string, database types.Database, active []tlsca.RouteToDatabase, accessChecker services.AccessChecker, verbose bool) []string { - name := database.GetName() +// TODO(greedy52) more refactoring on db printing and move them to db_print.go. + +func getDatabaseRow(proxy, cluster, clusterFlag string, database types.Database, active []tlsca.RouteToDatabase, accessChecker services.AccessChecker, verbose bool) databaseTableRow { displayName := common.FormatResourceName(database, verbose) var connect string for _, a := range active { - if a.ServiceName == name { - a.ServiceName = displayName + if a.ServiceName == database.GetName() { // format the db name with the display name - displayName = formatActiveDB(a) + displayName = formatActiveDB(a, displayName) // then revert it for connect string - a.ServiceName = name switch a.Protocol { case defaults.ProtocolDynamoDB: // DynamoDB does not support "tsh db connect", so print the proxy command instead. @@ -2922,81 +2931,60 @@ func getDatabaseRow(proxy, cluster, clusterFlag string, database types.Database, } } - row := make([]string, 0) - if proxy != "" && cluster != "" { - row = append(row, proxy, cluster) - } - - labels := common.FormatLabels(database.GetAllLabels(), verbose) - if verbose { - row = append(row, - displayName, - database.GetDescription(), - database.GetProtocol(), - database.GetType(), - database.GetURI(), - formatUsersForDB(database, accessChecker), - labels, - connect, - ) - } else { - row = append(row, - displayName, - database.GetDescription(), - formatUsersForDB(database, accessChecker), - labels, - connect, - ) + return databaseTableRow{ + Proxy: proxy, + Cluster: cluster, + DisplayName: displayName, + Description: database.GetDescription(), + Protocol: database.GetProtocol(), + Type: database.GetType(), + URI: database.GetURI(), + AllowedUsers: formatUsersForDB(database, accessChecker), + DatabaseRoles: formatDatabaseRolesForDB(database, accessChecker), + Labels: common.FormatLabels(database.GetAllLabels(), verbose), + Connect: connect, } - - return row } -func showDatabasesAsText(w io.Writer, clusterFlag string, databases []types.Database, active []tlsca.RouteToDatabase, accessChecker services.AccessChecker, verbose bool) { - var rows [][]string +func showDatabasesAsText(cf *CLIConf, w io.Writer, databases []types.Database, active []tlsca.RouteToDatabase, accessChecker services.AccessChecker, verbose bool) { + var rows []databaseTableRow for _, database := range databases { rows = append(rows, getDatabaseRow("", "", - clusterFlag, + cf.SiteName, database, active, accessChecker, verbose)) } - var t asciitable.Table - if verbose { - t = asciitable.MakeTable([]string{"Name", "Description", "Protocol", "Type", "URI", "Allowed Users", "Labels", "Connect"}, rows...) - } else { - t = asciitable.MakeTableWithTruncatedColumn([]string{"Name", "Description", "Allowed Users", "Labels", "Connect"}, rows, "Labels") - } - fmt.Fprintln(w, t.AsBuffer().String()) + printDatabaseTable(printDatabaseTableConfig{ + writer: w, + rows: rows, + verbose: verbose, + }) } -func printDatabasesWithClusters(clusterFlag string, dbListings []databaseListing, active []tlsca.RouteToDatabase, verbose bool) { - var rows [][]string +func printDatabasesWithClusters(cf *CLIConf, dbListings []databaseListing, active []tlsca.RouteToDatabase) { + var rows []databaseTableRow for _, listing := range dbListings { rows = append(rows, getDatabaseRow( listing.Proxy, listing.Cluster, - clusterFlag, + cf.SiteName, listing.Database, active, listing.accessChecker, - verbose)) + cf.Verbose)) } - var t asciitable.Table - if verbose { - t = asciitable.MakeTable([]string{"Proxy", "Cluster", "Name", "Description", "Protocol", "Type", "URI", "Allowed Users", "Labels", "Connect", "Expires"}, rows...) - } else { - t = asciitable.MakeTableWithTruncatedColumn( - []string{"Proxy", "Cluster", "Name", "Description", "Allowed Users", "Labels", "Connect"}, - rows, - "Labels", - ) - } - fmt.Println(t.AsBuffer().String()) + printDatabaseTable(printDatabaseTableConfig{ + writer: cf.Stdout(), + rows: rows, + showProxyAndCluster: true, + verbose: cf.Verbose, + }) } -func formatActiveDB(active tlsca.RouteToDatabase) string { +func formatActiveDB(active tlsca.RouteToDatabase, displayName string) string { + active.ServiceName = displayName switch { case active.Username != "" && active.Database != "": return fmt.Sprintf("> %v (user: %v, db: %v)", active.ServiceName, active.Username, active.Database)