[v18] Backend support for scoped login on web (#69197)

* Backend support for scoped login on web (#68884)

* Backend support for scoped login on web

* Review

* Add support for MFA turned off

* Compute device trust mode for scoped sessions

* Review

* Prevent dropping the scope pin when extending a session

* Fix TestNewWebSessionScopedTrustedDeviceRequirement flakiness (#69506)
This commit is contained in:
Bartosz Leper
2026-08-13 21:36:54 +00:00
committed by GitHub
parent c9cf3882ac
commit 5754c6f4e8
15 changed files with 750 additions and 155 deletions
+3 -3
View File
@@ -27,19 +27,19 @@ const (
WebConfigAuthProviderOIDCType = "oidc"
// WebConfigAuthProviderOIDCURL is OIDC webapi endpoint.
// redirect_url MUST be the last query param, see the comment in parseSSORequestParams for an explanation.
WebConfigAuthProviderOIDCURL = "/v1/webapi/oidc/login/web?connector_id=:providerName&login_hint=:loginHint?&redirect_url=:redirect"
WebConfigAuthProviderOIDCURL = "/v1/webapi/oidc/login/web?connector_id=:providerName&login_hint=:loginHint?&scope=:scope?&redirect_url=:redirect"
// WebConfigAuthProviderSAMLType is SAML provider type
WebConfigAuthProviderSAMLType = "saml"
// WebConfigAuthProviderSAMLURL is SAML webapi endpoint.
// redirect_url MUST be the last query param, see the comment in parseSSORequestParams for an explanation.
WebConfigAuthProviderSAMLURL = "/v1/webapi/saml/sso?connector_id=:providerName&login_hint=:loginHint?&redirect_url=:redirect"
WebConfigAuthProviderSAMLURL = "/v1/webapi/saml/sso?connector_id=:providerName&login_hint=:loginHint?&scope=:scope?&redirect_url=:redirect"
// WebConfigAuthProviderGitHubType is GitHub provider type
WebConfigAuthProviderGitHubType = "github"
// WebConfigAuthProviderGitHubURL is GitHub webapi endpoint
// redirect_url MUST be the last query param, see the comment in parseSSORequestParams for an explanation.
WebConfigAuthProviderGitHubURL = "/v1/webapi/github/login/web?connector_id=:providerName&redirect_url=:redirect"
WebConfigAuthProviderGitHubURL = "/v1/webapi/github/login/web?connector_id=:providerName&scope=:scope?&redirect_url=:redirect"
)
// WebConfig is web application configuration served by the backend to be used in frontend apps.
+168 -4
View File
@@ -93,6 +93,7 @@ import (
"github.com/gravitational/teleport/lib/modules"
"github.com/gravitational/teleport/lib/modules/modulestest"
"github.com/gravitational/teleport/lib/scopes"
scopedaccess "github.com/gravitational/teleport/lib/scopes/access"
"github.com/gravitational/teleport/lib/service/servicecfg"
"github.com/gravitational/teleport/lib/services"
"github.com/gravitational/teleport/lib/services/local"
@@ -3572,10 +3573,20 @@ func TestGenerateKubernetesUserCert(t *testing.T) {
func TestNewWebSession(t *testing.T) {
t.Parallel()
ctx := context.Background()
p, err := newTestPack(ctx, testPackOptions{DataDir: t.TempDir()})
ctx := t.Context()
p, err := newTestPack(ctx, testPackOptions{
DataDir: t.TempDir(),
ScopesFeatures: scopes.Features{Enabled: true},
})
require.NoError(t, err)
t.Cleanup(func() {
if p.bk != nil {
p.bk.Close()
}
})
// Set a web idle timeout.
duration := time.Duration(5) * time.Minute
cfg := types.DefaultClusterNetworkingConfig()
@@ -3611,6 +3622,7 @@ func TestNewWebSession(t *testing.T) {
require.NotEmpty(t, ws.GetTLSCert())
t.Run("expands user metadata name in logins", func(t *testing.T) {
ctx := t.Context()
user, _, err := authtest.CreateUserAndRole(p.a, "templated-web-user", nil, nil, authtest.WithRoleMutator(func(role types.Role) {
role.SetLogins(types.Allow, []string{"{{user.metadata.name}}"})
}))
@@ -3624,13 +3636,165 @@ func TestNewWebSession(t *testing.T) {
SessionTTL: apidefaults.CertDuration,
}
_, checker, err := p.a.NewWebSession(ctx, req, nil /* opts */)
_, checkerCtx, err := p.a.NewWebSession(ctx, req, nil /* opts */)
require.NoError(t, err)
logins, err := checker.CheckLoginDuration(0)
logins, err := checkerCtx.CertParams().GetSSHLoginsForTTL(ctx, 0)
require.NoError(t, err)
require.Contains(t, logins, user.GetName())
})
t.Run("scoped session", func(t *testing.T) {
ctx := t.Context()
scopedService := local.NewScopedAccessService(p.bk)
_, err := scopedService.CreateScopedRole(ctx, scopedaccessv1pb.CreateScopedRoleRequest_builder{
Role: scopedaccessv1pb.ScopedRole_builder{
Kind: "scoped_role",
Metadata: headerv1.Metadata_builder{
Name: "web-session",
}.Build(),
Scope: "/test",
Spec: scopedaccessv1pb.ScopedRoleSpec_builder{
AssignableScopes: []string{"/test"},
}.Build(),
Version: types.V1,
}.Build(),
}.Build())
require.NoError(t, err)
_, err = scopedService.CreateScopedRoleAssignment(ctx, scopedaccessv1pb.CreateScopedRoleAssignmentRequest_builder{
Assignment: scopedaccessv1pb.ScopedRoleAssignment_builder{
Kind: "scoped_role_assignment",
Metadata: headerv1.Metadata_builder{
Name: uuid.NewString(),
}.Build(),
Scope: "/test",
SubKind: "dynamic",
Spec: scopedaccessv1pb.ScopedRoleAssignmentSpec_builder{
User: user.GetName(),
Assignments: []*scopedaccessv1pb.Assignment{
scopedaccessv1pb.Assignment_builder{
Role: "/test::web-session",
Scope: "/test",
}.Build(),
},
}.Build(),
Version: types.V1,
}.Build(),
}.Build())
require.NoError(t, err)
// Wait for scoped access cache to see the assignment.
pin := scopesv1pb.Pin_builder{Kind: scopesv1pb.PinKind_PIN_KIND_USER, Scope: "/test"}.Build()
require.EventuallyWithT(t, func(ct *assert.CollectT) {
err := p.a.ScopedAccessCache.PopulatePinnedAssignmentsForUser(ctx, user.GetName(), pin)
assert.NoError(ct, err)
assert.NotNil(ct, pin.GetAssignmentTree())
}, 15*time.Second, 100*time.Millisecond)
req := auth.NewWebSessionRequest{
User: user.GetName(),
Scope: "/test",
}
ws, _, err := p.a.NewWebSession(ctx, req, nil /* opts */)
require.NoError(t, err)
assert.Equal(t, user.GetName(), ws.GetUser())
_, identity := parseX509PEMAndIdentity(t, ws.GetTLSCert())
sp := identity.ScopePin
require.NotNil(t, sp)
assert.Equal(t, "/test", sp.GetScope())
assert.Equal(t, scopesv1pb.PinKind_PIN_KIND_USER, sp.GetKind())
})
}
func TestNewWebSessionScopedTrustedDeviceRequirement(t *testing.T) {
// Not parallel, because we set test modules.
modulestest.SetTestModules(t, *modulestest.EnterpriseModules())
ctx := t.Context()
p, err := newTestPack(ctx, testPackOptions{
DataDir: t.TempDir(),
ScopesFeatures: scopes.Features{Enabled: true},
})
require.NoError(t, err)
authPref, err := p.a.GetAuthPreference(ctx)
require.NoError(t, err)
authPref.SetDeviceTrust(&types.DeviceTrust{
Mode: constants.DeviceTrustModeRequired,
})
_, err = p.a.UpsertAuthPreference(ctx, authPref)
require.NoError(t, err)
_, _, err = authtest.CreateUserAndRole(p.a, "test-user", []string{}, nil)
require.NoError(t, err)
scopedService := p.a.ScopedAccess()
_, err = scopedService.CreateScopedRole(ctx, scopedaccessv1pb.CreateScopedRoleRequest_builder{
Role: scopedaccessv1pb.ScopedRole_builder{
Kind: "scoped_role",
Metadata: headerv1.Metadata_builder{
Name: "some-scoped-role",
}.Build(),
Scope: "/foo",
Spec: scopedaccessv1pb.ScopedRoleSpec_builder{
AssignableScopes: []string{"/foo/bar"},
}.Build(),
Version: types.V1,
}.Build(),
}.Build())
require.NoError(t, err)
_, err = scopedService.CreateScopedRoleAssignment(ctx, scopedaccessv1pb.CreateScopedRoleAssignmentRequest_builder{
Assignment: scopedaccessv1pb.ScopedRoleAssignment_builder{
Kind: "scoped_role_assignment",
Metadata: headerv1.Metadata_builder{
Name: "some-scoped-role-assignment",
}.Build(),
Scope: "/foo",
SubKind: scopedaccess.SubKindDynamic,
Spec: scopedaccessv1pb.ScopedRoleAssignmentSpec_builder{
User: "test-user",
Assignments: []*scopedaccessv1pb.Assignment{
scopedaccessv1pb.Assignment_builder{
Role: "/foo::some-scoped-role",
Scope: "/foo/bar",
}.Build(),
},
}.Build(),
Version: types.V1,
}.Build(),
}.Build())
require.NoError(t, err)
// Wait for scoped access cache to see the role and the assignment.
require.EventuallyWithT(t, func(ct *assert.CollectT) {
_, err := p.a.ScopedAccessCache.GetScopedRole(ctx, scopedaccessv1pb.GetScopedRoleRequest_builder{
Name: "some-scoped-role",
Scope: "/foo",
}.Build())
require.NoError(ct, err)
_, err = p.a.ScopedAccessCache.GetScopedRoleAssignment(ctx, scopedaccessv1pb.GetScopedRoleAssignmentRequest_builder{
Name: "some-scoped-role-assignment",
Scope: "/foo",
SubKind: scopedaccess.SubKindDynamic,
}.Build())
require.NoError(ct, err)
}, 15*time.Second, 100*time.Millisecond)
ws, _, err := p.a.NewWebSession(ctx, auth.NewWebSessionRequest{
User: "test-user",
Scope: "/foo/bar",
}, nil /* opts */)
require.NoError(t, err)
assert.Equal(t, types.TrustedDeviceRequirement_TRUSTED_DEVICE_REQUIREMENT_REQUIRED, ws.GetTrustedDeviceRequirement())
// Make sure this has actually created a scoped session.
_, identity := parseX509PEMAndIdentity(t, ws.GetTLSCert())
assert.NotNil(t, identity.ScopePin)
}
func TestDeleteMFADeviceSync(t *testing.T) {
+26
View File
@@ -3704,6 +3704,32 @@ func (a *ServerWithRoles) SubmitAccessReview(ctx context.Context, submission typ
return a.authServer.submitAccessReview(ctx, submission, &identity)
}
func (a *ScopedServerWithRoles) GetAccessCapabilities(ctx context.Context, req types.AccessCapabilitiesRequest) (*types.AccessCapabilities, error) {
if unscoped, ok := a.UnscopedServerWithRoles(); ok {
return unscoped.GetAccessCapabilities(ctx, req)
}
if a.scopedContext.User == nil {
return nil, trace.AccessDenied(
"access capabilities can only be retrieved by scoped identities if they are users",
)
}
// default to checking the capabilities of the caller
if req.User == "" {
req.User = a.scopedContext.User.GetName()
}
// Scoped identities can only check their own capabilities
if err := a.scopedCurrentUserAction(req.User); err != nil {
return nil, trace.Wrap(err)
}
// TODO(bl-nero): Implement once it's supported by the backend. Currently,
// there's no concept of scoped access requests.
return &types.AccessCapabilities{}, nil
}
func (a *ServerWithRoles) GetAccessCapabilities(ctx context.Context, req types.AccessCapabilitiesRequest) (*types.AccessCapabilities, error) {
// default to checking the capabilities of the caller
if req.User == "" {
+157
View File
@@ -14911,3 +14911,160 @@ func TestRoleWindowsDesktopLeastPrivilege(t *testing.T) {
require.True(t, trace.IsAccessDenied(err), "expected access denied, got: %v", err)
})
}
func TestGetAccessCapabilitiesRBAC(t *testing.T) {
t.Parallel()
ctx := t.Context()
srv, err := authtest.NewAuthServer(authtest.AuthServerConfig{Dir: t.TempDir()})
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, srv.Close()) })
targetUser, err := types.NewUser("target")
require.NoError(t, err)
_, err = srv.AuthServer.CreateUser(ctx, targetUser)
require.NoError(t, err)
tests := []struct {
name string
allowRules []types.Rule
callerUser string
queriedUser string
wantAccessDenied bool
}{
{
name: "querying for self doesn't require permissions is allowed",
callerUser: "caller-self-default",
queriedUser: "",
},
{
name: "querying for self doesn't require permissions is allowed (explicit user name)",
callerUser: "caller-self-explicit",
queriedUser: "caller-self-explicit",
},
{
name: "querying for other user without permissions is denied",
callerUser: "caller-other-denied",
queriedUser: "target",
wantAccessDenied: true,
},
{
name: "querying for other user with permissions is allowed",
allowRules: []types.Rule{
types.NewRule(types.KindUser, []string{types.VerbRead}),
types.NewRule(types.KindRole, []string{types.VerbList, types.VerbRead}),
},
callerUser: "caller-other-allowed",
queriedUser: "target",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
ctx := t.Context()
callerRole, err := types.NewRole(tt.callerUser+"-role", types.RoleSpecV6{
Allow: types.RoleConditions{
Rules: tt.allowRules,
},
})
require.NoError(t, err)
callerUser, err := authtest.CreateUser(ctx, srv.AuthServer, tt.callerUser, callerRole)
require.NoError(t, err)
callerIdentity := authz.LocalUser{
Username: tt.callerUser,
Identity: tlsca.Identity{
Username: tt.callerUser,
Groups: callerUser.GetRoles(),
},
}
authCtx, err := authz.ContextForLocalUser(
ctx, callerIdentity, srv.AuthServer.Services, srv.ClusterName, true, /* disableDeviceAuthz */
)
require.NoError(t, err)
authCtx.AdminActionAuthState = authz.AdminActionAuthMFAVerified
server := auth.NewServerWithRoles(srv.AuthServer, srv.AuditLog, *authCtx)
_, err = server.GetAccessCapabilities(authz.ContextWithUser(ctx, callerIdentity), types.AccessCapabilitiesRequest{
User: tt.queriedUser,
})
if tt.wantAccessDenied {
require.True(t, trace.IsAccessDenied(err), "expected access denied, got %T: %v", err, err)
} else {
require.NoError(t, err)
}
})
}
}
func TestGetAccessCapabilitiesScopedRBAC(t *testing.T) {
t.Parallel()
ctx := t.Context()
srv, err := authtest.NewAuthServer(authtest.AuthServerConfig{Dir: t.TempDir()})
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, srv.Close()) })
targetUser, err := types.NewUser("target")
require.NoError(t, err)
_, err = srv.AuthServer.CreateUser(ctx, targetUser)
require.NoError(t, err)
tests := []struct {
name string
callerUser string
queriedUser string
wantAccessDenied bool
}{
{
name: "querying for self is allowed",
callerUser: "scoped-caller-self",
queriedUser: "",
},
{
name: "querying for other user is denied",
callerUser: "scoped-caller-other",
queriedUser: "target",
wantAccessDenied: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
callerUser, err := types.NewUser(tt.callerUser)
require.NoError(t, err)
callerUser, err = srv.AuthServer.CreateUser(t.Context(), callerUser)
require.NoError(t, err)
callerIdentity := authz.LocalUser{
Username: tt.callerUser,
Identity: tlsca.Identity{
Username: tt.callerUser,
ScopePin: scopesv1.Pin_builder{
Kind: scopesv1.PinKind_PIN_KIND_USER,
Scope: "/test",
}.Build(),
},
}
server := auth.NewScopedServerWithRoles(srv.AuthServer, srv.AuditLog, &authz.ScopedContext{
User: callerUser,
Identity: callerIdentity,
})
t.Cleanup(func() { server.Close() })
_, err = server.GetAccessCapabilities(authz.ContextWithUser(t.Context(), callerIdentity), types.AccessCapabilitiesRequest{
User: tt.queriedUser,
})
if tt.wantAccessDenied {
require.True(t, trace.IsAccessDenied(err), "expected access denied, got %T: %v", err, err)
} else {
require.NoError(t, err)
}
})
}
}
+1 -1
View File
@@ -159,7 +159,7 @@ func (a *Server) NewWebSession(
ctx context.Context,
req NewWebSessionRequest,
opts *newWebSessionOpts,
) (types.WebSession, services.AccessChecker, error) {
) (types.WebSession, *services.ScopedAccessCheckerContext, error) {
return a.newWebSession(ctx, req, opts)
}
+3 -3
View File
@@ -1274,11 +1274,11 @@ func (g *GRPCServer) GetAccessRequestAllowedPromotions(ctx context.Context, requ
}
func (g *GRPCServer) GetAccessCapabilities(ctx context.Context, req *types.AccessCapabilitiesRequest) (*types.AccessCapabilities, error) {
auth, err := g.authenticate(ctx)
auth, err := g.scopedAuthenticate(ctx)
if err != nil {
return nil, trace.Wrap(err)
}
caps, err := auth.ServerWithRoles.GetAccessCapabilities(ctx, *req)
caps, err := auth.ScopedServerWithRoles.GetAccessCapabilities(ctx, *req)
if err != nil {
return nil, trace.Wrap(err)
}
@@ -6503,7 +6503,7 @@ func NewGRPCServer(cfg GRPCServerConfig) (*GRPCServer, error) {
// Initialize and register the user preferences service.
userPreferencesSrv, err := userpreferencesv1.NewService(&userpreferencesv1.ServiceConfig{
Backend: cfg.AuthServer.Services,
Authorizer: cfg.Authorizer,
Authorizer: cfg.ScopedAuthorizer,
})
if err != nil {
return nil, trace.Wrap(err)
+40 -19
View File
@@ -55,11 +55,6 @@ import (
type NewWebSessionRequest = sessionreq.NewWebSessionRequest
func (a *Server) CreateWebSessionFromReq(ctx context.Context, req NewWebSessionRequest) (types.WebSession, error) {
if req.Scope != "" {
// TODO(fspmarshall/scopes): add scoping support for web sessions
return nil, trace.BadParameter("web sessions cannot be pinned to a scope")
}
session, _, err := a.newWebSession(ctx, req, nil /* opts */)
if err != nil {
return nil, trace.Wrap(err)
@@ -151,7 +146,16 @@ func (a *Server) newWebSession(
ctx context.Context,
req NewWebSessionRequest,
opts *newWebSessionOpts,
) (types.WebSession, services.AccessChecker, error) {
) (types.WebSession, *services.ScopedAccessCheckerContext, error) {
if req.Scope != "" {
if req.DelegationSessionID != "" {
return nil, nil, trace.BadParameter("access delegation is only supported for unscoped sessions")
}
if err := a.scopesFeatures.AssertEnabled(); err != nil {
return nil, nil, trace.Wrap(err)
}
}
userState, err := a.GetUserOrLoginState(ctx, req.User)
if err != nil {
return nil, nil, trace.Wrap(err)
@@ -166,15 +170,25 @@ func (a *Server) newWebSession(
return nil, nil, trace.Wrap(err)
}
checker, err := services.NewAccessChecker(&services.AccessInfo{
Username: userState.GetName(),
Roles: req.Roles,
Traits: req.Traits,
AllowedResourceAccessIDs: req.RequestedResourceAccessIDs,
DelegationSessionID: req.DelegationSessionID,
}, clusterName.GetClusterName(), a)
if err != nil {
return nil, nil, trace.Wrap(err)
var checkerCtx *services.ScopedAccessCheckerContext
var unscopedChecker services.AccessChecker
if req.Scope != "" {
checkerCtx, err = a.AccessCheckerForScope(ctx, req.Scope, userState, req.RequestedResourceAccessIDs)
if err != nil {
return nil, nil, trace.Wrap(err)
}
} else {
unscopedChecker, err = services.NewAccessChecker(&services.AccessInfo{
Username: userState.GetName(),
Roles: req.Roles,
Traits: req.Traits,
AllowedResourceAccessIDs: req.RequestedResourceAccessIDs,
DelegationSessionID: req.DelegationSessionID,
}, clusterName.GetClusterName(), a)
if err != nil {
return nil, nil, trace.Wrap(err)
}
checkerCtx = services.NewScopedAccessCheckerContextFromUnscoped(unscopedChecker)
}
idleTimeout, err := a.getWebIdleTimeout(ctx)
@@ -205,7 +219,7 @@ func (a *Server) newWebSession(
sessionTTL := req.SessionTTL
if sessionTTL == 0 {
sessionTTL = checker.AdjustSessionTTL(apidefaults.CertDuration)
sessionTTL = checkerCtx.CertParams().AdjustSessionTTL(apidefaults.CertDuration)
}
if req.AttestWebSession {
@@ -239,7 +253,7 @@ func (a *Server) newWebSession(
TTL: sessionTTL,
SSHPublicKey: sshAuthorizedKey,
TLSPublicKey: tlsPublicKeyPEM,
CheckerContext: services.NewScopedAccessCheckerContextFromUnscoped(checker), // TODO(fspmarshall/scopes): add scoping support to newWebSession.
CheckerContext: checkerCtx,
Traits: req.Traits,
ActiveRequests: req.AccessRequests,
}
@@ -320,7 +334,14 @@ func (a *Server) newWebSession(
}
if tdr, err := a.calculateTrustedDeviceMode(ctx, func() ([]types.Role, error) {
return checker.Roles(), nil
if unscopedChecker != nil {
return unscopedChecker.Roles(), nil
}
// For scoped sessions, no traditional roles apply, so let's only compute
// the trusted device mode from global cluster settings.
// TODO(bl-nero): Update this once there's actual support for device trust
// in scoped sessions.
return nil, nil
}); err != nil {
a.logger.WarnContext(ctx, "Failed to calculate trusted device mode for session", "error", err)
} else {
@@ -334,7 +355,7 @@ func (a *Server) newWebSession(
}
}
return sess, checker, nil
return sess, checkerCtx, nil
}
func (a *Server) getWebIdleTimeout(ctx context.Context) (time.Duration, error) {
@@ -32,7 +32,7 @@ import (
// ServiceConfig holds configuration options for the user preferences service.
type ServiceConfig struct {
Backend services.UserPreferences
Authorizer authz.Authorizer
Authorizer authz.ScopedAuthorizer
}
// Service implements the teleport.userpreferences.v1.UserPreferencesService RPC service.
@@ -40,7 +40,7 @@ type Service struct {
userpreferences.UnimplementedUserPreferencesServiceServer
backend services.UserPreferences
authorizer authz.Authorizer
authorizer authz.ScopedAuthorizer
}
// NewService returns a new user preferences gRPC service.
@@ -60,11 +60,14 @@ func NewService(cfg *ServiceConfig) (*Service, error) {
// GetUserPreferences returns the user preferences for a given user.
func (a *Service) GetUserPreferences(ctx context.Context, _ *userpreferences.GetUserPreferencesRequest) (*userpreferences.GetUserPreferencesResponse, error) {
authCtx, err := a.authorizer.Authorize(ctx)
authCtx, err := a.authorizer.AuthorizeScoped(ctx)
if err != nil {
return nil, trace.Wrap(err)
}
if authCtx.User == nil {
return nil, trace.AccessDenied("user preferences are only available to users, not scoped agents")
}
username := authCtx.User.GetName()
prefs, err := a.backend.GetUserPreferences(ctx, username)
@@ -79,11 +82,14 @@ func (a *Service) GetUserPreferences(ctx context.Context, _ *userpreferences.Get
// UpsertUserPreferences creates or updates user preferences for a given username.
func (a *Service) UpsertUserPreferences(ctx context.Context, req *userpreferences.UpsertUserPreferencesRequest) (*emptypb.Empty, error) {
authCtx, err := a.authorizer.Authorize(ctx)
authCtx, err := a.authorizer.AuthorizeScoped(ctx)
if err != nil {
return nil, trace.Wrap(err)
}
if authCtx.User == nil {
return nil, trace.AccessDenied("user preferences are only available to users, not scoped agents")
}
username := authCtx.User.GetName()
return &emptypb.Empty{}, trace.Wrap(a.backend.UpsertUserPreferences(ctx, username, req.Preferences))
@@ -193,10 +193,11 @@ func initSvc(t *testing.T) (map[string]context.Context, *Service) {
})
require.NoError(t, err)
authorizer, err := authz.NewAuthorizer(authz.AuthorizerOpts{
ClusterName: "test-cluster",
AccessPoint: accessPoint,
LockWatcher: lockWatcher,
authorizer, err := authz.NewScopedAuthorizer(authz.AuthorizerOpts{
ClusterName: "test-cluster",
AccessPoint: accessPoint,
LockWatcher: lockWatcher,
ScopedRoleReader: local.NewScopedAccessService(backend),
})
require.NoError(t, err)
+2
View File
@@ -229,6 +229,8 @@ type AuthenticateWebUserRequest struct {
User string `json:"user"`
// WebauthnAssertionResponse is a signed WebAuthn credential assertion.
WebauthnAssertionResponse *wantypes.CredentialAssertionResponse `json:"webauthnAssertionResponse,omitempty"`
// Scope is a scope for with the user is authenticated. Empty means unscoped.
Scope string `json:"scope,omitempty"`
}
type HeadlessRequest struct {
+89 -25
View File
@@ -614,6 +614,7 @@ func NewHandler(cfg Config, opts ...HandlerOption) (*APIHandler, error) {
sessionCache, err := newSessionCache(h.cfg.Context, sessionCacheOptions{
proxyClient: cfg.ProxyClient,
accessPoint: cfg.AccessPoint,
scopedRoleReader: cfg.AccessPoint.ScopedRoleReader(),
servers: []utils.NetAddr{cfg.AuthServers},
cipherSuites: cfg.CipherSuites,
clock: h.clock,
@@ -857,7 +858,7 @@ func (h *Handler) authenticateWebSession(w http.ResponseWriter, r *http.Request)
if err != nil {
return webSession{}, trace.Wrap(err)
}
resp, err := newSessionResponse(ctx)
resp, err := newSessionResponse(r.Context(), ctx)
if err != nil {
return webSession{}, trace.Wrap(err)
}
@@ -1512,11 +1513,34 @@ func (h *Handler) getUserContext(w http.ResponseWriter, r *http.Request, p httpr
})
userContext.AvailableScopes = assignedScopes
userContext.Scope, err = scopeFromSessionContext(c)
if err != nil {
return nil, trace.Wrap(err)
}
}
return userContext, nil
}
func scopeFromSessionContext(c *SessionContext) (string, error) {
id, err := c.GetIdentity()
if err != nil {
return "", trace.Wrap(err)
}
if id == nil {
return "", nil
}
pin := id.ScopePin
if pin == nil {
return "", nil
}
return pin.GetScope(), nil
}
// PublicProxyAddr returns the publicly advertised proxy address
func (h *Handler) PublicProxyAddr() string {
return h.cfg.PublicProxyAddr
@@ -2481,7 +2505,6 @@ func (h *Handler) githubLoginWeb(w http.ResponseWriter, r *http.Request, p httpr
logger.ErrorContext(r.Context(), "Failed to parse request remote address", "error", err)
return client.LoginFailedRedirectURL
}
response, err := h.cfg.ProxyClient.CreateGithubAuthRequest(r.Context(), types.GithubAuthRequest{
CSRFToken: req.CSRFToken,
ConnectorID: req.ConnectorID,
@@ -2489,6 +2512,7 @@ func (h *Handler) githubLoginWeb(w http.ResponseWriter, r *http.Request, p httpr
ClientRedirectURL: req.ClientRedirectURL,
ClientLoginIP: remoteAddr,
ClientUserAgent: r.UserAgent(),
Scope: req.Scope,
})
if err != nil {
logger.ErrorContext(r.Context(), "Error creating auth request", "error", err)
@@ -2815,6 +2839,9 @@ type CreateSessionReq struct {
Pass string `json:"pass"`
// SecondFactorToken is the OTP.
SecondFactorToken string `json:"second_factor_token"`
// Scope is the scope for which this session is created. Empty means
// unscoped.
Scope string `json:"scope"`
}
// String returns text description of this response
@@ -2850,14 +2877,26 @@ type CreateSessionResponse struct {
TrustedDeviceRequirement int32 `json:"trustedDeviceRequirement,omitempty"`
}
func newSessionResponse(sctx *SessionContext) (*CreateSessionResponse, error) {
func newSessionResponse(ctx context.Context, sctx *SessionContext) (*CreateSessionResponse, error) {
accessChecker, err := sctx.GetUserAccessChecker()
if err != nil {
return nil, trace.Wrap(err)
}
_, err = accessChecker.CheckLoginDuration(0)
if err != nil {
return nil, trace.Wrap(err)
if accessChecker.AccessInfo().ScopePin == nil {
_, err = accessChecker.CheckLoginDuration(0)
if err != nil {
return nil, trace.Wrap(err)
}
} else {
checkerContext, err := sctx.GetUserScopedAccessCheckerContext(ctx)
if err != nil {
return nil, trace.Wrap(err)
}
_, err = checkerContext.CertParams().GetSSHLoginsForTTL(ctx, 0)
if err != nil {
return nil, trace.Wrap(err)
}
}
token, err := sctx.getToken()
@@ -2909,11 +2948,11 @@ func (h *Handler) createWebSession(w http.ResponseWriter, r *http.Request, p htt
var webSession types.WebSession
switch {
case !cap.IsSecondFactorEnforced():
webSession, err = h.auth.AuthWithoutOTP(r.Context(), req.User, req.Pass, clientMeta)
webSession, err = h.auth.AuthWithoutOTP(r.Context(), req.User, req.Pass, req.Scope, clientMeta)
case req.SecondFactorToken == "" && !cap.IsSecondFactorEnforced():
webSession, err = h.auth.AuthWithoutOTP(r.Context(), req.User, req.Pass, clientMeta)
webSession, err = h.auth.AuthWithoutOTP(r.Context(), req.User, req.Pass, req.Scope, clientMeta)
case cap.IsSecondFactorTOTPAllowed():
webSession, err = h.auth.AuthWithOTP(r.Context(), req.User, req.Pass, req.SecondFactorToken, clientMeta)
webSession, err = h.auth.AuthWithOTP(r.Context(), req.User, req.Pass, req.SecondFactorToken, req.Scope, clientMeta)
default:
return nil, trace.AccessDenied("direct login with password+otp not supported by this cluster")
}
@@ -2938,7 +2977,7 @@ func (h *Handler) createWebSession(w http.ResponseWriter, r *http.Request, p htt
return nil, trace.AccessDenied("need auth")
}
res, err := newSessionResponse(ctx)
res, err := newSessionResponse(r.Context(), ctx)
return res, trace.Wrap(err)
}
@@ -3035,6 +3074,19 @@ type renewSessionRequest struct {
// - ReloadUser (opt): similar to default but updates user related data (e.g login traits) by retrieving it from the backend
// - default (none set): create new session with currently assigned roles
func (h *Handler) renewWebSession(w http.ResponseWriter, r *http.Request, params httprouter.Params, ctx *SessionContext) (interface{}, error) {
// TODO(bl-nero): Fix session renewal for scoped sessions. This endpoint
// doesn't work for scoped sessions, but currently, the web UI doesn't even
// call it in such scenario.
identity, err := ctx.GetIdentity()
if err != nil {
return nil, trace.Wrap(err)
}
if identity.ScopePin != nil {
// Reject scoped sessions explicitly to prevent removing a scope pin from an existing session without reauthentication.
return nil, trace.BadParameter("Scoped sessions are not supported yet")
}
req := renewSessionRequest{}
if err := httplib.ReadResourceJSON(r, &req); err != nil {
return nil, trace.Wrap(err)
@@ -3056,7 +3108,7 @@ func (h *Handler) renewWebSession(w http.ResponseWriter, r *http.Request, params
return nil, trace.Wrap(err)
}
res, err := newSessionResponse(newContext)
res, err := newSessionResponse(r.Context(), newContext)
return res, trace.Wrap(err)
}
@@ -3134,7 +3186,7 @@ func (h *Handler) changeUserAuthentication(w http.ResponseWriter, r *http.Reques
}
// Checks for at least one valid login.
if _, err := newSessionResponse(ctx); err != nil {
if _, err := newSessionResponse(r.Context(), ctx); err != nil {
return nil, trace.Wrap(err)
}
@@ -3350,7 +3402,7 @@ func (h *Handler) mfaLoginFinishSession(w http.ResponseWriter, r *http.Request,
return nil, trace.AccessDenied("need auth")
}
return newSessionResponse(ctx)
return newSessionResponse(r.Context(), ctx)
}
// getClusters returns a list of cluster and its data.
@@ -3437,7 +3489,7 @@ func (h *Handler) getSiteNamespaces(w http.ResponseWriter, r *http.Request, _ ht
}, nil
}
func makeUnifiedResourceRequest(r *http.Request) (*proto.ListUnifiedResourcesRequest, error) {
func makeUnifiedResourceRequest(r *http.Request, scopePin *scopesv1.Pin) (*proto.ListUnifiedResourcesRequest, error) {
values := r.URL.Query()
limit, err := QueryLimitAsInt32(values, "limit", defaults.MaxIterationLimit)
@@ -3461,16 +3513,23 @@ func makeUnifiedResourceRequest(r *http.Request) (*proto.ListUnifiedResourcesReq
}
// set default kinds to be requested if none exist in the request
scopedIdentity := scopePin.GetScope() != ""
if len(kinds) == 0 {
kinds = []string{
types.KindApp,
types.KindDatabase,
types.KindNode,
types.KindWindowsDesktop,
types.KindLinuxDesktop,
types.KindKubernetesCluster,
types.KindSAMLIdPServiceProvider,
types.KindGitServer,
if scopedIdentity {
// TODO(bl-nero): Support more resource kinds when they are supported for
// scoped sessions.
kinds = []string{types.KindNode}
} else {
kinds = []string{
types.KindApp,
types.KindDatabase,
types.KindNode,
types.KindWindowsDesktop,
types.KindLinuxDesktop,
types.KindKubernetesCluster,
types.KindSAMLIdPServiceProvider,
types.KindGitServer,
}
}
}
@@ -3487,7 +3546,7 @@ func makeUnifiedResourceRequest(r *http.Request) (*proto.ListUnifiedResourcesReq
PredicateExpression: values.Get("query"),
SearchKeywords: client.ParseSearchKeywords(values.Get("search"), ' '),
UseSearchAsRoles: useSearchAsRoles,
IncludeLogins: true,
IncludeLogins: !scopedIdentity,
IncludeRequestable: includeRequestable,
}, nil
}
@@ -3553,7 +3612,7 @@ func (h *Handler) clusterUnifiedResourcesGet(w http.ResponseWriter, request *htt
return nil, trace.Wrap(err)
}
req, err := makeUnifiedResourceRequest(request)
req, err := makeUnifiedResourceRequest(request, identity.ScopePin)
if err != nil {
return nil, trace.Wrap(err)
}
@@ -5745,6 +5804,8 @@ type SSORequestParams struct {
CSRFToken string
// LoginHint is the user's identifier (email) for identifier-first login.
LoginHint string
// Scope is the scope that this session will be pinned to.
Scope string
}
// ParseSSORequestParams extracts the SSO request parameters from an http.Request,
@@ -5786,11 +5847,14 @@ func ParseSSORequestParams(r *http.Request) (*SSORequestParams, error) {
return nil, trace.Wrap(err)
}
scope := query.Get("scope")
return &SSORequestParams{
ClientRedirectURL: clientRedirectURL,
ConnectorID: connectorID,
CSRFToken: csrfToken,
LoginHint: loginHint,
Scope: scope,
}, nil
}
+177 -79
View File
@@ -142,7 +142,7 @@ import (
"github.com/gravitational/teleport/lib/reversetunnel"
"github.com/gravitational/teleport/lib/reversetunnelclient"
"github.com/gravitational/teleport/lib/scopes"
access "github.com/gravitational/teleport/lib/scopes/access"
scopedaccess "github.com/gravitational/teleport/lib/scopes/access"
scopedapp "github.com/gravitational/teleport/lib/scopes/app"
"github.com/gravitational/teleport/lib/secret"
"github.com/gravitational/teleport/lib/service/servicecfg"
@@ -301,6 +301,9 @@ type webSuiteConfig struct {
// middleware adds optional middleware to the test handler.
middleware func(next http.Handler) http.Handler
// scopesFeatures controls scoped access feature flags.
scopesFeatures scopes.Features
}
func newWebSuiteWithConfig(t *testing.T, cfg webSuiteConfig) *WebSuite {
@@ -337,6 +340,7 @@ func newWebSuiteWithConfig(t *testing.T, cfg webSuiteConfig) *WebSuite {
Clock: s.clock,
ClusterNetworkingConfig: networkingConfig,
AuthPreferenceSpec: cfg.authPreferenceSpec,
ScopesFeatures: cfg.scopesFeatures,
},
}
@@ -636,6 +640,7 @@ func newWebSuiteWithConfig(t *testing.T, cfg webSuiteConfig) *WebSuite {
ProxySSHAddr: "127.0.0.1",
AccessPoint: s.server.Auth(),
},
ScopesFeatures: cfg.scopesFeatures,
SessionControl: SessionControllerFunc(func(ctx context.Context, sctx *SessionContext, login, localAddr, remoteAddr string) (context.Context, error) {
controller := srv.WebSessionController(proxySessionController)
ctx, err := controller(ctx, sctx, login, localAddr, remoteAddr)
@@ -3164,10 +3169,14 @@ func TestLogin_PrivateKeyEnabledError(t *testing.T) {
func TestLogin(t *testing.T) {
t.Parallel()
s := newWebSuite(t)
ctx := t.Context()
s := newWebSuiteWithConfig(t, webSuiteConfig{
scopesFeatures: scopes.Features{Enabled: true},
})
ap, err := types.NewAuthPreference(types.AuthPreferenceSpecV2{
Type: constants.Local,
SecondFactor: constants.SecondFactorOff,
SecondFactor: constants.SecondFactorOTP,
})
require.NoError(t, err)
_, err = s.server.Auth().UpsertAuthPreference(s.ctx, ap)
@@ -3177,63 +3186,130 @@ func TestLogin(t *testing.T) {
const user = "user1"
const pass = "password1234"
s.createUser(t, user, "root", pass, "")
otpSecret := newOTPSharedSecret()
dev, err := services.NewTOTPDevice("otp-device", otpSecret, s.clock.Now())
require.NoError(t, err)
err = s.server.Auth().UpsertMFADevice(ctx, user, dev)
require.NoError(t, err)
_, err = s.server.Auth().ScopedAccess().CreateScopedRole(
ctx, scopedaccessv1.CreateScopedRoleRequest_builder{
Role: scopedaccessv1.ScopedRole_builder{
Kind: scopedaccess.KindScopedRole,
Version: types.V1,
Metadata: headerv1.Metadata_builder{
Name: "prod-role",
}.Build(),
Scope: "/prod",
Spec: scopedaccessv1.ScopedRoleSpec_builder{
AssignableScopes: []string{"/prod/east"},
}.Build(),
}.Build(),
}.Build(),
)
require.NoError(t, err)
_, err = s.server.Auth().ScopedAccess().CreateScopedRoleAssignment(
ctx, scopedaccessv1.CreateScopedRoleAssignmentRequest_builder{
Assignment: scopedaccessv1.ScopedRoleAssignment_builder{
Kind: scopedaccess.KindScopedRoleAssignment,
SubKind: scopedaccess.SubKindDynamic,
Version: types.V1,
Metadata: headerv1.Metadata_builder{
Name: "prod-role-assignment",
}.Build(),
Scope: "/prod",
Spec: scopedaccessv1.ScopedRoleAssignmentSpec_builder{
User: "user1",
Assignments: []*scopedaccessv1.Assignment{
scopedaccessv1.Assignment_builder{
Role: "/prod::prod-role",
Scope: "/prod/east",
}.Build(),
},
}.Build(),
}.Build(),
}.Build(),
)
require.NoError(t, err)
clt := s.client(t)
ctx := context.Background()
const ua = "test-ua"
sessionResp, httpResp := loginWebOTP(t, ctx, loginWebOTPParams{
webClient: clt,
user: user,
password: pass,
userAgent: ua,
})
cases := []struct {
name string
scope string
}{
{name: "unscoped", scope: ""},
{name: "scoped", scope: "/prod/east"},
}
events, _, err := s.server.AuthServer.AuditLog.SearchEvents(ctx, events.SearchEventsRequest{
From: s.clock.Now().Add(-time.Hour),
To: s.clock.Now().Add(time.Hour),
EventTypes: []string{events.UserLoginEvent},
Limit: 1,
Order: types.EventOrderDescending,
})
require.NoError(t, err)
event := events[0].(*apievents.UserLogin)
require.True(t, event.Success)
require.Equal(t, ua, event.UserAgent)
require.True(t, strings.HasPrefix(event.RemoteAddr, "127.0.0.1:"))
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
ctx := t.Context()
const ua = "test-ua"
s.clock.Advance(time.Minute) // Prevent reusing old OTP
sessionResp, httpResp := loginWebOTP(t, ctx, loginWebOTPParams{
webClient: clt,
clock: s.clock,
user: user,
password: pass,
otpSecret: otpSecret,
scope: tc.scope,
userAgent: ua,
})
cookies := httpResp.Cookies()
require.Len(t, cookies, 1)
require.NotEmpty(t, sessionResp.SessionExpires)
events, _, err := s.server.AuthServer.AuditLog.SearchEvents(ctx, events.SearchEventsRequest{
From: s.clock.Now().Add(-time.Hour),
To: s.clock.Now().Add(time.Hour),
EventTypes: []string{events.UserLoginEvent},
Limit: 1,
Order: types.EventOrderDescending,
})
require.NoError(t, err)
event := events[0].(*apievents.UserLogin)
require.True(t, event.Success)
require.Equal(t, ua, event.UserAgent)
require.True(t, strings.HasPrefix(event.RemoteAddr, "127.0.0.1:"))
// now make sure we are logged in by calling authenticated method
// we need to supply both session cookie and bearer token for
// request to succeed
jar, err := cookiejar.New(nil)
require.NoError(t, err)
cookies := httpResp.Cookies()
require.Len(t, cookies, 1)
require.NotEmpty(t, sessionResp.SessionExpires)
clt = s.client(t, roundtrip.BearerAuth(sessionResp.Token), roundtrip.CookieJar(jar))
jar.SetCookies(s.url(), cookies)
// now make sure we are logged in by calling authenticated method
// we need to supply both session cookie and bearer token for
// request to succeed
jar, err := cookiejar.New(nil)
require.NoError(t, err)
re, err := clt.Get(s.ctx, clt.Endpoint("webapi", "sites"), url.Values{})
require.NoError(t, err)
clt = s.client(t, roundtrip.BearerAuth(sessionResp.Token), roundtrip.CookieJar(jar))
jar.SetCookies(s.url(), cookies)
var clusters []webui.Cluster
require.NoError(t, json.Unmarshal(re.Bytes(), &clusters))
re, err := clt.Get(
s.ctx,
clt.Endpoint("webapi", "sites", s.server.ClusterName(), "context"),
url.Values{},
)
require.NoError(t, err)
// in absence of session cookie or bearer auth the same request fill fail
var userContext webui.UserContext
require.NoError(t, json.Unmarshal(re.Bytes(), &userContext))
assert.Equal(t, tc.scope, userContext.Scope)
// no session cookie:
clt = s.client(t, roundtrip.BearerAuth(sessionResp.Token))
_, err = clt.Get(s.ctx, clt.Endpoint("webapi", "sites"), url.Values{})
require.Error(t, err)
require.True(t, trace.IsAccessDenied(err))
// in absence of session cookie or bearer auth the same request fill fail
// no bearer token:
clt = s.client(t, roundtrip.CookieJar(jar))
_, err = clt.Get(s.ctx, clt.Endpoint("webapi", "sites"), url.Values{})
require.Error(t, err)
require.True(t, trace.IsAccessDenied(err))
// no session cookie:
clt = s.client(t, roundtrip.BearerAuth(sessionResp.Token))
_, err = clt.Get(s.ctx, clt.Endpoint("webapi", "sites"), url.Values{})
require.Error(t, err)
require.True(t, trace.IsAccessDenied(err))
// no bearer token:
clt = s.client(t, roundtrip.CookieJar(jar))
_, err = clt.Get(s.ctx, clt.Endpoint("webapi", "sites"), url.Values{})
require.Error(t, err)
require.True(t, trace.IsAccessDenied(err))
})
}
}
// TestEmptyMotD ensures that responses returned by both /webapi/ping and
@@ -9436,21 +9512,17 @@ type testProxy struct {
webURL url.URL
}
// authPack returns new authenticated package consisting of created valid
// user, otp token, created web session and authenticated client.
func (r *testProxy) authPack(t *testing.T, teleportUser string, roles []types.Role) *authPack {
// authPackWithLoginParams returns new authenticated package consisting of
// created valid user, otp token, created web session and authenticated client
// with given login params.
func (r *testProxy) authPackWithLoginParams(
t *testing.T, teleportUser string, loginParams loginWebOTPParams, roles []types.Role,
) *authPack {
ctx := context.Background()
const (
pass = "abcdef123456"
rawSecret = "def456"
)
u, err := user.Current()
require.NoError(t, err)
loginUser := u.Username
otpSecret := newOTPSharedSecret()
ap, err := types.NewAuthPreference(types.AuthPreferenceSpecV2{
Type: constants.Local,
SecondFactor: constants.SecondFactorOTP,
@@ -9460,15 +9532,11 @@ func (r *testProxy) authPack(t *testing.T, teleportUser string, roles []types.Ro
_, err = r.auth.Auth().UpsertAuthPreference(ctx, ap)
require.NoError(t, err)
r.createUser(context.Background(), t, teleportUser, loginUser, pass, otpSecret, roles)
r.createUser(
context.Background(), t, teleportUser, loginUser, loginParams.password, loginParams.otpSecret, roles,
)
sessionResp, httpResp := loginWebOTP(t, ctx, loginWebOTPParams{
webClient: r.newClient(t),
clock: r.clock,
user: teleportUser,
password: pass,
otpSecret: otpSecret,
})
sessionResp, httpResp := loginWebOTP(t, ctx, loginParams)
jar, err := cookiejar.New(nil)
require.NoError(t, err)
@@ -9477,19 +9545,48 @@ func (r *testProxy) authPack(t *testing.T, teleportUser string, roles []types.Ro
jar.SetCookies(&r.webURL, httpResp.Cookies())
return &authPack{
otpSecret: otpSecret,
otpSecret: loginParams.otpSecret,
user: teleportUser,
login: loginUser,
session: sessionResp,
clt: clt,
cookies: httpResp.Cookies(),
password: pass,
password: loginParams.password,
device: &authtest.Device{
TOTPSecret: otpSecret,
TOTPSecret: loginParams.otpSecret,
},
}
}
func (r *testProxy) authPack(t *testing.T, teleportUser string, roles []types.Role) *authPack {
return r.authPackWithLoginParams(
t, teleportUser,
loginWebOTPParams{
webClient: r.newClient(t),
clock: r.clock,
user: teleportUser,
password: "abcdef123456",
otpSecret: newOTPSharedSecret(),
},
roles,
)
}
func (r *testProxy) scopedAuthPack(t *testing.T, teleportUser string, scope string, roles []types.Role) *authPack {
return r.authPackWithLoginParams(
t, teleportUser,
loginWebOTPParams{
webClient: r.newClient(t),
clock: r.clock,
user: teleportUser,
password: "abcdef123456",
otpSecret: newOTPSharedSecret(),
scope: scope,
},
roles,
)
}
func (r *testProxy) authPackFromPack(t *testing.T, pack *authPack) *authPack {
jar, err := cookiejar.New(nil)
require.NoError(t, err)
@@ -9705,7 +9802,7 @@ func TestUserContextWithAccessRequest(t *testing.T) {
require.Equal(t, accessRequestID, userContext.ConsumedAccessRequestID)
}
func TestUerContextWithScopesNoAssignments(t *testing.T) {
func TestUserContextWithScopesNoAssignments(t *testing.T) {
t.Parallel()
env := newWebPack(t, 1, withScopesFeatures(scopes.Features{Enabled: true}))
proxy := env.proxies[0]
@@ -9728,7 +9825,7 @@ func TestUerContextWithScopesNoAssignments(t *testing.T) {
require.Empty(t, userContext.AvailableScopes)
}
func TestUerContextWithScopes(t *testing.T) {
func TestUserContextWithScopes(t *testing.T) {
t.Parallel()
env := newWebPack(t, 1, withScopesFeatures(scopes.Features{Enabled: true}))
proxy := env.proxies[0]
@@ -9737,7 +9834,7 @@ func TestUerContextWithScopes(t *testing.T) {
// Create scoped roles.
_, err := env.server.Auth().ScopedAccess().CreateScopedRole(ctx, scopedaccessv1.CreateScopedRoleRequest_builder{
Role: scopedaccessv1.ScopedRole_builder{
Kind: access.KindScopedRole,
Kind: scopedaccess.KindScopedRole,
Version: types.V1,
Metadata: headerv1.Metadata_builder{
Name: "role-a",
@@ -9751,7 +9848,7 @@ func TestUerContextWithScopes(t *testing.T) {
require.NoError(t, err)
_, err = env.server.Auth().ScopedAccess().CreateScopedRole(ctx, scopedaccessv1.CreateScopedRoleRequest_builder{
Role: scopedaccessv1.ScopedRole_builder{
Kind: access.KindScopedRole,
Kind: scopedaccess.KindScopedRole,
Version: types.V1,
Metadata: headerv1.Metadata_builder{
Name: "role-b",
@@ -9768,8 +9865,8 @@ func TestUerContextWithScopes(t *testing.T) {
username := "dave"
assignment1, err := env.server.Auth().ScopedAccess().CreateScopedRoleAssignment(ctx, scopedaccessv1.CreateScopedRoleAssignmentRequest_builder{
Assignment: scopedaccessv1.ScopedRoleAssignment_builder{
Kind: access.KindScopedRoleAssignment,
SubKind: access.SubKindDynamic,
Kind: scopedaccess.KindScopedRoleAssignment,
SubKind: scopedaccess.SubKindDynamic,
Version: types.V1,
Metadata: headerv1.Metadata_builder{
Name: "assignment-1",
@@ -9795,8 +9892,8 @@ func TestUerContextWithScopes(t *testing.T) {
require.NoError(t, err)
assignment2, err := env.server.Auth().ScopedAccess().CreateScopedRoleAssignment(ctx, scopedaccessv1.CreateScopedRoleAssignmentRequest_builder{
Assignment: scopedaccessv1.ScopedRoleAssignment_builder{
Kind: access.KindScopedRoleAssignment,
SubKind: access.SubKindDynamic,
Kind: scopedaccess.KindScopedRoleAssignment,
SubKind: scopedaccess.SubKindDynamic,
Version: types.V1,
Metadata: headerv1.Metadata_builder{
Name: "assignment-2",
@@ -9822,7 +9919,7 @@ func TestUerContextWithScopes(t *testing.T) {
waitForSRACache(t, env.server.TLS, assignment1, assignment2)
// Create and authenticate the test user.
pack := proxy.authPack(t, username, []types.Role{})
pack := proxy.scopedAuthPack(t, username, "/test/a2", []types.Role{})
// Make a request to fetch the userContext.
endpoint := pack.clt.Endpoint("webapi", "sites", env.server.ClusterName(), "context")
@@ -9835,7 +9932,8 @@ func TestUerContextWithScopes(t *testing.T) {
require.NoError(t, err)
// Verify that the userContext returned contains the assigned scopes.
require.Equal(t, []string{"/test/a1", "/test/a2", "/test/b1"}, userContext.AvailableScopes)
assert.Equal(t, []string{"/test/a1", "/test/a2", "/test/b1"}, userContext.AvailableScopes)
assert.Equal(t, "/test/a2", userContext.Scope)
}
func waitForSRACache(t *testing.T, srv *authtest.TLSServer, resps ...*scopedaccessv1.CreateScopedRoleAssignmentResponse) {
+2
View File
@@ -54,6 +54,7 @@ type loginWebOTPParams struct {
// If empty then no OTP is sent in the request.
otpSecret string
scope string // Optional.
userAgent string // Optional.
overrideContentType string // Optional.
}
@@ -110,6 +111,7 @@ func rawLoginWebOTP(ctx context.Context, params loginWebOTPParams) (resp *Draine
User: params.user,
Pass: params.password,
SecondFactorToken: code,
Scope: params.scope,
})
if err != nil {
return nil, nil, trace.Wrap(err, "request marshal")
+64 -13
View File
@@ -107,6 +107,14 @@ type SessionContextConfig struct {
// "GetNodes".
UnsafeCachedAuthClient authclient.ReadProxyAccessPoint
// UnsafeScoopedRoleReader is a scoped role reader. It's authenticated with
// the identity of the node, hence it's "unsafe".
//
// Only use it where the identity of the caller will not affect the result of
// the RPC. For example, never call it to get a list of roles and return it
// to the user, as it may return more than the user is allowed to see.
UnsafeScopedRoleReader services.ScopedRoleReader
Parent *sessionCache
// Resources is a persistent resource store this context is bound to.
// The store maintains a list of resources between session renewals
@@ -139,6 +147,10 @@ func (c *SessionContextConfig) CheckAndSetDefaults() error {
return trace.BadParameter("Session required")
}
if c.UnsafeCachedAuthClient == nil {
return trace.BadParameter("Scoped role reader required")
}
if c.Log == nil {
c.Log = slog.With(
"user", c.User,
@@ -517,6 +529,37 @@ func (c *SessionContext) GetUserAccessChecker() (services.AccessChecker, error)
return accessChecker, trace.Wrap(err)
}
func (c *SessionContext) GetUserScopedAccessCheckerContext(ctx context.Context) (*services.ScopedAccessCheckerContext, error) {
cert, err := c.GetSSHCertificate()
if err != nil {
return nil, trace.Wrap(err)
}
ident, err := sshca.DecodeIdentity(cert)
if err != nil {
return nil, trace.Wrap(err)
}
accessInfo := services.AccessInfoFromLocalSSHIdentity(ident)
if accessInfo.ScopePin == nil {
checker, err := c.GetUserAccessChecker()
if err != nil {
return nil, trace.Wrap(err)
}
return services.NewScopedAccessCheckerContextFromUnscoped(checker), nil
}
checkerCtx, err := services.NewScopedAccessCheckerContext(
ctx, accessInfo, c.cfg.RootClusterName, c.cfg.UnsafeScopedRoleReader,
)
if err != nil {
return nil, trace.Wrap(err)
}
return checkerCtx, err
}
// GetProxyListenerMode returns cluster proxy listener mode form cluster networking config.
func (c *SessionContext) GetProxyListenerMode(ctx context.Context) (types.ProxyListenerMode, error) {
resp, err := c.cfg.UnsafeCachedAuthClient.GetClusterNetworkingConfig(ctx)
@@ -626,11 +669,12 @@ func (c *SessionContext) expired(ctx context.Context) bool {
const cachedSessionLingeringThreshold = 2 * time.Minute
type sessionCacheOptions struct {
proxyClient authclient.ClientI
accessPoint authclient.ReadProxyAccessPoint
servers []utils.NetAddr
cipherSuites []uint16
clock clockwork.Clock
proxyClient authclient.ClientI
scopedRoleReader services.ScopedRoleReader
accessPoint authclient.ReadProxyAccessPoint
servers []utils.NetAddr
cipherSuites []uint16
clock clockwork.Clock
// sessionLingeringThreshold specifies the time the session will linger
// in the cache before getting purged after it has expired
sessionLingeringThreshold time.Duration
@@ -667,6 +711,7 @@ func newSessionCache(ctx context.Context, config sessionCacheOptions) (*sessionC
cache := &sessionCache{
clusterName: clusterName.GetClusterName(),
proxyClient: config.proxyClient,
scopedRoleReader: config.scopedRoleReader,
accessPoint: config.accessPoint,
sessions: make(map[string]*SessionContext),
resources: make(map[string]*sessionResources),
@@ -701,13 +746,14 @@ func newSessionCache(ctx context.Context, config sessionCacheOptions) (*sessionC
// sessionCache handles web session authentication,
// and holds in-memory contexts associated with each session
type sessionCache struct {
log *slog.Logger
proxyClient authclient.ClientI
authServers []utils.NetAddr
accessPoint authclient.ReadProxyAccessPoint
closer *utils.CloseBroadcaster
clusterName string
clock clockwork.Clock
log *slog.Logger
proxyClient authclient.ClientI
authServers []utils.NetAddr
accessPoint authclient.ReadProxyAccessPoint
scopedRoleReader services.ScopedRoleReader
closer *utils.CloseBroadcaster
clusterName string
clock clockwork.Clock
// sessionLingeringThreshold specifies the time the session will linger
// in the cache before getting purged after it has expired
sessionLingeringThreshold time.Duration
@@ -939,6 +985,7 @@ func (s *sessionCache) releaseResourcesIfNoDeviceExtensions(ctx context.Context,
func (s *sessionCache) AuthWithOTP(
ctx context.Context,
user, pass, otpToken string,
scope string,
clientMeta *authclient.ForwardedClientMetadata,
) (types.WebSession, error) {
return s.proxyClient.AuthenticateWebUser(ctx, authclient.AuthenticateUserRequest{
@@ -949,13 +996,14 @@ func (s *sessionCache) AuthWithOTP(
Token: otpToken,
},
ClientMetadata: clientMeta,
Scope: scope,
})
}
// AuthWithoutOTP authenticates the specified user with the given password.
// Returns a new web session if successful.
func (s *sessionCache) AuthWithoutOTP(
ctx context.Context, user, pass string, clientMeta *authclient.ForwardedClientMetadata,
ctx context.Context, user, pass string, scope string, clientMeta *authclient.ForwardedClientMetadata,
) (types.WebSession, error) {
return s.proxyClient.AuthenticateWebUser(ctx, authclient.AuthenticateUserRequest{
Username: user,
@@ -963,6 +1011,7 @@ func (s *sessionCache) AuthWithoutOTP(
Password: []byte(pass),
},
ClientMetadata: clientMeta,
Scope: scope,
})
}
@@ -972,6 +1021,7 @@ func (s *sessionCache) AuthenticateWebUser(
authReq := authclient.AuthenticateUserRequest{
Username: req.User,
ClientMetadata: clientMeta,
Scope: req.Scope,
}
if req.WebauthnAssertionResponse != nil {
authReq.Webauthn = req.WebauthnAssertionResponse
@@ -1230,6 +1280,7 @@ func (s *sessionCache) newSessionContextFromSession(ctx context.Context, session
User: session.GetUser(),
RootClient: userClient,
UnsafeCachedAuthClient: s.accessPoint,
UnsafeScopedRoleReader: s.scopedRoleReader,
Parent: s,
Resources: s.upsertSessionContext(session.GetUser()),
Session: session,
+3
View File
@@ -75,6 +75,9 @@ type UserContext struct {
// AvailableScopes is a list of scopes available to the user through their
// scoped role assignments.
AvailableScopes []string `json:"availableScopes"`
// Scope contains the scope pinned to the current session. An empty string
// indicates an unscoped session.
Scope string `json:"scope"`
}
func getAccessStrategy(roleset services.RoleSet) accessStrategy {