Files
teleport/lib/services/access_request_test.go
T
Nic Klaassen ec046e1a0e feat: add regexp.match to access request filter and where expressions (#40568)
This PR updates the parsers for the role
`spec.allow.request.thresholds.filter` and `spec.allow.review.where`
expression fields to support a new function `regexp.match(list, re)`.

It follows the typical teleport style following from role templates,
`where` expressions, login rules, and label expressions:
- `list` can be a `[]string` or a `string` (a single string is treated
  as a list of length 1).
- `re` can be a glob pattern like `example-*` or a proper regex like
  `^example-.*$` when surrounded by `^$`.

Some examples are included in the docs and tests.

I have switched from using `NewJSONBoolParser` to new dedicated parsers
based on `typical`. These should have better performance in terms of
speed and memory, be easier to extend in the future, and provide better
error messages when users write invalid expressions.

These were the only uses of `NewJSONBoolParser`, so it has been removed.

Changelog: add `regexp.match` to access request `filter` and `where`
expressions
2024-04-18 00:17:02 +00:00

2528 lines
76 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"
"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/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(opts ...MarshalOption) (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)
var s []byte
for i := 0; i <= maxAccessRequestReasonSize; i++ {
s = append(s, 'a')
}
req.SetRequestReason(string(s))
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)
}{
{
name: "no roles",
assertion: func(t *testing.T, validator *RequestValidator) {
require.False(t, validator.requireReason)
require.False(t, validator.autoRequest)
require.Empty(t, validator.prompt)
},
},
{
name: "with prompt",
roles: []types.Role{empty, optionalRole, promptRole},
assertion: func(t *testing.T, validator *RequestValidator) {
require.False(t, validator.requireReason)
require.False(t, validator.autoRequest)
require.Equal(t, "test prompt", validator.prompt)
},
},
{
name: "with auto request",
roles: []types.Role{alwaysRole},
assertion: func(t *testing.T, validator *RequestValidator) {
require.False(t, validator.requireReason)
require.True(t, validator.autoRequest)
require.Empty(t, validator.prompt)
},
},
{
name: "with prompt and auto request",
roles: []types.Role{promptRole, alwaysRole},
assertion: func(t *testing.T, validator *RequestValidator) {
require.False(t, validator.requireReason)
require.True(t, validator.autoRequest)
require.Equal(t, "test prompt", validator.prompt)
},
},
{
name: "with reason and auto prompt",
roles: []types.Role{reasonRole},
assertion: func(t *testing.T, validator *RequestValidator) {
require.True(t, validator.requireReason)
require.True(t, validator.autoRequest)
require.Empty(t, validator.prompt)
},
},
}
for _, test := range cases {
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),
}
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(context.Background(), clock, getter, uls.GetName(), ExpandVars(true))
require.NoError(t, err)
test.assertion(t, &validator)
}
}
type mockClusterGetter struct {
localCluster types.ClusterName
remoteClusters map[string]types.RemoteCluster
}
func (mcg mockClusterGetter) GetClusterName(opts ...MarshalOption) (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 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),
},
},
},
}
// describes a collection of users with various roles
userDesc := map[string][]string{
"alice": {"shortMaxDurationReqRole"},
"bob": {"defaultRole"},
"carol": {"shortMaxDurationReqRole", "shortMaxDurationReqRole2"},
"david": {"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: "role max_duration is ignored when requestedMaxDuration is not set",
requestor: "alice",
roles: []string{"requestedRole"}, // role max_duration capped to 3 days
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: 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,
},
}
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)
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())
}
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
}