From 52939d88e43c26cce7b23f7fca6d96e7e70e2a18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Skrz=C4=99tnicki?= Date: Thu, 14 Apr 2022 11:45:40 +0200 Subject: [PATCH] Make `tsh db ls` lists available db users. (#10458) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Show available db users in "tsh db ls". Co-authored-by: Marek SmoliƄski --- lib/services/role.go | 104 ++++++++++++++++++++++++++++++++ lib/services/role_test.go | 122 ++++++++++++++++++++++++++++++++++++++ tool/tsh/db.go | 22 ++++++- tool/tsh/tsh.go | 29 ++++++++- tool/tsh/tsh_test.go | 100 +++++++++++++++++++++++++++++++ 5 files changed, 373 insertions(+), 4 deletions(-) diff --git a/lib/services/role.go b/lib/services/role.go index ac9db3012dedc..4eca185b160d1 100644 --- a/lib/services/role.go +++ b/lib/services/role.go @@ -840,6 +840,110 @@ func NewRoleSet(roles ...types.Role) RoleSet { // RoleSet is a set of roles that implements access control functionality type RoleSet []types.Role +// EnumerationResult is a result of enumerating a role set against some property, e.g. allowed names or logins. +type EnumerationResult struct { + allowedDeniedMap map[string]bool + wildcardAllowed bool + wildcardDenied bool +} + +func (result *EnumerationResult) filtered(value bool) []string { + var filtered []string + + for entity, allow := range result.allowedDeniedMap { + if allow == value { + filtered = append(filtered, entity) + } + } + + sort.Strings(filtered) + + return filtered +} + +// Denied returns all explicitly denied users. +func (result *EnumerationResult) Denied() []string { + return result.filtered(false) +} + +// Allowed returns all known allowed users. +func (result *EnumerationResult) Allowed() []string { + if result.WildcardDenied() { + return nil + } + return result.filtered(true) +} + +// WildcardAllowed is true if there * username allowed for given rule set. +func (result *EnumerationResult) WildcardAllowed() bool { + return result.wildcardAllowed && !result.wildcardDenied +} + +// WildcardDenied is true if there * username deny for given rule set. +func (result *EnumerationResult) WildcardDenied() bool { + return result.wildcardDenied +} + +// NewEnumerationResult returns new EnumerationResult. +func NewEnumerationResult() EnumerationResult { + return EnumerationResult{ + allowedDeniedMap: map[string]bool{}, + wildcardAllowed: false, + wildcardDenied: false, + } +} + +// EnumerateDatabaseUsers works on a given role set to return a minimal description of allowed set of usernames. +// It is biased towards *allowed* usernames; It is meant to describe what the user can do, rather than cannot do. +// For that reason if the user isn't allowed to pick *any* entities, the output will be empty. +// +// In cases where * is listed in set of allowed users, it may be hard for users to figure out the expected username. +// For this reason the parameter extraUsers provides an extra set of users to be checked against RoleSet. +// This extra set of users may be sourced e.g. from user connection history. +func (set RoleSet) EnumerateDatabaseUsers(database types.Database, extraUsers ...string) EnumerationResult { + result := NewEnumerationResult() + + // gather users for checking from the roles, check wildcards. + var users []string + for _, role := range set { + wildcardAllowed := false + wildcardDenied := false + + for _, user := range role.GetDatabaseUsers(types.Allow) { + if user == types.Wildcard { + wildcardAllowed = true + } else { + users = append(users, user) + } + } + + for _, user := range role.GetDatabaseUsers(types.Deny) { + if user == types.Wildcard { + wildcardDenied = true + } else { + users = append(users, user) + } + } + + result.wildcardDenied = result.wildcardDenied || wildcardDenied + + if err := NewRoleSet(role).CheckAccess(database, AccessMFAParams{Verified: true}); err == nil { + result.wildcardAllowed = result.wildcardAllowed || wildcardAllowed + } + + } + + users = apiutils.Deduplicate(append(users, extraUsers...)) + + // check each individual user against the database. + for _, user := range users { + err := set.CheckAccess(database, AccessMFAParams{Verified: true}, &DatabaseUserMatcher{User: user}) + result.allowedDeniedMap[user] = err == nil + } + + return result +} + // MatchNamespace returns true if given list of namespace matches // target namespace, wildcard matches everything. func MatchNamespace(selectors []string, namespace string) (bool, string) { diff --git a/lib/services/role_test.go b/lib/services/role_test.go index 7d690212c7814..97cc9154ea7d6 100644 --- a/lib/services/role_test.go +++ b/lib/services/role_test.go @@ -2651,6 +2651,128 @@ func TestCheckAccessToDatabaseUser(t *testing.T) { } } +func TestRoleSetEnumerateDatabaseUsers(t *testing.T) { + dbStage, err := types.NewDatabaseV3(types.Metadata{ + Name: "stage", + Labels: map[string]string{"env": "stage"}, + }, types.DatabaseSpecV3{ + Protocol: "protocol", + URI: "uri", + }) + require.NoError(t, err) + dbProd, err := types.NewDatabaseV3(types.Metadata{ + Name: "prod", + Labels: map[string]string{"env": "prod"}, + }, types.DatabaseSpecV3{ + Protocol: "protocol", + URI: "uri", + }) + require.NoError(t, err) + roleDevStage := &types.RoleV4{ + Metadata: types.Metadata{Name: "dev-stage", Namespace: apidefaults.Namespace}, + Spec: types.RoleSpecV4{ + Allow: types.RoleConditions{ + Namespaces: []string{apidefaults.Namespace}, + DatabaseLabels: types.Labels{"env": []string{"stage"}}, + DatabaseUsers: []string{types.Wildcard}, + }, + Deny: types.RoleConditions{ + Namespaces: []string{apidefaults.Namespace}, + DatabaseUsers: []string{"superuser"}, + }, + }, + } + roleDevProd := &types.RoleV4{ + Metadata: types.Metadata{Name: "dev-prod", Namespace: apidefaults.Namespace}, + Spec: types.RoleSpecV4{ + Allow: types.RoleConditions{ + Namespaces: []string{apidefaults.Namespace}, + DatabaseLabels: types.Labels{"env": []string{"prod"}}, + DatabaseUsers: []string{"dev"}, + }, + }, + } + + roleNoDBAccess := &types.RoleV4{ + Metadata: types.Metadata{Name: "no_db_access", Namespace: apidefaults.Namespace}, + Spec: types.RoleSpecV4{ + Deny: types.RoleConditions{ + Namespaces: []string{apidefaults.Namespace}, + DatabaseUsers: []string{"*"}, + DatabaseNames: []string{"*"}, + }, + }, + } + + roleAllowDenySame := &types.RoleV4{ + Metadata: types.Metadata{Name: "allow_deny_same", Namespace: apidefaults.Namespace}, + Spec: types.RoleSpecV4{ + Allow: types.RoleConditions{ + Namespaces: []string{apidefaults.Namespace}, + DatabaseUsers: []string{"superuser"}, + }, + Deny: types.RoleConditions{ + Namespaces: []string{apidefaults.Namespace}, + DatabaseUsers: []string{"superuser"}, + }, + }, + } + + testCases := []struct { + name string + roles RoleSet + server types.Database + enumResult EnumerationResult + }{ + { + name: "deny overrides allow", + roles: RoleSet{roleAllowDenySame}, + server: dbStage, + enumResult: EnumerationResult{ + allowedDeniedMap: map[string]bool{"superuser": false}, + wildcardAllowed: false, + wildcardDenied: false, + }, + }, + { + name: "developer allowed any username in stage database except superuser", + roles: RoleSet{roleDevStage, roleDevProd}, + server: dbStage, + enumResult: EnumerationResult{ + allowedDeniedMap: map[string]bool{"dev": true, "superuser": false}, + wildcardAllowed: true, + wildcardDenied: false, + }, + }, + { + name: "developer allowed only specific username/database in prod database", + roles: RoleSet{roleDevStage, roleDevProd}, + server: dbProd, + enumResult: EnumerationResult{ + allowedDeniedMap: map[string]bool{"dev": true, "superuser": false}, + wildcardAllowed: false, + wildcardDenied: false, + }, + }, + { + name: "there may be users disallowed from all users", + roles: RoleSet{roleDevStage, roleDevProd, roleNoDBAccess}, + server: dbProd, + enumResult: EnumerationResult{ + allowedDeniedMap: map[string]bool{"dev": false, "superuser": false}, + wildcardAllowed: false, + wildcardDenied: true, + }, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + enumResult := tc.roles.EnumerateDatabaseUsers(tc.server) + require.Equal(t, tc.enumResult, enumResult) + }) + } +} + func TestCheckDatabaseNamesAndUsers(t *testing.T) { roleEmpty := &types.RoleV4{ Metadata: types.Metadata{Name: "roleA", Namespace: apidefaults.Namespace}, diff --git a/tool/tsh/db.go b/tool/tsh/db.go index b8d0e898d5784..31a95f42d276f 100644 --- a/tool/tsh/db.go +++ b/tool/tsh/db.go @@ -32,6 +32,7 @@ import ( dbprofile "github.com/gravitational/teleport/lib/client/db" "github.com/gravitational/teleport/lib/client/db/postgres" "github.com/gravitational/teleport/lib/defaults" + "github.com/gravitational/teleport/lib/services" "github.com/gravitational/teleport/lib/srv/alpnproxy" "github.com/gravitational/teleport/lib/tlsca" "github.com/gravitational/teleport/lib/utils" @@ -53,11 +54,29 @@ func onListDatabases(cf *CLIConf) error { if err != nil { return trace.Wrap(err) } + + proxy, err := tc.ConnectToProxy(cf.Context) + if err != nil { + return trace.Wrap(err) + } + + cluster, err := proxy.ConnectToCurrentCluster(cf.Context, false) + if err != nil { + return trace.Wrap(err) + } + defer cluster.Close() + // Retrieve profile to be able to show which databases user is logged into. profile, err := client.StatusCurrent(cf.HomePath, cf.Proxy) if err != nil { return trace.Wrap(err) } + + roleSet, err := services.FetchRoles(profile.Roles, cluster, profile.Traits) + if err != nil { + return trace.Wrap(err) + } + sort.Slice(databases, func(i, j int) bool { return databases[i].GetName() < databases[j].GetName() }) @@ -66,7 +85,8 @@ func onListDatabases(cf *CLIConf) error { if err != nil { return trace.Wrap(err) } - showDatabases(cf.SiteName, databases, activeDatabases, cf.Verbose) + + showDatabases(cf.SiteName, databases, activeDatabases, roleSet, cf.Verbose) return nil } diff --git a/tool/tsh/tsh.go b/tool/tsh/tsh.go index 4f30f80e3dd83..5a565430420e6 100644 --- a/tool/tsh/tsh.go +++ b/tool/tsh/tsh.go @@ -1538,9 +1538,29 @@ func makeTableWithTruncatedColumn(columnOrder []string, rows [][]string, truncat return t } -func showDatabases(clusterFlag string, databases []types.Database, active []tlsca.RouteToDatabase, verbose bool) { +func getUsersForDb(database types.Database, roleSet services.RoleSet) string { + dbUsers := roleSet.EnumerateDatabaseUsers(database) + allowed := dbUsers.Allowed() + + if dbUsers.WildcardAllowed() { + // start the list with * + allowed = append([]string{types.Wildcard}, allowed...) + } + + if len(allowed) == 0 { + return "(none)" + } + + denied := dbUsers.Denied() + if len(denied) == 0 || !dbUsers.WildcardAllowed() { + return fmt.Sprintf("%v", allowed) + } + return fmt.Sprintf("%v, except: %v", allowed, denied) +} + +func showDatabases(clusterFlag string, databases []types.Database, active []tlsca.RouteToDatabase, roleSet services.RoleSet, verbose bool) { if verbose { - t := asciitable.MakeTable([]string{"Name", "Description", "Protocol", "Type", "URI", "Labels", "Connect", "Expires"}) + t := asciitable.MakeTable([]string{"Name", "Description", "Protocol", "Type", "URI", "Allowed Users", "Labels", "Connect", "Expires"}) for _, database := range databases { name := database.GetName() var connect string @@ -1550,12 +1570,14 @@ func showDatabases(clusterFlag string, databases []types.Database, active []tlsc connect = formatConnectCommand(clusterFlag, a) } } + t.AddRow([]string{ name, database.GetDescription(), database.GetProtocol(), database.GetType(), database.GetURI(), + getUsersForDb(database, roleSet), database.LabelsString(), connect, database.Expiry().Format(constants.HumanDateFormatSeconds), @@ -1576,11 +1598,12 @@ func showDatabases(clusterFlag string, databases []types.Database, active []tlsc rows = append(rows, []string{ name, database.GetDescription(), + getUsersForDb(database, roleSet), formatDatabaseLabels(database), connect, }) } - t := makeTableWithTruncatedColumn([]string{"Name", "Description", "Labels", "Connect"}, rows, "Labels") + t := makeTableWithTruncatedColumn([]string{"Name", "Description", "Allowed Users", "Labels", "Connect"}, rows, "Labels") fmt.Println(t.AsBuffer().String()) } } diff --git a/tool/tsh/tsh_test.go b/tool/tsh/tsh_test.go index 3b4b9bfab67f4..d1fa59c641f2d 100644 --- a/tool/tsh/tsh_test.go +++ b/tool/tsh/tsh_test.go @@ -1469,3 +1469,103 @@ func setHomePath(path string) cliOption { return nil } } + +func Test_getUsersForDb(t *testing.T) { + dbStage, err := types.NewDatabaseV3(types.Metadata{ + Name: "stage", + Labels: map[string]string{"env": "stage"}, + }, types.DatabaseSpecV3{ + Protocol: "protocol", + URI: "uri", + }) + require.NoError(t, err) + + dbProd, err := types.NewDatabaseV3(types.Metadata{ + Name: "prod", + Labels: map[string]string{"env": "prod"}, + }, types.DatabaseSpecV3{ + Protocol: "protocol", + URI: "uri", + }) + require.NoError(t, err) + + roleDevStage := &types.RoleV4{ + Metadata: types.Metadata{Name: "dev-stage", Namespace: apidefaults.Namespace}, + Spec: types.RoleSpecV4{ + Allow: types.RoleConditions{ + Namespaces: []string{apidefaults.Namespace}, + DatabaseLabels: types.Labels{"env": []string{"stage"}}, + DatabaseUsers: []string{types.Wildcard}, + }, + Deny: types.RoleConditions{ + Namespaces: []string{apidefaults.Namespace}, + DatabaseUsers: []string{"superuser"}, + }, + }, + } + roleDevProd := &types.RoleV4{ + Metadata: types.Metadata{Name: "dev-prod", Namespace: apidefaults.Namespace}, + Spec: types.RoleSpecV4{ + Allow: types.RoleConditions{ + Namespaces: []string{apidefaults.Namespace}, + DatabaseLabels: types.Labels{"env": []string{"prod"}}, + DatabaseUsers: []string{"dev"}, + }, + }, + } + + testCases := []struct { + name string + roles services.RoleSet + database types.Database + result string + }{ + { + name: "developer allowed any username in stage database except superuser", + roles: services.RoleSet{roleDevStage, roleDevProd}, + database: dbStage, + result: "[* dev], except: [superuser]", + }, + { + name: "developer allowed only specific username/database in prod database", + roles: services.RoleSet{roleDevStage, roleDevProd}, + database: dbProd, + result: "[dev]", + }, + + { + name: "roleDevStage x dbStage", + roles: services.RoleSet{roleDevStage}, + database: dbStage, + result: "[*], except: [superuser]", + }, + + { + name: "roleDevStage x dbProd", + roles: services.RoleSet{roleDevStage}, + database: dbProd, + result: "(none)", + }, + + { + name: "roleDevProd x dbStage", + roles: services.RoleSet{roleDevProd}, + database: dbStage, + result: "(none)", + }, + + { + name: "roleDevProd x dbProd", + roles: services.RoleSet{roleDevProd}, + database: dbProd, + result: "[dev]", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + got := getUsersForDb(tc.database, tc.roles) + require.Equal(t, tc.result, got) + }) + } +}