Add scope aware profiles after tsh login (#64233)

* Add scope aware profiles after tsh login

Update tsh.go

fix some minor issues

Update client_store_test.go

fix rebase

Update client_store.go

Add scope changing feature

Update tsh.go

Update tsh.go

* Remove "none" descoping

Update tsh.go

clean up

* nil the scope pin when "" is in the profile instead of scope pin ""
This commit is contained in:
williamong-tel
2026-03-12 19:20:53 +00:00
committed by GitHub
parent 5be00e4f21
commit fee0426568
7 changed files with 229 additions and 10 deletions
+3
View File
@@ -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.
+1
View File
@@ -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
+2
View File
@@ -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 {
@@ -1010,6 +1011,7 @@ func (c *Config) Profile() *profile.Profile {
SAMLSingleLogoutEnabled: c.SAMLSingleLogoutEnabled,
SSHDialTimeout: c.SSHDialTimeout,
SSOHost: c.SSOHost,
Scope: c.Scope,
}
}
+7
View File
@@ -30,6 +30,7 @@ import (
"golang.org/x/crypto/ssh"
"github.com/gravitational/teleport"
scopesv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/scopes/v1"
"github.com/gravitational/teleport/api/profile"
"github.com/gravitational/teleport/api/utils/keys"
"github.com/gravitational/teleport/api/utils/keys/hardwarekey"
@@ -267,6 +268,11 @@ func (s *Store) ReadProfileStatus(proxyAddressOrProfile string) (*ProfileStatus,
Username: profile.Username,
}
var scopePin *scopesv1.Pin
if profile.Scope != "" {
scopePin = &scopesv1.Pin{Scope: profile.Scope}
}
// If we can't find a keyRing to match the profile, connect to the keyRing (hardware key),
// or read the full profile status, return a partial status.
// This is used for some superficial functions `tsh logout` and `tsh status`.
@@ -284,6 +290,7 @@ func (s *Store) ReadProfileStatus(proxyAddressOrProfile string) (*ProfileStatus,
ValidUntil: time.Now(),
SAMLSingleLogoutEnabled: profile.SAMLSingleLogoutEnabled,
SSOHost: profile.SSOHost,
ScopePin: scopePin,
}
keyRing, err := s.GetKeyRing(idx, WithAllCerts...)
+44
View File
@@ -262,6 +262,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)
@@ -292,6 +293,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)
@@ -319,11 +321,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.ScopePin, expectOtherStatus.ScopePin)
})
})
}
}
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.Nil(t, status.ScopePin)
})
})
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.ScopePin)
require.Equal(t, "/production", status.ScopePin.Scope)
})
})
}
// TestProxySSHConfig tests proxy client SSH config function
// that generates SSH client configuration for proxy tunnel connections
func TestProxySSHConfig(t *testing.T) {
+128
View File
@@ -0,0 +1,128 @@
/*
* 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"
scopesv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/scopes/v1"
"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{ScopePin: &scopesv1.Pin{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{ScopePin: &scopesv1.Pin{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{ScopePin: &scopesv1.Pin{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{ScopePin: &scopesv1.Pin{Scope: "/staging/west"}},
wantScope: "",
wantScopeChange: true,
},
{
name: "descope with empty string, unscoped profile -> empty, no change",
cf: &CLIConf{
Scope: "",
ScopeSetByUser: true,
},
profile: &client.ProfileStatus{},
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)
})
}
}
+44 -10
View File
@@ -156,8 +156,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
@@ -1273,7 +1275,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)
@@ -2238,6 +2242,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.ScopePin.Scope, scopeChanged = false
// 2. --scope="" -> explicitly descope, scopeChanged = (profile.ScopePin.Scope != "")
// 3. --scope=/foo -> switch to /foo, scopeChanged = (profile.ScopePin.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.ScopePin != nil {
currentScope = profile.ScopePin.Scope
}
return targetScope, targetScope != currentScope
}
// --scope not provided, inherit from profile.
if profile != nil && profile.ScopePin != nil {
return profile.ScopePin.Scope, false
}
return "", false
}
// onLogin logs in with remote proxy and gets signed certificates
func onLogin(cf *CLIConf, reExecArgs ...string) (err error) {
showAlerts := true
@@ -2275,6 +2308,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
@@ -2287,10 +2324,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:
@@ -2328,8 +2361,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 {
switch {
// in case if nothing is specified, re-fetch kube clusters and print
// current status
@@ -4992,8 +5025,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