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

Add automatic role access requests #39003

Merged
merged 2 commits into from
Apr 5, 2024
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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 @@ -2501,6 +2501,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,241 changes: 1,663 additions & 1,578 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 @@ -5031,10 +5031,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