mirror of
https://github.com/gravitational/teleport.git
synced 2026-10-25 18:59:51 +08:00
* search access requests by requester display values * Refactor ListUsers request and response handling in user search functions * Refactor user search functions to use ListUsers API and improve test coverage * Enhance user search functionality by adding display traits and refining search logic * Refactor user search functionality by removing deprecated access request search files and updating related tests * reexport function * Refactor access request and user search functionality to normalize search keywords * update user search logging and add pagination handling in findUsernamesBySearchKeywords * use Set
2800 lines
90 KiB
Go
2800 lines
90 KiB
Go
/*
|
|
* Teleport
|
|
* Copyright (C) 2023 Gravitational, Inc.
|
|
*
|
|
* This program is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU Affero General Public License as published by
|
|
* the Free Software Foundation, either version 3 of the License, or
|
|
* (at your option) any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU Affero General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU Affero General Public License
|
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
package auth_test
|
|
|
|
import (
|
|
"cmp"
|
|
"context"
|
|
"crypto/tls"
|
|
"maps"
|
|
"slices"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gravitational/trace"
|
|
"github.com/jonboulle/clockwork"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/gravitational/teleport"
|
|
"github.com/gravitational/teleport/api/client/proto"
|
|
"github.com/gravitational/teleport/api/constants"
|
|
"github.com/gravitational/teleport/api/defaults"
|
|
headerv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/header/v1"
|
|
machineidv1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/machineid/v1"
|
|
userspb "github.com/gravitational/teleport/api/gen/proto/go/teleport/users/v1"
|
|
"github.com/gravitational/teleport/api/types"
|
|
"github.com/gravitational/teleport/api/types/accesslist"
|
|
"github.com/gravitational/teleport/api/types/header"
|
|
"github.com/gravitational/teleport/api/utils/sshutils"
|
|
"github.com/gravitational/teleport/lib/auth"
|
|
"github.com/gravitational/teleport/lib/auth/authclient"
|
|
"github.com/gravitational/teleport/lib/auth/authtest"
|
|
"github.com/gravitational/teleport/lib/modules"
|
|
"github.com/gravitational/teleport/lib/modules/modulestest"
|
|
"github.com/gravitational/teleport/lib/services"
|
|
"github.com/gravitational/teleport/lib/sshca"
|
|
"github.com/gravitational/teleport/lib/tlsca"
|
|
)
|
|
|
|
type accessRequestTestPack struct {
|
|
tlsServer *authtest.TLSServer
|
|
clusterName string
|
|
roles map[string]types.RoleSpecV6
|
|
users map[string][]string
|
|
tlsPrivKey []byte
|
|
tlsPubKey []byte
|
|
sshPrivKey []byte
|
|
sshPubKey []byte
|
|
}
|
|
|
|
func newAccessRequestTestPack(ctx context.Context, t *testing.T) *accessRequestTestPack {
|
|
testAuthServer, err := authtest.NewAuthServer(authtest.AuthServerConfig{
|
|
Dir: t.TempDir(),
|
|
Modules: modulestest.EnterpriseModules(),
|
|
})
|
|
require.NoError(t, err, "%s", trace.DebugReport(err))
|
|
t.Cleanup(func() { require.NoError(t, testAuthServer.Close()) })
|
|
|
|
tlsServer, err := testAuthServer.NewTestTLSServer()
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { require.NoError(t, tlsServer.Close()) })
|
|
|
|
clusterName, err := tlsServer.Auth().GetClusterName(ctx)
|
|
require.NoError(t, err)
|
|
|
|
roles := map[string]types.RoleSpecV6{
|
|
// superadmins have access to all nodes
|
|
"superadmins": {
|
|
Allow: types.RoleConditions{
|
|
Logins: []string{"root"},
|
|
NodeLabels: types.Labels{
|
|
"*": []string{"*"},
|
|
},
|
|
},
|
|
},
|
|
// admins have access to nodes in prod and staging
|
|
"admins": {
|
|
Allow: types.RoleConditions{
|
|
Logins: []string{"root"},
|
|
NodeLabels: types.Labels{
|
|
"env": []string{"prod", "staging"},
|
|
},
|
|
ReviewRequests: &types.AccessReviewConditions{
|
|
Roles: []string{"admins"},
|
|
},
|
|
},
|
|
},
|
|
// operators can request the admins role
|
|
"operators": {
|
|
Allow: types.RoleConditions{
|
|
Request: &types.AccessRequestConditions{
|
|
Roles: []string{"admins"},
|
|
},
|
|
},
|
|
},
|
|
// responders can request the admins role but only with a
|
|
// resource request limited to specific resources
|
|
"responders": {
|
|
Allow: types.RoleConditions{
|
|
Request: &types.AccessRequestConditions{
|
|
SearchAsRoles: []string{"admins"},
|
|
},
|
|
},
|
|
},
|
|
// requesters can request everything possible
|
|
"requesters": {
|
|
Allow: types.RoleConditions{
|
|
Request: &types.AccessRequestConditions{
|
|
Roles: []string{"admins", "superadmins"},
|
|
SearchAsRoles: []string{"admins", "superadmins"},
|
|
MaxDuration: types.Duration(services.MaxAccessDuration),
|
|
},
|
|
},
|
|
},
|
|
// requesters-threshold can request everything possible, with threshold of 2 approvals
|
|
"requesters-threshold": {
|
|
Allow: types.RoleConditions{
|
|
Request: &types.AccessRequestConditions{
|
|
Roles: []string{"admins", "superadmins"},
|
|
SearchAsRoles: []string{"admins", "superadmins"},
|
|
MaxDuration: types.Duration(services.MaxAccessDuration),
|
|
Thresholds: []types.AccessReviewThreshold{
|
|
{Approve: 2},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
// plugin-reviewers can submit reviews for every user
|
|
"plugin-reviewers": {
|
|
Allow: types.RoleConditions{
|
|
ReviewRequests: &types.AccessReviewConditions{
|
|
SubmitForUsers: []string{"*"},
|
|
},
|
|
Rules: []types.Rule{
|
|
types.NewRule(types.KindUser, services.RO()),
|
|
},
|
|
},
|
|
},
|
|
"empty": {},
|
|
}
|
|
for roleName, roleSpec := range roles {
|
|
role, err := types.NewRole(roleName, roleSpec)
|
|
require.NoError(t, err)
|
|
|
|
_, err = tlsServer.Auth().UpsertRole(ctx, role)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
users := map[string][]string{
|
|
"admin": {"admins"},
|
|
"responder": {"responders"},
|
|
"operator": {"operators"},
|
|
"requester": {"requesters"},
|
|
"nobody": {"empty"},
|
|
"admin2": {"admins"},
|
|
"requester-threshold": {"requesters-threshold"},
|
|
"plugin-reviewer": {"plugin-reviewers", "requesters"}, // "requesters" tests edge case where plugin reviewer applys a review on its own request
|
|
}
|
|
for name, roles := range users {
|
|
user, err := types.NewUser(name)
|
|
require.NoError(t, err)
|
|
user.SetRoles(roles)
|
|
_, err = tlsServer.Auth().UpsertUser(ctx, user)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
type nodeDesc struct {
|
|
labels map[string]string
|
|
}
|
|
nodes := map[string]nodeDesc{
|
|
"staging": {
|
|
labels: map[string]string{
|
|
"env": "staging",
|
|
},
|
|
},
|
|
"prod": {
|
|
labels: map[string]string{
|
|
"env": "prod",
|
|
},
|
|
},
|
|
}
|
|
for nodeName, desc := range nodes {
|
|
node, err := types.NewServerWithLabels(nodeName, types.KindNode, types.ServerSpecV2{}, desc.labels)
|
|
require.NoError(t, err)
|
|
_, err = tlsServer.Auth().UpsertNode(context.Background(), node)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
sshPrivKey, sshPubKey, tlsPrivKey, tlsPubKey := newSSHAndTLSKeyPairs(t)
|
|
return &accessRequestTestPack{
|
|
tlsServer: tlsServer,
|
|
clusterName: clusterName.GetClusterName(),
|
|
roles: roles,
|
|
users: users,
|
|
sshPrivKey: sshPrivKey,
|
|
sshPubKey: sshPubKey,
|
|
tlsPrivKey: tlsPrivKey,
|
|
tlsPubKey: tlsPubKey,
|
|
}
|
|
}
|
|
|
|
func TestAccessRequest(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := t.Context()
|
|
|
|
testPack := newAccessRequestTestPack(ctx, t)
|
|
t.Run("single", func(t *testing.T) { testSingleAccessRequests(t, testPack) })
|
|
t.Run("multi", func(t *testing.T) { testMultiAccessRequests(t, testPack) })
|
|
t.Run("role refresh with bogus request ID", func(t *testing.T) { testRoleRefreshWithBogusRequestID(t, testPack) })
|
|
t.Run("drop requests keeps access list grants", func(t *testing.T) { testDropRequestsKeepsAccessListGrants(t, testPack) })
|
|
t.Run("bot user approver", func(t *testing.T) { testBotAccessRequestReview(t, testPack) })
|
|
t.Run("deny", func(t *testing.T) { testAccessRequestDenyRules(t, testPack) })
|
|
t.Run("cert extension resource IDs", func(t *testing.T) { testCertExtensionResourceIDs(t, testPack) })
|
|
t.Run("submit_for_users review", func(t *testing.T) { testSubmitAccessReview_SubmitForUsers(t, testPack) })
|
|
}
|
|
|
|
// waitForAccessRequests is a helper for writing access request tests that need to wait for access request CRUD. the supplied condition is
|
|
// repeatedly called with the contents of the access request cache until it returns true or a reasonably long timeout is exceeded. this is
|
|
// similar to require.Eventually except that it is safe to use normal (test-failing) assertions within the supplied condition closure.
|
|
func waitForAccessRequests(t *testing.T, ctx context.Context, getter services.AccessRequestGetter, condition func([]*types.AccessRequestV3) bool) {
|
|
t.Helper()
|
|
|
|
timeout := time.After(time.Second * 30)
|
|
for {
|
|
var reqs []*types.AccessRequestV3
|
|
var nextKey string
|
|
Paginate:
|
|
for {
|
|
rsp, err := getter.ListAccessRequests(ctx, &proto.ListAccessRequestsRequest{
|
|
Limit: 1_000,
|
|
StartKey: nextKey,
|
|
})
|
|
require.NoError(t, err, "ListAccessRequests API call should succeed")
|
|
|
|
reqs = append(reqs, rsp.AccessRequests...)
|
|
nextKey = rsp.NextKey
|
|
if nextKey == "" {
|
|
break Paginate
|
|
}
|
|
}
|
|
|
|
if condition(reqs) {
|
|
return
|
|
}
|
|
|
|
select {
|
|
case <-time.After(time.Millisecond * 150):
|
|
case <-timeout:
|
|
require.FailNow(t, "timeout waiting for access request condition to pass")
|
|
}
|
|
}
|
|
}
|
|
|
|
func upsertUser(t *testing.T, ctx context.Context, authServer *auth.Server, name string, roles []string, traits map[string][]string) {
|
|
t.Helper()
|
|
|
|
user, err := types.NewUser(name)
|
|
require.NoError(t, err)
|
|
user.SetRoles(roles)
|
|
user.SetTraits(traits)
|
|
_, err = authServer.UpsertUser(ctx, user)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
func upsertAccessRequest(t *testing.T, ctx context.Context, authServer *auth.Server, name string, user string, roles []string, mutate func(types.AccessRequest)) {
|
|
t.Helper()
|
|
|
|
req, err := types.NewAccessRequest(name, user, roles...)
|
|
require.NoError(t, err)
|
|
if mutate != nil {
|
|
mutate(req)
|
|
}
|
|
require.NoError(t, authServer.UpsertAccessRequest(ctx, req))
|
|
}
|
|
|
|
func waitForUserSearchMatch(t *testing.T, ctx context.Context, users services.UserSearchLister, searchKeywords []string, username string) {
|
|
t.Helper()
|
|
|
|
timeout := time.After(time.Second * 30)
|
|
for {
|
|
rsp, err := users.ListUsers(ctx, userspb.ListUsersRequest_builder{
|
|
PageSize: defaults.DefaultChunkSize,
|
|
Filter: &types.UserFilter{
|
|
SearchKeywords: searchKeywords,
|
|
SkipSystemUsers: true,
|
|
},
|
|
}.Build())
|
|
require.NoError(t, err)
|
|
for _, user := range rsp.GetUsers() {
|
|
if user.GetName() == username {
|
|
return
|
|
}
|
|
}
|
|
|
|
select {
|
|
case <-time.After(time.Millisecond * 150):
|
|
case <-timeout:
|
|
require.FailNow(t, "timeout waiting for user search match")
|
|
}
|
|
}
|
|
}
|
|
|
|
func collectAccessRequestNames(t *testing.T, ctx context.Context, getter services.AccessRequestGetter, req proto.ListAccessRequestsRequest) []string {
|
|
t.Helper()
|
|
|
|
var names []string
|
|
for {
|
|
rsp, err := getter.ListAccessRequests(ctx, &req)
|
|
require.NoError(t, err)
|
|
for _, accessRequest := range rsp.AccessRequests {
|
|
names = append(names, accessRequest.GetName())
|
|
}
|
|
req.StartKey = rsp.NextKey
|
|
if req.StartKey == "" {
|
|
return names
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestAccessRequestResourceRBACLimits verifies the special constraint conditions put on resource-level access
|
|
// request permissions (create/update) to mitigate their power.
|
|
func TestAccessRequestResourceRBACLimits(t *testing.T) {
|
|
const (
|
|
staticRoleName = "static-role"
|
|
dynamicRoleName = "dynamic-role"
|
|
userName = "alice@example.com"
|
|
otherUserName = "bob@example.com"
|
|
)
|
|
|
|
t.Parallel()
|
|
|
|
clock := clockwork.NewFakeClock()
|
|
|
|
authServer, err := authtest.NewAuthServer(authtest.AuthServerConfig{
|
|
Dir: t.TempDir(),
|
|
Clock: clock,
|
|
})
|
|
require.NoError(t, err)
|
|
defer authServer.Close()
|
|
|
|
tlsServer, err := authServer.NewTestTLSServer()
|
|
require.NoError(t, err)
|
|
defer tlsServer.Close()
|
|
|
|
ctx := t.Context()
|
|
|
|
staticRole, err := types.NewRole(staticRoleName, types.RoleSpecV6{
|
|
Allow: types.RoleConditions{
|
|
Request: &types.AccessRequestConditions{
|
|
Roles: []string{dynamicRoleName},
|
|
},
|
|
Rules: []types.Rule{
|
|
types.NewRule(types.KindAccessRequest, services.RW()),
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
dynamicRole, err := types.NewRole(dynamicRoleName, types.RoleSpecV6{})
|
|
require.NoError(t, err)
|
|
|
|
_, err = tlsServer.Auth().UpsertRole(ctx, staticRole)
|
|
require.NoError(t, err)
|
|
|
|
_, err = tlsServer.Auth().UpsertRole(ctx, dynamicRole)
|
|
require.NoError(t, err)
|
|
|
|
user, err := types.NewUser(userName)
|
|
require.NoError(t, err)
|
|
|
|
user.SetRoles([]string{staticRoleName})
|
|
_, err = tlsServer.Auth().UpsertUser(ctx, user)
|
|
require.NoError(t, err)
|
|
|
|
otherUser, err := types.NewUser(otherUserName)
|
|
require.NoError(t, err)
|
|
|
|
otherUser.SetRoles([]string{staticRoleName})
|
|
_, err = tlsServer.Auth().UpsertUser(ctx, otherUser)
|
|
require.NoError(t, err)
|
|
|
|
clt, err := tlsServer.NewClient(authtest.TestUser(userName))
|
|
require.NoError(t, err)
|
|
defer clt.Close()
|
|
|
|
// try to create a pre-approved request for self
|
|
req, err := services.NewAccessRequest(userName, dynamicRoleName)
|
|
require.NoError(t, err)
|
|
|
|
req.SetState(types.RequestState_APPROVED)
|
|
_, err = clt.CreateAccessRequestV2(ctx, req)
|
|
require.Error(t, err)
|
|
|
|
// verify that we got the expected rejection
|
|
require.Equal(t, "cannot create access request for self in non-pending state", err.Error())
|
|
|
|
// verify that creating pre-approved requests for others still works
|
|
// (note: we'd like to eventually deprecate ability too).
|
|
req, err = services.NewAccessRequest(otherUserName, dynamicRoleName)
|
|
require.NoError(t, err)
|
|
|
|
req.SetState(types.RequestState_APPROVED)
|
|
|
|
_, err = clt.CreateAccessRequestV2(ctx, req)
|
|
require.NoError(t, err)
|
|
|
|
// create a pending request for self
|
|
req, err = services.NewAccessRequest(userName, dynamicRoleName)
|
|
require.NoError(t, err)
|
|
|
|
req.SetState(types.RequestState_PENDING)
|
|
req, err = clt.CreateAccessRequestV2(ctx, req)
|
|
require.NoError(t, err)
|
|
|
|
// attempt to self-approve
|
|
err = clt.SetAccessRequestState(ctx, types.AccessRequestUpdate{
|
|
RequestID: req.GetName(),
|
|
State: types.RequestState_APPROVED,
|
|
})
|
|
require.Error(t, err)
|
|
|
|
// verify that we got the expected rejection
|
|
require.Equal(t, "directly updating the state of your own access requests is not permitted", err.Error())
|
|
|
|
req, err = services.NewAccessRequest(otherUserName, dynamicRoleName)
|
|
require.NoError(t, err)
|
|
|
|
req.SetState(types.RequestState_PENDING)
|
|
req, err = clt.CreateAccessRequestV2(ctx, req)
|
|
require.NoError(t, err)
|
|
|
|
// approve other
|
|
err = clt.SetAccessRequestState(ctx, types.AccessRequestUpdate{
|
|
RequestID: req.GetName(),
|
|
State: types.RequestState_APPROVED,
|
|
})
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
// TestListAccessRequests tests some basic functionality of the ListAccessRequests API, including access-control,
|
|
// filtering, sort, and pagination.
|
|
func TestListAccessRequests(t *testing.T) {
|
|
const (
|
|
requestsPerUser = 200
|
|
pageSize = 7
|
|
)
|
|
|
|
t.Parallel()
|
|
|
|
clock := clockwork.NewFakeClock()
|
|
|
|
authServer, err := authtest.NewAuthServer(authtest.AuthServerConfig{
|
|
Dir: t.TempDir(),
|
|
Clock: clock,
|
|
})
|
|
require.NoError(t, err)
|
|
defer authServer.Close()
|
|
|
|
tlsServer, err := authServer.NewTestTLSServer()
|
|
require.NoError(t, err)
|
|
defer tlsServer.Close()
|
|
|
|
ctx := t.Context()
|
|
|
|
userA, userB := "lister-a", "lister-b"
|
|
roleA, roleB := userA+"-role", userB+"-role"
|
|
rroleA, rroleB := userA+"-rrole", userB+"-rrole"
|
|
|
|
roles := map[string]types.RoleSpecV6{
|
|
roleA: {
|
|
Allow: types.RoleConditions{
|
|
Request: &types.AccessRequestConditions{
|
|
Roles: []string{rroleA},
|
|
},
|
|
},
|
|
},
|
|
roleB: {
|
|
Allow: types.RoleConditions{
|
|
Request: &types.AccessRequestConditions{
|
|
Roles: []string{rroleB},
|
|
},
|
|
},
|
|
},
|
|
rroleA: {},
|
|
rroleB: {},
|
|
}
|
|
|
|
for roleName, roleSpec := range roles {
|
|
role, err := types.NewRole(roleName, roleSpec)
|
|
require.NoError(t, err)
|
|
_, err = tlsServer.Auth().UpsertRole(ctx, role)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
for userName, roleName := range map[string]string{userA: roleA, userB: roleB} {
|
|
user, err := types.NewUser(userName)
|
|
require.NoError(t, err)
|
|
user.SetRoles([]string{roleName})
|
|
_, err = tlsServer.Auth().UpsertUser(ctx, user)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
clientA, err := tlsServer.NewClient(authtest.TestUser(userA))
|
|
require.NoError(t, err)
|
|
defer clientA.Close()
|
|
|
|
clientB, err := tlsServer.NewClient(authtest.TestUser(userB))
|
|
require.NoError(t, err)
|
|
defer clientB.Close()
|
|
|
|
// orderedIDs is a list of all access request IDs in order of creation (used to
|
|
// verify sort order).
|
|
var orderedIDs []string
|
|
|
|
for range requestsPerUser {
|
|
clock.Advance(time.Second)
|
|
reqA, err := services.NewAccessRequest(userA, rroleA)
|
|
require.NoError(t, err)
|
|
rr, err := clientA.CreateAccessRequestV2(ctx, reqA)
|
|
require.NoError(t, err)
|
|
orderedIDs = append(orderedIDs, rr.GetName())
|
|
}
|
|
|
|
for range requestsPerUser {
|
|
clock.Advance(time.Second)
|
|
reqB, err := services.NewAccessRequest(userB, rroleB)
|
|
require.NoError(t, err)
|
|
rr, err := clientB.CreateAccessRequestV2(ctx, reqB)
|
|
require.NoError(t, err)
|
|
orderedIDs = append(orderedIDs, rr.GetName())
|
|
}
|
|
|
|
// wait for all written requests to propagate to cache
|
|
waitForAccessRequests(t, ctx, tlsServer.Auth(), func(reqs []*types.AccessRequestV3) bool {
|
|
return len(reqs) == len(orderedIDs)
|
|
})
|
|
|
|
var reqs []*types.AccessRequestV3
|
|
var observedIDs []string
|
|
var nextKey string
|
|
for {
|
|
rsp, err := tlsServer.Auth().ListAccessRequests(ctx, &proto.ListAccessRequestsRequest{
|
|
Limit: pageSize,
|
|
StartKey: nextKey,
|
|
Sort: proto.AccessRequestSort_CREATED,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
for _, r := range rsp.AccessRequests {
|
|
observedIDs = append(observedIDs, r.GetName())
|
|
}
|
|
|
|
reqs = append(reqs, rsp.AccessRequests...)
|
|
|
|
nextKey = rsp.NextKey
|
|
if nextKey == "" {
|
|
break
|
|
}
|
|
|
|
require.Len(t, rsp.AccessRequests, pageSize)
|
|
}
|
|
|
|
// verify that we observed the requests in the same order that they were created (i.e. that the
|
|
// default sort order is ascending and time-based).
|
|
require.Equal(t, orderedIDs, observedIDs)
|
|
|
|
// verify that time-based sorting can be checked via creation time field as expected (relied upon later)
|
|
require.True(t, slices.IsSortedFunc(reqs, func(a, b *types.AccessRequestV3) int {
|
|
return a.GetCreationTime().Compare(b.GetCreationTime())
|
|
}))
|
|
|
|
reqs = nil
|
|
nextKey = ""
|
|
for {
|
|
rsp, err := tlsServer.Auth().ListAccessRequests(ctx, &proto.ListAccessRequestsRequest{
|
|
Filter: &types.AccessRequestFilter{
|
|
User: userB,
|
|
},
|
|
Limit: pageSize,
|
|
StartKey: nextKey,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
reqs = append(reqs, rsp.AccessRequests...)
|
|
|
|
nextKey = rsp.NextKey
|
|
if nextKey == "" {
|
|
break
|
|
}
|
|
|
|
require.Len(t, rsp.AccessRequests, pageSize)
|
|
}
|
|
require.Len(t, reqs, requestsPerUser)
|
|
|
|
// verify that access-control filtering is applied and exercise a different combination of sort params
|
|
for _, clt := range []authclient.ClientI{clientA, clientB} {
|
|
reqs = nil
|
|
nextKey = ""
|
|
for {
|
|
rsp, err := clt.ListAccessRequests(ctx, &proto.ListAccessRequestsRequest{
|
|
Limit: pageSize,
|
|
StartKey: nextKey,
|
|
Sort: proto.AccessRequestSort_CREATED,
|
|
Descending: true,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
reqs = append(reqs, rsp.AccessRequests...)
|
|
|
|
nextKey = rsp.NextKey
|
|
if nextKey == "" {
|
|
break
|
|
}
|
|
|
|
require.Len(t, rsp.AccessRequests, pageSize)
|
|
}
|
|
|
|
require.Len(t, reqs, requestsPerUser)
|
|
require.True(t, slices.IsSortedFunc(reqs, func(a, b *types.AccessRequestV3) int {
|
|
// note that we flip `a` and `b` to assert Descending ordering.
|
|
return b.GetCreationTime().Compare(a.GetCreationTime())
|
|
}))
|
|
}
|
|
|
|
// set requests to a variety of states so that state-based ordering
|
|
// is distinctly different from time-based ordering.
|
|
var deny bool
|
|
expectStates := make(map[string]types.RequestState)
|
|
for i, id := range observedIDs {
|
|
if i%2 == 0 {
|
|
// leave half the requests as pending
|
|
continue
|
|
}
|
|
state := types.RequestState_APPROVED
|
|
if deny {
|
|
state = types.RequestState_DENIED
|
|
}
|
|
deny = !deny // toggle next target state
|
|
|
|
require.NoError(t, tlsServer.Auth().SetAccessRequestState(ctx, types.AccessRequestUpdate{
|
|
RequestID: id,
|
|
State: state,
|
|
}))
|
|
expectStates[id] = state
|
|
}
|
|
|
|
// wait until all requests in cache to present the expected state
|
|
waitForAccessRequests(t, ctx, tlsServer.Auth(), func(reqs []*types.AccessRequestV3) bool {
|
|
for _, r := range reqs {
|
|
if expected, ok := expectStates[r.GetName()]; ok && r.GetState() != expected {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
})
|
|
|
|
// aggregate requests by descending state ordering
|
|
reqs = nil
|
|
nextKey = ""
|
|
for {
|
|
rsp, err := clientA.ListAccessRequests(ctx, &proto.ListAccessRequestsRequest{
|
|
Sort: proto.AccessRequestSort_STATE,
|
|
Descending: true,
|
|
Limit: pageSize,
|
|
StartKey: nextKey,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
reqs = append(reqs, rsp.AccessRequests...)
|
|
|
|
nextKey = rsp.NextKey
|
|
if nextKey == "" {
|
|
break
|
|
}
|
|
|
|
require.Len(t, rsp.AccessRequests, pageSize)
|
|
}
|
|
|
|
require.Len(t, reqs, requestsPerUser)
|
|
|
|
// verify that requests are sorted by state
|
|
require.True(t, slices.IsSortedFunc(reqs, func(a, b *types.AccessRequestV3) int {
|
|
// state sort index sorts by the string representation of state. note that we
|
|
// flip `a` and `b` to assert Descending ordering.
|
|
return cmp.Compare(b.GetState().String(), a.GetState().String())
|
|
}))
|
|
|
|
// sanity-check to ensure that we did force a custom state ordering
|
|
require.False(t, slices.IsSortedFunc(reqs, func(a, b *types.AccessRequestV3) int {
|
|
return b.GetCreationTime().Compare(a.GetCreationTime())
|
|
}))
|
|
}
|
|
|
|
func TestListAccessRequestsRequesterDisplaySearch(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const (
|
|
existingRequestID = "00000000-0000-0000-0000-000000000001"
|
|
displayRequestID = "00000000-0000-0000-0000-000000000002"
|
|
displayRoleName = "display-role"
|
|
storedRoleName = "stored-role"
|
|
displayUserName = "123456"
|
|
storedUserName = "stored-user"
|
|
)
|
|
|
|
authServer, err := authtest.NewAuthServer(authtest.AuthServerConfig{
|
|
Dir: t.TempDir(),
|
|
})
|
|
require.NoError(t, err)
|
|
defer authServer.Close()
|
|
|
|
tlsServer, err := authServer.NewTestTLSServer()
|
|
require.NoError(t, err)
|
|
defer tlsServer.Close()
|
|
|
|
ctx := t.Context()
|
|
authServerClient := tlsServer.Auth()
|
|
|
|
upsertUser(t, ctx, authServerClient, displayUserName, nil, map[string][]string{
|
|
"okta/displayName": {"Jane Garcia"},
|
|
"okta/email": {"jane.garcia@example.com"},
|
|
})
|
|
upsertUser(t, ctx, authServerClient, storedUserName, nil, nil)
|
|
waitForUserSearchMatch(t, ctx, authServerClient, []string{"Jane"}, displayUserName)
|
|
|
|
upsertAccessRequest(t, ctx, authServerClient, existingRequestID, storedUserName, []string{storedRoleName}, func(req types.AccessRequest) {
|
|
req.SetStaticLabels(map[string]string{"owner": "Jane"})
|
|
})
|
|
upsertAccessRequest(t, ctx, authServerClient, displayRequestID, displayUserName, []string{displayRoleName}, func(req types.AccessRequest) {
|
|
req.SetState(types.RequestState_APPROVED)
|
|
})
|
|
|
|
waitForAccessRequests(t, ctx, authServerClient, func(reqs []*types.AccessRequestV3) bool {
|
|
return len(reqs) == 2
|
|
})
|
|
|
|
for _, tc := range []struct {
|
|
name string
|
|
filter *types.AccessRequestFilter
|
|
limit int32
|
|
expected []string
|
|
}{
|
|
{
|
|
name: "display primary partial first name",
|
|
filter: &types.AccessRequestFilter{
|
|
SearchKeywords: []string{"Jane"},
|
|
},
|
|
// existingRequestID has a label with "Jane" in it,
|
|
// displayRequestID has a requester whose display name contains "Jane", so both should match
|
|
expected: []string{existingRequestID, displayRequestID},
|
|
},
|
|
{
|
|
name: "search keywords are normalized",
|
|
filter: &types.AccessRequestFilter{
|
|
SearchKeywords: []string{" Jane "},
|
|
},
|
|
expected: []string{existingRequestID, displayRequestID},
|
|
},
|
|
{
|
|
name: "blank search keywords are ignored",
|
|
filter: &types.AccessRequestFilter{
|
|
SearchKeywords: []string{"", " "},
|
|
},
|
|
expected: []string{existingRequestID, displayRequestID},
|
|
},
|
|
{
|
|
name: "display secondary",
|
|
filter: &types.AccessRequestFilter{
|
|
SearchKeywords: []string{"jane.garcia@example.com"},
|
|
},
|
|
// displayRequestID has a requester whose email is "jane.garcia@example.com", so it should match
|
|
expected: []string{displayRequestID},
|
|
},
|
|
{
|
|
name: "display primary multi token",
|
|
filter: &types.AccessRequestFilter{
|
|
SearchKeywords: []string{"Jane", "Garcia"},
|
|
},
|
|
// displayRequestID has a requester whose display name contains both "Jane" and "Garcia", so it should match
|
|
expected: []string{displayRequestID},
|
|
},
|
|
{
|
|
name: "requester username search still works",
|
|
filter: &types.AccessRequestFilter{
|
|
SearchKeywords: []string{displayUserName},
|
|
},
|
|
expected: []string{displayRequestID},
|
|
},
|
|
{
|
|
name: "mixed display and role terms",
|
|
filter: &types.AccessRequestFilter{
|
|
SearchKeywords: []string{"Jane", displayRoleName},
|
|
},
|
|
expected: []string{displayRequestID},
|
|
},
|
|
{
|
|
name: "existing role search still works",
|
|
filter: &types.AccessRequestFilter{
|
|
SearchKeywords: []string{storedRoleName},
|
|
},
|
|
expected: []string{existingRequestID},
|
|
},
|
|
{
|
|
name: "state filter still ANDs with display search",
|
|
filter: &types.AccessRequestFilter{
|
|
SearchKeywords: []string{"Jane", "Garcia"},
|
|
State: types.RequestState_PENDING,
|
|
},
|
|
// No requests are set up to be PENDING so this should return no results
|
|
expected: nil,
|
|
},
|
|
{
|
|
name: "approved state filter still returns display search match",
|
|
filter: &types.AccessRequestFilter{
|
|
SearchKeywords: []string{"Jane", "Garcia"},
|
|
State: types.RequestState_APPROVED,
|
|
},
|
|
// displayRequestID is set up to be APPROVED and has a requester whose display name contains both "Jane" and "Garcia", so it should match
|
|
expected: []string{displayRequestID},
|
|
},
|
|
{
|
|
name: "no match",
|
|
filter: &types.AccessRequestFilter{
|
|
SearchKeywords: []string{"NoSuchPerson"},
|
|
},
|
|
expected: nil,
|
|
},
|
|
{
|
|
name: "mixed existing and display pagination",
|
|
filter: &types.AccessRequestFilter{
|
|
SearchKeywords: []string{"Jane"},
|
|
},
|
|
// force pagination to be exercised with search
|
|
limit: 1,
|
|
expected: []string{existingRequestID, displayRequestID},
|
|
},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
req := proto.ListAccessRequestsRequest{
|
|
Filter: tc.filter,
|
|
Limit: tc.limit,
|
|
}
|
|
require.Equal(t, tc.expected, collectAccessRequestNames(t, ctx, authServerClient, req))
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestListAccessRequestsRequesterDisplaySearchPreservesRBAC(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const (
|
|
reviewableRequestID = "00000000-0000-0000-0000-000000000011"
|
|
displayUserName = "123456"
|
|
reviewerUserName = "reviewer"
|
|
reviewableRole = "reviewable-role"
|
|
unreviewableRole = "unreviewable-role"
|
|
reviewerRole = "reviewer-role"
|
|
)
|
|
|
|
authServer, err := authtest.NewAuthServer(authtest.AuthServerConfig{
|
|
Dir: t.TempDir(),
|
|
})
|
|
require.NoError(t, err)
|
|
defer authServer.Close()
|
|
|
|
tlsServer, err := authServer.NewTestTLSServer()
|
|
require.NoError(t, err)
|
|
defer tlsServer.Close()
|
|
|
|
ctx := t.Context()
|
|
authServerClient := tlsServer.Auth()
|
|
|
|
for roleName, roleSpec := range map[string]types.RoleSpecV6{
|
|
reviewableRole: {},
|
|
unreviewableRole: {},
|
|
reviewerRole: {
|
|
Allow: types.RoleConditions{
|
|
ReviewRequests: &types.AccessReviewConditions{
|
|
Roles: []string{reviewableRole},
|
|
},
|
|
},
|
|
},
|
|
} {
|
|
role, err := types.NewRole(roleName, roleSpec)
|
|
require.NoError(t, err)
|
|
_, err = authServerClient.UpsertRole(ctx, role)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
upsertUser(t, ctx, authServerClient, displayUserName, nil, map[string][]string{
|
|
"okta/displayName": {"Jane Garcia"},
|
|
"department": {"classified-department"},
|
|
})
|
|
upsertUser(t, ctx, authServerClient, reviewerUserName, []string{reviewerRole}, nil)
|
|
waitForUserSearchMatch(t, ctx, authServerClient, []string{"Jane"}, displayUserName)
|
|
|
|
upsertAccessRequest(t, ctx, authServerClient, reviewableRequestID, displayUserName, []string{reviewableRole}, nil)
|
|
upsertAccessRequest(t, ctx, authServerClient, "00000000-0000-0000-0000-000000000012", displayUserName, []string{unreviewableRole}, nil)
|
|
|
|
waitForAccessRequests(t, ctx, authServerClient, func(reqs []*types.AccessRequestV3) bool {
|
|
return len(reqs) == 2
|
|
})
|
|
|
|
reviewerClient, err := tlsServer.NewClient(authtest.TestUser(reviewerUserName))
|
|
require.NoError(t, err)
|
|
defer reviewerClient.Close()
|
|
|
|
names := collectAccessRequestNames(t, ctx, reviewerClient, proto.ListAccessRequestsRequest{
|
|
Filter: &types.AccessRequestFilter{
|
|
SearchKeywords: []string{"Jane"},
|
|
},
|
|
})
|
|
require.Equal(t, []string{reviewableRequestID}, names)
|
|
|
|
names = collectAccessRequestNames(t, ctx, reviewerClient, proto.ListAccessRequestsRequest{
|
|
Filter: &types.AccessRequestFilter{
|
|
SearchKeywords: []string{"classified-department"},
|
|
},
|
|
})
|
|
require.Empty(t, names)
|
|
}
|
|
|
|
func TestListAccessRequestsUserDisplays(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
authServer, err := authtest.NewAuthServer(authtest.AuthServerConfig{
|
|
Dir: t.TempDir(),
|
|
})
|
|
require.NoError(t, err)
|
|
defer authServer.Close()
|
|
|
|
tlsServer, err := authServer.NewTestTLSServer()
|
|
require.NoError(t, err)
|
|
defer tlsServer.Close()
|
|
|
|
ctx := t.Context()
|
|
|
|
const (
|
|
requester = "display-requester"
|
|
reviewer = "display-reviewer"
|
|
suggestedReviewer = "display-suggested-reviewer"
|
|
plainUser = "display-plain-user"
|
|
missingUser = "display-missing-user"
|
|
roleName = "display-role"
|
|
)
|
|
|
|
role, err := types.NewRole(roleName, types.RoleSpecV6{})
|
|
require.NoError(t, err)
|
|
_, err = tlsServer.Auth().UpsertRole(ctx, role)
|
|
require.NoError(t, err)
|
|
|
|
for _, user := range []types.User{
|
|
newAccessRequestDisplayUser(t, requester, "Request User", "requester@example.com", roleName),
|
|
newAccessRequestDisplayUser(t, reviewer, "Review User", "reviewer@example.com", roleName),
|
|
newAccessRequestDisplayUser(t, suggestedReviewer, "Suggested User", "suggested@example.com", roleName),
|
|
newAccessRequestDisplayUser(t, plainUser, "", "", roleName),
|
|
} {
|
|
_, err := tlsServer.Auth().UpsertUser(ctx, user)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
req, err := services.NewAccessRequest(requester, roleName)
|
|
require.NoError(t, err)
|
|
req.SetReviews([]types.AccessReview{{Author: reviewer}})
|
|
req.SetSuggestedReviewers([]string{suggestedReviewer, plainUser, missingUser})
|
|
require.NoError(t, tlsServer.Auth().UpsertAccessRequest(ctx, req))
|
|
|
|
waitForAccessRequests(t, ctx, tlsServer.Auth(), func(reqs []*types.AccessRequestV3) bool {
|
|
return len(reqs) == 1
|
|
})
|
|
|
|
adminClient, err := tlsServer.NewClient(authtest.TestAdmin())
|
|
require.NoError(t, err)
|
|
defer adminClient.Close()
|
|
|
|
rsp, err := adminClient.ListAccessRequests(ctx, &proto.ListAccessRequestsRequest{
|
|
Filter: &types.AccessRequestFilter{
|
|
ID: req.GetName(),
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
require.Len(t, rsp.AccessRequests, 1)
|
|
require.Equal(t, map[string]*proto.UserDisplay{
|
|
requester: {
|
|
Primary: "Request User",
|
|
Secondary: "requester@example.com",
|
|
},
|
|
reviewer: {
|
|
Primary: "Review User",
|
|
Secondary: "reviewer@example.com",
|
|
},
|
|
suggestedReviewer: {
|
|
Primary: "Suggested User",
|
|
Secondary: "suggested@example.com",
|
|
},
|
|
plainUser: {},
|
|
}, rsp.UserDisplays)
|
|
require.NotContains(t, rsp.UserDisplays, missingUser)
|
|
}
|
|
|
|
func newAccessRequestDisplayUser(t *testing.T, name, primary, secondary, role string) types.User {
|
|
t.Helper()
|
|
|
|
user, err := types.NewUser(name)
|
|
require.NoError(t, err)
|
|
user.SetRoles([]string{role})
|
|
|
|
traits := make(map[string][]string)
|
|
if primary != "" {
|
|
traits["displayName"] = []string{primary}
|
|
}
|
|
if secondary != "" {
|
|
traits["email"] = []string{secondary}
|
|
}
|
|
user.SetTraits(traits)
|
|
return user
|
|
}
|
|
|
|
func TestCreateAccessRequestV2DryRunUserDisplays(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const (
|
|
requester = "dry-run-display-requester"
|
|
policySuggestedReviewer = "dry-run-display-policy-reviewer"
|
|
dynamicSuggestedReviewer = "dry-run-display-dynamic-reviewer"
|
|
callerSuggestedReviewer = "dry-run-display-caller-reviewer"
|
|
missingUser = "dry-run-display-missing-user"
|
|
requestableRole = "dry-run-display-requestable-role"
|
|
requesterRole = "dry-run-display-requester-role"
|
|
)
|
|
|
|
testModules := modulestest.EnterpriseModules()
|
|
testModules.GenerateAccessRequestSuggestedReviewersFn = func(context.Context, modules.AccessResourcesGetter, types.AccessRequest) ([]string, error) {
|
|
return []string{dynamicSuggestedReviewer}, nil
|
|
}
|
|
|
|
authServer, err := authtest.NewAuthServer(authtest.AuthServerConfig{
|
|
Dir: t.TempDir(),
|
|
Modules: testModules,
|
|
})
|
|
require.NoError(t, err)
|
|
defer authServer.Close()
|
|
|
|
tlsServer, err := authServer.NewTestTLSServer()
|
|
require.NoError(t, err)
|
|
defer tlsServer.Close()
|
|
|
|
ctx := t.Context()
|
|
|
|
requestable, err := types.NewRole(requestableRole, types.RoleSpecV6{})
|
|
require.NoError(t, err)
|
|
_, err = tlsServer.Auth().UpsertRole(ctx, requestable)
|
|
require.NoError(t, err)
|
|
|
|
// The requester can request the requestable role but cannot read other
|
|
// users, so displays must come from server-side resolution.
|
|
role, err := types.NewRole(requesterRole, types.RoleSpecV6{
|
|
Allow: types.RoleConditions{
|
|
Request: &types.AccessRequestConditions{
|
|
Roles: []string{requestableRole},
|
|
SuggestedReviewers: []string{policySuggestedReviewer},
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
_, err = tlsServer.Auth().UpsertRole(ctx, role)
|
|
require.NoError(t, err)
|
|
|
|
for _, user := range []types.User{
|
|
newAccessRequestDisplayUser(t, requester, "Request User", "requester@example.com", requesterRole),
|
|
newAccessRequestDisplayUser(t, policySuggestedReviewer, "Policy Reviewer", "policy@example.com", requestableRole),
|
|
newAccessRequestDisplayUser(t, dynamicSuggestedReviewer, "Dynamic Reviewer", "dynamic@example.com", requestableRole),
|
|
newAccessRequestDisplayUser(t, callerSuggestedReviewer, "Caller Reviewer", "caller@example.com", requestableRole),
|
|
} {
|
|
_, err := tlsServer.Auth().UpsertUser(ctx, user)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
clt, err := tlsServer.NewClient(authtest.TestUser(requester))
|
|
require.NoError(t, err)
|
|
defer clt.Close()
|
|
|
|
t.Run("server suggested reviewers", func(t *testing.T) {
|
|
req, err := services.NewAccessRequest(requester, requestableRole)
|
|
require.NoError(t, err)
|
|
req.SetDryRun(true)
|
|
|
|
resp, err := clt.CreateAccessRequestV2(ctx, req)
|
|
require.NoError(t, err)
|
|
|
|
enrichment := resp.GetDryRunEnrichment()
|
|
require.NotNil(t, enrichment)
|
|
require.Equal(t, types.RequestReasonModeOptional, enrichment.ReasonMode)
|
|
require.Equal(t, map[string]types.AccessRequestUserDisplay{
|
|
requester: {
|
|
Primary: "Request User",
|
|
Secondary: "requester@example.com",
|
|
},
|
|
policySuggestedReviewer: {
|
|
Primary: "Policy Reviewer",
|
|
Secondary: "policy@example.com",
|
|
},
|
|
dynamicSuggestedReviewer: {
|
|
Primary: "Dynamic Reviewer",
|
|
Secondary: "dynamic@example.com",
|
|
},
|
|
}, enrichment.UserDisplays)
|
|
})
|
|
|
|
t.Run("caller suggested reviewers", func(t *testing.T) {
|
|
req, err := services.NewAccessRequest(requester, requestableRole)
|
|
require.NoError(t, err)
|
|
req.SetDryRun(true)
|
|
req.SetSuggestedReviewers([]string{callerSuggestedReviewer, missingUser})
|
|
|
|
resp, err := clt.CreateAccessRequestV2(ctx, req)
|
|
require.NoError(t, err)
|
|
|
|
enrichment := resp.GetDryRunEnrichment()
|
|
require.NotNil(t, enrichment)
|
|
require.Equal(t, map[string]types.AccessRequestUserDisplay{
|
|
requester: {
|
|
Primary: "Request User",
|
|
Secondary: "requester@example.com",
|
|
},
|
|
dynamicSuggestedReviewer: {
|
|
Primary: "Dynamic Reviewer",
|
|
Secondary: "dynamic@example.com",
|
|
},
|
|
}, enrichment.UserDisplays)
|
|
require.Contains(t, resp.GetSuggestedReviewers(), callerSuggestedReviewer)
|
|
require.NotContains(t, enrichment.UserDisplays, callerSuggestedReviewer)
|
|
require.NotContains(t, enrichment.UserDisplays, missingUser)
|
|
})
|
|
}
|
|
|
|
func testAccessRequestDenyRules(t *testing.T, testPack *accessRequestTestPack) {
|
|
t.Parallel()
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
t.Cleanup(cancel)
|
|
|
|
userName := "denied"
|
|
|
|
accessRequest, err := services.NewAccessRequest(userName, "admins")
|
|
require.NoError(t, err)
|
|
|
|
for _, tc := range []struct {
|
|
desc string
|
|
roles map[string]types.RoleSpecV6
|
|
expectGetDenied bool
|
|
expectCreateDenied bool
|
|
}{
|
|
{
|
|
desc: "all allowed",
|
|
roles: map[string]types.RoleSpecV6{
|
|
"allow": {
|
|
Allow: types.RoleConditions{
|
|
Request: &types.AccessRequestConditions{
|
|
Roles: []string{"admins"},
|
|
},
|
|
ReviewRequests: &types.AccessReviewConditions{
|
|
Roles: []string{"admins"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
desc: "all denied",
|
|
roles: map[string]types.RoleSpecV6{
|
|
"allow": {
|
|
Allow: types.RoleConditions{
|
|
Request: &types.AccessRequestConditions{
|
|
Roles: []string{"admins"},
|
|
},
|
|
ReviewRequests: &types.AccessReviewConditions{
|
|
Roles: []string{"admins"},
|
|
},
|
|
},
|
|
},
|
|
"deny": {
|
|
Deny: types.RoleConditions{
|
|
Rules: []types.Rule{
|
|
{
|
|
Resources: []string{"access_request"},
|
|
Verbs: []string{"read", "create", "list"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
expectGetDenied: true,
|
|
expectCreateDenied: true,
|
|
},
|
|
{
|
|
desc: "create denied",
|
|
roles: map[string]types.RoleSpecV6{
|
|
"allow": {
|
|
Allow: types.RoleConditions{
|
|
Request: &types.AccessRequestConditions{
|
|
Roles: []string{"admins"},
|
|
},
|
|
ReviewRequests: &types.AccessReviewConditions{
|
|
Roles: []string{"admins"},
|
|
},
|
|
},
|
|
},
|
|
"deny": {
|
|
Deny: types.RoleConditions{
|
|
Rules: []types.Rule{
|
|
{
|
|
Resources: []string{"access_request"},
|
|
Verbs: []string{"create"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
expectCreateDenied: true,
|
|
},
|
|
{
|
|
desc: "get denied",
|
|
roles: map[string]types.RoleSpecV6{
|
|
"allow": {
|
|
Allow: types.RoleConditions{
|
|
Request: &types.AccessRequestConditions{
|
|
Roles: []string{"admins"},
|
|
},
|
|
ReviewRequests: &types.AccessReviewConditions{
|
|
Roles: []string{"admins"},
|
|
},
|
|
},
|
|
},
|
|
"deny": {
|
|
Deny: types.RoleConditions{
|
|
Rules: []types.Rule{
|
|
{
|
|
Resources: []string{"access_request"},
|
|
Verbs: []string{"read"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
expectGetDenied: true,
|
|
},
|
|
{
|
|
desc: "list denied",
|
|
roles: map[string]types.RoleSpecV6{
|
|
"allow": {
|
|
Allow: types.RoleConditions{
|
|
Request: &types.AccessRequestConditions{
|
|
Roles: []string{"admins"},
|
|
},
|
|
ReviewRequests: &types.AccessReviewConditions{
|
|
Roles: []string{"admins"},
|
|
},
|
|
},
|
|
},
|
|
"deny": {
|
|
Deny: types.RoleConditions{
|
|
Rules: []types.Rule{
|
|
{
|
|
Resources: []string{"access_request"},
|
|
Verbs: []string{"list"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
expectGetDenied: true,
|
|
},
|
|
} {
|
|
t.Run(tc.desc, func(t *testing.T) {
|
|
for roleName, roleSpec := range tc.roles {
|
|
role, err := types.NewRole(roleName, roleSpec)
|
|
require.NoError(t, err)
|
|
_, err = testPack.tlsServer.Auth().UpsertRole(ctx, role)
|
|
require.NoError(t, err)
|
|
}
|
|
user, err := types.NewUser(userName)
|
|
require.NoError(t, err)
|
|
user.SetRoles(slices.Collect(maps.Keys(tc.roles)))
|
|
_, err = testPack.tlsServer.Auth().UpsertUser(ctx, user)
|
|
require.NoError(t, err)
|
|
|
|
client, err := testPack.tlsServer.NewClient(authtest.TestUser(userName))
|
|
require.NoError(t, err)
|
|
|
|
_, err = client.GetAccessRequests(ctx, types.AccessRequestFilter{})
|
|
if tc.expectGetDenied {
|
|
assert.True(t, trace.IsAccessDenied(err), "want access denied, got %v", err)
|
|
} else {
|
|
assert.NoError(t, err)
|
|
}
|
|
|
|
_, err = client.CreateAccessRequestV2(ctx, accessRequest)
|
|
if tc.expectCreateDenied {
|
|
assert.True(t, trace.IsAccessDenied(err), "want access denied, got %v", err)
|
|
} else {
|
|
assert.NoError(t, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func testSingleAccessRequests(t *testing.T, testPack *accessRequestTestPack) {
|
|
t.Parallel()
|
|
|
|
testCases := []struct {
|
|
desc string
|
|
requester string
|
|
reviewer string
|
|
expectRequestableRoles []string
|
|
requestRoles []string
|
|
expectRoles []string
|
|
expectNodes []string
|
|
requestResources []string
|
|
expectRequestError error
|
|
expectReviewError error
|
|
}{
|
|
{
|
|
desc: "role request",
|
|
requester: "operator",
|
|
reviewer: "admin",
|
|
expectRequestableRoles: []string{"admins"},
|
|
requestRoles: []string{"admins"},
|
|
expectRoles: []string{"operators", "admins"},
|
|
expectNodes: []string{"prod", "staging"},
|
|
},
|
|
{
|
|
desc: "no requestable roles",
|
|
requester: "nobody",
|
|
requestRoles: []string{"admins"},
|
|
expectRequestError: trace.BadParameter(`user "nobody" can not request role "admins"`),
|
|
},
|
|
{
|
|
desc: "role not allowed",
|
|
requester: "operator",
|
|
expectRequestableRoles: []string{"admins"},
|
|
requestRoles: []string{"super-admins"},
|
|
expectRequestError: trace.BadParameter(`user "operator" can not request role "super-admins"`),
|
|
},
|
|
{
|
|
desc: "review own request",
|
|
requester: "operator",
|
|
reviewer: "operator",
|
|
expectRequestableRoles: []string{"admins"},
|
|
requestRoles: []string{"admins"},
|
|
expectReviewError: trace.AccessDenied(`user "operator" cannot submit reviews`),
|
|
},
|
|
{
|
|
desc: "resource request for staging",
|
|
requester: "responder",
|
|
reviewer: "admin",
|
|
requestResources: []string{"staging"},
|
|
expectRoles: []string{"responders", "admins"},
|
|
expectNodes: []string{"staging"},
|
|
},
|
|
{
|
|
desc: "resource request for prod",
|
|
requester: "responder",
|
|
reviewer: "admin",
|
|
requestResources: []string{"prod"},
|
|
expectRoles: []string{"responders", "admins"},
|
|
expectNodes: []string{"prod"},
|
|
},
|
|
{
|
|
desc: "resource request for both",
|
|
requester: "responder",
|
|
reviewer: "admin",
|
|
requestResources: []string{"prod", "staging"},
|
|
expectRoles: []string{"responders", "admins"},
|
|
expectNodes: []string{"prod", "staging"},
|
|
},
|
|
{
|
|
desc: "no search_as_roles",
|
|
requester: "nobody",
|
|
requestResources: []string{"prod"},
|
|
expectRequestError: trace.AccessDenied(`Resource Access Requests require usable "search_as_roles", none found for user "nobody"`),
|
|
},
|
|
}
|
|
for _, tc := range testCases {
|
|
t.Run(tc.desc, func(t *testing.T) {
|
|
t.Parallel()
|
|
requester := authtest.TestUser(tc.requester)
|
|
requesterClient, err := testPack.tlsServer.NewClient(requester)
|
|
require.NoError(t, err)
|
|
|
|
// generateCerts executes a GenerateUserCerts request, optionally applying
|
|
// one or more access-requests to the certificate.
|
|
generateCerts := func(reqIDs ...string) (*proto.Certs, error) {
|
|
return requesterClient.GenerateUserCerts(t.Context(), proto.UserCertsRequest{
|
|
SSHPublicKey: testPack.sshPubKey,
|
|
TLSPublicKey: testPack.tlsPubKey,
|
|
Username: tc.requester,
|
|
Expires: time.Now().Add(time.Hour).UTC(),
|
|
Format: constants.CertificateFormatStandard,
|
|
AccessRequests: reqIDs,
|
|
})
|
|
}
|
|
|
|
// sanity check we can get certs with no access request
|
|
certs, err := generateCerts()
|
|
require.NoError(t, err)
|
|
|
|
// should have no logins, requests, or resources in ssh cert
|
|
checkCerts(t, certs, testPack.users[tc.requester], nil, nil, nil)
|
|
|
|
// should not be able to list any nodes
|
|
nodes, err := requesterClient.GetNodes(t.Context(), defaults.Namespace)
|
|
require.NoError(t, err)
|
|
require.Empty(t, nodes)
|
|
|
|
// requestable roles should be correct
|
|
caps, err := requesterClient.GetAccessCapabilities(t.Context(), types.AccessCapabilitiesRequest{
|
|
RequestableRoles: true,
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, tc.expectRequestableRoles, caps.RequestableRoles)
|
|
|
|
// create the access request object
|
|
requestResourceIDs := []types.ResourceAccessID{}
|
|
for _, nodeName := range tc.requestResources {
|
|
requestResourceIDs = append(requestResourceIDs, types.ResourceAccessID{
|
|
Id: types.ResourceID{
|
|
ClusterName: testPack.clusterName,
|
|
Kind: types.KindNode,
|
|
Name: nodeName,
|
|
},
|
|
})
|
|
}
|
|
req, err := services.NewAccessRequestWithResources(tc.requester, tc.requestRoles, requestResourceIDs)
|
|
require.NoError(t, err)
|
|
|
|
// send the request to the auth server
|
|
req, err = requesterClient.CreateAccessRequestV2(t.Context(), req)
|
|
require.ErrorIs(t, err, tc.expectRequestError)
|
|
if tc.expectRequestError != nil {
|
|
return
|
|
}
|
|
|
|
// try logging in with request in PENDING state (should fail)
|
|
_, err = generateCerts(req.GetName())
|
|
require.ErrorIs(t, err, trace.AccessDenied("access request %q is awaiting approval", req.GetName()))
|
|
|
|
reviewer := authtest.TestUser(tc.reviewer)
|
|
reviewerClient, err := testPack.tlsServer.NewClient(reviewer)
|
|
require.NoError(t, err)
|
|
|
|
// approve the request
|
|
req, err = reviewerClient.SubmitAccessReview(t.Context(), types.AccessReviewSubmission{
|
|
RequestID: req.GetName(),
|
|
Review: types.AccessReview{
|
|
ProposedState: types.RequestState_APPROVED,
|
|
},
|
|
})
|
|
require.ErrorIs(t, err, tc.expectReviewError)
|
|
if tc.expectReviewError != nil {
|
|
return
|
|
}
|
|
require.NoError(t, reviewerClient.Close())
|
|
require.Equal(t, types.RequestState_APPROVED, req.GetState())
|
|
|
|
// log in now that request has been approved
|
|
certs, err = generateCerts(req.GetName())
|
|
require.NoError(t, err)
|
|
|
|
// cert should have login from requested role, the access request
|
|
// should be in the cert, and any requested resources should be
|
|
// encoded in the cert
|
|
checkCerts(t,
|
|
certs,
|
|
tc.expectRoles,
|
|
[]string{"root"},
|
|
[]string{req.GetName()},
|
|
requestResourceIDs)
|
|
|
|
elevatedCert, err := tls.X509KeyPair(certs.TLS, testPack.tlsPrivKey)
|
|
require.NoError(t, err)
|
|
elevatedClient, err := testPack.tlsServer.NewClientWithCert(elevatedCert)
|
|
require.NoError(t, err)
|
|
|
|
// should be able to list the expected nodes
|
|
nodes, err = elevatedClient.GetNodes(t.Context(), defaults.Namespace)
|
|
require.NoError(t, err)
|
|
gotNodes := make([]string, 0, len(nodes))
|
|
for _, node := range nodes {
|
|
gotNodes = append(gotNodes, node.GetName())
|
|
}
|
|
sort.Strings(gotNodes)
|
|
require.Equal(t, tc.expectNodes, gotNodes)
|
|
|
|
// renew elevated certs
|
|
newCerts, err := elevatedClient.GenerateUserCerts(t.Context(), proto.UserCertsRequest{
|
|
SSHPublicKey: testPack.sshPubKey,
|
|
TLSPublicKey: testPack.tlsPubKey,
|
|
Username: tc.requester,
|
|
Expires: time.Now().Add(time.Hour).UTC(),
|
|
// no new access requests
|
|
AccessRequests: nil,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// in spite of providing no access requests, we still have elevated
|
|
// roles and the certificate shows the original access request
|
|
checkCerts(t,
|
|
newCerts,
|
|
tc.expectRoles,
|
|
[]string{"root"},
|
|
[]string{req.GetName()},
|
|
requestResourceIDs)
|
|
|
|
// ensure that once in the APPROVED state, a request cannot be set to DENIED.
|
|
err = testPack.tlsServer.Auth().SetAccessRequestState(t.Context(), types.AccessRequestUpdate{
|
|
RequestID: req.GetName(),
|
|
State: types.RequestState_DENIED,
|
|
})
|
|
require.True(t, trace.IsBadParameter(err), "unexpected error: %v", err)
|
|
|
|
// ensure that once in the APPROVED state, a request cannot be updated again.
|
|
err = testPack.tlsServer.Auth().SetAccessRequestState(t.Context(), types.AccessRequestUpdate{
|
|
RequestID: req.GetName(),
|
|
State: types.RequestState_APPROVED,
|
|
})
|
|
require.True(t, trace.IsBadParameter(err), "unexpected error: %v", err)
|
|
|
|
deniedReq, err := services.NewAccessRequestWithResources(tc.requester, tc.requestRoles, requestResourceIDs)
|
|
require.NoError(t, err)
|
|
deniedReq, err = requesterClient.CreateAccessRequestV2(t.Context(), deniedReq)
|
|
require.NoError(t, err)
|
|
|
|
// attempt to use a request in DENIED state (should fail)
|
|
require.NoError(t, testPack.tlsServer.Auth().SetAccessRequestState(t.Context(), types.AccessRequestUpdate{
|
|
RequestID: deniedReq.GetName(),
|
|
State: types.RequestState_DENIED,
|
|
}))
|
|
|
|
// ensure that identities with requests in the DENIED state can't reissue new certs.
|
|
_, err = generateCerts(deniedReq.GetName())
|
|
require.ErrorIs(t, err, trace.AccessDenied("access request %q has been denied", deniedReq.GetName()))
|
|
|
|
// ensure that once in the DENIED state, a request cannot be set back to PENDING state.
|
|
err = testPack.tlsServer.Auth().SetAccessRequestState(t.Context(), types.AccessRequestUpdate{
|
|
RequestID: deniedReq.GetName(),
|
|
State: types.RequestState_PENDING,
|
|
})
|
|
require.True(t, trace.IsBadParameter(err), "unexpected error: %v", err)
|
|
|
|
// ensure that once in the DENIED state, a request cannot be set back to APPROVED state.
|
|
err = testPack.tlsServer.Auth().SetAccessRequestState(t.Context(), types.AccessRequestUpdate{
|
|
RequestID: deniedReq.GetName(),
|
|
State: types.RequestState_APPROVED,
|
|
})
|
|
require.True(t, trace.IsBadParameter(err), "unexpected error: %v", err)
|
|
|
|
// ensure that once in the DENIED state, a request cannot be updated again.
|
|
err = testPack.tlsServer.Auth().SetAccessRequestState(t.Context(), types.AccessRequestUpdate{
|
|
RequestID: deniedReq.GetName(),
|
|
State: types.RequestState_DENIED,
|
|
})
|
|
require.True(t, trace.IsBadParameter(err), "unexpected error: %v", err)
|
|
})
|
|
}
|
|
}
|
|
|
|
// testBotAccessRequestReview specifically ensures that a bots output cert
|
|
// can be used to review a access request. This is because there's a special
|
|
// case to handle their role impersonated certs correctly.
|
|
func testBotAccessRequestReview(t *testing.T, testPack *accessRequestTestPack) {
|
|
t.Parallel()
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
t.Cleanup(cancel)
|
|
|
|
// Create the bot
|
|
adminClient, err := testPack.tlsServer.NewClient(authtest.TestAdmin())
|
|
require.NoError(t, err)
|
|
defer adminClient.Close()
|
|
bot, err := adminClient.BotServiceClient().CreateBot(ctx, machineidv1pb.CreateBotRequest_builder{
|
|
Bot: machineidv1pb.Bot_builder{
|
|
Kind: types.KindBot,
|
|
Version: types.V1,
|
|
Metadata: headerv1.Metadata_builder{
|
|
Name: "request-approver",
|
|
}.Build(),
|
|
Spec: machineidv1pb.BotSpec_builder{
|
|
Roles: []string{
|
|
// Grants the ability to approve requests
|
|
"admins",
|
|
},
|
|
}.Build(),
|
|
}.Build(),
|
|
}.Build())
|
|
require.NoError(t, err)
|
|
|
|
// Use the bot user to generate some certs using role impersonation.
|
|
// This mimics what the bot actually does.
|
|
botClient, err := testPack.tlsServer.NewClient(authtest.TestUser(bot.GetStatus().GetUserName()))
|
|
require.NoError(t, err)
|
|
defer botClient.Close()
|
|
certRes, err := botClient.GenerateUserCerts(ctx, proto.UserCertsRequest{
|
|
Username: bot.GetStatus().GetUserName(),
|
|
TLSPublicKey: testPack.tlsPubKey,
|
|
Expires: time.Now().Add(time.Hour),
|
|
|
|
RoleRequests: []string{"admins"},
|
|
UseRoleRequests: true,
|
|
})
|
|
require.NoError(t, err)
|
|
tlsCert, err := tls.X509KeyPair(certRes.TLS, testPack.tlsPrivKey)
|
|
require.NoError(t, err)
|
|
impersonatedBotClient, err := testPack.tlsServer.NewClientWithCert(tlsCert)
|
|
require.NoError(t, err)
|
|
defer impersonatedBotClient.Close()
|
|
|
|
// Create an access request for the bot to approve
|
|
requesterClient, err := testPack.tlsServer.NewClient(authtest.TestUser("requester"))
|
|
require.NoError(t, err)
|
|
defer requesterClient.Close()
|
|
accessRequest, err := services.NewAccessRequest("requester", "admins")
|
|
require.NoError(t, err)
|
|
accessRequest, err = requesterClient.CreateAccessRequestV2(ctx, accessRequest)
|
|
require.NoError(t, err)
|
|
|
|
// Approve the access request with the bot
|
|
accessRequest, err = impersonatedBotClient.SubmitAccessReview(ctx, types.AccessReviewSubmission{
|
|
RequestID: accessRequest.GetName(),
|
|
Review: types.AccessReview{
|
|
ProposedState: types.RequestState_APPROVED,
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Check the final state of the request
|
|
require.Equal(t, bot.GetStatus().GetUserName(), accessRequest.GetReviews()[0].Author)
|
|
require.Equal(t, types.RequestState_APPROVED, accessRequest.GetState())
|
|
}
|
|
|
|
func testMultiAccessRequests(t *testing.T, testPack *accessRequestTestPack) {
|
|
t.Parallel()
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
t.Cleanup(cancel)
|
|
|
|
username := "requester"
|
|
|
|
prodResourceIDs := []types.ResourceAccessID{{
|
|
Id: types.ResourceID{
|
|
ClusterName: testPack.clusterName,
|
|
Kind: types.KindNode,
|
|
Name: "prod",
|
|
},
|
|
}}
|
|
prodResourceRequest, err := services.NewAccessRequestWithResources(username, []string{"admins"}, prodResourceIDs)
|
|
require.NoError(t, err)
|
|
|
|
stagingResourceIDs := []types.ResourceAccessID{{
|
|
Id: types.ResourceID{
|
|
ClusterName: testPack.clusterName,
|
|
Kind: types.KindNode,
|
|
Name: "staging",
|
|
},
|
|
}}
|
|
stagingResourceRequest, err := services.NewAccessRequestWithResources(username, []string{"admins"}, stagingResourceIDs)
|
|
require.NoError(t, err)
|
|
|
|
adminRequest, err := services.NewAccessRequest(username, "admins")
|
|
require.NoError(t, err)
|
|
|
|
superAdminRequest, err := services.NewAccessRequest(username, "superadmins")
|
|
require.NoError(t, err)
|
|
|
|
bothRolesRequest, err := services.NewAccessRequest(username, "admins", "superadmins")
|
|
require.NoError(t, err)
|
|
|
|
for _, request := range []types.AccessRequest{
|
|
prodResourceRequest,
|
|
stagingResourceRequest,
|
|
adminRequest,
|
|
superAdminRequest,
|
|
bothRolesRequest,
|
|
} {
|
|
request.SetState(types.RequestState_APPROVED)
|
|
request.SetAccessExpiry(time.Now().Add(time.Hour).UTC())
|
|
require.NoError(t, testPack.tlsServer.Auth().UpsertAccessRequest(ctx, request))
|
|
}
|
|
|
|
requester := authtest.TestUser(username)
|
|
requesterClient, err := testPack.tlsServer.NewClient(requester)
|
|
require.NoError(t, err)
|
|
|
|
type newClientFunc func(*testing.T, *authclient.Client, *proto.Certs) (*authclient.Client, *proto.Certs)
|
|
updateClientWithNewAndDroppedRequests := func(newRequests, dropRequests []string) newClientFunc {
|
|
return func(t *testing.T, clt *authclient.Client, _ *proto.Certs) (*authclient.Client, *proto.Certs) {
|
|
certs, err := clt.GenerateUserCerts(ctx, proto.UserCertsRequest{
|
|
SSHPublicKey: testPack.sshPubKey,
|
|
TLSPublicKey: testPack.tlsPubKey,
|
|
Username: username,
|
|
Expires: time.Now().Add(time.Hour).UTC(),
|
|
AccessRequests: newRequests,
|
|
DropAccessRequests: dropRequests,
|
|
})
|
|
require.NoError(t, err)
|
|
tlsCert, err := tls.X509KeyPair(certs.TLS, testPack.tlsPrivKey)
|
|
require.NoError(t, err)
|
|
client, err := testPack.tlsServer.NewClientWithCert(tlsCert)
|
|
require.NoError(t, err)
|
|
return client, certs
|
|
}
|
|
}
|
|
applyAccessRequests := func(newRequests ...string) newClientFunc {
|
|
return updateClientWithNewAndDroppedRequests(newRequests, nil)
|
|
}
|
|
dropAccessRequests := func(dropRequests ...string) newClientFunc {
|
|
return updateClientWithNewAndDroppedRequests(nil, dropRequests)
|
|
}
|
|
failToApplyAccessRequests := func(reqs ...string) newClientFunc {
|
|
return func(t *testing.T, clt *authclient.Client, certs *proto.Certs) (*authclient.Client, *proto.Certs) {
|
|
// assert that this request fails
|
|
_, err := clt.GenerateUserCerts(ctx, proto.UserCertsRequest{
|
|
SSHPublicKey: testPack.sshPubKey,
|
|
TLSPublicKey: testPack.tlsPubKey,
|
|
Username: username,
|
|
Expires: time.Now().Add(time.Hour).UTC(),
|
|
AccessRequests: reqs,
|
|
})
|
|
assert.Error(t, err)
|
|
// return original client and certs unchanged
|
|
return clt, certs
|
|
}
|
|
}
|
|
|
|
for _, tc := range []struct {
|
|
desc string
|
|
steps []newClientFunc
|
|
expectRoles []string
|
|
expectResources []types.ResourceAccessID
|
|
expectAccessRequests []string
|
|
expectLogins []string
|
|
}{
|
|
{
|
|
desc: "multi role requests",
|
|
steps: []newClientFunc{
|
|
applyAccessRequests(adminRequest.GetName()),
|
|
applyAccessRequests(superAdminRequest.GetName()),
|
|
},
|
|
expectRoles: []string{"requesters", "admins", "superadmins"},
|
|
expectLogins: []string{"root"},
|
|
expectAccessRequests: []string{adminRequest.GetName(), superAdminRequest.GetName()},
|
|
},
|
|
{
|
|
desc: "multi resource requests",
|
|
steps: []newClientFunc{
|
|
applyAccessRequests(prodResourceRequest.GetName()),
|
|
failToApplyAccessRequests(stagingResourceRequest.GetName()),
|
|
failToApplyAccessRequests(prodResourceRequest.GetName(), stagingResourceRequest.GetName()),
|
|
},
|
|
expectRoles: []string{"requesters", "admins"},
|
|
expectLogins: []string{"root"},
|
|
expectResources: prodResourceIDs,
|
|
expectAccessRequests: []string{prodResourceRequest.GetName()},
|
|
},
|
|
{
|
|
desc: "role then resource",
|
|
steps: []newClientFunc{
|
|
applyAccessRequests(adminRequest.GetName()),
|
|
applyAccessRequests(prodResourceRequest.GetName()),
|
|
},
|
|
expectRoles: []string{"requesters", "admins"},
|
|
expectLogins: []string{"root"},
|
|
expectResources: prodResourceIDs,
|
|
expectAccessRequests: []string{adminRequest.GetName(), prodResourceRequest.GetName()},
|
|
},
|
|
{
|
|
desc: "resource then role",
|
|
steps: []newClientFunc{
|
|
applyAccessRequests(prodResourceRequest.GetName()),
|
|
applyAccessRequests(adminRequest.GetName()),
|
|
},
|
|
expectRoles: []string{"requesters", "admins"},
|
|
expectLogins: []string{"root"},
|
|
expectResources: prodResourceIDs,
|
|
expectAccessRequests: []string{adminRequest.GetName(), prodResourceRequest.GetName()},
|
|
},
|
|
{
|
|
desc: "combined",
|
|
steps: []newClientFunc{
|
|
failToApplyAccessRequests(stagingResourceRequest.GetName(), prodResourceRequest.GetName()),
|
|
applyAccessRequests(prodResourceRequest.GetName(), adminRequest.GetName()),
|
|
failToApplyAccessRequests(stagingResourceRequest.GetName(), superAdminRequest.GetName()),
|
|
applyAccessRequests(superAdminRequest.GetName()),
|
|
},
|
|
expectRoles: []string{"requesters", "admins", "superadmins"},
|
|
expectLogins: []string{"root"},
|
|
expectResources: prodResourceIDs,
|
|
expectAccessRequests: []string{adminRequest.GetName(), prodResourceRequest.GetName(), superAdminRequest.GetName()},
|
|
},
|
|
{
|
|
desc: "drop resource request",
|
|
steps: []newClientFunc{
|
|
applyAccessRequests(prodResourceRequest.GetName(), adminRequest.GetName(), superAdminRequest.GetName()),
|
|
dropAccessRequests(prodResourceRequest.GetName()),
|
|
},
|
|
expectRoles: []string{"requesters", "admins", "superadmins"},
|
|
expectLogins: []string{"root"},
|
|
expectAccessRequests: []string{adminRequest.GetName(), superAdminRequest.GetName()},
|
|
},
|
|
{
|
|
desc: "drop role request",
|
|
steps: []newClientFunc{
|
|
applyAccessRequests(prodResourceRequest.GetName(), adminRequest.GetName(), superAdminRequest.GetName()),
|
|
dropAccessRequests(superAdminRequest.GetName()),
|
|
},
|
|
expectRoles: []string{"requesters", "admins"},
|
|
expectResources: prodResourceIDs,
|
|
expectLogins: []string{"root"},
|
|
expectAccessRequests: []string{adminRequest.GetName(), prodResourceRequest.GetName()},
|
|
},
|
|
{
|
|
desc: "drop all",
|
|
steps: []newClientFunc{
|
|
applyAccessRequests(prodResourceRequest.GetName(), adminRequest.GetName(), superAdminRequest.GetName()),
|
|
dropAccessRequests("*"),
|
|
},
|
|
expectRoles: []string{"requesters"},
|
|
},
|
|
{
|
|
desc: "switch resource requests",
|
|
steps: []newClientFunc{
|
|
applyAccessRequests(adminRequest.GetName()),
|
|
applyAccessRequests(prodResourceRequest.GetName()),
|
|
failToApplyAccessRequests(stagingResourceRequest.GetName()),
|
|
dropAccessRequests(prodResourceRequest.GetName()),
|
|
applyAccessRequests(stagingResourceRequest.GetName()),
|
|
failToApplyAccessRequests(prodResourceRequest.GetName()),
|
|
dropAccessRequests(stagingResourceRequest.GetName()),
|
|
},
|
|
expectRoles: []string{"requesters", "admins"},
|
|
expectLogins: []string{"root"},
|
|
expectAccessRequests: []string{adminRequest.GetName()},
|
|
},
|
|
} {
|
|
t.Run(tc.desc, func(t *testing.T) {
|
|
t.Parallel()
|
|
client := requesterClient
|
|
var certs *proto.Certs
|
|
for _, step := range tc.steps {
|
|
client, certs = step(t, client, certs)
|
|
}
|
|
checkCerts(t, certs, tc.expectRoles, tc.expectLogins, tc.expectAccessRequests, tc.expectResources)
|
|
})
|
|
}
|
|
}
|
|
|
|
// testRoleRefreshWithBogusRequestID verifies that GenerateUserCerts refreshes the role list based
|
|
// on the server state, even when supplied an ID for a nonexistent access request.
|
|
//
|
|
// Teleport Connect depends on this behavior when setting up roles for Connect My Computer.
|
|
// See [teleterm.connectmycomputer.RoleSetup].
|
|
func testRoleRefreshWithBogusRequestID(t *testing.T, testPack *accessRequestTestPack) {
|
|
t.Parallel()
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
t.Cleanup(cancel)
|
|
|
|
username := "user-for-role-refresh"
|
|
auth := testPack.tlsServer.Auth()
|
|
|
|
// Create a user.
|
|
user, err := types.NewUser(username)
|
|
require.NoError(t, err)
|
|
user.AddRole("requesters")
|
|
user, err = auth.UpsertUser(ctx, user)
|
|
require.NoError(t, err)
|
|
|
|
// Create a client with the old set of roles.
|
|
clt, err := testPack.tlsServer.NewClient(authtest.TestUser(username))
|
|
require.NoError(t, err)
|
|
|
|
// Add a new role to the user on the server.
|
|
user.AddRole("operators")
|
|
_, err = auth.UpdateUser(ctx, user)
|
|
require.NoError(t, err)
|
|
|
|
certs, err := clt.GenerateUserCerts(ctx, proto.UserCertsRequest{
|
|
SSHPublicKey: testPack.sshPubKey,
|
|
TLSPublicKey: testPack.tlsPubKey,
|
|
Username: username,
|
|
Expires: time.Now().Add(time.Hour).UTC(),
|
|
DropAccessRequests: []string{"bogus-request-id"},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Verify that the new certs issued for the old client have the new role.
|
|
checkCerts(t, certs, []string{"requesters", "operators"}, nil, nil, nil)
|
|
}
|
|
|
|
// testDropRequestsKeepsAccessListGrants verifies that dropping access requests
|
|
// resets the certificate roles and traits to the user login state, which
|
|
// includes roles and traits granted by access lists, rather than to the bare
|
|
// backend user. See https://github.com/gravitational/teleport/issues/45797.
|
|
func testDropRequestsKeepsAccessListGrants(t *testing.T, testPack *accessRequestTestPack) {
|
|
t.Parallel()
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
t.Cleanup(cancel)
|
|
|
|
username := "requester-with-access-list"
|
|
authServer := testPack.tlsServer.Auth()
|
|
|
|
// Create a user with the base "requesters" role.
|
|
user, err := types.NewUser(username)
|
|
require.NoError(t, err)
|
|
user.AddRole("requesters")
|
|
_, err = authServer.UpsertUser(ctx, user)
|
|
require.NoError(t, err)
|
|
|
|
// Create a role that is granted only through an access list.
|
|
aclRole, err := types.NewRole("access-list-granted", types.RoleSpecV6{
|
|
Allow: types.RoleConditions{
|
|
Logins: []string{"acl-login"},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
_, err = authServer.UpsertRole(ctx, aclRole)
|
|
require.NoError(t, err)
|
|
|
|
// Grant the role through a real access list. Dropping access requests
|
|
// regenerates the user login state from the backend user and access
|
|
// lists, so the grant must be backed by an actual list membership.
|
|
acl, err := accesslist.NewAccessList(
|
|
header.Metadata{
|
|
Name: "requester-acl",
|
|
},
|
|
accesslist.Spec{
|
|
Title: "requester-acl",
|
|
Owners: []accesslist.Owner{{Name: "acl-owner"}},
|
|
Audit: accesslist.Audit{NextAuditDate: time.Now().Add(24 * time.Hour).UTC()},
|
|
Grants: accesslist.Grants{
|
|
Roles: []string{aclRole.GetName()},
|
|
},
|
|
},
|
|
)
|
|
require.NoError(t, err)
|
|
_, err = authServer.UpsertAccessList(ctx, acl)
|
|
require.NoError(t, err)
|
|
|
|
member, err := accesslist.NewAccessListMember(
|
|
header.Metadata{
|
|
Name: username,
|
|
},
|
|
accesslist.AccessListMemberSpec{
|
|
AccessList: acl.GetName(),
|
|
Name: username,
|
|
Joined: time.Now().UTC(),
|
|
AddedBy: "acl-owner",
|
|
Expires: time.Now().Add(24 * time.Hour).UTC(),
|
|
},
|
|
)
|
|
require.NoError(t, err)
|
|
_, err = authServer.UpsertAccessListMember(ctx, member)
|
|
require.NoError(t, err)
|
|
|
|
// Create an approved access request for the "admins" role.
|
|
accessRequest, err := services.NewAccessRequest(username, "admins")
|
|
require.NoError(t, err)
|
|
accessRequest.SetState(types.RequestState_APPROVED)
|
|
accessRequest.SetAccessExpiry(time.Now().Add(time.Hour).UTC())
|
|
require.NoError(t, authServer.UpsertAccessRequest(ctx, accessRequest))
|
|
|
|
clt, err := testPack.tlsServer.NewClient(authtest.TestUser(username))
|
|
require.NoError(t, err)
|
|
defer clt.Close()
|
|
|
|
// Assume the access request.
|
|
certs, err := clt.GenerateUserCerts(ctx, proto.UserCertsRequest{
|
|
SSHPublicKey: testPack.sshPubKey,
|
|
TLSPublicKey: testPack.tlsPubKey,
|
|
Username: username,
|
|
Expires: time.Now().Add(time.Hour).UTC(),
|
|
AccessRequests: []string{accessRequest.GetName()},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
tlsCert, err := tls.X509KeyPair(certs.TLS, testPack.tlsPrivKey)
|
|
require.NoError(t, err)
|
|
elevatedClient, err := testPack.tlsServer.NewClientWithCert(tlsCert)
|
|
require.NoError(t, err)
|
|
defer elevatedClient.Close()
|
|
|
|
// Drop all access requests. The reissued certs must keep the roles and
|
|
// traits granted by access lists via the user login state.
|
|
certs, err = elevatedClient.GenerateUserCerts(ctx, proto.UserCertsRequest{
|
|
SSHPublicKey: testPack.sshPubKey,
|
|
TLSPublicKey: testPack.tlsPubKey,
|
|
Username: username,
|
|
Expires: time.Now().Add(time.Hour).UTC(),
|
|
DropAccessRequests: []string{"*"},
|
|
})
|
|
require.NoError(t, err)
|
|
checkCerts(t, certs, []string{"requesters", aclRole.GetName()}, []string{"acl-login"}, nil, nil)
|
|
|
|
// Dropping a single request by ID must behave the same way.
|
|
certs, err = elevatedClient.GenerateUserCerts(ctx, proto.UserCertsRequest{
|
|
SSHPublicKey: testPack.sshPubKey,
|
|
TLSPublicKey: testPack.tlsPubKey,
|
|
Username: username,
|
|
Expires: time.Now().Add(time.Hour).UTC(),
|
|
DropAccessRequests: []string{accessRequest.GetName()},
|
|
})
|
|
require.NoError(t, err)
|
|
checkCerts(t, certs, []string{"requesters", aclRole.GetName()}, []string{"acl-login"}, nil, nil)
|
|
}
|
|
|
|
// checkCerts checks that the ssh and tls certs include the given roles, logins,
|
|
// accessRequests, and resourceIDs
|
|
func checkCerts(t *testing.T,
|
|
certs *proto.Certs,
|
|
roles []string,
|
|
logins []string,
|
|
accessRequests []string,
|
|
resourceAccessIDs []types.ResourceAccessID,
|
|
) {
|
|
t.Helper()
|
|
|
|
// Parse SSH cert.
|
|
sshCert, err := sshutils.ParseCertificate(certs.SSH)
|
|
require.NoError(t, err)
|
|
sshIdentity, err := sshca.DecodeIdentity(sshCert)
|
|
require.NoError(t, err)
|
|
|
|
// Parse TLS cert.
|
|
tlsCert, err := tlsca.ParseCertificatePEM(certs.TLS)
|
|
require.NoError(t, err)
|
|
tlsIdentity, err := tlsca.FromSubject(tlsCert.Subject, tlsCert.NotAfter)
|
|
require.NoError(t, err)
|
|
|
|
// Make sure both certs have the expected roles.
|
|
assert.ElementsMatch(t, roles, sshIdentity.Roles)
|
|
assert.ElementsMatch(t, roles, tlsIdentity.Groups)
|
|
|
|
// Make sure both certs have the expected logins/principals.
|
|
for _, certLogins := range [][]string{sshIdentity.Principals, tlsIdentity.Principals} {
|
|
// filter out invalid logins placed in the cert
|
|
validCertLogins := []string{}
|
|
for _, certLogin := range certLogins {
|
|
if !strings.HasPrefix(certLogin, "-teleport") {
|
|
validCertLogins = append(validCertLogins, certLogin)
|
|
}
|
|
}
|
|
assert.ElementsMatch(t, logins, validCertLogins)
|
|
}
|
|
|
|
// Make sure both certs have the expected access requests, if any.
|
|
assert.ElementsMatch(t, accessRequests, sshIdentity.ActiveRequests)
|
|
assert.ElementsMatch(t, accessRequests, tlsIdentity.ActiveRequests)
|
|
|
|
// Make sure both certs have the expected allowed resources, if any.
|
|
sshCertAllowedResources, err := types.ResourceAccessIDsFromString(sshCert.Permissions.Extensions[teleport.CertExtensionAllowedResourceAccessIDs])
|
|
require.NoError(t, err)
|
|
assert.ElementsMatch(t, resourceAccessIDs, sshCertAllowedResources)
|
|
assert.ElementsMatch(t, resourceAccessIDs, sshIdentity.AllowedResourceAccessIDs)
|
|
assert.ElementsMatch(t, resourceAccessIDs, tlsIdentity.AllowedResourceAccessIDs)
|
|
|
|
// Verify the legacy AllowedResourceIDs extension contains the expected values.
|
|
// Plain (unconstrained) resource IDs should appear in the old extension so that older
|
|
// agents/proxies that don't understand AllowedResourceAccessIDs can still enforce
|
|
// resource-level restrictions. The sentinel should only appear when all requested
|
|
// resources carry constraints that old agents can't enforce.
|
|
sshCertLegacyResources, err := types.ResourceIDsFromString(sshCert.Permissions.Extensions[teleport.CertExtensionAllowedResources])
|
|
require.NoError(t, err)
|
|
plainIDs, constrainedOnly := types.UnwrapResourceAccessIDs(resourceAccessIDs)
|
|
if len(plainIDs) > 0 {
|
|
// Plain resources should be in the old extension.
|
|
assert.ElementsMatch(t, plainIDs, sshCertLegacyResources, "SSH cert legacy extension should contain plain resource IDs")
|
|
// Sentinel should not be present.
|
|
for _, rid := range sshCertLegacyResources {
|
|
assert.False(t, types.IsSentinelResourceID(rid), "SSH cert legacy extension should not contain sentinel when plain resources are present")
|
|
}
|
|
} else if len(constrainedOnly) > 0 {
|
|
// Constraint-only requests should have the sentinel in the old extension.
|
|
require.Len(t, sshCertLegacyResources, 1, "SSH cert legacy extension should contain only sentinel")
|
|
assert.True(t, types.IsSentinelResourceID(sshCertLegacyResources[0]), "SSH cert legacy extension should be sentinel")
|
|
}
|
|
}
|
|
|
|
func TestCreateSuggestions(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
testAuthServer, err := authtest.NewAuthServer(authtest.AuthServerConfig{
|
|
Dir: t.TempDir(),
|
|
})
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { require.NoError(t, testAuthServer.Close()) })
|
|
|
|
const username = "admin"
|
|
|
|
// Create the access request, so we can attach the promotions to it.
|
|
adminRequest, err := services.NewAccessRequest(username, "admins")
|
|
require.NoError(t, err)
|
|
|
|
authSrvClient := testAuthServer.AuthServer
|
|
err = authSrvClient.UpsertAccessRequest(context.Background(), adminRequest)
|
|
require.NoError(t, err)
|
|
|
|
// Create the promotions.
|
|
err = authSrvClient.CreateAccessRequestAllowedPromotions(context.Background(), adminRequest, &types.AccessRequestAllowedPromotions{
|
|
Promotions: []*types.AccessRequestAllowedPromotion{
|
|
{AccessListName: "a"},
|
|
{AccessListName: "b"},
|
|
{AccessListName: "c"}},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Get the promotions and verify them.
|
|
promotions, err := authSrvClient.GetAccessRequestAllowedPromotions(context.Background(), adminRequest)
|
|
require.NoError(t, err)
|
|
require.Len(t, promotions.Promotions, 3)
|
|
require.Equal(t, []string{"a", "b", "c"},
|
|
[]string{
|
|
promotions.Promotions[0].AccessListName,
|
|
promotions.Promotions[1].AccessListName,
|
|
promotions.Promotions[2].AccessListName,
|
|
})
|
|
}
|
|
|
|
func TestPromotedRequest(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := t.Context()
|
|
|
|
testPack := newAccessRequestTestPack(ctx, t)
|
|
|
|
const requesterUserName = "requester"
|
|
requester := authtest.TestUser(requesterUserName)
|
|
requesterClient, err := testPack.tlsServer.NewClient(requester)
|
|
require.NoError(t, err)
|
|
|
|
t.Cleanup(func() { require.NoError(t, requesterClient.Close()) })
|
|
|
|
// create the access request object
|
|
req, err := services.NewAccessRequest(requesterUserName, "admins")
|
|
require.NoError(t, err)
|
|
|
|
// send the request to the auth server
|
|
createdReq, err := requesterClient.CreateAccessRequestV2(ctx, req)
|
|
require.NoError(t, err)
|
|
|
|
const adminUser = "admin"
|
|
approveAs := func(reviewerName string) (types.AccessRequest, error) {
|
|
reviewer := authtest.TestUser(reviewerName)
|
|
reviewerClient, err := testPack.tlsServer.NewClient(reviewer)
|
|
require.NoError(t, err)
|
|
|
|
t.Cleanup(func() { require.NoError(t, reviewerClient.Close()) })
|
|
|
|
// try to promote the request
|
|
return reviewerClient.SubmitAccessReview(ctx, types.AccessReviewSubmission{
|
|
RequestID: req.GetName(),
|
|
Review: types.AccessReview{
|
|
ProposedState: types.RequestState_PROMOTED,
|
|
Author: adminUser,
|
|
AccessList: &types.PromotedAccessList{
|
|
Title: "ACL title",
|
|
Name: "0000-00-00-0000",
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
t.Run("try promoting using access request API", func(t *testing.T) {
|
|
// Access request promotion is prohibited for everyone, including admins.
|
|
// An access request can be only approved by using Ent AccessRequestPromote API
|
|
// operator can't promote the request
|
|
_, err = approveAs("operator")
|
|
require.Error(t, err)
|
|
|
|
// admin can't promote the request
|
|
_, err = approveAs(adminUser)
|
|
require.Error(t, err)
|
|
|
|
req2, err := requesterClient.GetAccessRequests(ctx, types.AccessRequestFilter{
|
|
ID: createdReq.GetMetadata().Name,
|
|
})
|
|
require.NoError(t, err)
|
|
require.Len(t, req2, 1)
|
|
|
|
// the state should be still pending
|
|
require.Equal(t, types.RequestState_PENDING, req2[0].GetState())
|
|
})
|
|
|
|
t.Run("promote without access list data fails", func(t *testing.T) {
|
|
// The only way to promote the request is to use Ent AccessRequestPromote API
|
|
// which is not available in OSS. As a workaround, we can use the access request
|
|
// server API.
|
|
_, err := testPack.tlsServer.AuthServer.AuthServer.SubmitAccessReview(ctx, types.AccessReviewSubmission{
|
|
RequestID: createdReq.GetName(),
|
|
Review: types.AccessReview{
|
|
ProposedState: types.RequestState_PROMOTED,
|
|
},
|
|
})
|
|
// Promoting without access list information is prohibited.
|
|
require.Error(t, err)
|
|
})
|
|
|
|
t.Run("promote", func(t *testing.T) {
|
|
promotedRequest, err := testPack.tlsServer.AuthServer.AuthServer.SubmitAccessReview(ctx, types.AccessReviewSubmission{
|
|
RequestID: createdReq.GetName(),
|
|
Review: types.AccessReview{
|
|
ProposedState: types.RequestState_PROMOTED,
|
|
Author: adminUser,
|
|
AccessList: &types.PromotedAccessList{
|
|
Title: "ACL title",
|
|
Name: "0000-00-00-0000",
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// verify promotion related fields
|
|
require.Equal(t, types.RequestState_PROMOTED, promotedRequest.GetState())
|
|
require.Equal(t, "0000-00-00-0000", promotedRequest.GetPromotedAccessListName())
|
|
require.Equal(t, "ACL title", promotedRequest.GetPromotedAccessListTitle())
|
|
})
|
|
}
|
|
|
|
func TestUpdateAccessRequestWithAdditionalReviewers(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
mustRequest := func(suggestedReviewers ...string) types.AccessRequest {
|
|
req, err := services.NewAccessRequest("test-user", "admins")
|
|
require.NoError(t, err)
|
|
req.SetSuggestedReviewers(suggestedReviewers)
|
|
return req
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
req types.AccessRequest
|
|
suggestedReviewers []string
|
|
expectedReviewers []string
|
|
}{
|
|
{
|
|
name: "nil additional reviewers",
|
|
req: mustRequest("rev1", "rev2"),
|
|
expectedReviewers: []string{"rev1", "rev2"},
|
|
},
|
|
{
|
|
name: "a few additional reviewers",
|
|
req: mustRequest("rev1", "rev2"),
|
|
suggestedReviewers: []string{"name1", "name2", "name3"},
|
|
expectedReviewers: []string{"rev1", "rev2", "name1", "name2", "name3"},
|
|
},
|
|
{
|
|
name: "no additional reviewers",
|
|
req: mustRequest("rev1", "rev2"),
|
|
suggestedReviewers: []string{},
|
|
expectedReviewers: []string{"rev1", "rev2"},
|
|
},
|
|
{
|
|
name: "duplicate additional reviewers",
|
|
req: mustRequest("rev1", "rev2"),
|
|
suggestedReviewers: []string{"rev2", "name1", "name2", "name1"},
|
|
expectedReviewers: []string{"rev1", "rev2", "name1", "name2"},
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := context.Background()
|
|
req := test.req.Copy()
|
|
auth.UpdateAccessRequestWithAdditionalReviewers(ctx, req, test.suggestedReviewers)
|
|
require.ElementsMatch(t, test.expectedReviewers, req.GetSuggestedReviewers())
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAssumeStartTime_CreateAccessRequestV2(t *testing.T) {
|
|
ctx := context.Background()
|
|
s := createAccessRequestWithStartTime(t)
|
|
|
|
testCases := []struct {
|
|
name string
|
|
startTime time.Time
|
|
errCheck require.ErrorAssertionFunc
|
|
}{
|
|
{
|
|
name: "too far in the future",
|
|
startTime: s.invalidMaxedAssumeStartTime,
|
|
errCheck: func(tt require.TestingT, err error, i ...any) {
|
|
require.True(t, trace.IsBadParameter(err), "expected bad parameter, got %v", err)
|
|
require.ErrorContains(t, err, "assume start time is too far in the future")
|
|
},
|
|
},
|
|
{
|
|
name: "after access expiry time",
|
|
startTime: s.invalidExpiredAssumeStartTime,
|
|
errCheck: func(tt require.TestingT, err error, i ...any) {
|
|
require.True(t, trace.IsBadParameter(err), "expected bad parameter, got %v", err)
|
|
require.ErrorContains(t, err, "assume start time must be prior to access expiry time")
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
req, err := services.NewAccessRequest(s.requesterUserName, "admins")
|
|
require.NoError(t, err)
|
|
req.SetMaxDuration(s.maxDuration)
|
|
req.SetAssumeStartTime(tc.startTime)
|
|
_, err = s.requesterClient.CreateAccessRequestV2(ctx, req)
|
|
tc.errCheck(t, err)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAssumeStartTime_SubmitAccessReview(t *testing.T) {
|
|
ctx := context.Background()
|
|
s := createAccessRequestWithStartTime(t)
|
|
|
|
testCases := []struct {
|
|
name string
|
|
startTime time.Time
|
|
errCheck require.ErrorAssertionFunc
|
|
}{
|
|
{
|
|
name: "too far in the future",
|
|
startTime: s.invalidMaxedAssumeStartTime,
|
|
errCheck: func(tt require.TestingT, err error, i ...any) {
|
|
require.True(t, trace.IsBadParameter(err), "expected bad parameter, got %v", err)
|
|
require.ErrorContains(t, err, "assume start time is too far in the future")
|
|
},
|
|
},
|
|
{
|
|
name: "after access expiry time",
|
|
startTime: s.invalidExpiredAssumeStartTime,
|
|
errCheck: func(tt require.TestingT, err error, i ...any) {
|
|
require.True(t, trace.IsBadParameter(err), "expected bad parameter, got %v", err)
|
|
require.ErrorContains(t, err, "assume start time must be prior to access expiry time")
|
|
},
|
|
},
|
|
{
|
|
name: "valid submission",
|
|
startTime: s.validStartTime,
|
|
errCheck: require.NoError,
|
|
},
|
|
}
|
|
review := types.AccessReviewSubmission{
|
|
RequestID: s.createdRequest.GetName(),
|
|
Review: types.AccessReview{
|
|
Author: "admin",
|
|
ProposedState: types.RequestState_APPROVED,
|
|
},
|
|
}
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
review.Review.AssumeStartTime = &tc.startTime
|
|
resp, err := s.testPack.tlsServer.AuthServer.AuthServer.SubmitAccessReview(ctx, review)
|
|
tc.errCheck(t, err)
|
|
if err == nil {
|
|
require.Equal(t, tc.startTime, *resp.GetAssumeStartTime())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAssumeStartTime_SetAccessRequestState(t *testing.T) {
|
|
ctx := context.Background()
|
|
s := createAccessRequestWithStartTime(t)
|
|
|
|
testCases := []struct {
|
|
name string
|
|
startTime time.Time
|
|
errCheck require.ErrorAssertionFunc
|
|
}{
|
|
{
|
|
name: "too far in the future",
|
|
startTime: s.invalidMaxedAssumeStartTime,
|
|
errCheck: func(tt require.TestingT, err error, i ...any) {
|
|
require.True(t, trace.IsBadParameter(err), "expected bad parameter, got %v", err)
|
|
require.ErrorContains(t, err, "assume start time is too far in the future")
|
|
},
|
|
},
|
|
{
|
|
name: "after access expiry time",
|
|
startTime: s.invalidExpiredAssumeStartTime,
|
|
errCheck: func(tt require.TestingT, err error, i ...any) {
|
|
require.True(t, trace.IsBadParameter(err), "expected bad parameter, got %v", err)
|
|
require.ErrorContains(t, err, "assume start time must be prior to access expiry time")
|
|
},
|
|
},
|
|
{
|
|
name: "valid set state",
|
|
startTime: s.validStartTime,
|
|
errCheck: require.NoError,
|
|
},
|
|
}
|
|
update := types.AccessRequestUpdate{
|
|
RequestID: s.createdRequest.GetName(),
|
|
State: types.RequestState_APPROVED,
|
|
}
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
update.AssumeStartTime = &tc.startTime
|
|
err := s.testPack.tlsServer.Auth().SetAccessRequestState(ctx, update)
|
|
tc.errCheck(t, err)
|
|
if err == nil {
|
|
resp, err := s.testPack.tlsServer.AuthServer.AuthServer.GetAccessRequests(ctx, types.AccessRequestFilter{})
|
|
require.NoError(t, err)
|
|
require.Len(t, resp, 1)
|
|
require.Equal(t, tc.startTime, *resp[0].GetAssumeStartTime())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
type accessRequestWithStartTime struct {
|
|
testPack *accessRequestTestPack
|
|
requesterClient *authclient.Client
|
|
invalidMaxedAssumeStartTime time.Time
|
|
invalidExpiredAssumeStartTime time.Time
|
|
validStartTime time.Time
|
|
maxDuration time.Time
|
|
requesterUserName string
|
|
createdRequest types.AccessRequest
|
|
}
|
|
|
|
func createAccessRequestWithStartTime(t *testing.T) accessRequestWithStartTime {
|
|
t.Helper()
|
|
|
|
ctx := t.Context()
|
|
|
|
testPack := newAccessRequestTestPack(ctx, t)
|
|
|
|
const requesterUserName = "requester"
|
|
requester := authtest.TestUser(requesterUserName)
|
|
requesterClient, err := testPack.tlsServer.NewClient(requester)
|
|
require.NoError(t, err)
|
|
|
|
t.Cleanup(func() { require.NoError(t, requesterClient.Close()) })
|
|
|
|
now := time.Now().UTC()
|
|
day := 24 * time.Hour
|
|
|
|
maxDuration := time.Now().UTC().Add(12 * day)
|
|
|
|
invalidMaxedAssumeStartTime := now.Add(constants.MaxAssumeStartDuration + (1 * day))
|
|
invalidExpiredAssumeStartTime := now.Add(100 * day)
|
|
validStartTime := now.Add(6 * day)
|
|
|
|
// create the access request object
|
|
req, err := services.NewAccessRequest(requesterUserName, "admins")
|
|
require.NoError(t, err)
|
|
req.SetMaxDuration(maxDuration)
|
|
|
|
req.SetAssumeStartTime(validStartTime)
|
|
createdReq, err := requesterClient.CreateAccessRequestV2(ctx, req)
|
|
require.NoError(t, err)
|
|
require.Equal(t, validStartTime, *createdReq.GetAssumeStartTime())
|
|
|
|
return accessRequestWithStartTime{
|
|
testPack: testPack,
|
|
requesterClient: requesterClient,
|
|
invalidMaxedAssumeStartTime: invalidMaxedAssumeStartTime,
|
|
invalidExpiredAssumeStartTime: invalidExpiredAssumeStartTime,
|
|
validStartTime: validStartTime,
|
|
maxDuration: maxDuration,
|
|
requesterUserName: requesterUserName,
|
|
createdRequest: createdReq,
|
|
}
|
|
}
|
|
|
|
// testCertExtensionResourceIDs verifies that both the legacy AllowedResourceIDs
|
|
// and the new AllowedResourceAccessIDs cert extensions are correctly populated
|
|
// for access requests with constrained resources only, unconstrained resources
|
|
// only, and a mix of both.
|
|
//
|
|
// Plain (unconstrained) resources should appear in both extensions.
|
|
// Constrained resources appear only in the new extension. When all resources
|
|
// are constrained, the legacy extension receives a sentinel value to prevent
|
|
// older agents from interpreting an empty extension as "unrestricted access".
|
|
// When a mix of plain and constrained resources is requested, only the plain
|
|
// resource IDs appear in the legacy extension (no sentinel).
|
|
func testCertExtensionResourceIDs(t *testing.T, testPack *accessRequestTestPack) {
|
|
t.Parallel()
|
|
|
|
ctx := t.Context()
|
|
|
|
username := "requester"
|
|
|
|
constrainedResourceIDs := []types.ResourceAccessID{{
|
|
Id: types.ResourceID{
|
|
ClusterName: testPack.clusterName,
|
|
Kind: types.KindNode,
|
|
Name: "staging",
|
|
},
|
|
Constraints: &types.ResourceConstraints{
|
|
Details: &types.ResourceConstraints_Ssh{
|
|
Ssh: &types.SSHResourceConstraints{Logins: []string{"root"}},
|
|
},
|
|
},
|
|
}}
|
|
|
|
plainResourceIDs := []types.ResourceAccessID{{
|
|
Id: types.ResourceID{
|
|
ClusterName: testPack.clusterName,
|
|
Kind: types.KindNode,
|
|
Name: "prod",
|
|
},
|
|
}}
|
|
|
|
mixedResourceIDs := append(plainResourceIDs, constrainedResourceIDs...)
|
|
|
|
constrainedRequest, err := services.NewAccessRequestWithResources(username, []string{"admins"}, constrainedResourceIDs)
|
|
require.NoError(t, err)
|
|
plainRequest, err := services.NewAccessRequestWithResources(username, []string{"admins"}, plainResourceIDs)
|
|
require.NoError(t, err)
|
|
mixedRequest, err := services.NewAccessRequestWithResources(username, []string{"admins"}, mixedResourceIDs)
|
|
require.NoError(t, err)
|
|
|
|
for _, req := range []types.AccessRequest{constrainedRequest, plainRequest, mixedRequest} {
|
|
req.SetState(types.RequestState_APPROVED)
|
|
req.SetAccessExpiry(time.Now().Add(time.Hour).UTC())
|
|
require.NoError(t, testPack.tlsServer.Auth().UpsertAccessRequest(ctx, req))
|
|
}
|
|
|
|
requester := authtest.TestUser(username)
|
|
requesterClient, err := testPack.tlsServer.NewClient(requester)
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { require.NoError(t, requesterClient.Close()) })
|
|
|
|
generateCerts := func(t *testing.T, requestName string) *proto.Certs {
|
|
t.Helper()
|
|
certs, err := requesterClient.GenerateUserCerts(ctx, proto.UserCertsRequest{
|
|
SSHPublicKey: testPack.sshPubKey,
|
|
TLSPublicKey: testPack.tlsPubKey,
|
|
Username: username,
|
|
Expires: time.Now().Add(time.Hour).UTC(),
|
|
AccessRequests: []string{requestName},
|
|
})
|
|
require.NoError(t, err)
|
|
return certs
|
|
}
|
|
|
|
type certResources struct {
|
|
AllowedResourceIDs []types.ResourceID
|
|
AllowedResourceAccessIDs []types.ResourceAccessID
|
|
}
|
|
|
|
parseCertResources := func(t *testing.T, certs *proto.Certs) (ssh, tls certResources) {
|
|
t.Helper()
|
|
|
|
sshCert, err := sshutils.ParseCertificate(certs.SSH)
|
|
require.NoError(t, err)
|
|
ssh.AllowedResourceIDs, err = types.ResourceIDsFromString(sshCert.Permissions.Extensions[teleport.CertExtensionAllowedResources])
|
|
require.NoError(t, err)
|
|
ssh.AllowedResourceAccessIDs, err = types.ResourceAccessIDsFromString(sshCert.Permissions.Extensions[teleport.CertExtensionAllowedResourceAccessIDs])
|
|
require.NoError(t, err)
|
|
|
|
tlsCert, err := tlsca.ParseCertificatePEM(certs.TLS)
|
|
require.NoError(t, err)
|
|
for _, attr := range tlsCert.Subject.Names {
|
|
switch {
|
|
case attr.Type.Equal(tlsca.AllowedResourcesASN1ExtensionOID):
|
|
tls.AllowedResourceIDs, err = types.ResourceIDsFromString(attr.Value.(string))
|
|
require.NoError(t, err)
|
|
case attr.Type.Equal(tlsca.AllowedResourceAccessIDsASN1ExtensionOID):
|
|
tls.AllowedResourceAccessIDs, err = types.ResourceAccessIDsFromString(attr.Value.(string))
|
|
require.NoError(t, err)
|
|
}
|
|
}
|
|
|
|
return ssh, tls
|
|
}
|
|
|
|
t.Run("constrained", func(t *testing.T) {
|
|
sshCert, tlsCert := parseCertResources(t, generateCerts(t, constrainedRequest.GetName()))
|
|
|
|
// All resources are constrained, so the legacy extension gets the
|
|
// sentinel and the new extension carries the full access IDs.
|
|
want := certResources{
|
|
AllowedResourceIDs: []types.ResourceID{types.CreateSentinelResourceID()},
|
|
AllowedResourceAccessIDs: constrainedResourceIDs,
|
|
}
|
|
require.Equal(t, want, sshCert)
|
|
require.Equal(t, want, tlsCert)
|
|
})
|
|
|
|
t.Run("without constraints", func(t *testing.T) {
|
|
sshCert, tlsCert := parseCertResources(t, generateCerts(t, plainRequest.GetName()))
|
|
|
|
// No constraints, so the legacy extension carries the actual
|
|
// resource IDs and no sentinel is needed.
|
|
want := certResources{
|
|
AllowedResourceIDs: []types.ResourceID{plainResourceIDs[0].Id},
|
|
AllowedResourceAccessIDs: plainResourceIDs,
|
|
}
|
|
require.Equal(t, want, sshCert)
|
|
require.Equal(t, want, tlsCert)
|
|
})
|
|
|
|
t.Run("mixed constrained and unconstrained", func(t *testing.T) {
|
|
sshCert, tlsCert := parseCertResources(t, generateCerts(t, mixedRequest.GetName()))
|
|
|
|
// The legacy extension should contain only the plain resource IDs
|
|
// (no sentinel) because older agents can still enforce those.
|
|
// The new extension carries all resources including constrained ones.
|
|
want := certResources{
|
|
AllowedResourceIDs: []types.ResourceID{plainResourceIDs[0].Id},
|
|
AllowedResourceAccessIDs: mixedResourceIDs,
|
|
}
|
|
require.Equal(t, want, sshCert)
|
|
require.Equal(t, want, tlsCert)
|
|
})
|
|
}
|
|
|
|
type reviewState struct {
|
|
author string
|
|
wantState types.RequestState
|
|
wantErr error
|
|
}
|
|
|
|
// testSubmitAccessReview_SubmitForUsers tests if plugin users with the `review_requests.submit_for_users` rule
|
|
// can submit reviews for other users.
|
|
func testSubmitAccessReview_SubmitForUsers(t *testing.T, testPack *accessRequestTestPack) {
|
|
t.Parallel()
|
|
ctx := t.Context()
|
|
|
|
// Create access plugin reviewer without "submit for" review permissions.
|
|
_, err := authtest.CreateUser(ctx, testPack.tlsServer.Auth(), "plugin-no-review", services.NewPresetAccessPluginRole())
|
|
require.NoError(t, err)
|
|
|
|
tests := []struct {
|
|
name string
|
|
reviewStates []reviewState
|
|
requester string
|
|
reviewer string // (plugin) identity submitting the review request to Auth Service
|
|
}{
|
|
{
|
|
name: "access-plugin without review",
|
|
reviewer: "plugin-no-review",
|
|
reviewStates: []reviewState{
|
|
{
|
|
author: "admin",
|
|
wantErr: trace.AccessDenied("user %q cannot submit reviews for %q", "plugin-no-review", "admin"),
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "access-plugin with review; submitted for admin",
|
|
reviewStates: []reviewState{
|
|
{
|
|
author: "admin",
|
|
wantState: types.RequestState_APPROVED,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "access-plugin with review; submitted for nobody",
|
|
reviewStates: []reviewState{
|
|
{
|
|
author: "nobody",
|
|
wantErr: trace.AccessDenied("user %q cannot submit reviews", "nobody"),
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "access-plugin with review; submitted for non-existent user",
|
|
reviewStates: []reviewState{
|
|
{
|
|
author: "fake-user",
|
|
wantErr: trace.AccessDenied("user %q cannot submit reviews for %q, user could not be fetched from local store",
|
|
"plugin-reviewer",
|
|
"fake-user",
|
|
),
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "access-plugin with review; submitted for same user",
|
|
requester: "requester-threshold",
|
|
reviewStates: []reviewState{
|
|
{
|
|
author: "admin",
|
|
wantState: types.RequestState_PENDING,
|
|
},
|
|
{
|
|
author: "admin",
|
|
wantErr: trace.AlreadyExists("user %q has already reviewed this request", "admin"),
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "access-plugin with review; submitted for multiple users",
|
|
requester: "requester-threshold",
|
|
reviewStates: []reviewState{
|
|
{
|
|
author: "admin",
|
|
wantState: types.RequestState_PENDING,
|
|
},
|
|
{
|
|
author: "admin2",
|
|
wantState: types.RequestState_APPROVED,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "access-plugin with review; submitted for multiple users, but already approved",
|
|
reviewStates: []reviewState{
|
|
{
|
|
author: "admin",
|
|
wantState: types.RequestState_APPROVED,
|
|
},
|
|
{
|
|
author: "admin2",
|
|
wantErr: trace.AccessDenied("the access request has been already approved"),
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "access-plugin with review; cannot apply on own request",
|
|
requester: "plugin-reviewer",
|
|
reviewStates: []reviewState{
|
|
{
|
|
author: "admin2",
|
|
wantErr: trace.AccessDenied("review submitter %q cannot apply a review on their own request", "plugin-reviewer"),
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
if tt.requester == "" {
|
|
tt.requester = "requester"
|
|
}
|
|
if tt.reviewer == "" {
|
|
tt.reviewer = "plugin-reviewer"
|
|
}
|
|
|
|
// Create requester client.
|
|
requesterClient, err := testPack.tlsServer.NewClient(authtest.TestUser(tt.requester))
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { require.NoError(t, requesterClient.Close()) })
|
|
|
|
// Create access request.
|
|
request, err := services.NewAccessRequest(tt.requester, "admins")
|
|
require.NoError(t, err)
|
|
request, err = requesterClient.CreateAccessRequestV2(ctx, request)
|
|
require.NoError(t, err)
|
|
|
|
// Create plugin reviewer client.
|
|
reviewerClient, err := testPack.tlsServer.NewClient(authtest.TestUser(tt.reviewer))
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { require.NoError(t, reviewerClient.Close()) })
|
|
|
|
// Plugin reviewer should be able to submit for multiple human authors.
|
|
for _, r := range tt.reviewStates {
|
|
review := types.AccessReviewSubmission{
|
|
RequestID: request.GetName(),
|
|
Review: types.AccessReview{
|
|
Author: r.author,
|
|
SubmittedOnBehalfOfAuthor: true,
|
|
ProposedState: types.RequestState_APPROVED,
|
|
},
|
|
}
|
|
updatedRequest, err := reviewerClient.SubmitAccessReview(ctx, review)
|
|
if r.wantErr != nil {
|
|
require.ErrorIs(t, err, r.wantErr)
|
|
continue
|
|
}
|
|
require.NoError(t, err)
|
|
require.Equal(t, r.wantState, updatedRequest.GetState())
|
|
}
|
|
})
|
|
}
|
|
}
|