Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions api/profile/profile.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,9 @@ type Profile struct {
// with WebProxyAddr, to determine if a webpage is safe to open. Currently used by Teleport
// Connect in the proxy host allow list.
SSOHost string `yaml:"sso_host,omitempty"`

// Scope is the target scope that credentials are pinned to, if any.
Scope string `yaml:"scope,omitempty"`
}

// Copy returns a shallow copy of p, or nil if p is nil.
Expand Down
1 change: 1 addition & 0 deletions api/profile/profile_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ func TestProfileBasics(t *testing.T) {
SiteName: "example.com",
AuthConnector: "passwordless",
MFAMode: "auto",
Scope: "/team-a",
}

// verify that profile name is proxy host component
Expand Down
2 changes: 2 additions & 0 deletions lib/client/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -942,6 +942,7 @@ func (c *Config) LoadProfile(proxyAddr string) error {
c.SAMLSingleLogoutEnabled = profile.SAMLSingleLogoutEnabled
c.SSHDialTimeout = profile.SSHDialTimeout
c.SSOHost = profile.SSOHost
c.Scope = profile.Scope

c.AuthenticatorAttachment, err = parseMFAMode(profile.MFAMode)
if err != nil {
Expand Down Expand Up @@ -1010,6 +1011,7 @@ func (c *Config) Profile() *profile.Profile {
SAMLSingleLogoutEnabled: c.SAMLSingleLogoutEnabled,
SSHDialTimeout: c.SSHDialTimeout,
SSOHost: c.SSOHost,
Scope: c.Scope,
}
}

Expand Down
1 change: 1 addition & 0 deletions lib/client/client_store.go
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,7 @@ func (s *Store) ReadProfileStatus(proxyAddressOrProfile string) (*ProfileStatus,
ValidUntil: time.Now(),
SAMLSingleLogoutEnabled: profile.SAMLSingleLogoutEnabled,
SSOHost: profile.SSOHost,
Scope: profile.Scope,
}

keyRing, err := s.GetKeyRing(idx, WithAllCerts...)
Expand Down
44 changes: 44 additions & 0 deletions lib/client/client_store_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,7 @@ func TestClientStore(t *testing.T) {
WebProxyAddr: net.JoinHostPort(idx.ProxyHost, "3080"),
SiteName: idx.ClusterName,
Username: idx.Username,
Scope: "/production",
}
err = clientStore.SaveProfile(profile, true)
require.NoError(t, err)
Expand Down Expand Up @@ -293,6 +294,7 @@ func TestClientStore(t *testing.T) {

otherProfile := profile.Copy()
otherProfile.WebProxyAddr = "other.example.com:3080"
otherProfile.Scope = "/staging"
err = clientStore.SaveProfile(otherProfile, false)
require.NoError(t, err)

Expand Down Expand Up @@ -320,11 +322,53 @@ func TestClientStore(t *testing.T) {
require.Equal(t, expectOtherStatus, currentStatus)
require.Len(t, otherStatuses, 1)
require.Equal(t, expectStatus, otherStatuses[0])
require.Equal(t, currentStatus.Scope, expectOtherStatus.Scope)
})
})
}
}

func TestPartialProfileStatusScope(t *testing.T) {
t.Parallel()

t.Run("nil ScopePin when profile has no scope", func(t *testing.T) {
t.Parallel()
testEachClientStore(t, func(t *testing.T, clientStore *Store) {
p := &profile.Profile{
WebProxyAddr: "noscope.example.com:3080",
SiteName: "root",
Username: "alice",
}
err := clientStore.SaveProfile(p, true)
require.NoError(t, err)

// No key ring saved — ReadProfileStatus should return partial status.
status, err := clientStore.ReadProfileStatus(p.Name())
require.NoError(t, err)
require.Empty(t, status.Scope)
})
})

t.Run("ScopePin set when profile has scope", func(t *testing.T) {
t.Parallel()
testEachClientStore(t, func(t *testing.T, clientStore *Store) {
p := &profile.Profile{
WebProxyAddr: "scoped.example.com:3080",
SiteName: "root",
Username: "alice",
Scope: "/production",
}
err := clientStore.SaveProfile(p, true)
require.NoError(t, err)

status, err := clientStore.ReadProfileStatus(p.Name())
require.NoError(t, err)
require.NotNil(t, status.Scope)
require.Equal(t, "/production", status.Scope)
})
})
}

// TestProxySSHConfig tests proxy client SSH config function
// that generates SSH client configuration for proxy tunnel connections
func TestProxySSHConfig(t *testing.T) {
Expand Down
127 changes: 127 additions & 0 deletions tool/tsh/common/scope_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/*
* Teleport
* Copyright (C) 2026 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 common

import (
"testing"

"github.com/stretchr/testify/require"

"github.com/gravitational/teleport/lib/client"
)

func TestResolveDesiredScope(t *testing.T) {
tests := []struct {
name string
cf *CLIConf
profile *client.ProfileStatus
wantScope string
wantScopeChange bool
}{
{
name: "no flag, no profile -> empty scope, no change",
cf: &CLIConf{},
profile: nil,
wantScope: "",
wantScopeChange: false,
},
{
name: "no flag, unscoped profile -> inherit empty, no change",
cf: &CLIConf{},
profile: &client.ProfileStatus{},
wantScope: "",
wantScopeChange: false,
},
{
name: "no flag, scoped profile -> inherit scope, no change",
cf: &CLIConf{},
profile: &client.ProfileStatus{Scope: "/staging/west"},
wantScope: "/staging/west",
wantScopeChange: false,
},
{
name: "explicit scope, no profile -> set scope, changed",
cf: &CLIConf{
Scope: "/staging/east",
ScopeSetByUser: true,
},
profile: nil,
wantScope: "/staging/east",
wantScopeChange: true,
},
{
name: "explicit scope, unscoped profile -> set scope, changed",
cf: &CLIConf{
Scope: "/staging/east",
ScopeSetByUser: true,
},
profile: &client.ProfileStatus{},
wantScope: "/staging/east",
wantScopeChange: true,
},
{
name: "explicit scope, same scoped profile -> same scope, no change",
cf: &CLIConf{
Scope: "/staging/east",
ScopeSetByUser: true,
},
profile: &client.ProfileStatus{Scope: "/staging/east"},
wantScope: "/staging/east",
wantScopeChange: false,
},
{
name: "explicit scope, different scoped profile -> new scope, changed",
cf: &CLIConf{
Scope: "/staging/east",
ScopeSetByUser: true,
},
profile: &client.ProfileStatus{Scope: "/staging/west"},
wantScope: "/staging/east",
wantScopeChange: true,
},
{
name: "descope with empty string, scoped profile -> empty, changed",
cf: &CLIConf{
Scope: "",
ScopeSetByUser: true,
},
profile: &client.ProfileStatus{Scope: "/staging/west"},
wantScope: "",
wantScopeChange: true,
},
{
name: "descope with empty string, unscoped profile -> empty, no change",
cf: &CLIConf{
Scope: "",
ScopeSetByUser: true,
},
profile: &client.ProfileStatus{Scope: ""},
wantScope: "",
wantScopeChange: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotScope, gotChanged := resolveScope(tt.cf, tt.profile)
require.Equal(t, tt.wantScope, gotScope)
require.Equal(t, tt.wantScopeChange, gotChanged)
})
}
}
54 changes: 44 additions & 10 deletions tool/tsh/common/tsh.go
Original file line number Diff line number Diff line change
Expand Up @@ -154,8 +154,10 @@ type ClientInitFunc func(cf *CLIConf) (*client.TeleportClient, error)

// CLIConf stores command line arguments and flags:
type CLIConf struct {
// Scope constrains the current operation to a specific target scope
// Scope constrains the current operation to a specific target scope. A scope of "" descopes the user.
Scope string
// ScopeSetByUser specifies whether the flag was set by the user.
ScopeSetByUser bool
// UserHost contains "[login]@hostname" argument to SSH command
UserHost string
// Commands to execute on a remote host
Expand Down Expand Up @@ -1259,7 +1261,9 @@ func Run(ctx context.Context, args []string, opts ...CliOption) error {
login.Flag("request-nowait", "Finish without waiting for request resolution").BoolVar(&cf.NoWait)
login.Flag("request-id", "Login with the roles requested in the given request").StringVar(&cf.RequestID)
login.Arg("cluster", clusterHelp).StringVar(&cf.SiteName)
login.Flag("scope", "Scope pins credentials to a given scope.").StringVar(&cf.Scope)
login.Flag("scope", `Scope pins credentials to a given scope. Use "" to explicitly remove scoping.`).
IsSetByUser(&cf.ScopeSetByUser).
StringVar(&cf.Scope)
login.Flag("browser", browserHelp).StringVar(&cf.Browser)
login.Flag("kube-cluster", "Name of the Kubernetes cluster to login to").StringVar(&cf.KubernetesCluster)
login.Flag("verbose", "Show extra status information").Short('v').BoolVar(&cf.Verbose)
Expand Down Expand Up @@ -2211,6 +2215,35 @@ func serializeVersion(format string, proxyVersion string, proxyPublicAddress str
return string(out), trace.Wrap(err)
}

// resolveScope determines the target scope based on the CLI flag state and the current
// profile. It returns the desired scope string and whether the scope differs from the profile's
// current scope. The 3 cases are:
// 1. --scope not provided at all -> inherit profile.Scope, scopeChanged = false
// 2. --scope="" -> explicitly descope, scopeChanged = (profile.Scope != "")
// 3. --scope=/foo -> switch to /foo, scopeChanged = (profile.Scope != "/foo")
func resolveScope(cf *CLIConf, profile *client.ProfileStatus) (string, bool) {
// --scope was explicitly set by the user
if cf.ScopeSetByUser {
// passing in "" descopes
targetScope := cf.Scope

currentScope := ""
if profile != nil && profile.Scope != "" {
currentScope = profile.Scope
}

return targetScope, targetScope != currentScope
}

// --scope not provided, inherit from profile.
if profile != nil && profile.Scope != "" {
return profile.Scope, false
}

return "", false

}

// onLogin logs in with remote proxy and gets signed certificates
func onLogin(cf *CLIConf, reExecArgs ...string) (err error) {
showAlerts := true
Expand Down Expand Up @@ -2248,6 +2281,10 @@ func onLogin(cf *CLIConf, reExecArgs ...string) (err error) {
}
}

// Resolve the desired scope based on CLI flags and current profile state.
targetScope, scopeChanged := resolveScope(cf, profile)
cf.Scope = targetScope

if cf.Scope != "" {
// auto-request behavior is incompatible with scopes
autoRequest = false
Expand All @@ -2260,10 +2297,6 @@ func onLogin(cf *CLIConf, reExecArgs ...string) (err error) {
if err := scopes.StrongValidate(cf.Scope); err != nil {
return trace.Wrap(err)
}

// TODO(fspmarshall/scopes): this is a clunky way to handle the forced reauth on scope change,
// look into doing something smarter.
profile, profiles = nil, nil
}

// make the teleport client and retrieve the certificate from the proxy:
Expand Down Expand Up @@ -2301,8 +2334,8 @@ func onLogin(cf *CLIConf, reExecArgs ...string) (err error) {
}
}

// client is already logged in and profile is not expired
if profile != nil && !profile.IsExpired(time.Now()) {
// client is already logged in and profile is not expired and scope hasn't changed
if profile != nil && !profile.IsExpired(time.Now()) && !scopeChanged {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Run update check before relogin on scope changes

This new guard makes scope-changing logins skip the branch that contains the fallback autoupdatetools.CheckAndUpdateRemote call, while the earlier profile == nil update path is also skipped because an unexpired profile exists. In practice, tsh login --scope=/new (or --scope="") from an existing valid session now reauthenticates without any managed update check, which regresses the previous behavior where scoped logins forced profile=nil and always performed the update check first.

Useful? React with 👍 / 👎.

switch {
// in case if nothing is specified, re-fetch kube clusters and print
// current status
Expand Down Expand Up @@ -4956,8 +4989,9 @@ func loadClientConfigFromCLIConf(cf *CLIConf, proxy string) (*client.Config, err
c.RemoteForwardPorts = rPorts
}

// TODO(fspmarshall/scopes): decide if we want some kind of persistence for the CLI arg.
c.Scope = cf.Scope
if cf.ScopeSetByUser || cf.Scope != "" {
c.Scope = cf.Scope
}

if cf.SiteName != "" {
c.SiteName = cf.SiteName
Expand Down
Loading