diff --git a/lib/services/role.go b/lib/services/role.go index 2f417a91421e9..8cef7c276fb3a 100644 --- a/lib/services/role.go +++ b/lib/services/role.go @@ -862,6 +862,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 bf8406ca2612c..93d4aef07960d 100644 --- a/lib/services/role_test.go +++ b/lib/services/role_test.go @@ -2680,6 +2680,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.RoleV5{ + Metadata: types.Metadata{Name: "dev-stage", Namespace: apidefaults.Namespace}, + Spec: types.RoleSpecV5{ + 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.RoleV5{ + Metadata: types.Metadata{Name: "dev-prod", Namespace: apidefaults.Namespace}, + Spec: types.RoleSpecV5{ + Allow: types.RoleConditions{ + Namespaces: []string{apidefaults.Namespace}, + DatabaseLabels: types.Labels{"env": []string{"prod"}}, + DatabaseUsers: []string{"dev"}, + }, + }, + } + + roleNoDBAccess := &types.RoleV5{ + Metadata: types.Metadata{Name: "no_db_access", Namespace: apidefaults.Namespace}, + Spec: types.RoleSpecV5{ + Deny: types.RoleConditions{ + Namespaces: []string{apidefaults.Namespace}, + DatabaseUsers: []string{"*"}, + DatabaseNames: []string{"*"}, + }, + }, + } + + roleAllowDenySame := &types.RoleV5{ + Metadata: types.Metadata{Name: "allow_deny_same", Namespace: apidefaults.Namespace}, + Spec: types.RoleSpecV5{ + 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.RoleV5{ Metadata: types.Metadata{Name: "roleA", Namespace: apidefaults.Namespace}, diff --git a/tool/tsh/db.go b/tool/tsh/db.go index 43bb6686be39c..0bb38687ae830 100644 --- a/tool/tsh/db.go +++ b/tool/tsh/db.go @@ -29,6 +29,7 @@ import ( "github.com/gravitational/teleport/lib/client" dbprofile "github.com/gravitational/teleport/lib/client/db" "github.com/gravitational/teleport/lib/defaults" + "github.com/gravitational/teleport/lib/services" "github.com/gravitational/teleport/lib/tlsca" "github.com/gravitational/teleport/lib/utils" @@ -49,11 +50,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() }) @@ -62,7 +81,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 810c8b15eee36..35dc445a562e0 100644 --- a/tool/tsh/tsh.go +++ b/tool/tsh/tsh.go @@ -1541,9 +1541,29 @@ func showApps(apps []types.Application, active []tlsca.RouteToApp, verbose bool) } } -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 @@ -1553,12 +1573,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), @@ -1579,11 +1601,12 @@ func showDatabases(clusterFlag string, databases []types.Database, active []tlsc rows = append(rows, []string{ name, database.GetDescription(), + getUsersForDb(database, roleSet), formatDatabaseLabels(database), connect, }) } - t := asciitable.MakeTableWithTruncatedColumn([]string{"Name", "Description", "Labels", "Connect"}, rows, "Labels") + t := asciitable.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 6bd648822bddc..34eee77e1fc63 100644 --- a/tool/tsh/tsh_test.go +++ b/tool/tsh/tsh_test.go @@ -1457,3 +1457,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.RoleV5{ + Metadata: types.Metadata{Name: "dev-stage", Namespace: apidefaults.Namespace}, + Spec: types.RoleSpecV5{ + 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.RoleV5{ + Metadata: types.Metadata{Name: "dev-prod", Namespace: apidefaults.Namespace}, + Spec: types.RoleSpecV5{ + 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) + }) + } +}