Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

[v9] Make tsh db ls lists available db users. (#10458) #11942

Merged
merged 6 commits into from
Apr 20, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions lib/services/role.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
122 changes: 122 additions & 0 deletions lib/services/role_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down
21 changes: 20 additions & 1 deletion tool/tsh/db.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,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"

Expand All @@ -50,11 +51,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()
})
Expand All @@ -63,7 +82,7 @@ func onListDatabases(cf *CLIConf) error {
if err != nil {
return trace.Wrap(err)
}
return trace.Wrap(showDatabases(cf.SiteName, databases, activeDatabases, cf.Format, cf.Verbose))
return trace.Wrap(showDatabases(cf.SiteName, databases, activeDatabases, roleSet, cf.Format, cf.Verbose))
}

// onDatabaseLogin implements "tsh db login" command.
Expand Down
33 changes: 28 additions & 5 deletions tool/tsh/tsh.go
Original file line number Diff line number Diff line change
Expand Up @@ -1648,11 +1648,31 @@ func showAppsAsText(apps []types.Application, active []tlsca.RouteToApp, verbose
}
}

func showDatabases(clusterFlag string, databases []types.Database, active []tlsca.RouteToDatabase, format string, verbose bool) error {
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, format string, verbose bool) error {
format = strings.ToLower(format)
switch format {
case teleport.Text, "":
showDatabasesAsText(clusterFlag, databases, active, verbose)
showDatabasesAsText(clusterFlag, databases, active, roleSet, verbose)
case teleport.JSON, teleport.YAML:
out, err := serializeDatabases(databases, format)
if err != nil {
Expand All @@ -1679,9 +1699,9 @@ func serializeDatabases(databases []types.Database, format string) (string, erro
return string(out), trace.Wrap(err)
}

func showDatabasesAsText(clusterFlag string, databases []types.Database, active []tlsca.RouteToDatabase, verbose bool) {
func showDatabasesAsText(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
Expand All @@ -1691,12 +1711,14 @@ func showDatabasesAsText(clusterFlag string, databases []types.Database, active
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),
Expand All @@ -1717,11 +1739,12 @@ func showDatabasesAsText(clusterFlag string, databases []types.Database, active
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())
}
}
Expand Down
Loading