Files
teleport/lib/services/access_request_test.go
T
Tiago Silva 674a3a1ec1 Removes deprecated KindKubeService objects (#22663)
This PR removes the `types.KindKubeService` objects that were
deprecated in Teleport 11.

Teleport 12 removed the double heartbeat and for Teleport 13 we can
safely drop the support of the old hearbeat based on `types.Server`
objects.

Clients older than Teleport 12 connecting to a Teleport 13 server will
fail since they cannot heartbeat using the old mechanism.
2023-03-13 10:55:39 +00:00

1683 lines
48 KiB
Go

/*
Copyright 2019 Gravitational, Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package services
import (
"context"
"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/lib/defaults"
"github.com/gravitational/teleport/lib/fixtures"
"github.com/gravitational/teleport/lib/tlsca"
)
// mockGetter mocks the UserAndRoleGetter interface.
type mockGetter struct {
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()
user, err := types.NewUser(name)
require.NoError(t, err)
user.SetRoles(roles)
m.users[name] = user
return name
}
func (m *mockGetter) GetUser(name string, withSecrets bool) (types.User, error) {
if withSecrets {
return nil, trace.NotImplemented("mock getter does not store secrets")
}
user, ok := m.users[name]
if !ok {
return nil, trace.NotFound("no such user: %q", name)
}
return user, 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) 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) {
// 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
},
},
// 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["mechanisms"],"uprising")`,
},
},
// the intelligentsia can put dictators into power via consensus
"intelligentsia": {
ReviewRequests: &types.AccessReviewConditions{
Roles: []string{"dictator"},
Where: `contains(request.system_annotations["mechanism"],"consensus")`,
},
},
// the military can put dictators into power via a coup our treachery
"military": {
ReviewRequests: &types.AccessReviewConditions{
Roles: []string{"dictator"},
Where: `contains(request.system_annotations["mechanisms"],"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
userDesc := map[string][]string{
"alice": {"populist", "proletariat", "intelligentsia", "military"},
"bob": {"general", "proletariat", "intelligentsia", "military"},
"carol": {"conqueror", "proletariat", "intelligentsia", "military"},
"dave": {"populist", "general", "conqueror"},
"erika": {"populist", "idealist"},
}
users := make(map[string]types.User)
for name, roles := range userDesc {
user, err := types.NewUser(name)
require.NoError(t, err)
user.SetRoles(roles)
users[name] = user
}
g := &mockGetter{
roles: roles,
users: users,
}
const (
approve = types.RequestState_APPROVED
deny = types.RequestState_DENIED
)
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
}
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
reviews []review
}{
{
desc: "populist approval via multi-threshold match",
requestor: "alice", // permitted by role populist
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 to all thresholds, no effect since a state-transition was already triggered.
author: g.user(t, "proletariat", "intelligentsia", "military"),
propose: deny,
expect: approve,
},
},
},
{
desc: "populist approval via consensus threshold",
requestor: "alice", // permitted by role populist
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 triger 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
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 triger 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
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
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
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"},
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
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,
},
},
},
}
for _, tt := range tts {
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)
clock := clockwork.NewFakeClock()
identity := tlsca.Identity{
Expires: clock.Now().UTC().Add(8 * time.Hour),
}
// perform request validation (necessary in order to initialize internal
// request variables like annotations and thresholds).
validator, err := NewRequestValidator(context.Background(), clock, g, tt.requestor, ExpandVars(true))
require.NoError(t, err, "scenario=%q", tt.desc)
require.NoError(t, validator.Validate(context.Background(), 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(context.TODO(), g, rt.author)
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
}
rev := types.AccessReview{
Author: rt.author,
ProposedState: rt.propose,
}
author, ok := users[rt.author]
require.True(t, ok, "scenario=%q, rev=%d", tt.desc, ri)
err = ApplyAccessReview(req, rev, author)
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: "plz",
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,"plz") && equals(review.reason,"ok")`,
`contains(reviewer.roles,"admin") || contains(reviewer.roles,"dev")`,
`!(contains(reviewer.roles,"foo") || contains(reviewer.roles,"bar"))`,
},
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")`,
},
// 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 {
parser, err := NewJSONBoolParser(tt.ctx)
require.NoError(t, err)
for _, expr := range tt.willMatch {
result, err := parser.EvalBoolPredicate(expr)
require.NoError(t, err)
require.True(t, result)
}
for _, expr := range tt.wontMatch {
result, err := parser.EvalBoolPredicate(expr)
require.NoError(t, err)
require.False(t, result)
}
for _, expr := range tt.wontParse {
_, err := parser.EvalBoolPredicate(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) {
user, err := types.NewUser("test-user")
require.NoError(t, err)
user.SetRoles(tc.currentRoles)
users := map[string]types.User{
user.GetName(): user,
}
g := &mockGetter{
roles: roles,
users: users,
clusterName: "my-cluster",
}
req, err := types.NewAccessRequestWithResources(
"some-id", user.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, user.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),
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",
"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"},
},
},
"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.users[user].SetTraits(map[string][]string{
"logins": {"responder"},
})
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: "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
userTraits map[string][]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: "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", "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, 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())
})
}
}
// TestRequestTTL verifies that the TTL for the Access Request gets reduced by
// requested access time and lifetime of the requesting certificate.
func TestRequestTTL(t *testing.T) {
clock := clockwork.NewFakeClock()
now := clock.Now().UTC()
tests := []struct {
desc string
expiry time.Time
identity tlsca.Identity
maxSessionTTL time.Duration
expectedTTL time.Duration
assertion require.ErrorAssertionFunc
}{
{
desc: "access request with ttl, below limit",
expiry: now.Add(8 * time.Hour),
identity: tlsca.Identity{Expires: now.Add(10 * time.Hour)},
maxSessionTTL: 10 * time.Hour,
expectedTTL: 8 * time.Hour,
assertion: require.NoError,
},
{
desc: "access request with ttl, above limit",
expiry: now.Add(11 * time.Hour),
identity: tlsca.Identity{Expires: now.Add(10 * time.Hour)},
maxSessionTTL: 10 * time.Hour,
assertion: require.Error,
},
{
desc: "access request without ttl (default ttl)",
expiry: time.Time{},
identity: tlsca.Identity{Expires: now.Add(10 * time.Hour)},
maxSessionTTL: 10 * time.Hour,
expectedTTL: defaults.PendingAccessDuration,
assertion: require.NoError,
},
{
desc: "access request without ttl (default ttl), truncation by identity expiration",
expiry: time.Time{},
identity: tlsca.Identity{Expires: now.Add(12 * time.Minute)},
maxSessionTTL: 13 * time.Minute,
expectedTTL: 12 * time.Minute,
assertion: require.NoError,
},
{
desc: "access request without ttl (default ttl), truncation by role max session ttl",
expiry: time.Time{},
identity: tlsca.Identity{Expires: now.Add(14 * time.Hour)},
maxSessionTTL: 13 * time.Minute,
expectedTTL: 13 * time.Minute,
assertion: require.NoError,
},
}
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)
user.SetRoles([]string{"bar"})
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.SetExpiry(tt.expiry)
require.NoError(t, err)
ttl, err := validator.requestTTL(context.Background(), tt.identity, request)
tt.assertion(t, err)
if err == nil {
require.Equal(t, tt.expectedTTL, 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)
user.SetRoles([]string{"bar"})
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)
tt.assertion(t, err)
if err == nil {
require.Equal(t, tt.expectedTTL, ttl)
}
})
}
}
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(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)
})
}
}