Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Make tsh db ls lists available db users. #10458

Merged
merged 8 commits into from
Apr 14, 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 {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This EnumerationResult is quite generic name for the role package. For example roles.EnumerationResult suggest that roles are enumerated.

Copy link
Contributor Author

@Tener Tener Apr 11, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any suggestions? RoleSetEnumerationResult is bit too long for my tastes.

allowedDeniedMap map[string]bool
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: splitting in two separate list instead of aggregating a user into single map should simplify the implemeantion:

Suggested change
allowedDeniedMap map[string]bool
allowedUsers []string
deniedUsers []string

For example the (result *EnumerationResult) filtered can be dropped.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need a map to deal with duplicates, so either we do it in one go or we need to deduplicate afterward, which makes the code more complex, not less.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The login duplication are already removed in the users = apiutils.Deduplicate(append(users,extraUsers) call

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The login duplication are already removed in the users = apiutils.Deduplicate(append(users,extraUsers) call

This is done outside of EnumerationResult code.

I can switch to two slices, but that makes it possible to have an internal representation which is inconsistent. The easiest way to efficiently remove duplicates is with map.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK. This was just a suggestion. Let's leave the current form.

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.
Comment on lines +922 to +924
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was this added with Teleterm in mind? I hope we actually get to use this. 🙊 But I'll think about how to utilize this once I actually get around to incorporating that in Teleterm.

Copy link
Contributor Author

@Tener Tener Apr 7, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, Teleterm was the planned user for this parameter. I guessed that it has a mechanism for storing recently used resources etc.

Alternatively, this may be a feature of the database itself: on successful auth, store the username which can be queried as a property of the database. This has the benefit of sharing the available usernames across all people who can access that particular database in Teleport.

The second mechanism is perhaps more robust and can be shared with tsh.

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

Comment on lines +931 to +933
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It can be moved outside the for loop.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On the contrary: it must be inside the loop, since these two bools refer to particular role.

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
22 changes: 21 additions & 1 deletion tool/tsh/db.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import (
dbprofile "github.com/gravitational/teleport/lib/client/db"
"github.com/gravitational/teleport/lib/client/db/dbcmd"
"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 @@ -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)
}
Comment on lines +54 to +57
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm fixing a bug in Connect where we prematurely close the proxy client when fetching db users. I was looking at how tsh does things (since it was working fine in tsh db ls) and I landed here and noticed that we don't seem to close the proxy client here at all.

Shouldn't we have defer proxy.Close() at the end here just as we have defer cluster.Close() below?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fix in Connect I was working on: #14230.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We probably should. There are, in fact, a few other places in codebase that are missing the deferred .Close() call on the result of ConnectToProxy().


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 @@ -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
}

Expand Down
29 changes: 26 additions & 3 deletions tool/tsh/tsh.go
Original file line number Diff line number Diff line change
Expand Up @@ -1570,9 +1570,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
Expand All @@ -1582,12 +1602,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),
Expand All @@ -1608,11 +1630,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())
}
}
Expand Down
Loading