mirror of
https://github.com/gravitational/teleport.git
synced 2026-11-02 23:31:08 +08:00
* Adjust GetClusterName method signature to include context.Context * Start hacking on gRPC GetClusterName impl * Fix test mocks to match signature * Fix integration tests * Fix integrations tests * Missing ctx * New GetClusterName RPC implementatin * Add fallback cluster name * Correct godoc comment * Remove unused method * Remove unused import * Fix E2E test * Fix tests * Fix deferred context cancel in test conflicting with parallel subtests * Fix import * Fix imports * Update lib/auth/auth_with_roles.go Co-authored-by: Marco Dinis <marco.dinis@goteleport.com> --------- Co-authored-by: Marco Dinis <marco.dinis@goteleport.com>
3539 lines
113 KiB
Go
3539 lines
113 KiB
Go
/*
|
|
* Teleport
|
|
* Copyright (C) 2023 Gravitational, Inc.
|
|
*
|
|
* This program is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU Affero General Public License as published by
|
|
* the Free Software Foundation, either version 3 of the License, or
|
|
* (at your option) any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU Affero General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU Affero General Public License
|
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
package services
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/go-cmp/cmp"
|
|
"github.com/google/uuid"
|
|
"github.com/gravitational/trace"
|
|
"github.com/jonboulle/clockwork"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/gravitational/teleport/api/client/proto"
|
|
"github.com/gravitational/teleport/api/types"
|
|
"github.com/gravitational/teleport/api/types/header"
|
|
"github.com/gravitational/teleport/api/types/trait"
|
|
"github.com/gravitational/teleport/api/types/userloginstate"
|
|
"github.com/gravitational/teleport/lib/defaults"
|
|
"github.com/gravitational/teleport/lib/fixtures"
|
|
"github.com/gravitational/teleport/lib/tlsca"
|
|
)
|
|
|
|
// mockGetter mocks the UserAndRoleGetter interface.
|
|
type mockGetter struct {
|
|
userStates map[string]*userloginstate.UserLoginState
|
|
users map[string]types.User
|
|
roles map[string]types.Role
|
|
nodes map[string]types.Server
|
|
kubeServers map[string]types.KubeServer
|
|
dbServers map[string]types.DatabaseServer
|
|
appServers map[string]types.AppServer
|
|
desktops map[string]types.WindowsDesktop
|
|
clusterName string
|
|
}
|
|
|
|
// user inserts a new user with the specified roles and returns the username.
|
|
func (m *mockGetter) user(t *testing.T, roles ...string) string {
|
|
name := uuid.New().String()
|
|
uls, err := userloginstate.New(header.Metadata{
|
|
Name: name,
|
|
}, userloginstate.Spec{
|
|
Roles: roles,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
m.userStates[name] = uls
|
|
return name
|
|
}
|
|
|
|
func (m *mockGetter) GetUserLoginStates(context.Context) ([]*userloginstate.UserLoginState, error) {
|
|
return nil, trace.NotImplemented("GetUserLoginStates is not implemented")
|
|
}
|
|
|
|
func (m *mockGetter) GetUserLoginState(ctx context.Context, name string) (*userloginstate.UserLoginState, error) {
|
|
uls, ok := m.userStates[name]
|
|
if !ok {
|
|
return nil, trace.NotFound("no such user login state: %q", name)
|
|
}
|
|
return uls, nil
|
|
}
|
|
|
|
func (m *mockGetter) GetRole(ctx context.Context, name string) (types.Role, error) {
|
|
role, ok := m.roles[name]
|
|
if !ok {
|
|
return nil, trace.NotFound("no such role: %q", name)
|
|
}
|
|
return role, nil
|
|
}
|
|
|
|
func (m *mockGetter) GetUser(ctx context.Context, name string, withSecrets bool) (types.User, error) {
|
|
if withSecrets {
|
|
return nil, trace.NotImplemented("")
|
|
}
|
|
|
|
user, ok := m.users[name]
|
|
if !ok {
|
|
return nil, trace.NotFound("no such user: %q", name)
|
|
}
|
|
return user, nil
|
|
}
|
|
|
|
func (m *mockGetter) GetRoles(ctx context.Context) ([]types.Role, error) {
|
|
roles := make([]types.Role, 0, len(m.roles))
|
|
for _, r := range m.roles {
|
|
roles = append(roles, r)
|
|
}
|
|
return roles, nil
|
|
}
|
|
|
|
// ListResources is a very dumb implementation for the mockGetter that just
|
|
// returns all resources which have names matching the request
|
|
// PredicateExpression.
|
|
func (m *mockGetter) ListResources(ctx context.Context, req proto.ListResourcesRequest) (*types.ListResourcesResponse, error) {
|
|
resp := &types.ListResourcesResponse{}
|
|
for nodeName, node := range m.nodes {
|
|
if strings.Contains(req.PredicateExpression, nodeName) {
|
|
resp.Resources = append(resp.Resources, types.ResourceWithLabels(node))
|
|
}
|
|
}
|
|
for kubeName, kubeService := range m.kubeServers {
|
|
if strings.Contains(req.PredicateExpression, kubeName) {
|
|
resp.Resources = append(resp.Resources, types.ResourceWithLabels(kubeService))
|
|
}
|
|
}
|
|
for dbName, dbServer := range m.dbServers {
|
|
if strings.Contains(req.PredicateExpression, dbName) {
|
|
resp.Resources = append(resp.Resources, dbServer)
|
|
}
|
|
}
|
|
for appName, appServer := range m.appServers {
|
|
if strings.Contains(req.PredicateExpression, appName) {
|
|
resp.Resources = append(resp.Resources, appServer)
|
|
}
|
|
}
|
|
for desktopName, desktop := range m.desktops {
|
|
if strings.Contains(req.PredicateExpression, desktopName) {
|
|
resp.Resources = append(resp.Resources, desktop)
|
|
}
|
|
}
|
|
return resp, nil
|
|
}
|
|
|
|
func (m *mockGetter) GetClusterName(_ context.Context) (types.ClusterName, error) {
|
|
return types.NewClusterName(types.ClusterNameSpecV2{
|
|
ClusterName: m.clusterName,
|
|
ClusterID: "testid",
|
|
})
|
|
}
|
|
|
|
// TestReviewThresholds tests various review threshold scenarios
|
|
func TestReviewThresholds(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
// describes a collection of roles with various approval/review
|
|
// permissions.
|
|
roleDesc := map[string]types.RoleConditions{
|
|
// dictator is the role that will be requested
|
|
"dictator": {
|
|
// ...
|
|
},
|
|
// populists can achieve dictatorship via uprising or consensus, but only
|
|
// be conclusively denied via uprising.
|
|
"populist": {
|
|
Request: &types.AccessRequestConditions{
|
|
Roles: []string{"dictator"},
|
|
Annotations: map[string][]string{
|
|
"mechanism": {"uprising", "consensus"},
|
|
},
|
|
Thresholds: []types.AccessReviewThreshold{
|
|
{
|
|
Name: "overwhelming consensus",
|
|
Approve: 3,
|
|
},
|
|
{
|
|
Name: "popular uprising",
|
|
Filter: `contains(reviewer.roles,"proletariat")`,
|
|
Approve: 2,
|
|
Deny: 2,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
// generals can achieve dictatorship via coup or consensus, but only
|
|
// be conclusively denied via coup.
|
|
"general": {
|
|
Request: &types.AccessRequestConditions{
|
|
Roles: []string{"dictator"},
|
|
Annotations: map[string][]string{
|
|
"mechanism": {"coup", "consensus"},
|
|
},
|
|
Thresholds: []types.AccessReviewThreshold{
|
|
{
|
|
Name: "overwhelming consensus",
|
|
Approve: 3,
|
|
},
|
|
{
|
|
Name: "bloodless coup",
|
|
Filter: `contains(reviewer.roles,"military")`,
|
|
Approve: 2,
|
|
Deny: 2,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
// conquerors can achieve, or be conclusively denied, dictatorship
|
|
// via treachery.
|
|
"conqueror": {
|
|
Request: &types.AccessRequestConditions{
|
|
Roles: []string{"dictator"},
|
|
Annotations: map[string][]string{
|
|
"mechanism": {"treachery"},
|
|
},
|
|
// no explicit thresholds defaults to single approval/denial
|
|
},
|
|
},
|
|
// rationalists can achieve dictatorship via consensus with a single
|
|
// approval if there is a good reason.
|
|
"rationalist": {
|
|
Request: &types.AccessRequestConditions{
|
|
Roles: []string{"dictator"},
|
|
Annotations: map[string][]string{
|
|
"mechanism": {"consensus"},
|
|
},
|
|
Thresholds: []types.AccessReviewThreshold{
|
|
{
|
|
Name: "rational consensus",
|
|
Filter: `regexp.match(request.reason, "*good*") && regexp.match(review.reason, "*good*")`,
|
|
Approve: 1,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
// idealists have an directive for requesting a role which
|
|
// does not exist, and no threshold which permit it to be assumed.
|
|
"idealist": {
|
|
Request: &types.AccessRequestConditions{
|
|
Roles: []string{"never"},
|
|
Thresholds: []types.AccessReviewThreshold{
|
|
{
|
|
Name: "reality check",
|
|
Deny: 1,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
// the proletariat can put dictators into power via uprising
|
|
"proletariat": {
|
|
ReviewRequests: &types.AccessReviewConditions{
|
|
Roles: []string{"dictator"},
|
|
Where: `contains(request.system_annotations["mechanism"],"uprising")`,
|
|
},
|
|
},
|
|
// the intelligentsia can put dictators into power via consensus, with a
|
|
// good reason.
|
|
"intelligentsia": {
|
|
ReviewRequests: &types.AccessReviewConditions{
|
|
Roles: []string{"dictator", "never"},
|
|
Where: `contains(request.system_annotations["mechanism"],"consensus") && regexp.match(request.reason, "*good*")`,
|
|
},
|
|
},
|
|
// the military can put dictators into power via a coup our treachery
|
|
"military": {
|
|
ReviewRequests: &types.AccessReviewConditions{
|
|
Roles: []string{"dictator"},
|
|
Where: `contains(request.system_annotations["mechanism"],"coup") || contains(request.system_annotations["mechanism"],"treachery")`,
|
|
},
|
|
},
|
|
// never is the role that will never be requested
|
|
"never": {
|
|
// ...
|
|
},
|
|
}
|
|
|
|
roles := make(map[string]types.Role)
|
|
|
|
for name, conditions := range roleDesc {
|
|
role, err := types.NewRole(name, types.RoleSpecV6{
|
|
Allow: conditions,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
roles[name] = role
|
|
}
|
|
|
|
// describes a collection of users with various roles
|
|
ulsDesc := map[string][]string{
|
|
"alice": {"populist", "proletariat", "intelligentsia", "military"},
|
|
"carol": {"conqueror", "proletariat", "intelligentsia", "military"},
|
|
"erika": {"populist", "idealist"},
|
|
}
|
|
|
|
userStates := make(map[string]*userloginstate.UserLoginState)
|
|
for name, roles := range ulsDesc {
|
|
uls, err := userloginstate.New(header.Metadata{
|
|
Name: name,
|
|
}, userloginstate.Spec{
|
|
Roles: roles,
|
|
})
|
|
require.NoError(t, err)
|
|
userStates[name] = uls
|
|
}
|
|
|
|
users := make(map[string]types.User)
|
|
userDesc := map[string][]string{
|
|
"bob": {"general", "proletariat", "intelligentsia", "military"},
|
|
"dave": {"populist", "general", "conqueror"},
|
|
"frank": {"rationalist"},
|
|
}
|
|
|
|
for name, roles := range userDesc {
|
|
user, err := types.NewUser(name)
|
|
require.NoError(t, err)
|
|
|
|
user.SetRoles(roles)
|
|
users[name] = user
|
|
}
|
|
|
|
g := &mockGetter{
|
|
roles: roles,
|
|
userStates: userStates,
|
|
users: users,
|
|
}
|
|
|
|
const (
|
|
pending = types.RequestState_PENDING
|
|
approve = types.RequestState_APPROVED
|
|
deny = types.RequestState_DENIED
|
|
promote = types.RequestState_PROMOTED
|
|
)
|
|
|
|
type review struct {
|
|
// author is the name of the review author
|
|
author string
|
|
// noReview indicates that author will not be allowed to review
|
|
noReview bool
|
|
// propose is the state proposed by the review
|
|
propose types.RequestState
|
|
// expect is the expected post-review state of the request (defaults to pending)
|
|
expect types.RequestState
|
|
// assumeStartTime to apply to review
|
|
assumeStartTime time.Time
|
|
// reason for the review
|
|
reason string
|
|
|
|
errCheck require.ErrorAssertionFunc
|
|
}
|
|
|
|
clock := clockwork.NewFakeClock()
|
|
tts := []struct {
|
|
// desc is a short description of the test scenario (should be unique)
|
|
desc string
|
|
// requestor is the name of the requesting user
|
|
requestor string
|
|
// the roles to be requested (defaults to "dictator")
|
|
roles []string
|
|
// the reason for the request
|
|
reason string
|
|
reviews []review
|
|
expiry time.Time
|
|
}{
|
|
{
|
|
desc: "populist approval via multi-threshold match",
|
|
requestor: "alice", // permitted by role populist
|
|
reason: "some very good reason",
|
|
reviews: []review{
|
|
{ // cannot review own requests
|
|
author: "alice",
|
|
noReview: true,
|
|
},
|
|
{ // no matching allow directives
|
|
author: g.user(t, "military"),
|
|
noReview: true,
|
|
},
|
|
{ // adds one approval to all thresholds
|
|
author: g.user(t, "proletariat", "intelligentsia", "military"),
|
|
propose: approve,
|
|
},
|
|
{ // adds one denial to all thresholds
|
|
author: g.user(t, "proletariat", "intelligentsia", "military"),
|
|
propose: deny,
|
|
},
|
|
{ // adds second approval to all thresholds, triggers "uprising".
|
|
author: g.user(t, "proletariat", "intelligentsia", "military"),
|
|
propose: approve,
|
|
expect: approve,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
desc: "trying to deny an already approved request",
|
|
requestor: "alice", // permitted by role populist
|
|
reason: "some very good reason",
|
|
reviews: []review{
|
|
{ // cannot review own requests
|
|
author: "alice",
|
|
noReview: true,
|
|
},
|
|
{ // no matching allow directives
|
|
author: g.user(t, "military"),
|
|
noReview: true,
|
|
},
|
|
{ // adds one approval to all thresholds
|
|
author: g.user(t, "proletariat", "intelligentsia", "military"),
|
|
propose: approve,
|
|
},
|
|
{ // adds one denial to all thresholds
|
|
author: g.user(t, "proletariat", "intelligentsia", "military"),
|
|
propose: deny,
|
|
},
|
|
{ // adds second approval to all thresholds, triggers "uprising".
|
|
author: g.user(t, "proletariat", "intelligentsia", "military"),
|
|
propose: approve,
|
|
expect: approve,
|
|
},
|
|
{ // adds second denial but request was already approved.
|
|
author: g.user(t, "proletariat", "intelligentsia", "military"),
|
|
propose: deny,
|
|
errCheck: func(tt require.TestingT, err error, i ...interface{}) {
|
|
require.ErrorIs(tt, err, trace.AccessDenied("the access request has been already approved"), i...)
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
desc: "trying to approve an already denied request",
|
|
requestor: "bob", // permitted by role general
|
|
reviews: []review{
|
|
{ // 1 of 2 required denials
|
|
author: g.user(t, "military"),
|
|
propose: deny,
|
|
},
|
|
{ // 2 of 2 required denials
|
|
author: g.user(t, "military"),
|
|
propose: deny,
|
|
expect: deny,
|
|
},
|
|
{ // tries to approve but it was already denied
|
|
author: g.user(t, "military"),
|
|
propose: approve,
|
|
errCheck: func(tt require.TestingT, err error, i ...interface{}) {
|
|
require.ErrorIs(tt, err, trace.AccessDenied("the access request has been already denied"), i...)
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
desc: "intelligentsia cannot review without reason",
|
|
requestor: "alice",
|
|
reviews: []review{
|
|
{
|
|
author: g.user(t, "intelligentsia"),
|
|
propose: approve,
|
|
noReview: true,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
desc: "populist approval via consensus threshold",
|
|
requestor: "alice", // permitted by role populist
|
|
reason: "some very good reason",
|
|
reviews: []review{
|
|
{ // matches "consensus" threshold
|
|
author: g.user(t, "intelligentsia"),
|
|
propose: approve,
|
|
},
|
|
{ // matches "uprising" and "consensus" thresholds
|
|
author: g.user(t, "proletariat"),
|
|
propose: approve,
|
|
},
|
|
{ // 1 of 2 required denials (does not trigger a threshold)
|
|
author: g.user(t, "proletariat"),
|
|
propose: deny,
|
|
},
|
|
{ // review is permitted but has no effect since "consensus" has no denial condition
|
|
author: g.user(t, "intelligentsia"),
|
|
propose: deny,
|
|
},
|
|
{ // triggers the "consensus" approval threshold
|
|
author: g.user(t, "intelligentsia"),
|
|
propose: approve,
|
|
expect: approve,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
desc: "general denial via coup threshold",
|
|
requestor: "bob", // permitted by role general
|
|
reason: "some very good reason",
|
|
reviews: []review{
|
|
{ // cannot review own requests
|
|
author: "bob",
|
|
noReview: true,
|
|
},
|
|
{ // matches "consensus" threshold
|
|
author: g.user(t, "intelligentsia"),
|
|
propose: approve,
|
|
},
|
|
{ // matches "coup" threshold
|
|
author: g.user(t, "military"),
|
|
propose: approve,
|
|
},
|
|
{ // no matching allow directives
|
|
author: g.user(t, "proletariat"),
|
|
noReview: true,
|
|
},
|
|
{ // 1 of 2 required denials for "coup" (does not trigger a threshold)
|
|
author: g.user(t, "military"),
|
|
propose: deny,
|
|
},
|
|
{ // review is permitted but matches no thresholds
|
|
author: g.user(t, "intelligentsia"),
|
|
propose: deny,
|
|
},
|
|
{ // tirggers the "coup" denial threshold
|
|
author: g.user(t, "military"),
|
|
propose: deny,
|
|
expect: deny,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
desc: "conqueror approval via default threshold",
|
|
requestor: "carol", // permitted by role conqueror
|
|
reason: "some very good reason",
|
|
reviews: []review{
|
|
{ // cannot review own requests
|
|
author: "carol",
|
|
noReview: true,
|
|
},
|
|
{ // no matching allow directives
|
|
author: g.user(t, "proletariat"),
|
|
noReview: true,
|
|
},
|
|
{ // no matching allow directives
|
|
author: g.user(t, "intelligentsia"),
|
|
noReview: true,
|
|
},
|
|
{ // triggers "default" threshold for immediate approval
|
|
author: g.user(t, "military"),
|
|
propose: approve,
|
|
expect: approve,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
desc: "conqueror denial via default threshold",
|
|
requestor: "carol", // permitted by role conqueror
|
|
reviews: []review{
|
|
{ // triggers "default" threshold for immediate denial
|
|
author: g.user(t, "military"),
|
|
propose: deny,
|
|
expect: deny,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
// this test case covers a scenario where multiple roles contributed
|
|
// to requestor's permissions. Current behavior is to require one threshold
|
|
// to pass from each contributing role, but future iterations may become
|
|
// "smart" about which thresholds really need to pass.
|
|
desc: "multi-role requestor approval via separate threshold matches",
|
|
requestor: "dave", // permitted by conqueror, general, *and* populist
|
|
reason: "some very good reason",
|
|
reviews: []review{
|
|
{ // matches "default", "coup", and "consensus" thresholds
|
|
author: g.user(t, "military"),
|
|
propose: approve,
|
|
},
|
|
|
|
{ // matches "default", "uprising", and "consensus" thresholds
|
|
author: g.user(t, "proletariat"),
|
|
propose: approve,
|
|
},
|
|
{ // matches "default" and "consensus" thresholds.
|
|
author: g.user(t, "intelligentsia"),
|
|
propose: approve,
|
|
expect: approve,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
// this test case covers a scenario where multiple roles contributed
|
|
// to requestor's permissions. since the reviewing users hold multiple
|
|
// roles which match various threshold filters, we reach approval with
|
|
// the minimum number reviews necessary s.t. one threshold passes from each
|
|
// requestor role.
|
|
desc: "multi-role requestor approval via multi-threshold match",
|
|
requestor: "dave", // permitted by conqueror, general, *and* populist
|
|
reviews: []review{
|
|
{ // matches all thresholds
|
|
author: g.user(t, "military", "proletariat"),
|
|
propose: approve,
|
|
},
|
|
|
|
{ // matches all thresholds
|
|
author: g.user(t, "military", "proletariat"),
|
|
propose: approve,
|
|
expect: approve,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
// this test case covers a scenario where multiple roles contributed
|
|
// to requestor's permissions. the interaction here is a bit unintuitive.
|
|
// review *submission* is allowed because of the system annotations applied
|
|
// by "general"/"populist", *but* we end up triggering a threshold which originates
|
|
// from "conqueror". this may be unexpected behavior for some people since it
|
|
// effectively allows a third role to open up the ability for the reviewer to
|
|
// trigger a threshold which they would not otherwise be able to trigger. while
|
|
// unintuitive, this effect only manifests as teleport being overly-eager
|
|
// about denying requests (i.e. erring on the side of caution).
|
|
desc: "multi-role requestor denial via short-circuit on default",
|
|
requestor: "dave", // permitted by conqueror, general, *and* populist
|
|
reason: "some very good reason",
|
|
reviews: []review{
|
|
{ // ...
|
|
author: g.user(t, "intelligentsia"),
|
|
propose: deny,
|
|
expect: deny,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
// this test case is just a sanity-check to make sure that the next
|
|
// case is testing what we think its testing (that thresholds associated
|
|
// with unrequested roles are not added to the request).
|
|
desc: "threshold omission related sanity-check",
|
|
requestor: "erika", // permitted by combination of populist and idealist
|
|
roles: []string{"dictator", "never"},
|
|
reason: "some very good reason",
|
|
reviews: []review{
|
|
{ // matches default threshold from idealist
|
|
author: g.user(t, "intelligentsia"),
|
|
propose: deny,
|
|
expect: deny,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
// this test case verifies that thresholds associated with unrelated roles
|
|
// are not added to an access request. erika holds a role with the default
|
|
// threshold (idealist) but she is only requesting dictator (which does not
|
|
// match any of the allow directives on idealist) so that threshold should
|
|
// be omitted.
|
|
desc: "threshold omission check",
|
|
requestor: "erika", // permitted by populist, but also holds idealist
|
|
reason: "some very good reason",
|
|
reviews: []review{
|
|
{ // review is permitted but matches no thresholds
|
|
author: g.user(t, "intelligentsia"),
|
|
propose: deny,
|
|
},
|
|
{ // matches "consensus" threshold
|
|
author: g.user(t, "intelligentsia"),
|
|
propose: approve,
|
|
},
|
|
{ // matches "consensus" and "uprising" thresholds
|
|
author: g.user(t, "proletariat"),
|
|
propose: deny,
|
|
},
|
|
{ // matches "consensus" threshold
|
|
author: g.user(t, "proletariat"),
|
|
propose: approve,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
desc: "promoted skips the threshold check",
|
|
requestor: "bob",
|
|
reason: "some very good reason",
|
|
reviews: []review{
|
|
{ // status should be set to promoted despite the approval threshold not being met
|
|
author: g.user(t, "intelligentsia"),
|
|
propose: promote,
|
|
expect: promote,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
desc: "trying to approve a request with assumeStartTime past expiry",
|
|
requestor: "bob", // permitted by role general
|
|
expiry: clock.Now().UTC().Add(8 * time.Hour),
|
|
reviews: []review{
|
|
{ // 1 of 2 required approvals
|
|
author: g.user(t, "military"),
|
|
propose: deny,
|
|
},
|
|
{ // tries to approve but assumeStartTime is after expiry
|
|
author: g.user(t, "military"),
|
|
propose: approve,
|
|
assumeStartTime: clock.Now().UTC().Add(10000 * time.Hour),
|
|
errCheck: func(tt require.TestingT, err error, i ...interface{}) {
|
|
require.ErrorContains(tt, err, "assume start time must be prior to access expiry time", i...)
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
desc: "rationalist approval with valid reasons",
|
|
requestor: "frank",
|
|
roles: []string{"dictator"},
|
|
reason: "some very good reason",
|
|
reviews: []review{
|
|
{
|
|
author: g.user(t, "intelligentsia"),
|
|
propose: approve,
|
|
reason: "frank is just okay",
|
|
expect: pending,
|
|
},
|
|
{
|
|
author: g.user(t, "intelligentsia"),
|
|
propose: approve,
|
|
reason: "frank is pretty good",
|
|
expect: approve,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tts {
|
|
t.Run(tt.desc, func(t *testing.T) {
|
|
if len(tt.roles) == 0 {
|
|
tt.roles = []string{"dictator"}
|
|
}
|
|
|
|
// create a request for the specified author
|
|
req, err := types.NewAccessRequest("some-id", tt.requestor, tt.roles...)
|
|
require.NoError(t, err, "scenario=%q", tt.desc)
|
|
req.SetRequestReason(tt.reason)
|
|
|
|
clock := clockwork.NewFakeClock()
|
|
identity := tlsca.Identity{
|
|
Expires: clock.Now().UTC().Add(8 * time.Hour),
|
|
}
|
|
|
|
if !tt.expiry.IsZero() {
|
|
req.SetExpiry(tt.expiry)
|
|
}
|
|
|
|
// perform request validation (necessary in order to initialize internal
|
|
// request variables like annotations and thresholds).
|
|
validator, err := NewRequestValidator(ctx, clock, g, tt.requestor, ExpandVars(true))
|
|
require.NoError(t, err, "scenario=%q", tt.desc)
|
|
|
|
require.NoError(t, validator.Validate(ctx, req, identity), "scenario=%q", tt.desc)
|
|
|
|
Inner:
|
|
for ri, rt := range tt.reviews {
|
|
if rt.expect.IsNone() {
|
|
rt.expect = types.RequestState_PENDING
|
|
}
|
|
|
|
checker, err := NewReviewPermissionChecker(ctx, g, rt.author, nil)
|
|
require.NoError(t, err, "scenario=%q, rev=%d", tt.desc, ri)
|
|
|
|
canReview, err := checker.CanReviewRequest(req)
|
|
require.NoError(t, err, "scenario=%q, rev=%d", tt.desc, ri)
|
|
|
|
if rt.noReview {
|
|
require.False(t, canReview, "scenario=%q, rev=%d", tt.desc, ri)
|
|
continue Inner
|
|
} else {
|
|
require.True(t, canReview, "scenario=%q, rev=%d", tt.desc, ri)
|
|
}
|
|
|
|
rev := types.AccessReview{
|
|
Author: rt.author,
|
|
ProposedState: rt.propose,
|
|
Reason: rt.reason,
|
|
AssumeStartTime: &rt.assumeStartTime,
|
|
}
|
|
|
|
author, ok := userStates[rt.author]
|
|
require.True(t, ok, "scenario=%q, rev=%d", tt.desc, ri)
|
|
|
|
err = ApplyAccessReview(req, rev, author)
|
|
if rt.errCheck != nil {
|
|
rt.errCheck(t, err)
|
|
continue
|
|
}
|
|
|
|
require.NoError(t, err, "scenario=%q, rev=%d", tt.desc, ri)
|
|
require.Equal(t, rt.expect.String(), req.GetState().String(), "scenario=%q, rev=%d", tt.desc, ri)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestMaxLength tests that we reject too large access requests.
|
|
func TestMaxLength(t *testing.T) {
|
|
req, err := types.NewAccessRequest("some-id", "dave", "dictator", "never")
|
|
require.NoError(t, err)
|
|
|
|
req.SetRequestReason(strings.Repeat("a", maxAccessRequestReasonSize))
|
|
require.Error(t, ValidateAccessRequest(req))
|
|
|
|
resourceIDs, err := types.ResourceIDsFromString(`["/cluster/node/some-node", "/cluster/node/some-other-node" ]`)
|
|
require.NoError(t, err)
|
|
|
|
req.SetRequestReason("")
|
|
req.SetRequestedResourceIDs(slices.Repeat(resourceIDs, maxResourcesPerRequest/2+1))
|
|
require.Error(t, ValidateAccessRequest(req))
|
|
}
|
|
|
|
// TestThresholdReviewFilter verifies basic filter syntax.
|
|
func TestThresholdReviewFilter(t *testing.T) {
|
|
// test cases consist of a context, and various filter expressions
|
|
// which should or should not match the supplied context.
|
|
tts := []struct {
|
|
ctx thresholdFilterContext
|
|
willMatch []string
|
|
wontMatch []string
|
|
wontParse []string
|
|
}{
|
|
{ // test expected matching behavior against a basic example context
|
|
ctx: thresholdFilterContext{
|
|
reviewer: reviewAuthorContext{
|
|
roles: []string{"dev"},
|
|
traits: map[string][]string{
|
|
"teams": {"staging-admin"},
|
|
},
|
|
},
|
|
review: reviewParamsContext{
|
|
reason: "ok",
|
|
annotations: map[string][]string{
|
|
"constraints": {"no-admin"},
|
|
},
|
|
},
|
|
request: reviewRequestContext{
|
|
roles: []string{"dev"},
|
|
reason: "Ticket 123",
|
|
systemAnnotations: map[string][]string{
|
|
"teams": {"staging-dev"},
|
|
},
|
|
},
|
|
},
|
|
willMatch: []string{
|
|
`contains(reviewer.roles,"dev")`,
|
|
`contains(reviewer.traits["teams"],"staging-admin") && contains(request.system_annotations["teams"],"staging-dev")`,
|
|
`!contains(review.annotations["constraints"],"no-admin") || !contains(request.roles,"admin")`,
|
|
`equals(request.reason,"Ticket 123") && equals(review.reason,"ok")`,
|
|
`contains(reviewer.roles,"admin") || contains(reviewer.roles,"dev")`,
|
|
`!(contains(reviewer.roles,"foo") || contains(reviewer.roles,"bar"))`,
|
|
`regexp.match(request.roles, "^dev(elopers)?$")`,
|
|
`regexp.match(request.reason, "^Ticket [0-9]+.*$") && !equals(review.reason, "")`,
|
|
},
|
|
wontMatch: []string{
|
|
`contains(reviewer.roles, "admin")`,
|
|
`equals(request.reason,review.reason)`,
|
|
`!contains(reviewer.traits["teams"],"staging-admin")`,
|
|
`contains(reviewer.roles,"admin") && contains(reviewer.roles,"dev")`,
|
|
},
|
|
},
|
|
{ // test expected matching behavior against zero values
|
|
willMatch: []string{
|
|
`equals(request.reason,review.reason)`,
|
|
`!contains(reviewer.traits["teams"],"staging-admin")`,
|
|
`!contains(review.annotations["constraints"],"no-admin") || !contains(request.roles,"admin")`,
|
|
`!(contains(reviewer.roles,"foo") || contains(reviewer.roles,"bar"))`,
|
|
},
|
|
wontMatch: []string{
|
|
`contains(reviewer.roles, "admin")`,
|
|
`contains(reviewer.roles,"admin") && contains(reviewer.roles,"dev")`,
|
|
`contains(reviewer.roles,"dev")`,
|
|
`contains(reviewer.traits["teams"],"staging-admin") && contains(request.system_annotations["teams"],"staging-dev")`,
|
|
`equals(request.reason,"plz") && equals(review.reason,"ok")`,
|
|
`contains(reviewer.roles,"admin") || contains(reviewer.roles,"dev")`,
|
|
`regexp.match(request.roles, "^dev(elopers)?$")`,
|
|
`regexp.match(request.reason, "^Ticket [0-9]+.*$") && !equals(review.reason, "")`,
|
|
},
|
|
// confirm that an empty context can be used to catch syntax errors
|
|
wontParse: []string{
|
|
`equals(fully.fake.path,"should-fail")`,
|
|
`fakefunc(reviewer.roles,"some-role")`,
|
|
`equals("too","many","params")`,
|
|
`contains("missing-param")`,
|
|
`contains(reviewer.partially.fake.path,"also fails")`,
|
|
`!`,
|
|
`&& missing-left`,
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tts {
|
|
for _, expr := range tt.willMatch {
|
|
parsed, err := parseThresholdFilterExpression(expr)
|
|
require.NoError(t, err)
|
|
result, err := parsed.Evaluate(tt.ctx)
|
|
require.NoError(t, err)
|
|
require.True(t, result)
|
|
}
|
|
|
|
for _, expr := range tt.wontMatch {
|
|
parsed, err := parseThresholdFilterExpression(expr)
|
|
require.NoError(t, err)
|
|
result, err := parsed.Evaluate(tt.ctx)
|
|
require.NoError(t, err)
|
|
require.False(t, result)
|
|
}
|
|
|
|
for _, expr := range tt.wontParse {
|
|
_, err := parseThresholdFilterExpression(expr)
|
|
require.Error(t, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestAccessRequestMarshaling verifies that marshaling/unmarshaling access requests
|
|
// works as expected (failures likely indicate a problem with json schema).
|
|
func TestAccessRequestMarshaling(t *testing.T) {
|
|
req1, err := NewAccessRequest("some-user", "role-1", "role-2")
|
|
require.NoError(t, err)
|
|
|
|
marshaled, err := MarshalAccessRequest(req1)
|
|
require.NoError(t, err)
|
|
|
|
req2, err := UnmarshalAccessRequest(marshaled)
|
|
require.NoError(t, err)
|
|
|
|
require.Equal(t, req1, req2)
|
|
}
|
|
|
|
// TestPluginDataExpectations verifies the correct behavior of the `Expect` mapping.
|
|
// Update operations which include an `Expect` mapping should not succeed unless
|
|
// all expectations match (e.g. `{"foo":"bar","spam":""}` matches the state where
|
|
// key `foo` has value `bar` and key `spam` does not exist).
|
|
func TestPluginDataExpectations(t *testing.T) {
|
|
const rname = "my-resource"
|
|
const pname = "my-plugin"
|
|
data, err := types.NewPluginData(rname, types.KindAccessRequest)
|
|
require.NoError(t, err)
|
|
|
|
// Set two keys, expecting them to be unset.
|
|
err = data.Update(types.PluginDataUpdateParams{
|
|
Kind: types.KindAccessRequest,
|
|
Resource: rname,
|
|
Plugin: pname,
|
|
Set: map[string]string{
|
|
"hello": "world",
|
|
"spam": "eggs",
|
|
},
|
|
Expect: map[string]string{
|
|
"hello": "",
|
|
"spam": "",
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Expect a value which does not exist.
|
|
err = data.Update(types.PluginDataUpdateParams{
|
|
Kind: types.KindAccessRequest,
|
|
Resource: rname,
|
|
Plugin: pname,
|
|
Set: map[string]string{
|
|
"should": "fail",
|
|
},
|
|
Expect: map[string]string{
|
|
"missing": "key",
|
|
},
|
|
})
|
|
fixtures.AssertCompareFailed(t, err)
|
|
|
|
// Expect a value to not exist when it does exist.
|
|
err = data.Update(types.PluginDataUpdateParams{
|
|
Kind: types.KindAccessRequest,
|
|
Resource: rname,
|
|
Plugin: pname,
|
|
Set: map[string]string{
|
|
"should": "fail",
|
|
},
|
|
Expect: map[string]string{
|
|
"hello": "world",
|
|
"spam": "",
|
|
},
|
|
})
|
|
fixtures.AssertCompareFailed(t, err)
|
|
|
|
// Expect the correct state, updating one key and removing another.
|
|
err = data.Update(types.PluginDataUpdateParams{
|
|
Kind: types.KindAccessRequest,
|
|
Resource: rname,
|
|
Plugin: pname,
|
|
Set: map[string]string{
|
|
"hello": "there",
|
|
"spam": "",
|
|
},
|
|
Expect: map[string]string{
|
|
"hello": "world",
|
|
"spam": "eggs",
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Expect the new updated state.
|
|
err = data.Update(types.PluginDataUpdateParams{
|
|
Kind: types.KindAccessRequest,
|
|
Resource: rname,
|
|
Plugin: pname,
|
|
Set: map[string]string{
|
|
"should": "succeed",
|
|
},
|
|
Expect: map[string]string{
|
|
"hello": "there",
|
|
"spam": "",
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
// TestPluginDataFilterMatching verifies the expected matching behavior for PluginDataFilter
|
|
func TestPluginDataFilterMatching(t *testing.T) {
|
|
const rname = "my-resource"
|
|
const pname = "my-plugin"
|
|
data, err := types.NewPluginData(rname, types.KindAccessRequest)
|
|
require.NoError(t, err)
|
|
|
|
var f types.PluginDataFilter
|
|
|
|
// Filter for a different resource
|
|
f.Resource = "other-resource"
|
|
require.False(t, f.Match(data))
|
|
|
|
// Filter for the same resource
|
|
f.Resource = rname
|
|
require.True(t, f.Match(data))
|
|
|
|
// Filter for a plugin which does not have data yet
|
|
f.Plugin = pname
|
|
require.False(t, f.Match(data))
|
|
|
|
// Add some data
|
|
err = data.Update(types.PluginDataUpdateParams{
|
|
Kind: types.KindAccessRequest,
|
|
Resource: rname,
|
|
Plugin: pname,
|
|
Set: map[string]string{
|
|
"spam": "eggs",
|
|
},
|
|
})
|
|
// Filter again now that data exists
|
|
require.NoError(t, err)
|
|
require.True(t, f.Match(data))
|
|
}
|
|
|
|
// TestRequestFilterMatching verifies expected matching behavior for AccessRequestFilter.
|
|
func TestRequestFilterMatching(t *testing.T) {
|
|
reqA, err := NewAccessRequest("alice", "role-a")
|
|
require.NoError(t, err)
|
|
|
|
reqB, err := NewAccessRequest("bob", "role-b")
|
|
require.NoError(t, err)
|
|
|
|
testCases := []struct {
|
|
user string
|
|
id string
|
|
matchA bool
|
|
matchB bool
|
|
}{
|
|
{"", "", true, true},
|
|
{"alice", "", true, false},
|
|
{"", reqA.GetName(), true, false},
|
|
{"bob", reqA.GetName(), false, false},
|
|
{"carol", "", false, false},
|
|
}
|
|
for _, tc := range testCases {
|
|
m := types.AccessRequestFilter{
|
|
User: tc.user,
|
|
ID: tc.id,
|
|
}
|
|
if m.Match(reqA) != tc.matchA {
|
|
t.Errorf("bad filter behavior (a) %+v", tc)
|
|
}
|
|
if m.Match(reqB) != tc.matchB {
|
|
t.Errorf("bad filter behavior (b) %+v", tc)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestRequestFilterConversion verifies that filters convert to and from
|
|
// maps correctly.
|
|
func TestRequestFilterConversion(t *testing.T) {
|
|
testCases := []struct {
|
|
f types.AccessRequestFilter
|
|
m map[string]string
|
|
}{
|
|
{
|
|
types.AccessRequestFilter{User: "alice", ID: "foo", State: types.RequestState_PENDING},
|
|
map[string]string{"user": "alice", "id": "foo", "state": "PENDING"},
|
|
},
|
|
{
|
|
types.AccessRequestFilter{User: "bob"},
|
|
map[string]string{"user": "bob"},
|
|
},
|
|
{
|
|
types.AccessRequestFilter{},
|
|
map[string]string{},
|
|
},
|
|
}
|
|
for _, tc := range testCases {
|
|
m := tc.f.IntoMap()
|
|
require.Empty(t, cmp.Diff(m, tc.m))
|
|
var f types.AccessRequestFilter
|
|
err := f.FromMap(tc.m)
|
|
require.NoError(t, err)
|
|
require.Empty(t, cmp.Diff(f, tc.f))
|
|
}
|
|
badMaps := []map[string]string{
|
|
{"food": "carrots"},
|
|
{"state": "homesick"},
|
|
}
|
|
for _, m := range badMaps {
|
|
var f types.AccessRequestFilter
|
|
require.Error(t, f.FromMap(m))
|
|
}
|
|
}
|
|
|
|
// TestRolesForResourceRequest tests that the correct roles are automatically
|
|
// determined for resource access requests
|
|
func TestRolesForResourceRequest(t *testing.T) {
|
|
// set up test roles
|
|
roleDesc := map[string]types.RoleSpecV6{
|
|
"db-admins": {
|
|
Allow: types.RoleConditions{
|
|
NodeLabels: types.Labels{
|
|
"owner": {"db-admins"},
|
|
},
|
|
},
|
|
},
|
|
"db-response-team": {
|
|
Allow: types.RoleConditions{
|
|
Request: &types.AccessRequestConditions{
|
|
SearchAsRoles: []string{"db-admins"},
|
|
},
|
|
},
|
|
},
|
|
"deny-db-request": {
|
|
Deny: types.RoleConditions{
|
|
Request: &types.AccessRequestConditions{
|
|
Roles: []string{"db-admins"},
|
|
},
|
|
},
|
|
},
|
|
"deny-db-search": {
|
|
Deny: types.RoleConditions{
|
|
Request: &types.AccessRequestConditions{
|
|
SearchAsRoles: []string{"db-admins"},
|
|
},
|
|
},
|
|
},
|
|
"splunk-admins": {
|
|
Allow: types.RoleConditions{
|
|
NodeLabels: types.Labels{
|
|
"owner": {"splunk-admins"},
|
|
},
|
|
},
|
|
},
|
|
"splunk-response-team": {
|
|
Allow: types.RoleConditions{
|
|
Request: &types.AccessRequestConditions{
|
|
SearchAsRoles: []string{"splunk-admins", "splunk-super-admins"},
|
|
},
|
|
},
|
|
},
|
|
// splunk-super-admins is a role that will never be requested
|
|
"splunk-super-admins": {
|
|
// ...
|
|
},
|
|
}
|
|
roles := make(map[string]types.Role)
|
|
for name, spec := range roleDesc {
|
|
role, err := types.NewRole(name, spec)
|
|
require.NoError(t, err)
|
|
roles[name] = role
|
|
}
|
|
|
|
resourceIDs := []types.ResourceID{{
|
|
ClusterName: "one",
|
|
Kind: "node",
|
|
Name: "some-uuid",
|
|
}}
|
|
|
|
testCases := []struct {
|
|
desc string
|
|
currentRoles []string
|
|
requestRoles []string
|
|
requestResourceIDs []types.ResourceID
|
|
expectError error
|
|
expectRequestedRoles []string
|
|
}{
|
|
{
|
|
desc: "basic case",
|
|
currentRoles: []string{"db-response-team"},
|
|
requestResourceIDs: resourceIDs,
|
|
expectRequestedRoles: []string{"db-admins"},
|
|
},
|
|
{
|
|
desc: "deny request without resources",
|
|
currentRoles: []string{"db-response-team"},
|
|
requestRoles: []string{"db-admins"},
|
|
requestResourceIDs: nil,
|
|
expectError: trace.BadParameter(`user "test-user" can not request role "db-admins"`),
|
|
},
|
|
{
|
|
desc: "deny search",
|
|
currentRoles: []string{"db-response-team", "deny-db-search"},
|
|
requestResourceIDs: resourceIDs,
|
|
expectError: trace.AccessDenied(`Resource Access Requests require usable "search_as_roles", none found for user "test-user"`),
|
|
},
|
|
{
|
|
desc: "deny request",
|
|
currentRoles: []string{"db-response-team", "deny-db-request"},
|
|
requestResourceIDs: resourceIDs,
|
|
expectError: trace.AccessDenied(`Resource Access Requests require usable "search_as_roles", none found for user "test-user"`),
|
|
},
|
|
{
|
|
desc: "multi allowed roles",
|
|
currentRoles: []string{"db-response-team", "splunk-response-team"},
|
|
requestResourceIDs: resourceIDs,
|
|
expectRequestedRoles: []string{"db-admins", "splunk-admins", "splunk-super-admins"},
|
|
},
|
|
{
|
|
desc: "multi allowed roles with denial",
|
|
currentRoles: []string{"db-response-team", "splunk-response-team", "deny-db-search"},
|
|
requestResourceIDs: resourceIDs,
|
|
expectRequestedRoles: []string{"splunk-admins", "splunk-super-admins"},
|
|
},
|
|
{
|
|
desc: "explicit roles request",
|
|
currentRoles: []string{"db-response-team", "splunk-response-team"},
|
|
requestResourceIDs: resourceIDs,
|
|
requestRoles: []string{"splunk-admins"},
|
|
expectRequestedRoles: []string{"splunk-admins"},
|
|
},
|
|
{
|
|
desc: "invalid explicit roles request",
|
|
currentRoles: []string{"db-response-team"},
|
|
requestResourceIDs: resourceIDs,
|
|
requestRoles: []string{"splunk-admins"},
|
|
expectError: trace.BadParameter(`user "test-user" can not request role "splunk-admins"`),
|
|
},
|
|
{
|
|
desc: "no allowed roles",
|
|
currentRoles: nil,
|
|
requestResourceIDs: resourceIDs,
|
|
expectError: trace.AccessDenied(`Resource Access Requests require usable "search_as_roles", none found for user "test-user"`),
|
|
},
|
|
}
|
|
for _, tc := range testCases {
|
|
t.Run(tc.desc, func(t *testing.T) {
|
|
uls, err := userloginstate.New(header.Metadata{
|
|
Name: "test-user",
|
|
}, userloginstate.Spec{
|
|
Roles: tc.currentRoles,
|
|
})
|
|
require.NoError(t, err)
|
|
userStates := map[string]*userloginstate.UserLoginState{
|
|
uls.GetName(): uls,
|
|
}
|
|
|
|
g := &mockGetter{
|
|
roles: roles,
|
|
userStates: userStates,
|
|
clusterName: "my-cluster",
|
|
}
|
|
|
|
req, err := types.NewAccessRequestWithResources(
|
|
"some-id", uls.GetName(), tc.requestRoles, tc.requestResourceIDs)
|
|
require.NoError(t, err)
|
|
|
|
clock := clockwork.NewFakeClock()
|
|
identity := tlsca.Identity{
|
|
Expires: clock.Now().UTC().Add(8 * time.Hour),
|
|
}
|
|
|
|
validator, err := NewRequestValidator(context.Background(), clock, g, uls.GetName(), ExpandVars(true))
|
|
require.NoError(t, err)
|
|
|
|
err = validator.Validate(context.Background(), req, identity)
|
|
require.ErrorIs(t, err, tc.expectError)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
require.Equal(t, tc.expectRequestedRoles, req.GetRoles())
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPruneRequestRoles(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
clusterName := "my-cluster"
|
|
|
|
g := &mockGetter{
|
|
roles: make(map[string]types.Role),
|
|
userStates: make(map[string]*userloginstate.UserLoginState),
|
|
users: make(map[string]types.User),
|
|
nodes: make(map[string]types.Server),
|
|
kubeServers: make(map[string]types.KubeServer),
|
|
dbServers: make(map[string]types.DatabaseServer),
|
|
appServers: make(map[string]types.AppServer),
|
|
desktops: make(map[string]types.WindowsDesktop),
|
|
clusterName: clusterName,
|
|
}
|
|
|
|
// set up test roles
|
|
roleDesc := map[string]types.RoleSpecV6{
|
|
"response-team": {
|
|
// By default has access to nothing, but can request many types of
|
|
// resources.
|
|
Allow: types.RoleConditions{
|
|
Request: &types.AccessRequestConditions{
|
|
SearchAsRoles: []string{
|
|
"node-admins",
|
|
"node-access",
|
|
"node-team",
|
|
"kube-admins",
|
|
"db-admins",
|
|
"app-admins",
|
|
"windows-admins",
|
|
"empty",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
"node-access": {
|
|
// Grants access with user's own login
|
|
Allow: types.RoleConditions{
|
|
NodeLabels: types.Labels{
|
|
"*": {"*"},
|
|
},
|
|
Logins: []string{"{{internal.logins}}"},
|
|
},
|
|
},
|
|
"node-admins": {
|
|
// Grants root access to specific nodes.
|
|
Allow: types.RoleConditions{
|
|
NodeLabels: types.Labels{
|
|
"owner": {"node-admins"},
|
|
},
|
|
Logins: []string{"{{internal.logins}}", "root"},
|
|
},
|
|
},
|
|
"node-team": {
|
|
// Grants root access to nodes owned by user's team via label
|
|
// expression.
|
|
Allow: types.RoleConditions{
|
|
NodeLabelsExpression: `contains(user.spec.traits["team"], labels["owner"])`,
|
|
Logins: []string{"{{internal.logins}}", "root"},
|
|
},
|
|
},
|
|
"kube-admins": {
|
|
Allow: types.RoleConditions{
|
|
KubernetesLabels: types.Labels{
|
|
"*": {"*"},
|
|
},
|
|
},
|
|
},
|
|
"db-admins": {
|
|
Allow: types.RoleConditions{
|
|
DatabaseLabels: types.Labels{
|
|
"*": {"*"},
|
|
},
|
|
},
|
|
},
|
|
"app-admins": {
|
|
Allow: types.RoleConditions{
|
|
AppLabels: types.Labels{
|
|
"*": {"*"},
|
|
},
|
|
},
|
|
},
|
|
"windows-admins": {
|
|
Allow: types.RoleConditions{
|
|
WindowsDesktopLabels: types.Labels{
|
|
"*": {"*"},
|
|
},
|
|
},
|
|
},
|
|
"empty": {
|
|
// Grants access to nothing, should never be requested.
|
|
},
|
|
}
|
|
for name, spec := range roleDesc {
|
|
role, err := types.NewRole(name, spec)
|
|
require.NoError(t, err)
|
|
g.roles[name] = role
|
|
}
|
|
|
|
user := g.user(t, "response-team")
|
|
g.userStates[user].Spec.Traits = map[string][]string{
|
|
"logins": {"responder"},
|
|
"team": {"response-team"},
|
|
}
|
|
|
|
nodeDesc := []struct {
|
|
name string
|
|
labels map[string]string
|
|
}{
|
|
{
|
|
name: "admins-node",
|
|
labels: map[string]string{
|
|
"owner": "node-admins",
|
|
},
|
|
},
|
|
{
|
|
name: "admins-node-2",
|
|
labels: map[string]string{
|
|
"owner": "node-admins",
|
|
},
|
|
},
|
|
{
|
|
name: "responders-node",
|
|
labels: map[string]string{
|
|
"owner": "response-team",
|
|
},
|
|
},
|
|
{
|
|
name: "denied-node",
|
|
},
|
|
}
|
|
for _, desc := range nodeDesc {
|
|
node, err := types.NewServerWithLabels(desc.name, types.KindNode, types.ServerSpecV2{}, desc.labels)
|
|
require.NoError(t, err)
|
|
g.nodes[desc.name] = node
|
|
}
|
|
|
|
kube, err := types.NewKubernetesClusterV3(types.Metadata{
|
|
Name: "kube",
|
|
},
|
|
types.KubernetesClusterSpecV3{},
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
kubeServer, err := types.NewKubernetesServerV3FromCluster(kube, "_", "_")
|
|
require.NoError(t, err)
|
|
g.kubeServers[kube.GetName()] = kubeServer
|
|
|
|
db, err := types.NewDatabaseV3(types.Metadata{
|
|
Name: "db",
|
|
}, types.DatabaseSpecV3{
|
|
Protocol: "postgres",
|
|
URI: "example.com:3000",
|
|
})
|
|
require.NoError(t, err)
|
|
dbServer, err := types.NewDatabaseServerV3(types.Metadata{
|
|
Name: db.GetName(),
|
|
}, types.DatabaseServerSpecV3{
|
|
HostID: "db-server",
|
|
Hostname: "db-server",
|
|
Database: db,
|
|
})
|
|
require.NoError(t, err)
|
|
g.dbServers[dbServer.GetName()] = dbServer
|
|
|
|
app, err := types.NewAppV3(types.Metadata{
|
|
Name: "app",
|
|
}, types.AppSpecV3{
|
|
URI: "example.com:3000",
|
|
})
|
|
require.NoError(t, err)
|
|
appServer, err := types.NewAppServerV3FromApp(app, "app-server", "app-server")
|
|
require.NoError(t, err)
|
|
g.appServers[app.GetName()] = appServer
|
|
|
|
desktop, err := types.NewWindowsDesktopV3("windows", nil, types.WindowsDesktopSpecV3{
|
|
Addr: "example.com:3001",
|
|
})
|
|
require.NoError(t, err)
|
|
g.desktops[desktop.GetName()] = desktop
|
|
|
|
testCases := []struct {
|
|
desc string
|
|
requestResourceIDs []types.ResourceID
|
|
loginHint string
|
|
expectRoles []string
|
|
expectError bool
|
|
}{
|
|
{
|
|
desc: "without login hint",
|
|
requestResourceIDs: []types.ResourceID{
|
|
{
|
|
ClusterName: clusterName,
|
|
Kind: types.KindNode,
|
|
Name: "admins-node",
|
|
},
|
|
},
|
|
// Without the login hint, all roles granting access will be
|
|
// requested.
|
|
expectRoles: []string{"node-admins", "node-access"},
|
|
},
|
|
{
|
|
desc: "label expression role",
|
|
requestResourceIDs: []types.ResourceID{
|
|
{
|
|
ClusterName: clusterName,
|
|
Kind: types.KindNode,
|
|
Name: "responders-node",
|
|
},
|
|
},
|
|
expectRoles: []string{"node-team", "node-access"},
|
|
},
|
|
{
|
|
desc: "user login hint",
|
|
requestResourceIDs: []types.ResourceID{
|
|
{
|
|
ClusterName: clusterName,
|
|
Kind: types.KindNode,
|
|
Name: "admins-node",
|
|
},
|
|
},
|
|
loginHint: "responder",
|
|
// With "responder" login hint, only request node-access.
|
|
expectRoles: []string{"node-access"},
|
|
},
|
|
{
|
|
desc: "multiple nodes",
|
|
requestResourceIDs: []types.ResourceID{
|
|
{
|
|
ClusterName: clusterName,
|
|
Kind: types.KindNode,
|
|
Name: "admins-node",
|
|
},
|
|
{
|
|
ClusterName: clusterName,
|
|
Kind: types.KindNode,
|
|
Name: "admins-node-2",
|
|
},
|
|
},
|
|
loginHint: "responder",
|
|
// With "responder" login hint, only request node-access.
|
|
expectRoles: []string{"node-access"},
|
|
},
|
|
{
|
|
desc: "root login hint",
|
|
requestResourceIDs: []types.ResourceID{
|
|
{
|
|
ClusterName: clusterName,
|
|
Kind: types.KindNode,
|
|
Name: "admins-node",
|
|
},
|
|
},
|
|
loginHint: "root",
|
|
// With "root" login hint, request node-admins.
|
|
expectRoles: []string{"node-admins"},
|
|
},
|
|
{
|
|
desc: "root login unavailable",
|
|
requestResourceIDs: []types.ResourceID{
|
|
{
|
|
ClusterName: clusterName,
|
|
Kind: types.KindNode,
|
|
Name: "denied-node",
|
|
},
|
|
},
|
|
loginHint: "root",
|
|
// No roles grant access with the desired login, return an error.
|
|
expectError: true,
|
|
},
|
|
{
|
|
desc: "kube request",
|
|
requestResourceIDs: []types.ResourceID{
|
|
{
|
|
ClusterName: clusterName,
|
|
Kind: types.KindKubernetesCluster,
|
|
Name: "kube",
|
|
},
|
|
},
|
|
// Request for kube cluster should only request kube-admins
|
|
expectRoles: []string{"kube-admins"},
|
|
},
|
|
{
|
|
desc: "db request",
|
|
requestResourceIDs: []types.ResourceID{
|
|
{
|
|
ClusterName: clusterName,
|
|
Kind: types.KindDatabase,
|
|
Name: "db",
|
|
},
|
|
},
|
|
// Request for db should only request db-admins
|
|
expectRoles: []string{"db-admins"},
|
|
},
|
|
{
|
|
desc: "app request",
|
|
requestResourceIDs: []types.ResourceID{
|
|
{
|
|
ClusterName: clusterName,
|
|
Kind: types.KindApp,
|
|
Name: "app",
|
|
},
|
|
},
|
|
// Request for app should only request app-admins
|
|
expectRoles: []string{"app-admins"},
|
|
},
|
|
{
|
|
desc: "windows request",
|
|
requestResourceIDs: []types.ResourceID{
|
|
{
|
|
ClusterName: clusterName,
|
|
Kind: types.KindWindowsDesktop,
|
|
Name: "windows",
|
|
},
|
|
},
|
|
// Request for windows should only request windows-admins
|
|
expectRoles: []string{"windows-admins"},
|
|
},
|
|
{
|
|
desc: "mixed request",
|
|
requestResourceIDs: []types.ResourceID{
|
|
{
|
|
ClusterName: clusterName,
|
|
Kind: types.KindNode,
|
|
Name: "admins-node",
|
|
},
|
|
{
|
|
ClusterName: clusterName,
|
|
Kind: types.KindKubernetesCluster,
|
|
Name: "kube",
|
|
},
|
|
{
|
|
ClusterName: clusterName,
|
|
Kind: types.KindDatabase,
|
|
Name: "db",
|
|
},
|
|
{
|
|
ClusterName: clusterName,
|
|
Kind: types.KindApp,
|
|
Name: "app",
|
|
},
|
|
{
|
|
ClusterName: clusterName,
|
|
Kind: types.KindWindowsDesktop,
|
|
Name: "windows",
|
|
},
|
|
},
|
|
// Request for different kinds should request all necessary roles
|
|
expectRoles: []string{"node-access", "node-admins", "kube-admins", "db-admins", "app-admins", "windows-admins"},
|
|
},
|
|
{
|
|
desc: "foreign resource",
|
|
requestResourceIDs: []types.ResourceID{
|
|
{
|
|
ClusterName: "leaf",
|
|
Kind: types.KindNode,
|
|
Name: "admins-node",
|
|
},
|
|
},
|
|
// Request for foreign resource should request all available roles,
|
|
// we don't know which one is necessary
|
|
expectRoles: []string{"node-access", "node-admins", "node-team", "kube-admins", "db-admins", "app-admins", "windows-admins", "empty"},
|
|
},
|
|
}
|
|
for _, tc := range testCases {
|
|
t.Run(tc.desc, func(t *testing.T) {
|
|
req, err := NewAccessRequestWithResources(user, nil, tc.requestResourceIDs)
|
|
require.NoError(t, err)
|
|
|
|
req.SetLoginHint(tc.loginHint)
|
|
|
|
clock := clockwork.NewFakeClock()
|
|
identity := tlsca.Identity{
|
|
Expires: clock.Now().UTC().Add(8 * time.Hour),
|
|
}
|
|
|
|
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))
|
|
if tc.expectError {
|
|
require.Error(t, err)
|
|
return
|
|
}
|
|
require.NoError(t, err)
|
|
|
|
if tc.loginHint == "" {
|
|
require.ElementsMatch(t, tc.expectRoles, accessCaps.ApplicableRolesForResources)
|
|
}
|
|
|
|
require.ElementsMatch(t, tc.expectRoles, req.GetRoles(),
|
|
"Pruned roles %v don't match expected roles %v", req.GetRoles(), tc.expectRoles)
|
|
require.Len(t, req.GetRoleThresholdMapping(), len(req.GetRoles()),
|
|
"Length of rtm does not match number of roles. rtm: %v roles %v",
|
|
req.GetRoleThresholdMapping(), req.GetRoles())
|
|
})
|
|
}
|
|
}
|
|
|
|
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)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestCalculatePendingRequestTTL 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 TestCalculatePendingRequestTTL(t *testing.T) {
|
|
clock := clockwork.NewFakeClock()
|
|
now := clock.Now().UTC()
|
|
|
|
tests := []struct {
|
|
desc string
|
|
// accessExpiryTTL == max access duration.
|
|
accessExpiryTTL time.Duration
|
|
// when the access request expires in the PENDING state.
|
|
requestPendingExpiryTTL time.Time
|
|
assertion require.ErrorAssertionFunc
|
|
expectedDuration time.Duration
|
|
}{
|
|
{
|
|
desc: "valid: requested ttl < access expiry",
|
|
accessExpiryTTL: requestTTL - (3 * day),
|
|
requestPendingExpiryTTL: now.Add(requestTTL - (4 * day)),
|
|
expectedDuration: requestTTL - (4 * day),
|
|
assertion: require.NoError,
|
|
},
|
|
{
|
|
desc: "valid: requested ttl == access expiry",
|
|
accessExpiryTTL: requestTTL - (3 * day),
|
|
requestPendingExpiryTTL: now.Add(requestTTL - (3 * day)),
|
|
expectedDuration: requestTTL - (3 * day),
|
|
assertion: require.NoError,
|
|
},
|
|
{
|
|
desc: "valid: requested ttl == default request ttl",
|
|
accessExpiryTTL: requestTTL,
|
|
requestPendingExpiryTTL: now.Add(requestTTL),
|
|
expectedDuration: requestTTL,
|
|
assertion: require.NoError,
|
|
},
|
|
{
|
|
desc: "valid: no TTL request defaults to the const requestTTL if access expiry is larger",
|
|
accessExpiryTTL: requestTTL + (3 * day),
|
|
expectedDuration: requestTTL,
|
|
assertion: require.NoError,
|
|
},
|
|
{
|
|
desc: "valid: no TTL request defaults to accessExpiry if const requestTTL is larger",
|
|
accessExpiryTTL: requestTTL - (3 * day),
|
|
expectedDuration: requestTTL - (3 * day),
|
|
assertion: require.NoError,
|
|
},
|
|
{
|
|
desc: "invalid: requested ttl > access expiry",
|
|
accessExpiryTTL: requestTTL - (3 * day),
|
|
requestPendingExpiryTTL: now.Add(requestTTL - (2 * day)),
|
|
assertion: require.Error,
|
|
},
|
|
{
|
|
desc: "invalid: requested ttl > default request TTL",
|
|
accessExpiryTTL: requestTTL + (1 * day),
|
|
requestPendingExpiryTTL: now.Add(requestTTL + (1 * day)),
|
|
assertion: require.Error,
|
|
},
|
|
{
|
|
desc: "invalid: requested ttl < now",
|
|
accessExpiryTTL: requestTTL - (3 * day),
|
|
requestPendingExpiryTTL: now.Add(-(3 * day)),
|
|
assertion: require.Error,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.desc, func(t *testing.T) {
|
|
// Setup test user "foo" and "bar" and the mock auth server that
|
|
// will return users and roles.
|
|
uls, err := userloginstate.New(header.Metadata{
|
|
Name: "foo",
|
|
}, userloginstate.Spec{
|
|
Roles: []string{"bar"},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
role, err := types.NewRole("bar", types.RoleSpecV6{})
|
|
require.NoError(t, err)
|
|
|
|
getter := &mockGetter{
|
|
userStates: map[string]*userloginstate.UserLoginState{"foo": uls},
|
|
roles: map[string]types.Role{"bar": role},
|
|
}
|
|
|
|
validator, err := NewRequestValidator(context.Background(), clock, getter, "foo", ExpandVars(true))
|
|
require.NoError(t, err)
|
|
|
|
request, err := types.NewAccessRequest("some-id", "foo", "bar")
|
|
require.NoError(t, err)
|
|
request.SetExpiry(tt.requestPendingExpiryTTL)
|
|
request.SetAccessExpiry(now.Add(tt.accessExpiryTTL))
|
|
|
|
ttl, err := validator.calculatePendingRequestTTL(request, now)
|
|
tt.assertion(t, err)
|
|
if err == nil {
|
|
require.Equal(t, tt.expectedDuration, ttl)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestSessionTTL verifies that the TTL for elevated access gets reduced by
|
|
// requested access time, lifetime of certificate, and strictest session TTL on
|
|
// any role.
|
|
func TestSessionTTL(t *testing.T) {
|
|
clock := clockwork.NewFakeClock()
|
|
now := clock.Now().UTC()
|
|
|
|
tests := []struct {
|
|
desc string
|
|
accessExpiry time.Time
|
|
identity tlsca.Identity
|
|
maxSessionTTL time.Duration
|
|
expectedTTL time.Duration
|
|
assertion require.ErrorAssertionFunc
|
|
}{
|
|
{
|
|
desc: "less than identity expiration and role session ttl allowed",
|
|
accessExpiry: now.Add(13 * time.Minute),
|
|
identity: tlsca.Identity{Expires: now.Add(defaults.MaxAccessDuration)},
|
|
maxSessionTTL: defaults.MaxAccessDuration,
|
|
expectedTTL: 13 * time.Minute,
|
|
assertion: require.NoError,
|
|
},
|
|
{
|
|
desc: "greater than identity expiration and role session ttl not allowed",
|
|
accessExpiry: now.Add(14 * time.Minute),
|
|
identity: tlsca.Identity{Expires: now.Add(13 * time.Minute)},
|
|
maxSessionTTL: 13 * time.Minute,
|
|
assertion: require.Error,
|
|
},
|
|
{
|
|
desc: "greater than certificate duration not allowed",
|
|
accessExpiry: now.Add(defaults.MaxAccessDuration).Add(1 * time.Minute),
|
|
identity: tlsca.Identity{Expires: now.Add(defaults.MaxAccessDuration)},
|
|
maxSessionTTL: defaults.MaxAccessDuration,
|
|
assertion: require.Error,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.desc, func(t *testing.T) {
|
|
// Setup test user "foo" and "bar" and the mock auth server that
|
|
// will return users and roles.
|
|
user, err := types.NewUser("foo")
|
|
require.NoError(t, err)
|
|
|
|
role, err := types.NewRole("bar", types.RoleSpecV6{
|
|
Options: types.RoleOptions{
|
|
MaxSessionTTL: types.NewDuration(tt.maxSessionTTL),
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
getter := &mockGetter{
|
|
users: map[string]types.User{"foo": user},
|
|
roles: map[string]types.Role{"bar": role},
|
|
}
|
|
|
|
validator, err := NewRequestValidator(context.Background(), clock, getter, "foo", ExpandVars(true))
|
|
require.NoError(t, err)
|
|
|
|
request, err := types.NewAccessRequest("some-id", "foo", "bar")
|
|
request.SetAccessExpiry(tt.accessExpiry)
|
|
require.NoError(t, err)
|
|
|
|
ttl, err := validator.sessionTTL(context.Background(), tt.identity, request, now)
|
|
tt.assertion(t, err)
|
|
if err == nil {
|
|
require.Equal(t, tt.expectedTTL, ttl)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAutoRequest(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
clock := clockwork.NewFakeClock()
|
|
|
|
empty, err := types.NewRole("empty", types.RoleSpecV6{})
|
|
require.NoError(t, err)
|
|
|
|
promptRole, err := types.NewRole("prompt", types.RoleSpecV6{
|
|
Options: types.RoleOptions{
|
|
RequestPrompt: "test prompt",
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
optionalRole, err := types.NewRole("optional", types.RoleSpecV6{
|
|
Options: types.RoleOptions{
|
|
RequestAccess: types.RequestStrategyOptional,
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
reasonRole, err := types.NewRole("reason", types.RoleSpecV6{
|
|
Options: types.RoleOptions{
|
|
RequestAccess: types.RequestStrategyReason,
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
alwaysRole, err := types.NewRole("always", types.RoleSpecV6{
|
|
Options: types.RoleOptions{
|
|
RequestAccess: types.RequestStrategyAlways,
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
cases := []struct {
|
|
name string
|
|
roles []types.Role
|
|
assertion func(t *testing.T, validator *RequestValidator, accessCaps *types.AccessCapabilities)
|
|
}{
|
|
{
|
|
name: "no roles",
|
|
assertion: func(t *testing.T, validator *RequestValidator, accessCaps *types.AccessCapabilities) {
|
|
require.False(t, validator.requireReasonForAllRoles)
|
|
require.False(t, validator.autoRequest)
|
|
require.Empty(t, validator.prompt)
|
|
|
|
require.False(t, accessCaps.RequireReason)
|
|
require.False(t, accessCaps.AutoRequest)
|
|
require.Empty(t, accessCaps.RequestPrompt)
|
|
},
|
|
},
|
|
{
|
|
name: "with prompt",
|
|
roles: []types.Role{empty, optionalRole, promptRole},
|
|
assertion: func(t *testing.T, validator *RequestValidator, accessCaps *types.AccessCapabilities) {
|
|
require.False(t, validator.requireReasonForAllRoles)
|
|
require.False(t, validator.autoRequest)
|
|
require.Equal(t, "test prompt", validator.prompt)
|
|
|
|
require.False(t, accessCaps.RequireReason)
|
|
require.False(t, accessCaps.AutoRequest)
|
|
require.Equal(t, "test prompt", accessCaps.RequestPrompt)
|
|
},
|
|
},
|
|
{
|
|
name: "with auto request",
|
|
roles: []types.Role{alwaysRole},
|
|
assertion: func(t *testing.T, validator *RequestValidator, accessCaps *types.AccessCapabilities) {
|
|
require.False(t, validator.requireReasonForAllRoles)
|
|
require.True(t, validator.autoRequest)
|
|
require.Empty(t, validator.prompt)
|
|
|
|
require.False(t, accessCaps.RequireReason)
|
|
require.True(t, accessCaps.AutoRequest)
|
|
require.Empty(t, accessCaps.RequestPrompt)
|
|
},
|
|
},
|
|
{
|
|
name: "with prompt and auto request",
|
|
roles: []types.Role{promptRole, alwaysRole},
|
|
assertion: func(t *testing.T, validator *RequestValidator, accessCaps *types.AccessCapabilities) {
|
|
require.False(t, validator.requireReasonForAllRoles)
|
|
require.True(t, validator.autoRequest)
|
|
require.Equal(t, "test prompt", validator.prompt)
|
|
|
|
require.False(t, accessCaps.RequireReason)
|
|
require.True(t, accessCaps.AutoRequest)
|
|
require.Equal(t, "test prompt", accessCaps.RequestPrompt)
|
|
},
|
|
},
|
|
{
|
|
name: "with reason and auto prompt",
|
|
roles: []types.Role{reasonRole},
|
|
assertion: func(t *testing.T, validator *RequestValidator, accessCaps *types.AccessCapabilities) {
|
|
require.True(t, validator.requireReasonForAllRoles)
|
|
require.True(t, validator.autoRequest)
|
|
require.Empty(t, validator.prompt)
|
|
|
|
require.True(t, accessCaps.RequireReason)
|
|
require.True(t, accessCaps.AutoRequest)
|
|
require.Empty(t, accessCaps.RequestPrompt)
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, test := range cases {
|
|
ctx := context.Background()
|
|
|
|
uls, err := userloginstate.New(header.Metadata{
|
|
Name: "foo",
|
|
}, userloginstate.Spec{})
|
|
require.NoError(t, err)
|
|
|
|
getter := &mockGetter{
|
|
userStates: make(map[string]*userloginstate.UserLoginState),
|
|
roles: make(map[string]types.Role),
|
|
clusterName: "test-cluster",
|
|
}
|
|
|
|
for _, r := range test.roles {
|
|
getter.roles[r.GetName()] = r
|
|
uls.Spec.Roles = append(uls.Spec.Roles, r.GetName())
|
|
}
|
|
|
|
getter.userStates[uls.GetName()] = uls
|
|
|
|
validator, err := NewRequestValidator(ctx, clock, getter, uls.GetName(), ExpandVars(true))
|
|
require.NoError(t, err)
|
|
|
|
accessCapabilities, err := CalculateAccessCapabilities(ctx, clock, getter, tlsca.Identity{}, types.AccessCapabilitiesRequest{
|
|
User: uls.GetName(),
|
|
RequestableRoles: true,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
test.assertion(t, &validator, accessCapabilities)
|
|
}
|
|
|
|
}
|
|
|
|
func TestReasonRequired(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := context.Background()
|
|
|
|
clusterName := "my-test-cluster"
|
|
|
|
g := &mockGetter{
|
|
roles: make(map[string]types.Role),
|
|
userStates: make(map[string]*userloginstate.UserLoginState),
|
|
users: make(map[string]types.User),
|
|
nodes: make(map[string]types.Server),
|
|
kubeServers: make(map[string]types.KubeServer),
|
|
dbServers: make(map[string]types.DatabaseServer),
|
|
appServers: make(map[string]types.AppServer),
|
|
desktops: make(map[string]types.WindowsDesktop),
|
|
clusterName: clusterName,
|
|
}
|
|
|
|
nodeDesc := []struct {
|
|
name string
|
|
labels map[string]string
|
|
}{
|
|
{
|
|
name: "fork-node",
|
|
labels: map[string]string{
|
|
"cutlery": "fork",
|
|
},
|
|
},
|
|
{
|
|
name: "spoon-node",
|
|
labels: map[string]string{
|
|
"cutlery": "spoon",
|
|
},
|
|
},
|
|
}
|
|
for _, desc := range nodeDesc {
|
|
node, err := types.NewServerWithLabels(desc.name, types.KindNode, types.ServerSpecV2{}, desc.labels)
|
|
require.NoError(t, err)
|
|
g.nodes[desc.name] = node
|
|
}
|
|
|
|
roleDesc := map[string]types.RoleSpecV6{
|
|
"cutlery-access": {
|
|
Allow: types.RoleConditions{
|
|
NodeLabels: types.Labels{
|
|
"cutlery": []string{types.Wildcard},
|
|
},
|
|
},
|
|
},
|
|
"fork-access": {
|
|
Allow: types.RoleConditions{
|
|
NodeLabels: types.Labels{
|
|
"cutlery": []string{"fork"},
|
|
},
|
|
},
|
|
},
|
|
|
|
"cutlery-access-requester": {
|
|
Allow: types.RoleConditions{
|
|
Request: &types.AccessRequestConditions{
|
|
Roles: []string{"cutlery-access"},
|
|
// "optional" is the default
|
|
// Reason: &types.AccessRequestConditionsReason{
|
|
// Mode: "optional",
|
|
// },
|
|
},
|
|
},
|
|
},
|
|
"cutlery-node-requester": {
|
|
Allow: types.RoleConditions{
|
|
Request: &types.AccessRequestConditions{
|
|
SearchAsRoles: []string{"cutlery-access"},
|
|
// set "optional" explicitly
|
|
Reason: &types.AccessRequestConditionsReason{
|
|
Mode: "optional",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
|
|
"fork-node-requester": {
|
|
Allow: types.RoleConditions{
|
|
Request: &types.AccessRequestConditions{
|
|
SearchAsRoles: []string{"fork-access"},
|
|
// everything not-"required" is basically "optional"
|
|
Reason: &types.AccessRequestConditionsReason{
|
|
Mode: "not-recognized-is-optional",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
"fork-node-requester-with-reason": {
|
|
Allow: types.RoleConditions{
|
|
Request: &types.AccessRequestConditions{
|
|
SearchAsRoles: []string{"fork-access"},
|
|
Reason: &types.AccessRequestConditionsReason{
|
|
Mode: "required",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
"fork-access-requester": {
|
|
Allow: types.RoleConditions{
|
|
Request: &types.AccessRequestConditions{
|
|
Roles: []string{"fork-access"},
|
|
},
|
|
},
|
|
},
|
|
"fork-access-requester-with-reason": {
|
|
Allow: types.RoleConditions{
|
|
Request: &types.AccessRequestConditions{
|
|
Roles: []string{"fork-access"},
|
|
Reason: &types.AccessRequestConditionsReason{
|
|
Mode: "required",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
for name, spec := range roleDesc {
|
|
role, err := types.NewRole(name, spec)
|
|
require.NoError(t, err)
|
|
g.roles[name] = role
|
|
}
|
|
|
|
testCases := []struct {
|
|
name string
|
|
currentRoles []string
|
|
requestRoles []string
|
|
requestResourceIDs []types.ResourceID
|
|
expectError error
|
|
}{
|
|
{
|
|
name: "role request: require reason when role has reason.required",
|
|
currentRoles: []string{"fork-access-requester-with-reason"},
|
|
requestRoles: []string{"fork-access"},
|
|
expectError: trace.BadParameter(`request reason must be specified (required for role "fork-access")`),
|
|
},
|
|
{
|
|
name: "resource request: require reason when role has reason.required",
|
|
currentRoles: []string{"fork-node-requester-with-reason"},
|
|
requestResourceIDs: []types.ResourceID{
|
|
{ClusterName: clusterName, Kind: types.KindNode, Name: "fork-node"},
|
|
},
|
|
expectError: trace.BadParameter(`request reason must be specified (required for role "fork-access")`),
|
|
},
|
|
{
|
|
name: "role request: do not require reason when role does not have reason.required",
|
|
currentRoles: []string{"fork-access-requester"},
|
|
requestRoles: []string{"fork-access"},
|
|
expectError: nil,
|
|
},
|
|
{
|
|
name: "resource request: do not require reason when role does not have reason.required",
|
|
currentRoles: []string{"fork-node-requester"},
|
|
requestResourceIDs: []types.ResourceID{
|
|
{ClusterName: clusterName, Kind: types.KindNode, Name: "fork-node"},
|
|
},
|
|
expectError: nil,
|
|
},
|
|
{
|
|
name: "resource request: but require reason when another role allowing _role_ access requires reason for the role",
|
|
currentRoles: []string{"fork-node-requester", "fork-access-requester-with-reason"},
|
|
requestResourceIDs: []types.ResourceID{
|
|
{ClusterName: clusterName, Kind: types.KindNode, Name: "fork-node"},
|
|
},
|
|
expectError: trace.BadParameter(`request reason must be specified (required for role "fork-access")`),
|
|
},
|
|
{
|
|
name: "role request: require reason when _any_ role has reason.required",
|
|
currentRoles: []string{"fork-access-requester", "fork-access-requester-with-reason", "cutlery-access-requester"},
|
|
requestRoles: []string{"fork-access"},
|
|
expectError: trace.BadParameter(`request reason must be specified (required for role "fork-access")`),
|
|
},
|
|
{
|
|
name: "resource request: require reason when _any_ role has reason.required",
|
|
currentRoles: []string{"fork-node-requester", "fork-node-requester-with-reason", "cutlery-node-requester"},
|
|
requestResourceIDs: []types.ResourceID{
|
|
{ClusterName: clusterName, Kind: types.KindNode, Name: "fork-node"},
|
|
},
|
|
expectError: trace.BadParameter(`request reason must be specified (required for role "fork-access")`),
|
|
},
|
|
{
|
|
name: "role request: do not require reason when all roles don't have reason.required",
|
|
currentRoles: []string{"fork-access-requester", "cutlery-access-requester"},
|
|
requestRoles: []string{"fork-access"},
|
|
expectError: nil,
|
|
},
|
|
{
|
|
name: "resource request: do not require reason when all roles don't have reason.required",
|
|
currentRoles: []string{"fork-node-requester", "cutlery-node-requester"},
|
|
requestResourceIDs: []types.ResourceID{
|
|
{ClusterName: clusterName, Kind: types.KindNode, Name: "fork-node"},
|
|
},
|
|
expectError: nil,
|
|
},
|
|
{
|
|
name: "role request: require reason when _any_ role with reason.required matches _any_ roles",
|
|
currentRoles: []string{"fork-access-requester-with-reason", "cutlery-access-requester"},
|
|
requestRoles: []string{"fork-access", "cutlery-access"},
|
|
expectError: trace.BadParameter(`request reason must be specified (required for role "fork-access")`),
|
|
},
|
|
{
|
|
name: "resource request: require reason when _any_ role with reason.required matches _any_ resource",
|
|
currentRoles: []string{"fork-node-requester-with-reason", "cutlery-node-requester"},
|
|
requestResourceIDs: []types.ResourceID{
|
|
{ClusterName: clusterName, Kind: types.KindNode, Name: "fork-node"},
|
|
{ClusterName: clusterName, Kind: types.KindNode, Name: "spoon-node"},
|
|
},
|
|
expectError: trace.BadParameter(`request reason must be specified (required for role "fork-access")`),
|
|
},
|
|
{
|
|
name: "role request: do not require reason when _all_ roles do not require reason for _all_ roles",
|
|
currentRoles: []string{"fork-access-requester", "cutlery-access-requester"},
|
|
requestRoles: []string{"fork-access", "cutlery-access"},
|
|
expectError: nil,
|
|
},
|
|
{
|
|
name: "role request: handle wildcard",
|
|
currentRoles: []string{"fork-access-requester-with-reason"},
|
|
requestRoles: []string{"*"},
|
|
expectError: trace.BadParameter(`request reason must be specified (required for role "fork-access")`),
|
|
},
|
|
{
|
|
name: "resource request: do not require reason when _all_ roles do not require reason for _all_ resources",
|
|
currentRoles: []string{"fork-node-requester", "cutlery-node-requester"},
|
|
requestResourceIDs: []types.ResourceID{
|
|
{ClusterName: clusterName, Kind: types.KindNode, Name: "fork-node"},
|
|
{ClusterName: clusterName, Kind: types.KindNode, Name: "spoon-node"},
|
|
},
|
|
expectError: nil,
|
|
},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
uls, err := userloginstate.New(header.Metadata{
|
|
Name: "test-user",
|
|
}, userloginstate.Spec{
|
|
Roles: tc.currentRoles,
|
|
Traits: trait.Traits{
|
|
"logins": []string{"abcd"},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
g.userStates[uls.GetName()] = uls
|
|
|
|
clock := clockwork.NewFakeClock()
|
|
identity := tlsca.Identity{
|
|
Expires: clock.Now().UTC().Add(8 * time.Hour),
|
|
}
|
|
|
|
// test RequestValidator.Validate
|
|
{
|
|
validator, err := NewRequestValidator(ctx, clock, g, uls.GetName(), ExpandVars(true))
|
|
require.NoError(t, err)
|
|
|
|
req, err := types.NewAccessRequestWithResources(
|
|
"some-id", uls.GetName(), tc.requestRoles, tc.requestResourceIDs)
|
|
require.NoError(t, err)
|
|
|
|
// No reason in the request.
|
|
err = validator.Validate(ctx, req.Copy(), identity)
|
|
require.ErrorIs(t, err, tc.expectError)
|
|
|
|
// White-space reason should be treated as no reason.
|
|
req.SetRequestReason(" \t \n ")
|
|
err = validator.Validate(ctx, req.Copy(), identity)
|
|
require.ErrorIs(t, err, tc.expectError)
|
|
|
|
// When non-empty reason is provided then validation should pass.
|
|
req.SetRequestReason("good reason")
|
|
err = validator.Validate(ctx, req.Copy(), identity)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
// test CalculateAccessCapabilities
|
|
{
|
|
req := types.AccessCapabilitiesRequest{
|
|
User: uls.GetName(),
|
|
ResourceIDs: tc.requestResourceIDs,
|
|
RequestableRoles: len(tc.requestResourceIDs) == 0,
|
|
}
|
|
|
|
res, err := CalculateAccessCapabilities(ctx, clock, g, identity, req)
|
|
require.NoError(t, err)
|
|
if tc.expectError != nil {
|
|
require.True(t, res.RequireReason)
|
|
} else {
|
|
require.False(t, res.RequireReason)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
type mockClusterGetter struct {
|
|
localCluster types.ClusterName
|
|
remoteClusters map[string]types.RemoteCluster
|
|
}
|
|
|
|
func (mcg mockClusterGetter) GetClusterName(_ context.Context) (types.ClusterName, error) {
|
|
return mcg.localCluster, nil
|
|
}
|
|
|
|
func (mcg mockClusterGetter) GetRemoteCluster(ctx context.Context, clusterName string) (types.RemoteCluster, error) {
|
|
if cluster, ok := mcg.remoteClusters[clusterName]; ok {
|
|
return cluster, nil
|
|
}
|
|
return nil, trace.NotFound("remote cluster %q was not found", clusterName)
|
|
}
|
|
|
|
func TestValidateResourceRequestSizeLimits(t *testing.T) {
|
|
g := &mockGetter{
|
|
roles: make(map[string]types.Role),
|
|
userStates: make(map[string]*userloginstate.UserLoginState),
|
|
users: make(map[string]types.User),
|
|
nodes: make(map[string]types.Server),
|
|
kubeServers: make(map[string]types.KubeServer),
|
|
dbServers: make(map[string]types.DatabaseServer),
|
|
appServers: make(map[string]types.AppServer),
|
|
desktops: make(map[string]types.WindowsDesktop),
|
|
clusterName: "someCluster",
|
|
}
|
|
|
|
for i := 1; i < 3; i++ {
|
|
node, err := types.NewServerWithLabels(
|
|
fmt.Sprintf("resource%d", i),
|
|
types.KindNode,
|
|
types.ServerSpecV2{},
|
|
map[string]string{"foo": "bar"},
|
|
)
|
|
require.NoError(t, err)
|
|
g.nodes[node.GetName()] = node
|
|
}
|
|
|
|
searchAsRole, err := types.NewRole("searchAs", types.RoleSpecV6{
|
|
Allow: types.RoleConditions{
|
|
Logins: []string{"root"},
|
|
NodeLabels: types.Labels{"*": []string{"*"}},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
g.roles[searchAsRole.GetName()] = searchAsRole
|
|
|
|
testRole, err := types.NewRole("testRole", types.RoleSpecV6{
|
|
Allow: types.RoleConditions{
|
|
Request: &types.AccessRequestConditions{
|
|
SearchAsRoles: []string{searchAsRole.GetName()},
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
g.roles[testRole.GetName()] = testRole
|
|
|
|
user := g.user(t, testRole.GetName())
|
|
|
|
clock := clockwork.NewFakeClock()
|
|
identity := tlsca.Identity{
|
|
Expires: clock.Now().UTC().Add(8 * time.Hour),
|
|
}
|
|
|
|
req, err := types.NewAccessRequestWithResources("name", user, nil, /* roles */
|
|
[]types.ResourceID{
|
|
{ClusterName: "someCluster", Kind: "node", Name: "resource1"},
|
|
{ClusterName: "someCluster", Kind: "node", Name: "resource1"}, // a duplicate
|
|
{ClusterName: "someCluster", Kind: "node", Name: "resource2"}, // not a duplicate
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
require.NoError(t, ValidateAccessRequestForUser(context.Background(), clock, g, req, identity, ExpandVars(true)))
|
|
require.Len(t, req.GetRequestedResourceIDs(), 2)
|
|
require.Equal(t, "/someCluster/node/resource1", types.ResourceIDToString(req.GetRequestedResourceIDs()[0]))
|
|
require.Equal(t, "/someCluster/node/resource2", types.ResourceIDToString(req.GetRequestedResourceIDs()[1]))
|
|
|
|
var requestedResourceIDs []types.ResourceID
|
|
for i := 0; i < 200; i++ {
|
|
requestedResourceIDs = append(requestedResourceIDs, types.ResourceID{
|
|
ClusterName: "someCluster",
|
|
Kind: "node",
|
|
Name: "resource" + strconv.Itoa(i),
|
|
})
|
|
}
|
|
req.SetRequestedResourceIDs(requestedResourceIDs)
|
|
require.ErrorContains(t, ValidateAccessRequestForUser(context.Background(), clock, g, req, identity, ExpandVars(true)), "access request exceeds maximum length")
|
|
}
|
|
|
|
func TestValidateAccessRequestClusterNames(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
localClusterName string
|
|
remoteClusterNames []string
|
|
expectedInErr string
|
|
}{
|
|
{
|
|
name: "local cluster is requested",
|
|
localClusterName: "someCluster",
|
|
remoteClusterNames: []string{},
|
|
expectedInErr: "",
|
|
}, {
|
|
name: "remote cluster is requested",
|
|
localClusterName: "notTheCorrectName",
|
|
remoteClusterNames: []string{"someCluster"},
|
|
expectedInErr: "",
|
|
}, {
|
|
name: "unknown cluster requested",
|
|
localClusterName: "notTheCorrectName",
|
|
remoteClusterNames: []string{"notTheCorrectClusterEither"},
|
|
expectedInErr: "invalid or unknown cluster names",
|
|
},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
localCluster, err := types.NewClusterName(types.ClusterNameSpecV2{
|
|
ClusterName: tc.localClusterName,
|
|
ClusterID: "someClusterID",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
remoteClusters := map[string]types.RemoteCluster{}
|
|
for _, remoteCluster := range tc.remoteClusterNames {
|
|
remoteClusters[remoteCluster], err = types.NewRemoteCluster(remoteCluster)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
mcg := mockClusterGetter{
|
|
localCluster: localCluster,
|
|
remoteClusters: remoteClusters,
|
|
}
|
|
req, err := types.NewAccessRequestWithResources("name", "user", []string{}, []types.ResourceID{
|
|
{ClusterName: "someCluster"},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
err = ValidateAccessRequestClusterNames(mcg, req)
|
|
if tc.expectedInErr != "" {
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), tc.expectedInErr)
|
|
return
|
|
}
|
|
require.NoError(t, err)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestValidate_RequestedMaxDuration tests requested max duration
|
|
// and the default values for session and pending TTL as a result
|
|
// of requested max duration.
|
|
func TestValidate_RequestedMaxDuration(t *testing.T) {
|
|
// describes a collection of roles and their conditions
|
|
roleDesc := roleTestSet{
|
|
"requestedRole": {
|
|
// ...
|
|
},
|
|
"requestedRole2": {
|
|
// ...
|
|
},
|
|
"setMaxTTLRole": {
|
|
options: types.RoleOptions{
|
|
MaxSessionTTL: types.Duration(6 * time.Hour),
|
|
},
|
|
},
|
|
"defaultRole": {
|
|
condition: types.RoleConditions{
|
|
Request: &types.AccessRequestConditions{
|
|
Roles: []string{"requestedRole", "setMaxTTLRole"},
|
|
},
|
|
},
|
|
},
|
|
"defaultShortRole": {
|
|
condition: types.RoleConditions{
|
|
Request: &types.AccessRequestConditions{
|
|
Roles: []string{"requestedRole", "setMaxTTLRole"},
|
|
},
|
|
},
|
|
},
|
|
"maxDurationReqRole": {
|
|
condition: types.RoleConditions{
|
|
Request: &types.AccessRequestConditions{
|
|
Roles: []string{"requestedRole", "setMaxTTLRole"},
|
|
MaxDuration: types.Duration(7 * day),
|
|
},
|
|
},
|
|
},
|
|
"shortMaxDurationReqRole": {
|
|
condition: types.RoleConditions{
|
|
Request: &types.AccessRequestConditions{
|
|
Roles: []string{"requestedRole"},
|
|
MaxDuration: types.Duration(3 * day),
|
|
},
|
|
},
|
|
},
|
|
"shortMaxDurationReqRole2": {
|
|
condition: types.RoleConditions{
|
|
Request: &types.AccessRequestConditions{
|
|
Roles: []string{"requestedRole2"},
|
|
MaxDuration: types.Duration(day),
|
|
},
|
|
},
|
|
},
|
|
"shortMaxDurationReqRole3": {
|
|
condition: types.RoleConditions{
|
|
Request: &types.AccessRequestConditions{
|
|
Roles: []string{"requestedRole"},
|
|
MaxDuration: types.Duration(day),
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
// describes a collection of users with various roles
|
|
userDesc := map[string][]string{
|
|
"alice": {"shortMaxDurationReqRole"},
|
|
"bob": {"defaultRole"},
|
|
"carol": {"shortMaxDurationReqRole", "shortMaxDurationReqRole2"},
|
|
"david": {"maxDurationReqRole"},
|
|
"erin": {"shortMaxDurationReqRole", "shortMaxDurationReqRole3", "maxDurationReqRole"},
|
|
}
|
|
|
|
defaultSessionTTL := 8 * time.Hour
|
|
|
|
g := getMockGetter(t, roleDesc, userDesc)
|
|
|
|
tts := []struct {
|
|
// desc is a short description of the test scenario (should be unique)
|
|
desc string
|
|
// requestor is the name of the requesting user
|
|
requestor string
|
|
// the roles to be requested (defaults to "dictator")
|
|
roles []string
|
|
// requestedMaxDuration is the requested requestedMaxDuration duration
|
|
requestedMaxDuration time.Duration
|
|
// expectedAccessDuration is the expected access duration
|
|
expectedAccessDuration time.Duration
|
|
// expectedSessionTTL is the expected session TTL
|
|
expectedSessionTTL time.Duration
|
|
// expectedPendingTTL is the time when request expires in PENDING state
|
|
expectedPendingTTL time.Duration
|
|
// DryRun is true if the request is a dry run
|
|
dryRun bool
|
|
}{
|
|
{
|
|
desc: "role max_duration is respected and sessionTTL does not exceed the calculated max duration",
|
|
requestor: "alice",
|
|
roles: []string{"requestedRole"}, // role max_duration capped to 3 days
|
|
requestedMaxDuration: 7 * day, // ignored b/c it's > role max_duration
|
|
expectedAccessDuration: 3 * day,
|
|
expectedSessionTTL: 8 * time.Hour, // caps to defaultSessionTTL b/c it's < than the expectedAccessDuration
|
|
expectedPendingTTL: 3 * day, // caps to expectedAccessDuration b/c it's < than the const default TTL
|
|
},
|
|
{
|
|
desc: "role max_duration is still respected even with dry run (which requests for longer maxDuration)",
|
|
requestor: "alice",
|
|
roles: []string{"requestedRole"}, // role max_duration capped to 3 days
|
|
requestedMaxDuration: 10 * day, // ignored b/c it's > role max_duration
|
|
expectedAccessDuration: 3 * day,
|
|
expectedPendingTTL: 3 * day,
|
|
expectedSessionTTL: 8 * time.Hour,
|
|
dryRun: true,
|
|
},
|
|
{
|
|
desc: "when role max_duration is not set: default to defaultSessionTTL when requestedMaxDuration is not set",
|
|
requestor: "bob",
|
|
roles: []string{"requestedRole"}, // role max_duration is not set (0)
|
|
expectedAccessDuration: 8 * time.Hour, // caps to defaultSessionTTL since requestedMaxDuration was not set
|
|
expectedPendingTTL: 8 * time.Hour,
|
|
expectedSessionTTL: 8 * time.Hour,
|
|
},
|
|
{
|
|
desc: "when role max_duration is not set: requestedMaxDuration is respected when < defaultSessionTTL",
|
|
requestor: "bob",
|
|
roles: []string{"requestedRole"}, // role max_duration is not set (0)
|
|
requestedMaxDuration: 5 * time.Hour,
|
|
expectedAccessDuration: 5 * time.Hour,
|
|
expectedPendingTTL: 5 * time.Hour,
|
|
expectedSessionTTL: 5 * time.Hour, // capped to expectedAccessDuration because it's < defaultSessionTTL (8h)
|
|
},
|
|
{
|
|
desc: "when role max_duration is not set: requestedMaxDuration is ignored if > defaultSessionTTL",
|
|
requestor: "bob",
|
|
roles: []string{"requestedRole"}, // role max_duration is not set (0)
|
|
requestedMaxDuration: 10 * time.Hour,
|
|
expectedAccessDuration: 8 * time.Hour, // caps to defaultSessionTTL (8h) which is < requestedMaxDuration
|
|
expectedPendingTTL: 8 * time.Hour,
|
|
expectedSessionTTL: 8 * time.Hour,
|
|
},
|
|
{
|
|
desc: "when role max_duration is not set: requestedMaxDuration is ignored if > role defined sesssionTTL (6h)",
|
|
requestor: "bob",
|
|
roles: []string{"setMaxTTLRole"}, // role max_duration is not set (0), caps sessionTTL to 6 hours
|
|
requestedMaxDuration: day,
|
|
expectedAccessDuration: 6 * time.Hour, // capped to the lowest sessionTTL found in role (6h) which is < requestedMaxDuration
|
|
expectedPendingTTL: 6 * time.Hour,
|
|
expectedSessionTTL: 6 * time.Hour,
|
|
},
|
|
{
|
|
desc: "when role max_duration is not set: requestedMaxDuration is respected when < role defined sessionTTL (6h)",
|
|
requestor: "bob",
|
|
roles: []string{"setMaxTTLRole"}, // role max_duration is not set (0), caps sessionTTL to 6 hours
|
|
requestedMaxDuration: 5 * time.Hour,
|
|
expectedAccessDuration: 5 * time.Hour, // caps to requestedMaxDuration which is < role defined sessionTTL (6h)
|
|
expectedPendingTTL: 5 * time.Hour,
|
|
expectedSessionTTL: 5 * time.Hour,
|
|
},
|
|
{
|
|
desc: "requestedMaxDuration is respected if it's < the max_duration set in role",
|
|
requestor: "david",
|
|
roles: []string{"setMaxTTLRole"}, // role max_duration capped to default MaxAccessDuration, caps sessionTTL to 6 hours
|
|
requestedMaxDuration: day, // respected because it's < default const MaxAccessDuration
|
|
expectedAccessDuration: day,
|
|
expectedPendingTTL: day,
|
|
expectedSessionTTL: 6 * time.Hour, // capped to the lowest sessionTTL found in role which is < requestedMaxDuration
|
|
},
|
|
{
|
|
desc: "expectedSessionTTL does not exceed requestedMaxDuration",
|
|
requestor: "david",
|
|
roles: []string{"setMaxTTLRole"}, // caps max_duration to default MaxAccessDuration, caps sessionTTL to 6 hours
|
|
requestedMaxDuration: 2 * time.Hour, // respected because it's < default const MaxAccessDuration
|
|
expectedAccessDuration: 2 * time.Hour,
|
|
expectedPendingTTL: 2 * time.Hour,
|
|
expectedSessionTTL: 2 * time.Hour, // capped to requestedMaxDuration because it's < role defined sessionTTL (6h)
|
|
},
|
|
{
|
|
desc: "only the assigned role that allows the requested roles are considered for maxDuration",
|
|
requestor: "carol", // has multiple roles assigned
|
|
roles: []string{"requestedRole"}, // caps max_duration to 3 days
|
|
requestedMaxDuration: 5 * day,
|
|
expectedAccessDuration: 3 * day,
|
|
expectedPendingTTL: 3 * day,
|
|
expectedSessionTTL: 8 * time.Hour,
|
|
},
|
|
{
|
|
desc: "only the assigned role that allows the requested roles are considered for maxDuration #2",
|
|
requestor: "carol", // has multiple roles assigned
|
|
roles: []string{"requestedRole2"}, // caps max_duration to 1 day
|
|
requestedMaxDuration: 6 * day,
|
|
expectedAccessDuration: day,
|
|
expectedPendingTTL: day,
|
|
expectedSessionTTL: 8 * time.Hour,
|
|
},
|
|
{
|
|
desc: "role max_duration is respected when requestedMaxDuration is not set",
|
|
requestor: "alice", // has shortMaxDurationReqRole role assigned
|
|
roles: []string{"requestedRole"}, // caps max_duration to 3 days
|
|
expectedAccessDuration: 3 * day,
|
|
expectedPendingTTL: 3 * day,
|
|
expectedSessionTTL: 8 * time.Hour,
|
|
},
|
|
{
|
|
desc: "multiple roles with different max durations assigned",
|
|
// erin has the maxDurationReqRole (7 days) role assigned
|
|
// along with 2 other roles with shorter durations
|
|
// shortMaxDurationReqRole (3 days) and shortMaxDurationReqRole3 (1 day)
|
|
requestor: "erin",
|
|
roles: []string{"requestedRole"}, // caps max_duration to 7 days
|
|
expectedAccessDuration: 7 * day,
|
|
expectedPendingTTL: 7 * day,
|
|
expectedSessionTTL: 8 * time.Hour,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tts {
|
|
t.Run(tt.desc, func(t *testing.T) {
|
|
require.NotEmpty(t, tt.roles, "at least one role must be specified")
|
|
|
|
// create a request for the specified author
|
|
req, err := types.NewAccessRequest("some-id", tt.requestor, tt.roles...)
|
|
require.NoError(t, err)
|
|
|
|
clock := clockwork.NewFakeClock()
|
|
now := clock.Now().UTC()
|
|
identity := tlsca.Identity{
|
|
Expires: now.Add(defaultSessionTTL),
|
|
}
|
|
|
|
validator, err := NewRequestValidator(context.Background(), clock, g, tt.requestor, ExpandVars(true))
|
|
require.NoError(t, err)
|
|
|
|
req.SetCreationTime(now)
|
|
if tt.requestedMaxDuration != 0 {
|
|
req.SetMaxDuration(now.Add(tt.requestedMaxDuration))
|
|
}
|
|
req.SetDryRun(tt.dryRun)
|
|
|
|
require.NoError(t, validator.Validate(context.Background(), req, identity))
|
|
require.Equal(t, now.Add(tt.expectedAccessDuration), req.GetAccessExpiry())
|
|
require.Equal(t, now.Add(tt.expectedAccessDuration), req.GetMaxDuration())
|
|
require.Equal(t, now.Add(tt.expectedSessionTTL), req.GetSessionTLL())
|
|
require.Equal(t, now.Add(tt.expectedPendingTTL), req.Expiry())
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestValidate_RequestedPendingTTLAndMaxDuration tests that both requested
|
|
// max duration and pending TTL is respected (given within limits).
|
|
func TestValidate_RequestedPendingTTLAndMaxDuration(t *testing.T) {
|
|
// describes a collection of roles and their conditions
|
|
roleDesc := roleTestSet{
|
|
"requestRole": {
|
|
condition: types.RoleConditions{
|
|
Request: &types.AccessRequestConditions{
|
|
Roles: []string{"requestRole"},
|
|
MaxDuration: types.Duration(5 * day),
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
// describes a collection of users with various roles
|
|
userDesc := map[string][]string{
|
|
"alice": {"requestRole"},
|
|
}
|
|
|
|
g := getMockGetter(t, roleDesc, userDesc)
|
|
req, err := types.NewAccessRequest("some-id", "alice", []string{"requestRole"}...)
|
|
require.NoError(t, err)
|
|
|
|
clock := clockwork.NewFakeClock()
|
|
now := clock.Now().UTC()
|
|
defaultSessionTTL := 8 * time.Hour
|
|
identity := tlsca.Identity{
|
|
Expires: now.Add(defaultSessionTTL),
|
|
}
|
|
|
|
validator, err := NewRequestValidator(context.Background(), clock, g, "alice", ExpandVars(true))
|
|
require.NoError(t, err)
|
|
|
|
requestedMaxDuration := 4 * day
|
|
requestedPendingTTL := 2 * day
|
|
|
|
req.SetCreationTime(now)
|
|
req.SetMaxDuration(now.Add(requestedMaxDuration))
|
|
req.SetExpiry(now.Add(requestedPendingTTL))
|
|
|
|
require.NoError(t, validator.Validate(context.Background(), req, identity))
|
|
require.Equal(t, now.Add(requestedMaxDuration), req.GetAccessExpiry())
|
|
require.Equal(t, now.Add(requestedMaxDuration), req.GetMaxDuration())
|
|
require.Equal(t, now.Add(defaultSessionTTL), req.GetSessionTLL())
|
|
require.Equal(t, now.Add(requestedPendingTTL), req.Expiry())
|
|
}
|
|
|
|
// TestValidate_WithAllowRequestKubernetesResources tests that requests containing
|
|
// kubernetes resources, the kinds are enforced defined by users static role
|
|
// field `request.kubernetes_resources`
|
|
func TestValidate_WithAllowRequestKubernetesResources(t *testing.T) {
|
|
myClusterName := "teleport-cluster"
|
|
|
|
// set up test roles
|
|
roleDesc := map[string]types.RoleSpecV6{
|
|
"kube-access-wildcard": {
|
|
Allow: types.RoleConditions{
|
|
KubernetesLabels: types.Labels{
|
|
"*": {"*"},
|
|
},
|
|
KubernetesResources: []types.KubernetesResource{
|
|
{Kind: "*", Namespace: "*", Name: "*", Verbs: []string{"*"}},
|
|
},
|
|
},
|
|
},
|
|
"kube-no-access": {
|
|
Allow: types.RoleConditions{},
|
|
},
|
|
"kube-access-namespace": {
|
|
Allow: types.RoleConditions{
|
|
KubernetesLabels: types.Labels{
|
|
"*": {"*"},
|
|
},
|
|
KubernetesResources: []types.KubernetesResource{
|
|
{Kind: types.KindNamespace, Namespace: "*", Name: "*", Verbs: []string{"*"}},
|
|
},
|
|
},
|
|
},
|
|
"kube-access-pod": {
|
|
Allow: types.RoleConditions{
|
|
KubernetesLabels: types.Labels{
|
|
"*": {"*"},
|
|
},
|
|
KubernetesResources: []types.KubernetesResource{
|
|
{Kind: types.KindKubePod, Namespace: "*", Name: "*", Verbs: []string{"*"}},
|
|
},
|
|
},
|
|
},
|
|
"kube-access-deployment": {
|
|
Allow: types.RoleConditions{
|
|
KubernetesLabels: types.Labels{
|
|
"*": {"*"},
|
|
},
|
|
KubernetesResources: []types.KubernetesResource{
|
|
{Kind: types.KindKubeDeployment, Namespace: "*", Name: "*", Verbs: []string{"*"}},
|
|
},
|
|
},
|
|
},
|
|
"db-access-wildcard": {
|
|
Allow: types.RoleConditions{
|
|
DatabaseLabels: types.Labels{
|
|
"*": {"*"},
|
|
},
|
|
},
|
|
},
|
|
|
|
"request-undefined_search-wildcard": {
|
|
Allow: types.RoleConditions{
|
|
Request: &types.AccessRequestConditions{
|
|
SearchAsRoles: []string{"kube-access-wildcard", "db-access-wildcard"},
|
|
},
|
|
},
|
|
},
|
|
"request-pod_search-as-roles-undefined": {
|
|
Allow: types.RoleConditions{
|
|
Request: &types.AccessRequestConditions{
|
|
KubernetesResources: []types.RequestKubernetesResource{
|
|
{Kind: types.KindKubePod},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
"request-namespace_search-namespace": {
|
|
Allow: types.RoleConditions{
|
|
Request: &types.AccessRequestConditions{
|
|
SearchAsRoles: []string{"kube-access-namespace", "db-access-wildcard"},
|
|
KubernetesResources: []types.RequestKubernetesResource{
|
|
{Kind: types.KindKubeNamespace},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
// Allows requesting for any subresources, but NOT kube_cluster
|
|
"request-wildcard_search-wildcard": {
|
|
Allow: types.RoleConditions{
|
|
Request: &types.AccessRequestConditions{
|
|
SearchAsRoles: []string{"kube-access-wildcard", "db-access-wildcard"},
|
|
KubernetesResources: []types.RequestKubernetesResource{
|
|
{Kind: types.Wildcard},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
// Allows wildcard search, but should only accept kube secret
|
|
"request-secret_search-wildcard": {
|
|
Allow: types.RoleConditions{
|
|
Request: &types.AccessRequestConditions{
|
|
SearchAsRoles: []string{"kube-access-wildcard"},
|
|
KubernetesResources: []types.RequestKubernetesResource{
|
|
{Kind: types.KindKubeSecret},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
"request-pod_search-pods": {
|
|
Allow: types.RoleConditions{
|
|
Request: &types.AccessRequestConditions{
|
|
SearchAsRoles: []string{"kube-access-pod"},
|
|
KubernetesResources: []types.RequestKubernetesResource{
|
|
{Kind: types.KindKubePod},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
"request-deployment_search-deployment": {
|
|
Allow: types.RoleConditions{
|
|
Request: &types.AccessRequestConditions{
|
|
SearchAsRoles: []string{"kube-access-deployment"},
|
|
KubernetesResources: []types.RequestKubernetesResource{
|
|
{Kind: types.KindKubeDeployment},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
"request-deployment-pod_search-deployment-pod": {
|
|
Allow: types.RoleConditions{
|
|
Request: &types.AccessRequestConditions{
|
|
SearchAsRoles: []string{"kube-access-deployment", "kube-access-pod"},
|
|
KubernetesResources: []types.RequestKubernetesResource{
|
|
{Kind: types.KindKubeDeployment},
|
|
{Kind: types.KindKubePod},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
"request-namespace-but-no-access": {
|
|
Allow: types.RoleConditions{
|
|
Request: &types.AccessRequestConditions{
|
|
SearchAsRoles: []string{"db-access-wildcard", "kube-no-access"},
|
|
KubernetesResources: []types.RequestKubernetesResource{
|
|
{Kind: types.KindNamespace},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
"request-namespace_search-namespace_deny-secret": {
|
|
Allow: types.RoleConditions{
|
|
Request: &types.AccessRequestConditions{
|
|
SearchAsRoles: []string{"kube-access-namespace"},
|
|
KubernetesResources: []types.RequestKubernetesResource{
|
|
{Kind: types.KindNamespace},
|
|
},
|
|
},
|
|
},
|
|
Deny: types.RoleConditions{
|
|
Request: &types.AccessRequestConditions{
|
|
KubernetesResources: []types.RequestKubernetesResource{
|
|
{Kind: types.KindKubeSecret},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
"request-undefined_search-wildcard_deny-deployment-pod": {
|
|
Allow: types.RoleConditions{
|
|
Request: &types.AccessRequestConditions{
|
|
SearchAsRoles: []string{"kube-access-wildcard"},
|
|
},
|
|
},
|
|
Deny: types.RoleConditions{
|
|
Request: &types.AccessRequestConditions{
|
|
KubernetesResources: []types.RequestKubernetesResource{
|
|
{Kind: types.KindKubeDeployment},
|
|
{Kind: types.KindKubePod},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
"request-wildcard-cancels-deny-wildcard": {
|
|
Allow: types.RoleConditions{
|
|
Request: &types.AccessRequestConditions{
|
|
SearchAsRoles: []string{"kube-access-namespace"},
|
|
KubernetesResources: []types.RequestKubernetesResource{
|
|
{Kind: types.Wildcard},
|
|
},
|
|
},
|
|
},
|
|
Deny: types.RoleConditions{
|
|
Request: &types.AccessRequestConditions{
|
|
KubernetesResources: []types.RequestKubernetesResource{
|
|
{Kind: types.Wildcard},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
roles := make(map[string]types.Role)
|
|
for name, spec := range roleDesc {
|
|
role, err := types.NewRole(name, spec)
|
|
require.NoError(t, err)
|
|
roles[name] = role
|
|
}
|
|
|
|
// Define a kube server
|
|
kube, err := types.NewKubernetesClusterV3(types.Metadata{
|
|
Name: "kube",
|
|
},
|
|
types.KubernetesClusterSpecV3{},
|
|
)
|
|
require.NoError(t, err)
|
|
kubeServer, err := types.NewKubernetesServerV3FromCluster(kube, "_", "_")
|
|
require.NoError(t, err)
|
|
|
|
// Define a db server
|
|
db, err := types.NewDatabaseV3(types.Metadata{
|
|
Name: "db",
|
|
}, types.DatabaseSpecV3{
|
|
Protocol: "postgres",
|
|
URI: "example.com:3000",
|
|
})
|
|
require.NoError(t, err)
|
|
dbServer, err := types.NewDatabaseServerV3(types.Metadata{
|
|
Name: db.GetName(),
|
|
}, types.DatabaseServerSpecV3{
|
|
HostID: "db-server",
|
|
Hostname: "db-server",
|
|
Database: db,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// start test
|
|
testCases := []struct {
|
|
desc string
|
|
userStaticRoles []string
|
|
requestResourceIDs []types.ResourceID
|
|
requestRoles []string
|
|
wantInvalidRequestKindErr bool
|
|
wantNoRolesConfiguredErr bool
|
|
expectedRequestRoles []string
|
|
}{
|
|
{
|
|
desc: "request.kubernetes_resources undefined allows anything (kube_cluster and its subresources)",
|
|
userStaticRoles: []string{"request-undefined_search-wildcard"},
|
|
expectedRequestRoles: []string{"kube-access-wildcard", "db-access-wildcard"},
|
|
requestResourceIDs: []types.ResourceID{
|
|
{Kind: types.KindKubernetesCluster, ClusterName: myClusterName, Name: "kube"},
|
|
{Kind: types.KindDatabase, ClusterName: myClusterName, Name: "db"},
|
|
{Kind: types.KindKubeNamespace, ClusterName: myClusterName, Name: "kube", SubResourceName: "some-namespace"},
|
|
},
|
|
},
|
|
{
|
|
desc: "request.kubernetes_resources undefined takes precedence over configured allow field (allows anything)",
|
|
userStaticRoles: []string{"request-undefined_search-wildcard", "request-secret_search-wildcard"},
|
|
expectedRequestRoles: []string{"kube-access-wildcard", "db-access-wildcard"},
|
|
requestResourceIDs: []types.ResourceID{
|
|
{Kind: types.KindKubernetesCluster, ClusterName: myClusterName, Name: "kube"},
|
|
{Kind: types.KindDatabase, ClusterName: myClusterName, Name: "db"},
|
|
{Kind: types.KindKubeNamespace, ClusterName: myClusterName, Name: "kube", SubResourceName: "some-namespace"},
|
|
},
|
|
},
|
|
{
|
|
desc: "configured deny request.kubernetes_resources takes precedence over undefined deny field",
|
|
userStaticRoles: []string{"request-wildcard-cancels-deny-wildcard", "request-namespace_search-namespace"},
|
|
requestResourceIDs: []types.ResourceID{
|
|
{Kind: types.KindKubeNamespace, ClusterName: myClusterName, Name: "kube", SubResourceName: "namespace"},
|
|
{Kind: types.KindDatabase, ClusterName: myClusterName, Name: "db"},
|
|
},
|
|
wantInvalidRequestKindErr: true,
|
|
},
|
|
{
|
|
desc: "request.kubernetes_resources does not get applied with a role without search_as_roles defined",
|
|
userStaticRoles: []string{"request-namespace_search-namespace", "request-pod_search-as-roles-undefined"},
|
|
expectedRequestRoles: []string{"kube-access-namespace", "db-access-wildcard"},
|
|
requestResourceIDs: []types.ResourceID{
|
|
{Kind: types.KindDatabase, ClusterName: myClusterName, Name: "db"},
|
|
{Kind: types.KindKubeNamespace, ClusterName: myClusterName, Name: "kube", SubResourceName: "some-namespace"},
|
|
},
|
|
},
|
|
{
|
|
desc: "wildcard allows any kube subresources",
|
|
userStaticRoles: []string{"request-wildcard_search-wildcard"},
|
|
expectedRequestRoles: []string{"kube-access-wildcard", "db-access-wildcard"},
|
|
requestResourceIDs: []types.ResourceID{
|
|
{Kind: types.KindKubeNamespace, ClusterName: myClusterName, Name: "kube", SubResourceName: "some-namespace"},
|
|
{Kind: types.KindDatabase, ClusterName: myClusterName, Name: "db"},
|
|
{Kind: types.KindKubeSecret, ClusterName: myClusterName, Name: "kube", SubResourceName: "namespace/secret-name"},
|
|
},
|
|
},
|
|
{
|
|
desc: "wildcard rejects kube_cluster kind among other valid requests",
|
|
userStaticRoles: []string{"request-wildcard_search-wildcard"},
|
|
requestResourceIDs: []types.ResourceID{
|
|
{Kind: types.KindKubeNamespace, ClusterName: myClusterName, Name: "kube", SubResourceName: "some-namespace"},
|
|
{Kind: types.KindDatabase, ClusterName: myClusterName, Name: "db"},
|
|
{Kind: types.KindKubernetesCluster, ClusterName: myClusterName, Name: "kube"},
|
|
},
|
|
wantInvalidRequestKindErr: true,
|
|
},
|
|
{
|
|
desc: "wildcard rejects single kube_cluster request",
|
|
userStaticRoles: []string{"request-wildcard_search-wildcard"},
|
|
requestResourceIDs: []types.ResourceID{
|
|
{Kind: types.KindKubernetesCluster, ClusterName: myClusterName, Name: "kube"},
|
|
},
|
|
wantInvalidRequestKindErr: true,
|
|
},
|
|
{
|
|
desc: "error with `no roles configured` if search_as_roles does not grant kube resource access",
|
|
userStaticRoles: []string{"request-namespace-but-no-access"},
|
|
requestResourceIDs: []types.ResourceID{
|
|
{Kind: types.KindDatabase, ClusterName: myClusterName, Name: "db"},
|
|
{Kind: types.KindKubeNamespace, ClusterName: myClusterName, Name: "kube", SubResourceName: "namespace"},
|
|
},
|
|
wantNoRolesConfiguredErr: true,
|
|
},
|
|
{
|
|
desc: "prune search_as_roles that does not meet request.kubernetes_resources (unconfigured field)",
|
|
// search as role "kube-access-namespace" got pruned b/c the request included "kube_cluster" which wasn't allowed.
|
|
userStaticRoles: []string{"request-undefined_search-wildcard", "request-namespace_search-namespace"},
|
|
expectedRequestRoles: []string{"kube-access-wildcard", "db-access-wildcard"},
|
|
requestResourceIDs: []types.ResourceID{
|
|
{Kind: types.KindKubernetesCluster, ClusterName: myClusterName, Name: "kube"},
|
|
{Kind: types.KindDatabase, ClusterName: myClusterName, Name: "db"},
|
|
{Kind: types.KindKubeNamespace, ClusterName: myClusterName, Name: "kube", SubResourceName: "some-namespace"},
|
|
},
|
|
},
|
|
{
|
|
desc: "prune search_as_roles that does not match request.kubernetes_resources field",
|
|
userStaticRoles: []string{
|
|
"request-pod_search-pods",
|
|
"request-namespace_search-namespace",
|
|
"request-deployment_search-deployment",
|
|
},
|
|
expectedRequestRoles: []string{"kube-access-namespace", "db-access-wildcard"},
|
|
requestResourceIDs: []types.ResourceID{
|
|
{Kind: types.KindDatabase, ClusterName: myClusterName, Name: "db"},
|
|
{Kind: types.KindKubeNamespace, ClusterName: myClusterName, Name: "kube", SubResourceName: "some-namespace"},
|
|
},
|
|
},
|
|
{
|
|
desc: "reject when kinds don't match (root only)",
|
|
userStaticRoles: []string{
|
|
"request-pod_search-pods",
|
|
"request-namespace_search-namespace",
|
|
"request-deployment_search-deployment",
|
|
},
|
|
expectedRequestRoles: []string{"kube-access-pod", "kube-access-namespace", "db-access-wildcard"},
|
|
requestResourceIDs: []types.ResourceID{
|
|
{Kind: types.KindDatabase, ClusterName: myClusterName, Name: "db"},
|
|
{Kind: types.KindKubeNamespace, ClusterName: myClusterName, Name: "kube", SubResourceName: "namespace"},
|
|
{Kind: types.KindKubePod, ClusterName: myClusterName, Name: "kube", SubResourceName: "namespace/pod-name"},
|
|
},
|
|
wantInvalidRequestKindErr: true,
|
|
},
|
|
{
|
|
desc: "reject when kinds don't match (leaf only)",
|
|
userStaticRoles: []string{
|
|
"request-pod_search-pods",
|
|
"request-namespace_search-namespace",
|
|
"request-deployment_search-deployment",
|
|
},
|
|
requestResourceIDs: []types.ResourceID{
|
|
{Kind: types.KindDatabase, ClusterName: "some-leaf", Name: "db2"},
|
|
{Kind: types.KindKubeSecret, ClusterName: "some-leaf", Name: "leaf-kube", SubResourceName: "namespace/secret-name"},
|
|
},
|
|
wantInvalidRequestKindErr: true,
|
|
},
|
|
{
|
|
desc: "reject when kinds don't match (mix of leaf and root cluster)",
|
|
userStaticRoles: []string{
|
|
"request-pod_search-pods",
|
|
"request-namespace_search-namespace",
|
|
"request-deployment_search-deployment",
|
|
},
|
|
requestResourceIDs: []types.ResourceID{
|
|
{Kind: types.KindKubeDeployment, ClusterName: "some-leaf", Name: "kube", SubResourceName: "namespace/secret-name"},
|
|
{Kind: types.KindNamespace, ClusterName: "some-leaf", Name: "kube-leaf", SubResourceName: "namespace"},
|
|
},
|
|
wantInvalidRequestKindErr: true,
|
|
},
|
|
{
|
|
desc: "prune roles that does not give you access",
|
|
userStaticRoles: []string{
|
|
"request-namespace_search-namespace",
|
|
"request-namespace-but-no-access",
|
|
"request-deployment_search-deployment",
|
|
},
|
|
expectedRequestRoles: []string{"kube-access-namespace"},
|
|
requestResourceIDs: []types.ResourceID{
|
|
{Kind: types.KindKubeNamespace, ClusterName: myClusterName, Name: "kube", SubResourceName: "namespace"},
|
|
{Kind: types.KindKubeNamespace, ClusterName: myClusterName, Name: "kube", SubResourceName: "namespace2"},
|
|
},
|
|
},
|
|
{
|
|
desc: "don't further prune roles when leaf requests are present",
|
|
userStaticRoles: []string{
|
|
"request-namespace_search-namespace",
|
|
"request-namespace-but-no-access",
|
|
"request-deployment_search-deployment",
|
|
},
|
|
// db-access-wildcard and kube-no-access shouldn't be in the list, but a leaf is present
|
|
// which skips matcher tests.
|
|
expectedRequestRoles: []string{"kube-access-namespace", "db-access-wildcard", "kube-no-access"},
|
|
requestResourceIDs: []types.ResourceID{
|
|
{Kind: types.KindKubeNamespace, ClusterName: myClusterName, Name: "kube", SubResourceName: "namespace"},
|
|
{Kind: types.KindKubeNamespace, ClusterName: myClusterName, Name: "kube", SubResourceName: "namespace2"},
|
|
{Kind: types.KindNamespace, ClusterName: "some-leaf", Name: "kube-leaf", SubResourceName: "namespace"},
|
|
},
|
|
},
|
|
{
|
|
desc: "reject if kinds don't match even though search_as_roles allows wildcard access",
|
|
userStaticRoles: []string{"request-secret_search-wildcard"},
|
|
requestResourceIDs: []types.ResourceID{
|
|
{Kind: types.KindKubeNamespace, ClusterName: myClusterName, Name: "kube", SubResourceName: "some-namespace"},
|
|
},
|
|
wantInvalidRequestKindErr: true,
|
|
},
|
|
{
|
|
desc: "allow namespace request when deny is not matched",
|
|
userStaticRoles: []string{"request-namespace_search-namespace_deny-secret"},
|
|
expectedRequestRoles: []string{"kube-access-namespace"},
|
|
requestResourceIDs: []types.ResourceID{
|
|
{Kind: types.KindKubeNamespace, ClusterName: myClusterName, Name: "kube", SubResourceName: "namespace"},
|
|
{Kind: types.KindKubeNamespace, ClusterName: myClusterName, Name: "kube", SubResourceName: "namespace2"},
|
|
},
|
|
},
|
|
{
|
|
desc: "allow namespace request when deny is not matched with leaf clusters",
|
|
userStaticRoles: []string{"request-namespace_search-namespace_deny-secret"},
|
|
expectedRequestRoles: []string{"kube-access-namespace"},
|
|
requestResourceIDs: []types.ResourceID{
|
|
{Kind: types.KindKubeNamespace, ClusterName: "leaf-cluster", Name: "kube", SubResourceName: "namespace"},
|
|
{Kind: types.KindKubeNamespace, ClusterName: "leaf-cluster", Name: "kube", SubResourceName: "namespace2"},
|
|
},
|
|
},
|
|
{
|
|
desc: "allow a list of different request.kubernetes_resources from same role",
|
|
userStaticRoles: []string{"request-deployment-pod_search-deployment-pod", "request-namespace_search-namespace"},
|
|
expectedRequestRoles: []string{"kube-access-deployment", "kube-access-pod"},
|
|
requestResourceIDs: []types.ResourceID{
|
|
{Kind: types.KindKubeDeployment, ClusterName: myClusterName, Name: "kube", SubResourceName: "namespace/deployment"},
|
|
{Kind: types.KindKubePod, ClusterName: myClusterName, Name: "kube", SubResourceName: "namespace/pod"},
|
|
},
|
|
},
|
|
{
|
|
desc: "deny request when deny is defined from another role (denies are globally matched)",
|
|
userStaticRoles: []string{"request-namespace_search-namespace_deny-secret", "request-secret_search-wildcard"},
|
|
requestResourceIDs: []types.ResourceID{
|
|
{Kind: types.KindKubeSecret, ClusterName: myClusterName, Name: "kube", SubResourceName: "namespace/secret-name"},
|
|
},
|
|
wantInvalidRequestKindErr: true,
|
|
},
|
|
{
|
|
desc: "allow wildcard request when deny is not matched",
|
|
userStaticRoles: []string{"request-undefined_search-wildcard_deny-deployment-pod"},
|
|
expectedRequestRoles: []string{"kube-access-wildcard"},
|
|
requestResourceIDs: []types.ResourceID{
|
|
{Kind: types.KindKubeNamespace, ClusterName: myClusterName, Name: "kube", SubResourceName: "namespace"},
|
|
{Kind: types.KindKubernetesCluster, ClusterName: myClusterName, Name: "kube"},
|
|
},
|
|
},
|
|
{
|
|
desc: "deny wildcard request when deny is matched",
|
|
userStaticRoles: []string{"request-undefined_search-wildcard_deny-deployment-pod"},
|
|
requestResourceIDs: []types.ResourceID{
|
|
{Kind: types.KindKubeNamespace, ClusterName: myClusterName, Name: "kube", SubResourceName: "namespace"},
|
|
{Kind: types.KindKubernetesCluster, ClusterName: myClusterName, Name: "kube"},
|
|
{Kind: types.KindKubeDeployment, ClusterName: myClusterName, Name: "kube", SubResourceName: "namespace/deployment"},
|
|
},
|
|
wantInvalidRequestKindErr: true,
|
|
},
|
|
{
|
|
desc: "request.kubernetes_resources cancel each other (config error where no kube resources becomes requestable)",
|
|
userStaticRoles: []string{"request-wildcard-cancels-deny-wildcard"},
|
|
requestResourceIDs: []types.ResourceID{
|
|
{Kind: types.KindKubeNamespace, ClusterName: myClusterName, Name: "kube", SubResourceName: "namespace"},
|
|
{Kind: types.KindKubeDeployment, ClusterName: myClusterName, Name: "kube", SubResourceName: "namespace/deployment"},
|
|
},
|
|
wantInvalidRequestKindErr: true,
|
|
},
|
|
{
|
|
desc: "request.kubernetes_resources cancel each other also rejects kube_cluster kinds (config error)",
|
|
userStaticRoles: []string{"request-wildcard-cancels-deny-wildcard"},
|
|
expectedRequestRoles: []string{"kube-access-wildcard"},
|
|
requestResourceIDs: []types.ResourceID{
|
|
{Kind: types.KindKubernetesCluster, ClusterName: myClusterName, Name: "kube"},
|
|
},
|
|
wantInvalidRequestKindErr: true,
|
|
},
|
|
{
|
|
desc: "allow when requested role matches requested kinds",
|
|
userStaticRoles: []string{
|
|
"request-namespace_search-namespace",
|
|
"request-namespace-but-no-access",
|
|
"request-deployment_search-deployment",
|
|
},
|
|
requestRoles: []string{"kube-access-namespace"},
|
|
expectedRequestRoles: []string{"kube-access-namespace"},
|
|
requestResourceIDs: []types.ResourceID{
|
|
{Kind: types.KindKubeNamespace, ClusterName: myClusterName, Name: "kube", SubResourceName: "namespace"},
|
|
{Kind: types.KindKubeNamespace, ClusterName: myClusterName, Name: "kube", SubResourceName: "namespace2"},
|
|
},
|
|
},
|
|
{
|
|
desc: "reject when requested role does not match ALL requested kinds",
|
|
userStaticRoles: []string{
|
|
"request-namespace_search-namespace",
|
|
"request-namespace-but-no-access",
|
|
"request-deployment_search-deployment",
|
|
},
|
|
requestRoles: []string{"kube-access-namespace", "kube-access-deployment"},
|
|
requestResourceIDs: []types.ResourceID{
|
|
{Kind: types.KindKubeNamespace, ClusterName: myClusterName, Name: "kube", SubResourceName: "namespace"},
|
|
{Kind: types.KindKubeNamespace, ClusterName: myClusterName, Name: "kube", SubResourceName: "namespace2"},
|
|
},
|
|
wantInvalidRequestKindErr: true,
|
|
},
|
|
{
|
|
desc: "reject when requested role does not allow all requested kinds",
|
|
userStaticRoles: []string{
|
|
"request-namespace_search-namespace",
|
|
"request-namespace-but-no-access",
|
|
"request-deployment_search-deployment",
|
|
},
|
|
requestRoles: []string{"kube-access-namespace"},
|
|
requestResourceIDs: []types.ResourceID{
|
|
{Kind: types.KindKubeNamespace, ClusterName: myClusterName, Name: "kube", SubResourceName: "namespace"},
|
|
{Kind: types.KindKubeDeployment, ClusterName: myClusterName, Name: "kube", SubResourceName: "namespace/deployment"},
|
|
},
|
|
wantInvalidRequestKindErr: true,
|
|
},
|
|
}
|
|
for _, tc := range testCases {
|
|
t.Run(tc.desc, func(t *testing.T) {
|
|
uls, err := userloginstate.New(header.Metadata{
|
|
Name: "test-user",
|
|
}, userloginstate.Spec{
|
|
Roles: tc.userStaticRoles,
|
|
})
|
|
require.NoError(t, err)
|
|
userStates := map[string]*userloginstate.UserLoginState{
|
|
uls.GetName(): uls,
|
|
}
|
|
|
|
g := &mockGetter{
|
|
roles: roles,
|
|
userStates: userStates,
|
|
clusterName: myClusterName,
|
|
kubeServers: make(map[string]types.KubeServer),
|
|
dbServers: make(map[string]types.DatabaseServer),
|
|
}
|
|
g.kubeServers[kube.GetName()] = kubeServer
|
|
g.dbServers[dbServer.GetName()] = dbServer
|
|
|
|
req, err := types.NewAccessRequestWithResources(
|
|
"some-id", uls.GetName(), tc.requestRoles, tc.requestResourceIDs)
|
|
require.NoError(t, err)
|
|
|
|
clock := clockwork.NewFakeClock()
|
|
identity := tlsca.Identity{
|
|
Expires: clock.Now().UTC().Add(8 * time.Hour),
|
|
}
|
|
|
|
validator, err := NewRequestValidator(context.Background(), clock, g, uls.GetName(), ExpandVars(true))
|
|
require.NoError(t, err)
|
|
|
|
err = validator.Validate(context.Background(), req, identity)
|
|
if tc.wantInvalidRequestKindErr {
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), InvalidKubernetesKindAccessRequest)
|
|
} else if tc.wantNoRolesConfiguredErr {
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), `no roles configured in the "search_as_roles"`)
|
|
} else {
|
|
require.NoError(t, err)
|
|
require.ElementsMatch(t, tc.expectedRequestRoles, req.GetRoles())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
type roleTestSet map[string]struct {
|
|
condition types.RoleConditions
|
|
options types.RoleOptions
|
|
}
|
|
|
|
func getMockGetter(t *testing.T, roleDesc roleTestSet, userDesc map[string][]string) *mockGetter {
|
|
t.Helper()
|
|
|
|
roles := make(map[string]types.Role)
|
|
|
|
for name, desc := range roleDesc {
|
|
role, err := types.NewRole(name, types.RoleSpecV6{
|
|
Allow: desc.condition,
|
|
Options: desc.options,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
roles[name] = role
|
|
}
|
|
|
|
userStates := make(map[string]*userloginstate.UserLoginState)
|
|
|
|
for name, roles := range userDesc {
|
|
uls, err := userloginstate.New(header.Metadata{
|
|
Name: name,
|
|
}, userloginstate.Spec{
|
|
Roles: roles,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
userStates[name] = uls
|
|
}
|
|
|
|
g := &mockGetter{
|
|
roles: roles,
|
|
userStates: userStates,
|
|
}
|
|
return g
|
|
}
|