mirror of
https://github.com/gravitational/teleport.git
synced 2026-10-31 20:14:26 +08:00
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.
1683 lines
48 KiB
Go
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)
|
|
})
|
|
}
|
|
}
|