diff --git a/tool/tsh/common/db.go b/tool/tsh/common/db.go
index ad252c4de6f0..9d1997b2a2c3 100644
--- a/tool/tsh/common/db.go
+++ b/tool/tsh/common/db.go
@@ -96,7 +96,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) {
@@ -232,7 +232,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 {
@@ -1669,7 +1669,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 000000000000..a2d26c1f2858
--- /dev/null
+++ b/tool/tsh/common/db_print.go
@@ -0,0 +1,135 @@
+/*
+ * Teleport
+ * Copyright (C) 2023 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 (
+ "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 000000000000..4ce92d0fdbcb
--- /dev/null
+++ b/tool/tsh/common/db_print_test.go
@@ -0,0 +1,198 @@
+/*
+ * Teleport
+ * Copyright (C) 2023 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 (
+ "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 868ae4f50777..58a9c6e4949f 100644
--- a/tool/tsh/common/tsh.go
+++ b/tool/tsh/common/tsh.go
@@ -2673,17 +2673,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)
}
@@ -2730,7 +2730,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 {
@@ -2763,6 +2764,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
}
@@ -2821,17 +2831,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.
@@ -2843,81 +2852,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)