Add support for delegation to tbot's relevant services (#64900)

* Add support for delegation to `tbot`'s relevant services

* Explicitly prevent use of delegation with leaf clusters

* Make `WithRoles` and `WithDelegation` mutually-exclusive

* Make `delegation_session_id` and `roles` mutually-exclusive in tbot config

* Use nil-tolerant proto getter functions

* Move delegation cert generation into separate method

* Stop delegation service from returning CA certificates

Per @strideynet's request, to avoid replicating existing bad patterns around
conflating the user and host CAs.

* Fix tbot OpenSSH output in delegation sessions
This commit is contained in:
Dan Upton
2026-04-17 10:24:15 +00:00
committed by GitHub
parent 8260094dd6
commit 9f160cbe6f
38 changed files with 350 additions and 54 deletions
+87 -5
View File
@@ -27,9 +27,11 @@ import (
"github.com/gravitational/trace"
"go.opentelemetry.io/otel"
"golang.org/x/crypto/ssh"
"google.golang.org/protobuf/types/known/durationpb"
apiclient "github.com/gravitational/teleport/api/client"
"github.com/gravitational/teleport/api/client/proto"
delegationv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/delegation/v1"
"github.com/gravitational/teleport/api/types"
"github.com/gravitational/teleport/api/utils/keys"
"github.com/gravitational/teleport/lib/cryptosuites"
@@ -99,6 +101,7 @@ type Generator struct {
}
type generateOpts struct {
delegationSessionID string
roles []string
ttl, renewalInterval time.Duration
currentIdentity *Identity
@@ -109,6 +112,16 @@ type generateOpts struct {
// GenerateOption allows you to customize aspects of the generated identity.
type GenerateOption func(*generateOpts)
// WithDelegation uses the given delegation session ID to generate certificates
// associated with a *human* user and delegation session.
//
// Note: this option is mutually-exclusive with WithRoles.
func WithDelegation(sessionID string) GenerateOption {
return func(opts *generateOpts) {
opts.delegationSessionID = sessionID
}
}
// WithRoles sets the roles the generated identity should include.
//
// Generally, if the user did not specify any roles, it's best to leave this
@@ -227,6 +240,10 @@ func (g *Generator) Generate(ctx context.Context, opts ...GenerateOption) (*Iden
log := cmp.Or(o.logger, g.logger)
if len(o.roles) != 0 && o.delegationSessionID != "" {
return nil, trace.BadParameter("delegation sessions and explicit roles are mutually-exclusive")
}
if len(o.roles) == 0 {
if o.currentIdentity != nil {
// If the caller provided an impersonated identity, take its roles.
@@ -289,11 +306,19 @@ func (g *Generator) Generate(ctx context.Context, opts ...GenerateOption) (*Iden
return nil, trace.Wrap(err)
}
// First, ask the auth server to generate a new set of certs with a new
// expiration date.
certs, err := g.client.GenerateUserCerts(ctx, req)
if err != nil {
return nil, trace.Wrap(err)
var certs *proto.Certs
if o.delegationSessionID == "" {
// Traditional bot certificates.
certs, err = g.client.GenerateUserCerts(ctx, req)
if err != nil {
return nil, trace.Wrap(err)
}
} else {
// Delegation certificates, tied to a human user identity.
certs, err = g.generateDelegationCertificates(ctx, req, o)
if err != nil {
return nil, trace.Wrap(err)
}
}
// The root CA included with the returned user certs will only contain the
@@ -357,6 +382,63 @@ func (g *Generator) botDefaultRoles(ctx context.Context) ([]string, error) {
return conditions.Roles, nil
}
func (g *Generator) generateDelegationCertificates(ctx context.Context, req proto.UserCertsRequest, o *generateOpts) (*proto.Certs, error) {
certReq := &delegationv1.GenerateCertsRequest{
DelegationSessionId: o.delegationSessionID,
SshPublicKey: req.SSHPublicKey,
TlsPublicKey: req.TLSPublicKey,
Ttl: durationpb.New(o.ttl),
}
if req.GetRouteToCluster() != o.currentIdentity.ClusterName {
return nil, trace.BadParameter("delegation sessions cannot be used with leaf clusters")
}
switch {
case req.GetRouteToApp().Name != "":
route := req.GetRouteToApp()
certReq.Routing = &delegationv1.GenerateCertsRequest_RouteToApp{
RouteToApp: &delegationv1.RouteToApp{
Name: route.GetName(),
PublicAddr: route.GetPublicAddr(),
ClusterName: route.GetClusterName(),
Uri: route.GetURI(),
TargetPort: route.GetTargetPort(),
AwsRoleArn: route.GetAWSRoleARN(),
AzureIdentity: route.GetAzureIdentity(),
GcpServiceAccount: route.GetGCPServiceAccount(),
},
}
case req.GetRouteToDatabase().ServiceName != "":
route := req.GetRouteToDatabase()
certReq.Routing = &delegationv1.GenerateCertsRequest_RouteToDatabase{
RouteToDatabase: &delegationv1.RouteToDatabase{
ServiceName: route.GetServiceName(),
Protocol: route.GetProtocol(),
Username: route.GetUsername(),
Database: route.GetDatabase(),
Roles: route.GetRoles(),
},
}
case req.GetKubernetesCluster() != "":
certReq.Routing = &delegationv1.GenerateCertsRequest_RouteToKubernetes{
RouteToKubernetes: &delegationv1.RouteToKubernetes{
ClusterName: req.GetKubernetesCluster(),
},
}
}
certsRsp, err := g.client.DelegationSessionServiceClient().
GenerateCerts(ctx, certReq)
if err != nil {
return nil, trace.Wrap(err)
}
return &proto.Certs{
SSH: certsRsp.GetSsh(),
TLS: certsRsp.GetTls(),
SSHCACerts: o.currentIdentity.SSHCACertBytes,
TLSCACerts: o.currentIdentity.TLSCACertsBytes,
}, nil
}
// warnOnEarlyExpiration logs a warning if the given identity is likely to
// expire problematically early. This can happen if either the configured TTL is
// less than the renewal interval, or if the server returns certs valid for a
+18 -1
View File
@@ -285,11 +285,28 @@ func MakeNameOrDiscoveredNamePredicate(name string) string {
}
func GetClusterNames(
ctx context.Context, client *apiclient.Client, connectedClusterName string,
ctx context.Context,
client *apiclient.Client,
connectedClusterName string,
inDelegationSession bool,
) ([]string, error) {
allClusterNames := []string{connectedClusterName}
leafClusters, err := client.GetRemoteClusters(ctx)
if trace.IsAccessDenied(err) && inDelegationSession {
// Currently, delegations sessions scoped to a specific node do not have
// permission to list remote clusters, and so the overall resource/verb
// check will fail.
//
// When we add support for delegating access to resources in remote
// clusters, we'll probably grant the session read access to the cluster
// automatically.
//
// In the mean time, we'll treat the GetRemoteClusters call as optional
// but only in a delegation session (to avoid a backward-incompatible
// change in behavior).
return allClusterNames, nil
}
if err != nil {
return nil, trace.Wrap(err)
}
@@ -48,6 +48,11 @@ type OutputConfig struct {
// be configured with specific paths to use, but exists for compatibility.
SpecificTLSExtensions bool `yaml:"specific_tls_naming"`
// DelegationSessionID optionally identifies the delegation session the
// generated credentials will be associated with, enabling the bot to act
// on a (human) user's behalf.
DelegationSessionID string `yaml:"delegation_session_id,omitempty"`
// CredentialLifetime contains configuration for how long credentials will
// last and the frequency at which they'll be renewed.
CredentialLifetime bot.CredentialLifetime `yaml:",inline"`
@@ -67,6 +72,9 @@ func (o *OutputConfig) CheckAndSetDefaults() error {
if o.AppName == "" {
return trace.BadParameter("app_name must not be empty")
}
if o.DelegationSessionID != "" && len(o.Roles) > 0 {
return trace.BadParameter("delegation_session_id: is mutually-exclusive with roles")
}
return nil
}
@@ -32,9 +32,10 @@ func TestApplicationOutput_YAML(t *testing.T) {
{
name: "full",
in: OutputConfig{
Destination: dest,
Roles: []string{"access"},
AppName: "my-app",
Destination: dest,
Roles: []string{"access"},
AppName: "my-app",
DelegationSessionID: "8a50ba48-2fad-4c2c-a8ce-f48bc18db9ee",
CredentialLifetime: bot.CredentialLifetime{
TTL: 1 * time.Minute,
RenewalInterval: 30 * time.Second,
@@ -83,6 +84,18 @@ func TestApplicationOutput_CheckAndSetDefaults(t *testing.T) {
},
wantErr: "app_name must not be empty",
},
{
name: "delegation session id conflicts with roles",
in: func() *OutputConfig {
return &OutputConfig{
Destination: destination.NewMemory(),
Roles: []string{"access"},
AppName: "app",
DelegationSessionID: "8a50ba48-2fad-4c2c-a8ce-f48bc18db9ee",
}
},
wantErr: "delegation_session_id: is mutually-exclusive with roles",
},
}
testCheckAndSetDefaults(t, tests)
}
@@ -122,10 +122,14 @@ func (s *OutputService) generate(ctx context.Context) error {
effectiveLifetime := cmp.Or(s.cfg.CredentialLifetime, s.defaultCredentialLifetime)
identityOpts := []identity.GenerateOption{
identity.WithRoles(s.cfg.Roles),
identity.WithLifetime(effectiveLifetime.TTL, effectiveLifetime.RenewalInterval),
identity.WithLogger(s.log),
}
if s.cfg.DelegationSessionID == "" {
identityOpts = append(identityOpts, identity.WithRoles(s.cfg.Roles))
} else {
identityOpts = append(identityOpts, identity.WithDelegation(s.cfg.DelegationSessionID))
}
id, err := s.identityGenerator.GenerateFacade(ctx, identityOpts...)
if err != nil {
return trace.Wrap(err)
@@ -46,6 +46,10 @@ type ProxyServiceConfig struct {
// last and the frequency at which they'll be renewed. For the application
// proxy, this is primarily an internal detail.
CredentialLifetime bot.CredentialLifetime `yaml:"credential_lifetime,omitempty"`
// DelegationSessionID optionally identifies the delegation session the
// generated credentials will be associated with, enabling the bot to act
// on a (human) user's behalf.
DelegationSessionID string `yaml:"delegation_session_id,omitempty"`
// Listener overrides "listen" and directly provides an opened listener to
// use. Primarily used for testing.
Listener net.Listener `yaml:"-"`
@@ -32,8 +32,9 @@ func TestProxyServiceConfig_YAML(t *testing.T) {
{
name: "full",
in: ProxyServiceConfig{
Name: "foo",
Listen: "tcp://0.0.0.0:3621",
Name: "foo",
Listen: "tcp://0.0.0.0:3621",
DelegationSessionID: "8a50ba48-2fad-4c2c-a8ce-f48bc18db9ee",
CredentialLifetime: bot.CredentialLifetime{
TTL: 1 * time.Minute,
RenewalInterval: 30 * time.Second,
@@ -227,6 +227,9 @@ func (s *ProxyService) issueCert(
identity.WithLifetime(effectiveLifetime.TTL, effectiveLifetime.RenewalInterval),
identity.WithLogger(s.log),
}
if s.cfg.DelegationSessionID != "" {
identityOpts = append(identityOpts, identity.WithDelegation(s.cfg.DelegationSessionID))
}
impersonatedIdentity, err := s.identityGenerator.GenerateFacade(ctx, identityOpts...)
if err != nil {
return nil, nil, trace.Wrap(err)
@@ -5,5 +5,6 @@ roles:
- access
app_name: my-app
specific_tls_naming: false
delegation_session_id: 8a50ba48-2fad-4c2c-a8ce-f48bc18db9ee
credential_ttl: 1m0s
renewal_interval: 30s
@@ -3,3 +3,4 @@ listen: tcp://0.0.0.0:3621
app_name: my-app
credential_ttl: 1m0s
renewal_interval: 30s
delegation_session_id: 8a50ba48-2fad-4c2c-a8ce-f48bc18db9ee
@@ -4,3 +4,4 @@ listen: tcp://0.0.0.0:3621
credential_lifetime:
credential_ttl: 1m0s
renewal_interval: 30s
delegation_session_id: 8a50ba48-2fad-4c2c-a8ce-f48bc18db9ee
@@ -52,6 +52,11 @@ type TunnelConfig struct {
// last and the frequency at which they'll be renewed.
CredentialLifetime bot.CredentialLifetime `yaml:",inline"`
// DelegationSessionID optionally identifies the delegation session the
// generated credentials will be associated with, enabling the bot to act
// on a (human) user's behalf.
DelegationSessionID string `yaml:"delegation_session_id,omitempty"`
// Listener overrides "listen" and directly provides an opened listener to
// use.
Listener net.Listener `yaml:"-"`
@@ -106,6 +111,9 @@ func (s *TunnelConfig) CheckAndSetDefaults() error {
if s.clock == nil {
s.clock = clockwork.NewRealClock()
}
if s.DelegationSessionID != "" && len(s.Roles) > 0 {
return trace.BadParameter("delegation_session_id: is mutually-exclusive with roles")
}
return nil
}
@@ -34,8 +34,9 @@ func TestApplicationTunnelService_YAML(t *testing.T) {
{
name: "full",
in: TunnelConfig{
Listen: "tcp://0.0.0.0:3621",
AppName: "my-app",
Listen: "tcp://0.0.0.0:3621",
AppName: "my-app",
DelegationSessionID: "8a50ba48-2fad-4c2c-a8ce-f48bc18db9ee",
CredentialLifetime: bot.CredentialLifetime{
TTL: 1 * time.Minute,
RenewalInterval: 30 * time.Second,
@@ -101,6 +102,18 @@ func TestApplicationTunnelService_CheckAndSetDefaults(t *testing.T) {
},
wantErr: "app_name: should not be empty",
},
{
name: "delegation session id conflicts with roles",
in: func() *TunnelConfig {
return &TunnelConfig{
Listen: "tcp://0.0.0.0:3621",
Roles: []string{"role1", "role2"},
AppName: "my-app",
DelegationSessionID: "8a50ba48-2fad-4c2c-a8ce-f48bc18db9ee",
}
},
wantErr: "delegation_session_id: is mutually-exclusive with roles",
},
}
testCheckAndSetDefaults(t, tests)
}
@@ -267,10 +267,14 @@ func (s *TunnelService) issueCert(
// routeToApp once.
effectiveLifetime := cmp.Or(s.cfg.CredentialLifetime, s.defaultCredentialLifetime)
identityOpts := []identity.GenerateOption{
identity.WithRoles(s.cfg.Roles),
identity.WithLifetime(effectiveLifetime.TTL, effectiveLifetime.RenewalInterval),
identity.WithLogger(s.log),
}
if s.cfg.DelegationSessionID == "" {
identityOpts = append(identityOpts, identity.WithRoles(s.cfg.Roles))
} else {
identityOpts = append(identityOpts, identity.WithDelegation(s.cfg.DelegationSessionID))
}
impersonatedIdentity, err := s.identityGenerator.GenerateFacade(ctx, identityOpts...)
if err != nil {
return nil, nil, trace.Wrap(err)
@@ -94,6 +94,11 @@ type OutputConfig struct {
// Username is the database username to request access as.
Username string `yaml:"username,omitempty"`
// DelegationSessionID optionally identifies the delegation session the
// generated credentials will be associated with, enabling the bot to act
// on a (human) user's behalf.
DelegationSessionID string `yaml:"delegation_session_id,omitempty"`
// CredentialLifetime contains configuration for how long credentials will
// last and the frequency at which they'll be renewed.
CredentialLifetime bot.CredentialLifetime `yaml:",inline"`
@@ -132,6 +137,9 @@ func (o *OutputConfig) CheckAndSetDefaults() error {
if !slices.Contains(databaseFormats, o.Format) {
return trace.BadParameter("unrecognized format (%s)", o.Format)
}
if o.DelegationSessionID != "" && len(o.Roles) > 0 {
return trace.BadParameter("delegation_session_id: is mutually-exclusive with roles")
}
return nil
}
@@ -32,12 +32,13 @@ func TestDatabaseOutput_YAML(t *testing.T) {
{
name: "full",
in: OutputConfig{
Destination: dest,
Roles: []string{"access"},
Format: TLSDatabaseFormat,
Service: "my-database-service",
Database: "my-database",
Username: "my-username",
Destination: dest,
Roles: []string{"access"},
Format: TLSDatabaseFormat,
Service: "my-database-service",
Database: "my-database",
Username: "my-username",
DelegationSessionID: "8a50ba48-2fad-4c2c-a8ce-f48bc18db9ee",
CredentialLifetime: bot.CredentialLifetime{
TTL: 1 * time.Minute,
RenewalInterval: 30 * time.Second,
@@ -99,6 +100,18 @@ func TestDatabaseOutput_CheckAndSetDefaults(t *testing.T) {
},
wantErr: "unrecognized format (no-such-format)",
},
{
name: "delegation session id conflicts with roles",
in: func() *OutputConfig {
return &OutputConfig{
Destination: destination.NewMemory(),
Roles: []string{"access"},
Service: "service",
DelegationSessionID: "8a50ba48-2fad-4c2c-a8ce-f48bc18db9ee",
}
},
wantErr: "delegation_session_id: is mutually-exclusive with roles",
},
}
testCheckAndSetDefaults(t, tests)
}
+5 -1
View File
@@ -120,10 +120,14 @@ func (s *OutputService) generate(ctx context.Context) error {
effectiveLifetime := cmp.Or(s.cfg.CredentialLifetime, s.defaultCredentialLifetime)
identityOpts := []identity.GenerateOption{
identity.WithRoles(s.cfg.Roles),
identity.WithLifetime(effectiveLifetime.TTL, effectiveLifetime.RenewalInterval),
identity.WithLogger(s.log),
}
if s.cfg.DelegationSessionID == "" {
identityOpts = append(identityOpts, identity.WithRoles(s.cfg.Roles))
} else {
identityOpts = append(identityOpts, identity.WithDelegation(s.cfg.DelegationSessionID))
}
id, err := s.identityGenerator.GenerateFacade(ctx, identityOpts...)
if err != nil {
return trace.Wrap(err, "generating identity")
@@ -7,5 +7,6 @@ format: tls
service: my-database-service
database: my-database
username: my-username
delegation_session_id: 8a50ba48-2fad-4c2c-a8ce-f48bc18db9ee
credential_ttl: 1m0s
renewal_interval: 30s
@@ -6,5 +6,6 @@ roles:
service: service
database: database
username: username
delegation_session_id: 8a50ba48-2fad-4c2c-a8ce-f48bc18db9ee
credential_ttl: 1m0s
renewal_interval: 30s
@@ -51,6 +51,11 @@ type TunnelConfig struct {
// Username is the database username to proxy as.
Username string `yaml:"username"`
// DelegationSessionID optionally identifies the delegation session the
// generated credentials will be associated with, enabling the bot to act
// on a (human) user's behalf.
DelegationSessionID string `yaml:"delegation_session_id,omitempty"`
// CredentialLifetime contains configuration for how long credentials will
// last and the frequency at which they'll be renewed.
CredentialLifetime bot.CredentialLifetime `yaml:",inline"`
@@ -102,6 +107,9 @@ func (s *TunnelConfig) CheckAndSetDefaults() error {
if _, err := url.Parse(s.Listen); err != nil {
return trace.Wrap(err, "parsing listen")
}
if s.DelegationSessionID != "" && len(s.Roles) > 0 {
return trace.BadParameter("delegation_session_id: is mutually-exclusive with roles")
}
return nil
}
@@ -32,11 +32,12 @@ func TestDatabaseTunnelService_YAML(t *testing.T) {
{
name: "full",
in: TunnelConfig{
Listen: "tcp://0.0.0.0:3621",
Roles: []string{"role1", "role2"},
Service: "service",
Database: "database",
Username: "username",
Listen: "tcp://0.0.0.0:3621",
Roles: []string{"role1", "role2"},
Service: "service",
Database: "database",
Username: "username",
DelegationSessionID: "8a50ba48-2fad-4c2c-a8ce-f48bc18db9ee",
CredentialLifetime: bot.CredentialLifetime{
TTL: 1 * time.Minute,
RenewalInterval: 30 * time.Second,
@@ -112,6 +113,20 @@ func TestDatabaseTunnelService_CheckAndSetDefaults(t *testing.T) {
},
wantErr: "username: should not be empty",
},
{
name: "delegation session id conflicts with roles",
in: func() *TunnelConfig {
return &TunnelConfig{
Listen: "tcp://0.0.0.0:3621",
Roles: []string{"role1", "role2"},
Service: "service",
Database: "database",
Username: "username",
DelegationSessionID: "8a50ba48-2fad-4c2c-a8ce-f48bc18db9ee",
}
},
wantErr: "delegation_session_id: is mutually-exclusive with roles",
},
}
testCheckAndSetDefaults(t, tests)
}
+16 -6
View File
@@ -282,11 +282,16 @@ func (s *TunnelService) getRouteToDatabaseWithImpersonation(ctx context.Context)
defer span.End()
effectiveLifetime := cmp.Or(s.cfg.CredentialLifetime, s.defaultCredentialLifetime)
impersonatedIdentity, err := s.identityGenerator.GenerateFacade(ctx,
identity.WithRoles(s.cfg.Roles),
identityOpts := []identity.GenerateOption{
identity.WithLifetime(effectiveLifetime.TTL, effectiveLifetime.RenewalInterval),
identity.WithLogger(s.log),
)
}
if s.cfg.DelegationSessionID == "" {
identityOpts = append(identityOpts, identity.WithRoles(s.cfg.Roles))
} else {
identityOpts = append(identityOpts, identity.WithDelegation(s.cfg.DelegationSessionID))
}
impersonatedIdentity, err := s.identityGenerator.GenerateFacade(ctx, identityOpts...)
if err != nil {
return proto.RouteToDatabase{}, trace.Wrap(err)
}
@@ -313,12 +318,17 @@ func (s *TunnelService) issueCert(
s.log.DebugContext(ctx, "Requesting issuance of certificate for tunnel proxy.")
effectiveLifetime := cmp.Or(s.cfg.CredentialLifetime, s.defaultCredentialLifetime)
ident, err := s.identityGenerator.Generate(ctx,
identity.WithRoles(s.cfg.Roles),
identityOpts := []identity.GenerateOption{
identity.WithLifetime(effectiveLifetime.TTL, effectiveLifetime.RenewalInterval),
identity.WithLogger(s.log),
identity.WithRouteToDatabase(route),
)
}
if s.cfg.DelegationSessionID == "" {
identityOpts = append(identityOpts, identity.WithRoles(s.cfg.Roles))
} else {
identityOpts = append(identityOpts, identity.WithDelegation(s.cfg.DelegationSessionID))
}
ident, err := s.identityGenerator.Generate(ctx, identityOpts...)
if err != nil {
return nil, trace.Wrap(err)
}
+6 -2
View File
@@ -146,11 +146,15 @@ func (s *OutputService) generate(ctx context.Context) error {
effectiveLifetime := cmp.Or(s.cfg.CredentialLifetime, s.defaultCredentialLifetime)
identityOpts := []identity.GenerateOption{
identity.WithRoles(s.cfg.Roles),
identity.WithLifetime(effectiveLifetime.TTL, effectiveLifetime.RenewalInterval),
identity.WithReissuableRoleImpersonation(s.cfg.AllowReissue),
identity.WithLogger(s.log),
}
if s.cfg.DelegationSessionID == "" {
identityOpts = append(identityOpts, identity.WithRoles(s.cfg.Roles))
} else {
identityOpts = append(identityOpts, identity.WithDelegation(s.cfg.DelegationSessionID))
}
id, err := s.identityGenerator.GenerateFacade(ctx, identityOpts...)
if err != nil {
return trace.Wrap(err, "generating identity")
@@ -191,7 +195,7 @@ func (s *OutputService) generate(ctx context.Context) error {
}
if s.cfg.SSHConfigMode == SSHConfigModeOn {
clusterNames, err := internal.GetClusterNames(ctx, impersonatedClient, id.Get().ClusterName)
clusterNames, err := internal.GetClusterNames(ctx, impersonatedClient, id.Get().ClusterName, s.cfg.DelegationSessionID != "")
if err != nil {
return trace.Wrap(err)
}
@@ -91,6 +91,11 @@ type OutputConfig struct {
// Defaults to false.
AllowReissue bool `yaml:"allow_reissue,omitempty"`
// DelegationSessionID optionally identifies the delegation session the
// generated credentials will be associated with, enabling the bot to act
// on a (human) user's behalf.
DelegationSessionID string `yaml:"delegation_session_id,omitempty"`
// CredentialLifetime contains configuration for how long credentials will
// last and the frequency at which they'll be renewed.
CredentialLifetime bot.CredentialLifetime `yaml:",inline"`
@@ -142,6 +147,9 @@ func (o *OutputConfig) CheckAndSetDefaults() error {
default:
return trace.BadParameter("ssh_config: unrecognized value %q", o.SSHConfigMode)
}
if o.DelegationSessionID != "" && len(o.Roles) > 0 {
return trace.BadParameter("delegation_session_id: is mutually-exclusive with roles")
}
return nil
}
@@ -32,11 +32,12 @@ func TestIdentityOutput_YAML(t *testing.T) {
{
name: "full",
in: OutputConfig{
Destination: dest,
Roles: []string{"access"},
Cluster: "leaf.example.com",
SSHConfigMode: SSHConfigModeOff,
AllowReissue: true,
Destination: dest,
Roles: []string{"access"},
Cluster: "leaf.example.com",
SSHConfigMode: SSHConfigModeOff,
AllowReissue: true,
DelegationSessionID: "8a50ba48-2fad-4c2c-a8ce-f48bc18db9ee",
CredentialLifetime: bot.CredentialLifetime{
TTL: 1 * time.Minute,
RenewalInterval: 30 * time.Second,
@@ -96,6 +97,18 @@ func TestIdentityOutput_CheckAndSetDefaults(t *testing.T) {
},
wantErr: "ssh_config: unrecognized value \"invalid\"",
},
{
name: "delegation session id conflicts with roles",
in: func() *OutputConfig {
return &OutputConfig{
Destination: destination.NewMemory(),
Roles: []string{"access"},
SSHConfigMode: SSHConfigModeOn,
DelegationSessionID: "8a50ba48-2fad-4c2c-a8ce-f48bc18db9ee",
}
},
wantErr: "delegation_session_id: is mutually-exclusive with roles",
},
}
testCheckAndSetDefaults(t, tests)
}
@@ -6,5 +6,6 @@ roles:
cluster: leaf.example.com
ssh_config: "off"
allow_reissue: true
delegation_session_id: 8a50ba48-2fad-4c2c-a8ce-f48bc18db9ee
credential_ttl: 1m0s
renewal_interval: 30s
+5 -1
View File
@@ -154,10 +154,14 @@ func (s *OutputV1Service) generate(ctx context.Context) error {
effectiveLifetime := cmp.Or(s.cfg.CredentialLifetime, s.defaultCredentialLifetime)
identityOpts := []identity.GenerateOption{
identity.WithRoles(s.cfg.Roles),
identity.WithLifetime(effectiveLifetime.TTL, effectiveLifetime.RenewalInterval),
identity.WithLogger(s.log),
}
if s.cfg.DelegationSessionID == "" {
identityOpts = append(identityOpts, identity.WithRoles(s.cfg.Roles))
} else {
identityOpts = append(identityOpts, identity.WithDelegation(s.cfg.DelegationSessionID))
}
id, err := s.identityGenerator.GenerateFacade(ctx, identityOpts...)
if err != nil {
return trace.Wrap(err, "generating identity")
@@ -55,6 +55,11 @@ type OutputV1Config struct {
// refresh the credentials within an individual invocation.
DisableExecPlugin bool `yaml:"disable_exec_plugin"`
// DelegationSessionID optionally identifies the delegation session the
// generated credentials will be associated with, enabling the bot to act
// on a (human) user's behalf.
DelegationSessionID string `yaml:"delegation_session_id,omitempty"`
// CredentialLifetime contains configuration for how long credentials will
// last and the frequency at which they'll be renewed.
CredentialLifetime bot.CredentialLifetime `yaml:",inline"`
@@ -80,6 +85,9 @@ func (o *OutputV1Config) CheckAndSetDefaults() error {
if o.KubernetesCluster == "" {
return trace.BadParameter("kubernetes_cluster must not be empty")
}
if o.DelegationSessionID != "" && len(o.Roles) > 0 {
return trace.BadParameter("delegation_session_id: is mutually-exclusive with roles")
}
return nil
}
+16 -3
View File
@@ -32,9 +32,10 @@ func TestKubernetesOutput_YAML(t *testing.T) {
{
name: "full",
in: OutputV1Config{
Destination: dest,
Roles: []string{"access"},
KubernetesCluster: "k8s.example.com",
Destination: dest,
Roles: []string{"access"},
KubernetesCluster: "k8s.example.com",
DelegationSessionID: "8a50ba48-2fad-4c2c-a8ce-f48bc18db9ee",
CredentialLifetime: bot.CredentialLifetime{
TTL: 1 * time.Minute,
RenewalInterval: 30 * time.Second,
@@ -83,6 +84,18 @@ func TestKubernetesOutput_CheckAndSetDefaults(t *testing.T) {
},
wantErr: "kubernetes_cluster must not be empty",
},
{
name: "delegation session id conflicts with roles",
in: func() *OutputV1Config {
return &OutputV1Config{
Destination: destination.NewMemory(),
Roles: []string{"access"},
KubernetesCluster: "my-cluster",
DelegationSessionID: "8a50ba48-2fad-4c2c-a8ce-f48bc18db9ee",
}
},
wantErr: "delegation_session_id: is mutually-exclusive with roles",
},
}
testCheckAndSetDefaults(t, tests)
}
+8 -2
View File
@@ -153,10 +153,16 @@ func (s *OutputV2Service) generate(ctx context.Context) error {
}
effectiveLifetime := cmp.Or(s.cfg.CredentialLifetime, s.defaultCredentialLifetime)
id, err := s.identityGenerator.GenerateFacade(ctx,
opts := []identity.GenerateOption{
identity.WithLifetime(effectiveLifetime.TTL, effectiveLifetime.RenewalInterval),
identity.WithLogger(s.log),
)
}
if s.cfg.DelegationSessionID != "" {
opts = append(opts, identity.WithDelegation(s.cfg.DelegationSessionID))
}
id, err := s.identityGenerator.GenerateFacade(ctx, opts...)
if err != nil {
return trace.Wrap(err, "generating identity")
}
@@ -72,6 +72,11 @@ type OutputV2Config struct {
// RelayAddress specifies the address of a relay transport server to use in
// the generated Kubernetes config file.
RelayAddress string `yaml:"relay_server,omitempty"`
// DelegationSessionID optionally identifies the delegation session the
// generated credentials will be associated with, enabling the bot to act
// on a (human) user's behalf.
DelegationSessionID string `yaml:"delegation_session_id,omitempty"`
}
// GetName returns the user-given name of the service, used for validation purposes.
@@ -32,8 +32,9 @@ func TestKubernetesV2Output_YAML(t *testing.T) {
{
name: "full",
in: OutputV2Config{
Destination: dest,
DisableExecPlugin: true,
Destination: dest,
DisableExecPlugin: true,
DelegationSessionID: "8a50ba48-2fad-4c2c-a8ce-f48bc18db9ee",
Selectors: []*KubernetesSelector{
{
Name: "foo",
@@ -5,5 +5,6 @@ roles:
- access
kubernetes_cluster: k8s.example.com
disable_exec_plugin: false
delegation_session_id: 8a50ba48-2fad-4c2c-a8ce-f48bc18db9ee
credential_ttl: 1m0s
renewal_interval: 30s
@@ -10,3 +10,4 @@ selectors:
credential_ttl: 1m0s
renewal_interval: 30s
context_name_template: '{{.KubeName}}'
delegation_session_id: 8a50ba48-2fad-4c2c-a8ce-f48bc18db9ee
+7 -3
View File
@@ -194,7 +194,7 @@ func (s *MultiplexerService) writeArtifacts(
) error {
dest := s.cfg.Destination.(*destination.Directory)
clusterNames, err := internal.GetClusterNames(ctx, authClient, s.identity.Get().ClusterName)
clusterNames, err := internal.GetClusterNames(ctx, authClient, s.identity.Get().ClusterName, s.cfg.DelegationSessionID != "")
if err != nil {
return trace.Wrap(err, "fetching cluster names")
}
@@ -395,10 +395,14 @@ func (s *MultiplexerService) setup(ctx context.Context) (
// the destination.
func (s *MultiplexerService) generateIdentity(ctx context.Context) (*identity.Facade, error) {
effectiveLifetime := cmp.Or(s.cfg.CredentialLifetime, s.defaultCredentialLifetime)
facade, err := s.identityGenerator.GenerateFacade(ctx,
identityOpts := []identity.GenerateOption{
identity.WithLifetime(effectiveLifetime.TTL, effectiveLifetime.RenewalInterval),
identity.WithLogger(s.log),
)
}
if s.cfg.DelegationSessionID != "" {
identityOpts = append(identityOpts, identity.WithDelegation(s.cfg.DelegationSessionID))
}
facade, err := s.identityGenerator.GenerateFacade(ctx, identityOpts...)
if err != nil {
return nil, trace.Wrap(err, "generating identity")
}
@@ -57,6 +57,11 @@ type MultiplexerConfig struct {
// all the SSH connections going through this mux.
RelayAddress string `yaml:"relay_server,omitempty"`
// DelegationSessionID optionally identifies the delegation session the
// generated credentials will be associated with, enabling the bot to act
// on a (human) user's behalf.
DelegationSessionID string `yaml:"delegation_session_id,omitempty"`
// CredentialLifetime contains configuration for how long credentials will
// last and the frequency at which they'll be renewed.
CredentialLifetime bot.CredentialLifetime `yaml:",inline"`
@@ -37,9 +37,10 @@ func TestSSHMultiplexerService_YAML(t *testing.T) {
Destination: &destination.Directory{
Path: "/opt/machine-id",
},
EnableResumption: ptr(true),
ProxyTemplatesPath: "/etc/teleport/templates",
ProxyCommand: []string{"rusty-boi"},
EnableResumption: ptr(true),
ProxyTemplatesPath: "/etc/teleport/templates",
ProxyCommand: []string{"rusty-boi"},
DelegationSessionID: "8a50ba48-2fad-4c2c-a8ce-f48bc18db9ee",
CredentialLifetime: bot.CredentialLifetime{
TTL: 1 * time.Minute,
RenewalInterval: 30 * time.Second,
@@ -6,5 +6,6 @@ enable_resumption: true
proxy_templates_path: /etc/teleport/templates
proxy_command:
- rusty-boi
delegation_session_id: 8a50ba48-2fad-4c2c-a8ce-f48bc18db9ee
credential_ttl: 1m0s
renewal_interval: 30s