Skip to content

Commit

Permalink
Add automatic role access requests (#39003)
Browse files Browse the repository at this point in the history
* Add automatic role requests

This change adds the optioin to automatically make a role access request
during tsh ssh instead of a resource access request.
  • Loading branch information
atburke committed Apr 5, 2024
1 parent d8d0b50 commit 9afcfb0
Show file tree
Hide file tree
Showing 8 changed files with 2,225 additions and 1,704 deletions.
6 changes: 6 additions & 0 deletions api/proto/teleport/legacy/types/types.proto
Original file line number Diff line number Diff line change
Expand Up @@ -2498,6 +2498,12 @@ message AccessCapabilitiesRequest {
(gogoproto.jsontag) = "resource_ids,omitempty",
(gogoproto.nullable) = false
];
// Login is the host login the user is requesting access for.
string Login = 5 [(gogoproto.jsontag) = "login,omitempty"];
// FilterRequestableRolesByResource is a flag indicating that the returned
// list of roles that the user can request should be filtered to only include
// roles that allow access to the provided ResourceIDs.
bool FilterRequestableRolesByResource = 6 [(gogoproto.jsontag) = "filter_requestable_roles_by_resource,omitempty"];
}

// ResourceID is a unique identifier for a teleport resource.
Expand Down
3,222 changes: 1,654 additions & 1,568 deletions api/types/types.pb.go

Large diffs are not rendered by default.

7 changes: 6 additions & 1 deletion lib/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -4972,10 +4972,15 @@ func (a *Server) submitAccessReview(
}

func (a *Server) GetAccessCapabilities(ctx context.Context, req types.AccessCapabilitiesRequest) (*types.AccessCapabilities, error) {
caps, err := services.CalculateAccessCapabilities(ctx, a.clock, a, req)
user, err := authz.UserFromContext(ctx)
if err != nil {
return nil, trace.Wrap(err)
}
caps, err := services.CalculateAccessCapabilities(ctx, a.clock, a, user.GetIdentity(), req)
if err != nil {
return nil, trace.Wrap(err)
}

return caps, nil
}

Expand Down
110 changes: 100 additions & 10 deletions lib/services/access_request.go
Original file line number Diff line number Diff line change
Expand Up @@ -203,9 +203,35 @@ type DynamicAccessOracle interface {
GetAccessCapabilities(ctx context.Context, req types.AccessCapabilitiesRequest) (*types.AccessCapabilities, error)
}

func shouldFilterRequestableRolesByResource(a RequestValidatorGetter, req types.AccessCapabilitiesRequest) (bool, error) {
if !req.FilterRequestableRolesByResource {
return false, nil
}
currentCluster, err := a.GetClusterName()
if err != nil {
return false, trace.Wrap(err)
}
for _, resourceID := range req.ResourceIDs {
if resourceID.ClusterName != currentCluster.GetClusterName() {
// Requested resource is from another cluster, so we can't know
// all of the roles which would grant access to it.
return false, nil
}
}
return true, nil
}

// CalculateAccessCapabilities aggregates the requested capabilities using the supplied getter
// to load relevant resources.
func CalculateAccessCapabilities(ctx context.Context, clock clockwork.Clock, clt RequestValidatorGetter, req types.AccessCapabilitiesRequest) (*types.AccessCapabilities, error) {
func CalculateAccessCapabilities(ctx context.Context, clock clockwork.Clock, clt RequestValidatorGetter, identity tlsca.Identity, req types.AccessCapabilitiesRequest) (*types.AccessCapabilities, error) {
shouldFilter, err := shouldFilterRequestableRolesByResource(clt, req)
if err != nil {
return nil, trace.Wrap(err)
}
if !shouldFilter && req.FilterRequestableRolesByResource {
req.ResourceIDs = nil
}

var caps types.AccessCapabilities
// all capabilities require use of a request validator. calculating suggested reviewers
// requires that the validator be configured for variable expansion.
Expand All @@ -214,15 +240,19 @@ func CalculateAccessCapabilities(ctx context.Context, clock clockwork.Clock, clt
return nil, trace.Wrap(err)
}

if len(req.ResourceIDs) != 0 {
caps.ApplicableRolesForResources, err = v.applicableSearchAsRoles(ctx, req.ResourceIDs, "")
if len(req.ResourceIDs) != 0 && !req.FilterRequestableRolesByResource {
caps.ApplicableRolesForResources, err = v.applicableSearchAsRoles(ctx, req.ResourceIDs, req.Login)
if err != nil {
return nil, trace.Wrap(err)
}
}

if req.RequestableRoles {
caps.RequestableRoles, err = v.GetRequestableRoles()
var resourceIDs []types.ResourceID
if req.FilterRequestableRolesByResource {
resourceIDs = req.ResourceIDs
}
caps.RequestableRoles, err = v.GetRequestableRoles(ctx, identity, resourceIDs, req.Login)
if err != nil {
return nil, trace.Wrap(err)
}
Expand Down Expand Up @@ -1123,7 +1153,7 @@ func (m *RequestValidator) Validate(ctx context.Context, req types.AccessRequest
return trace.BadParameter("unexpected wildcard request (this is a bug)")
}

requestable, err := m.GetRequestableRoles()
requestable, err := m.GetRequestableRoles(ctx, identity, nil, "")
if err != nil {
return trace.Wrap(err)
}
Expand Down Expand Up @@ -1384,20 +1414,77 @@ func (m *RequestValidator) truncateTTL(ctx context.Context, identity tlsca.Ident
return ttl, nil
}

// getResourceViewingRoles gets the subset of the user's roles that could be used
// to view resources (i.e. base roles + search as roles).
func (m *RequestValidator) getResourceViewingRoles() []string {
roles := slices.Clone(m.userState.GetRoles())
for _, role := range m.Roles.AllowSearch {
if m.CanSearchAsRole(role) {
roles = append(roles, role)
}
}
return apiutils.Deduplicate(roles)
}

// GetRequestableRoles gets the list of all existent roles which the user is
// able to request. This operation is expensive since it loads all existent
// roles in order to determine the role list. Prefer calling CanRequestRole
// when checking against a known role list.
func (m *RequestValidator) GetRequestableRoles() ([]string, error) {
allRoles, err := m.getter.GetRoles(context.TODO())
// when checking against a known role list. If resource IDs or a login hint
// are provided, roles will be filtered to only include those that would
// allow access to the given resource with the given login.
func (m *RequestValidator) GetRequestableRoles(ctx context.Context, identity tlsca.Identity, resourceIDs []types.ResourceID, loginHint string) ([]string, error) {
allRoles, err := m.getter.GetRoles(ctx)
if err != nil {
return nil, trace.Wrap(err)
}

resources, err := m.getUnderlyingResourcesByResourceIDs(ctx, resourceIDs)
if err != nil {
return nil, trace.Wrap(err)
}

cluster, err := m.getter.GetClusterName()
if err != nil {
return nil, trace.Wrap(err)
}
accessChecker, err := NewAccessChecker(&AccessInfo{
Roles: m.getResourceViewingRoles(),
Traits: m.userState.GetTraits(),
Username: m.userState.GetName(),
AllowedResourceIDs: identity.AllowedResourceIDs,
}, cluster.GetClusterName(), m.getter)
if err != nil {
return nil, trace.Wrap(err)
}

// Filter out resources the user requested but doesn't have access to.
filteredResources := make([]types.ResourceWithLabels, 0, len(resources))
for _, resource := range resources {
if err := accessChecker.CheckAccess(resource, AccessState{MFAVerified: true}); err == nil {
filteredResources = append(filteredResources, resource)
}
}

var expanded []string
for _, role := range allRoles {
if n := role.GetName(); !slices.Contains(m.userState.GetRoles(), n) && m.CanRequestRole(n) {
// user does not currently hold this role, and is allowed to request it.
n := role.GetName()
if slices.Contains(m.userState.GetRoles(), n) || !m.CanRequestRole(n) {
continue
}

roleAllowsAccess := true
for _, resource := range filteredResources {
access, err := m.roleAllowsResource(ctx, role, resource, loginHint)
if err != nil {
return nil, trace.Wrap(err)
}
if !access {
roleAllowsAccess = false
}
}

// user does not currently hold this role, and is allowed to request it.
if roleAllowsAccess {
expanded = append(expanded, n)
}
}
Expand Down Expand Up @@ -1921,6 +2008,9 @@ func resourceMatcherToMatcherSlice(resourceMatcher *KubeResourcesMatcher) []Role
// the underlying resources are the same as requested. If the resource requested
// is a Kubernetes resource, we return the underlying Kubernetes cluster.
func (m *RequestValidator) getUnderlyingResourcesByResourceIDs(ctx context.Context, resourceIDs []types.ResourceID) ([]types.ResourceWithLabels, error) {
if len(resourceIDs) == 0 {
return []types.ResourceWithLabels{}, nil
}
// When searching for Kube Resources, we change the resource Kind to the Kubernetes
// Cluster in order to load the roles that grant access to it and to verify
// if the access to it is allowed. We later verify if every Kubernetes Resource
Expand Down
168 changes: 167 additions & 1 deletion lib/services/access_request_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ package services

import (
"context"
"fmt"
"strconv"
"strings"
"testing"
"time"
Expand Down Expand Up @@ -1585,7 +1587,7 @@ func TestPruneRequestRoles(t *testing.T) {
Expires: clock.Now().UTC().Add(8 * time.Hour),
}

accessCaps, err := CalculateAccessCapabilities(ctx, clock, g, types.AccessCapabilitiesRequest{User: user, ResourceIDs: tc.requestResourceIDs})
accessCaps, err := CalculateAccessCapabilities(ctx, clock, g, tlsca.Identity{}, types.AccessCapabilitiesRequest{User: user, ResourceIDs: tc.requestResourceIDs})
require.NoError(t, err)

err = ValidateAccessRequestForUser(ctx, clock, g, req, identity, ExpandVars(true))
Expand All @@ -1608,6 +1610,170 @@ func TestPruneRequestRoles(t *testing.T) {
}
}

func TestGetRequestableRoles(t *testing.T) {
t.Parallel()
ctx := context.Background()

clusterName := "my-cluster"

g := &mockGetter{
roles: make(map[string]types.Role),
userStates: make(map[string]*userloginstate.UserLoginState),
nodes: make(map[string]types.Server),
clusterName: clusterName,
}

for i := 0; i < 10; i++ {
node, err := types.NewServerWithLabels(
fmt.Sprintf("node-%d", i),
types.KindNode,
types.ServerSpecV2{},
map[string]string{"index": strconv.Itoa(i)})
require.NoError(t, err)
g.nodes[node.GetName()] = node
}

getResourceID := func(i int) types.ResourceID {
return types.ResourceID{
ClusterName: clusterName,
Kind: types.KindNode,
Name: fmt.Sprintf("node-%d", i),
}
}

roleDesc := map[string]types.RoleSpecV6{
"partial-access": {
Allow: types.RoleConditions{
Request: &types.AccessRequestConditions{
Roles: []string{"full-access", "full-search"},
SearchAsRoles: []string{"full-access"},
},
NodeLabels: types.Labels{
"index": {"0", "1", "2", "3", "4"},
},
Logins: []string{"{{internal.logins}}"},
},
},
"full-access": {
Allow: types.RoleConditions{
NodeLabels: types.Labels{
"index": {"*"},
},
Logins: []string{"{{internal.logins}}"},
},
},
"full-search": {
Allow: types.RoleConditions{
Request: &types.AccessRequestConditions{
Roles: []string{"partial-access", "full-access"},
SearchAsRoles: []string{"full-access"},
},
},
},
"partial-search": {
Allow: types.RoleConditions{
Request: &types.AccessRequestConditions{
Roles: []string{"partial-access", "full-access"},
SearchAsRoles: []string{"partial-access"},
},
},
},
"partial-roles": {
Allow: types.RoleConditions{
Request: &types.AccessRequestConditions{
Roles: []string{"partial-access"},
SearchAsRoles: []string{"full-access"},
},
},
},
}

for name, spec := range roleDesc {
role, err := types.NewRole(name, spec)
require.NoError(t, err)
g.roles[name] = role
}

user := g.user(t)

tests := []struct {
name string
userRole string
requestedResources []types.ResourceID
disableFilter bool
allowedResourceIDs []types.ResourceID
expectedRoles []string
}{
{
name: "no resources to filter by",
userRole: "full-search",
expectedRoles: []string{"partial-access", "full-access"},
},
{
name: "filtering disabled",
userRole: "full-search",
requestedResources: []types.ResourceID{getResourceID(9)},
disableFilter: true,
expectedRoles: []string{"partial-access", "full-access"},
},
{
name: "filter by resources",
userRole: "full-search",
requestedResources: []types.ResourceID{getResourceID(9)},
expectedRoles: []string{"full-access"},
},
{
name: "resource in another cluster",
userRole: "full-search",
requestedResources: []types.ResourceID{
getResourceID(9),
{
ClusterName: "some-other-cluster",
Kind: types.KindNode,
Name: "node-9",
},
},
expectedRoles: []string{"partial-access", "full-access"},
},
{
name: "resource user shouldn't know about",
userRole: "partial-search",
requestedResources: []types.ResourceID{getResourceID(9)},
expectedRoles: []string{"partial-access", "full-access"},
},
{
name: "can view resource but not assume role",
userRole: "partial-roles",
requestedResources: []types.ResourceID{getResourceID(9)},
},
{
name: "prevent transitive access",
userRole: "partial-access",
requestedResources: []types.ResourceID{getResourceID(9)},
allowedResourceIDs: []types.ResourceID{getResourceID(0), getResourceID(1), getResourceID(2), getResourceID(3), getResourceID(4)},
expectedRoles: []string{"full-access", "full-search"},
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
g.userStates[user].Spec.Roles = []string{tc.userRole}
accessCaps, err := CalculateAccessCapabilities(ctx, clockwork.NewFakeClock(), g,
tlsca.Identity{
AllowedResourceIDs: tc.allowedResourceIDs,
},
types.AccessCapabilitiesRequest{
User: user,
RequestableRoles: true,
ResourceIDs: tc.requestedResources,
FilterRequestableRolesByResource: !tc.disableFilter,
})
require.NoError(t, err)
require.ElementsMatch(t, tc.expectedRoles, accessCaps.RequestableRoles)
})
}
}

// TestCalculatePendingRequesTTL verifies that the TTL for the Access Request is capped to the
// request's access expiry or capped to the default const requestTTL, whichever is smaller.
func TestCalculatePendingRequesTTL(t *testing.T) {
Expand Down

0 comments on commit 9afcfb0

Please sign in to comment.