feat(beams): carry beam_id through identity certificates (#67972)

* feat(certs): thread beam_id through identity certificates

Carries the beam identifier from a delegation session's BeamIDLabel into
issued SSH/TLS certificates and back out via the cert extensions/ASN.1 OID,
exposes it on tlsca/sshca Identity and the decision PDP identities, propagates
it through scoped cert reissue (auth_with_roles), and surfaces it on event
UserMetadata for attribution.

* handle missing event cases

* prevent overriding beams_id cert extension with roles cert extensions

* handle code review comment
This commit is contained in:
Tiago Silva
2026-06-23 13:36:36 +00:00
committed by GitHub
parent d7ae6f8afb
commit cf35a5f9ba
26 changed files with 461 additions and 2 deletions
+4
View File
@@ -547,6 +547,10 @@ const (
// CertExtensionDelegationSessionID contains the identifier of the
// Delegation Session this certificate was created for.
CertExtensionDelegationSessionID = "[email protected]"
// CertExtensionBeamID contains the identifier of the Beam this certificate
// was created for, derived from the delegation session's
// types.BeamIDLabel label.
CertExtensionBeamID = "[email protected]"
// CertCriticalOptionSourceAddress is a critical option that defines IP addresses (in CIDR notation)
// from which this certificate is accepted for authentication.
+2
View File
@@ -3896,6 +3896,7 @@ func generateCert(ctx context.Context, a *Server, req cert.Request, caType types
BotName: req.BotName,
BotInstanceID: req.BotInstanceID,
DelegationSessionID: req.DelegationSessionID,
BeamID: req.BeamID,
JoinToken: req.JoinToken,
CertificateExtensions: certificateExtensions,
AllowedResourceIDs: allowedResourceIDs,
@@ -4043,6 +4044,7 @@ func generateCert(ctx context.Context, a *Server, req cert.Request, caType types
BotInstanceID: req.BotInstanceID,
BotInternal: req.BotInternal,
DelegationSessionID: req.DelegationSessionID,
BeamID: req.BeamID,
JoinToken: req.JoinToken,
AllowedResourceIDs: allowedResourceIDs,
AllowedResourceAccessIDs: allowedResourceAccessIDs,
+21 -1
View File
@@ -4186,6 +4186,16 @@ func (a *ScopedServerWithRoles) generateUserCerts(ctx context.Context, req proto
}
}
// Read delegation and beam attribution from the unmapped identity. For a
// remote (trusted-cluster) caller the role remapping rebuilds a fresh
// identity that drops these attribution fields, so the original certificate
// values survive only on the unmapped identity. For local/scoped callers the
// unmapped identity is identical to the mapped one.
attributionIdentity := a.scopedContext.Identity.GetIdentity()
if unscoped, ok := a.scopedContext.UnscopedContext(); ok {
attributionIdentity = unscoped.UnmappedIdentity.GetIdentity()
}
var appSessionID string
var webSessionID string
if req.RouteToApp.Name != "" {
@@ -4212,6 +4222,11 @@ func (a *ScopedServerWithRoles) generateUserCerts(ctx context.Context, req proto
// through the "web_session" attestation. User's will be required to provide
// MFA instead.
AttestWebSession: true,
// Propagate delegation and beam attribution from the caller's
// identity so the app-session cert remains linked to the same
// delegation session and beam.
DelegationSessionID: attributionIdentity.DelegationSessionID,
BeamID: attributionIdentity.BeamID,
},
PublicAddr: req.RouteToApp.PublicAddr,
ClusterName: req.RouteToApp.ClusterName,
@@ -4310,6 +4325,11 @@ func (a *ScopedServerWithRoles) generateUserCerts(ctx context.Context, req proto
// identity.
JoinAttributes: a.scopedContext.Identity.GetIdentity().JoinAttributes,
WebSessionID: webSessionID,
// Propagate delegation and beam attribution from the current identity so
// reissued certificates remain attributed to the same delegation session
// and beam.
DelegationSessionID: attributionIdentity.DelegationSessionID,
BeamID: attributionIdentity.BeamID,
}
if user.GetName() != a.scopedContext.User.GetName() {
@@ -6472,7 +6492,7 @@ func (a *ServerWithRoles) CreateAppSession(ctx context.Context, req *proto.Creat
return nil, trace.Wrap(err)
}
session, err := a.authServer.CreateAppSession(ctx, req, a.context.Identity.GetIdentity(), a.context.Checker)
session, err := a.authServer.CreateAppSession(ctx, req, a.context.Identity.GetIdentity(), a.context.UnmappedIdentity.GetIdentity(), a.context.Checker)
if err != nil {
return nil, trace.Wrap(err)
}
@@ -250,6 +250,12 @@ func (s *SessionService) generateCertificates(
DelegationSessionID: session.GetMetadata().GetName(),
}
// If the delegation session is associated with a Beam, carry its ID through
// to the issued certificate so downstream services can attribute usage.
if beamID := session.GetMetadata().GetLabels()[types.BeamIDLabel]; beamID != "" {
certReq.BeamID = beamID
}
// Add the protocol-specific routing hints to the certificate.
switch req.WhichRouting() {
case delegationv1.GenerateCertsRequest_RouteToKubernetes_case:
@@ -278,6 +284,7 @@ func (s *SessionService) generateCertificates(
RequestedResourceAccessIDs: resourceIDs,
AttestWebSession: true,
DelegationSessionID: session.GetMetadata().GetName(),
BeamID: certReq.BeamID,
},
PublicAddr: certReq.AppPublicAddr,
ClusterName: certReq.AppClusterName,
@@ -162,6 +162,128 @@ func TestSessionService_GenerateCerts(t *testing.T) {
})
})
t.Run("beam id label is carried into the certificate", func(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
service, pack := sessionServiceTestPack(t)
pack.createUser(t, "bob", types.RoleSpecV6{
Allow: types.RoleConditions{
AppLabels: types.Labels{
"*": []string{"*"},
},
},
})
session := pack.createSessionWithLabels(t, delegationv1pb.DelegationSessionSpec_builder{
User: "bob",
Resources: []*delegationv1pb.DelegationResourceSpec{
delegationv1pb.DelegationResourceSpec_builder{Kind: types.Wildcard, Name: types.Wildcard}.Build(),
},
AuthorizedUsers: []*delegationv1pb.DelegationUserSpec{
delegationv1pb.DelegationUserSpec_builder{
Kind: types.KindBot,
BotName: proto.String("payroll-agent"),
}.Build(),
},
}.Build(), map[string]string{
types.BeamIDLabel: "my-beam",
})
appSession, err := types.NewWebSession(
uuid.NewString(),
types.KindAppSession,
types.WebSessionSpecV2{User: "bob"},
)
require.NoError(t, err)
pack.onCreateAppSession = func(_ context.Context, req sessionreq.NewAppSessionRequest) (types.WebSession, error) {
// The app session started for the delegation also carries the beam
// id, so the app-session certificate is attributed to the beam.
require.Equal(t, "my-beam", req.BeamID)
return appSession, nil
}
// The beam id from the delegation session's label must be threaded
// into the issued certificate request.
pack.onGenerateCert = func(_ context.Context, req cert.Request) (*clientproto.Certs, error) {
require.Equal(t, "my-beam", req.BeamID)
return &clientproto.Certs{TLS: tlsCertificate, SSH: sshCertificate}, nil
}
pack.authenticateBot("payroll-agent")
_, err = service.GenerateCerts(t.Context(), delegationv1pb.GenerateCertsRequest_builder{
DelegationSessionId: session.GetMetadata().GetName(),
SshPublicKey: sshPublicKey,
TlsPublicKey: tlsPublicKey,
Ttl: durationpb.New(5 * time.Minute),
RouteToApp: delegationv1pb.RouteToApp_builder{
Name: "hr-system",
PublicAddr: "hr-system.test.teleport.sh",
ClusterName: "test.teleport.sh",
Uri: "http://localhost:9000",
TargetPort: 9000,
}.Build(),
}.Build())
require.NoError(t, err)
})
})
t.Run("no beam id label leaves the certificate beam id empty", func(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
service, pack := sessionServiceTestPack(t)
pack.createUser(t, "bob", types.RoleSpecV6{
Allow: types.RoleConditions{
AppLabels: types.Labels{
"*": []string{"*"},
},
},
})
// Delegation session without a beam id label.
session := pack.createSession(t, delegationv1pb.DelegationSessionSpec_builder{
User: "bob",
Resources: []*delegationv1pb.DelegationResourceSpec{
delegationv1pb.DelegationResourceSpec_builder{Kind: types.Wildcard, Name: types.Wildcard}.Build(),
},
AuthorizedUsers: []*delegationv1pb.DelegationUserSpec{
delegationv1pb.DelegationUserSpec_builder{
Kind: types.KindBot,
BotName: proto.String("payroll-agent"),
}.Build(),
},
}.Build())
appSession, err := types.NewWebSession(
uuid.NewString(),
types.KindAppSession,
types.WebSessionSpecV2{User: "bob"},
)
require.NoError(t, err)
pack.onCreateAppSession = func(_ context.Context, req sessionreq.NewAppSessionRequest) (types.WebSession, error) {
require.Empty(t, req.BeamID)
return appSession, nil
}
pack.onGenerateCert = func(_ context.Context, req cert.Request) (*clientproto.Certs, error) {
require.Empty(t, req.BeamID)
return &clientproto.Certs{TLS: tlsCertificate, SSH: sshCertificate}, nil
}
pack.authenticateBot("payroll-agent")
_, err = service.GenerateCerts(t.Context(), delegationv1pb.GenerateCertsRequest_builder{
DelegationSessionId: session.GetMetadata().GetName(),
SshPublicKey: sshPublicKey,
TlsPublicKey: tlsPublicKey,
Ttl: durationpb.New(5 * time.Minute),
RouteToApp: delegationv1pb.RouteToApp_builder{
Name: "hr-system",
PublicAddr: "hr-system.test.teleport.sh",
ClusterName: "test.teleport.sh",
Uri: "http://localhost:9000",
TargetPort: 9000,
}.Build(),
}.Build())
require.NoError(t, err)
})
})
t.Run("success with wildcard resource", func(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
service, pack := sessionServiceTestPack(t)
@@ -250,12 +250,21 @@ func (p *sessionTestPack) authenticateBotInDelegationSession(botName, delegation
func (p *sessionTestPack) createSession(t *testing.T, spec *delegationv1pb.DelegationSessionSpec) *delegationv1pb.DelegationSession {
t.Helper()
return p.createSessionWithLabels(t, spec, nil)
}
// createSessionWithLabels is like createSession but also sets the given labels
// on the delegation session's metadata (e.g. types.BeamIDLabel).
func (p *sessionTestPack) createSessionWithLabels(t *testing.T, spec *delegationv1pb.DelegationSessionSpec, labels map[string]string) *delegationv1pb.DelegationSession {
t.Helper()
session, err := p.sessions.CreateDelegationSession(t.Context(), delegationv1pb.DelegationSession_builder{
Kind: types.KindDelegationSession,
Version: types.V1,
Metadata: headerv1.Metadata_builder{
Name: uuid.NewString(),
Expires: timestamppb.New(time.Now().Add(time.Hour)),
Labels: labels,
}.Build(),
Spec: spec,
}.Build())
+3
View File
@@ -176,6 +176,9 @@ type Request struct {
// DelegationSessionID is the ID of the Delegation Session this certificate
// is being generated for.
DelegationSessionID string
// BeamID is the identifier of the Beam this certificate is being generated
// for, derived from the delegation session's types.BeamIDLabel label.
BeamID string
// HeadlessAuthenticationID is the ID of the headless authentication
// resource this certificate is being generated for.
HeadlessAuthenticationID string
+3
View File
@@ -68,6 +68,9 @@ type NewWebSessionRequest struct {
// DelegationSessionID is the ID of the Delegation Session this session is
// being created for.
DelegationSessionID string
// BeamID is the identifier of the Beam this session is being created for,
// derived from the delegation session's types.BeamIDLabel label.
BeamID string
}
// CheckAndSetDefaults validates the request and sets defaults.
+13 -1
View File
@@ -376,7 +376,11 @@ type NewAppSessionRequest = sessionreq.NewAppSessionRequest
// backend with the identity of the caller used to generate the certificate.
// The certificate is used for all access requests, which is where access
// control is enforced.
func (a *Server) CreateAppSession(ctx context.Context, req *proto.CreateAppSessionRequest, identity tlsca.Identity, checker services.AccessChecker) (types.WebSession, error) {
// unmappedIdentity is the caller's original identity before any trusted-cluster
// role mapping. It is used solely to source attribution fields (delegation and
// beam IDs) that the role-mapping rebuild drops from the mapped identity. For
// local callers it is identical to identity.
func (a *Server) CreateAppSession(ctx context.Context, req *proto.CreateAppSessionRequest, identity, unmappedIdentity tlsca.Identity, checker services.AccessChecker) (types.WebSession, error) {
if !a.modules.Features().GetEntitlement(entitlements.App).Enabled {
return nil, trace.AccessDenied(
"this Teleport cluster is not licensed for application access, please contact the cluster administrator")
@@ -426,6 +430,13 @@ func (a *Server) CreateAppSession(ctx context.Context, req *proto.CreateAppSessi
// service on behalf of the user's Web Session. We can safely attest this child app session
// as a "web_session" as a result.
AttestWebSession: identity.PrivateKeyPolicy == keys.PrivateKeyPolicyWebSession,
// Propagate delegation and beam attribution from the caller's
// unmapped identity so the app-session cert remains linked to the
// same delegation session and beam. These are read from the unmapped
// identity because a remote (trusted-cluster) caller's mapped
// identity is rebuilt without these attribution fields.
DelegationSessionID: unmappedIdentity.DelegationSessionID,
BeamID: unmappedIdentity.BeamID,
},
PublicAddr: req.PublicAddr,
ClusterName: req.ClusterName,
@@ -540,6 +551,7 @@ func (a *Server) CreateAppSessionFromReq(ctx context.Context, req NewAppSessionR
BotName: req.BotName,
BotInstanceID: req.BotInstanceID,
DelegationSessionID: req.DelegationSessionID,
BeamID: req.BeamID,
})
if err != nil {
return nil, trace.Wrap(err)
+1
View File
@@ -825,6 +825,7 @@ func (a *authorizer) authorizeRemoteUser(ctx context.Context, u RemoteUser) (*Co
UserType: u.Identity.UserType,
OriginClusterName: u.Identity.TeleportCluster,
DeviceExtensions: u.Identity.DeviceExtensions,
BeamID: u.Identity.BeamID,
}
if checker.PinSourceIP() && identity.PinnedIP == "" {
return nil, trace.Wrap(ErrIPPinningMissing)
+67
View File
@@ -635,6 +635,73 @@ func (a *fakeMFAAuthenticator) ValidateMFAAuthResponse(ctx context.Context, resp
return mfaData, nil
}
// TestAuthorizer_Authorize_remoteUserBeamID verifies that BeamID is propagated
// through the authorizeRemoteUser path. A remote user whose original identity
// carries a BeamID should have that BeamID preserved in the mapped identity and
// in the resulting auth context's user metadata.
func TestAuthorizer_Authorize_remoteUserBeamID(t *testing.T) {
t.Parallel()
client, watcher, _ := newTestResources(t)
_, localRole, err := authtest.CreateUserAndRole(client, "local-user", []string{"local-user"}, nil)
require.NoError(t, err, "CreateUserAndRole")
remoteClusterName := "remote-cluster"
ca, err := types.NewCertAuthority(types.CertAuthoritySpecV2{
Type: types.UserCA,
ClusterName: remoteClusterName,
ActiveKeys: types.CAKeySet{
SSH: []*types.SSHKeyPair{{
PrivateKey: []byte(fixtures.SSHCAPrivateKey),
PublicKey: []byte(fixtures.SSHCAPublicKey),
}},
TLS: []*types.TLSKeyPair{{
Cert: []byte(fixtures.TLSCACertPEM),
Key: []byte(fixtures.TLSCAKeyPEM),
}},
},
RoleMap: types.RoleMap{{
Remote: localRole.GetName(),
Local: []string{localRole.GetName()},
}},
})
require.NoError(t, err, "NewCertAuthority failed")
require.NoError(t, client.UpsertCertAuthority(t.Context(), ca), "UpsertCertAuthority failed")
testBeamID := "test-beam-id-123"
remoteUser := authz.RemoteUser{
Username: "remote-user",
ClusterName: remoteClusterName,
RemoteRoles: []string{localRole.GetName()},
Principals: []string{"remote-user"},
Identity: tlsca.Identity{
Username: "remote-user",
Groups: []string{localRole.GetName()},
BeamID: testBeamID,
},
}
authorizer, err := authz.NewAuthorizer(authz.AuthorizerOpts{
ClusterName: clusterName,
AccessPoint: client,
LockWatcher: watcher,
})
require.NoError(t, err, "NewAuthorizer failed")
userCtx := authz.ContextWithUser(t.Context(), remoteUser)
authCtx, err := authorizer.Authorize(userCtx)
require.NoError(t, err, "Authorize failed")
// Verify BeamID is preserved in the mapped identity
assert.Equal(t, testBeamID, authCtx.Identity.GetIdentity().BeamID,
"BeamID should be preserved in the mapped identity")
// Verify BeamID is included in user metadata
metadata := authCtx.GetUserMetadata()
assert.Equal(t, testBeamID, metadata.BeamID,
"BeamID should be included in user metadata for audit events")
}
func TestAuthorizer_AuthorizeAdminAction(t *testing.T) {
ctx := context.Background()
client, watcher, _ := newTestResources(t)
+4
View File
@@ -426,6 +426,7 @@ func (s *Service) emitCommandEvent(eventBytes []byte) {
UserClusterName: ctx.UserOriginClusterName,
UserRoles: slices.Clone(ctx.UserRoles),
UserTraits: ctx.UserTraits.Clone(),
BeamID: ctx.BeamID,
},
BPFMetadata: apievents.BPFMetadata{
CgroupID: event.Cgroup,
@@ -518,6 +519,7 @@ func (s *Service) emitDiskEvent(eventBytes []byte) {
UserClusterName: ctx.UserOriginClusterName,
UserRoles: slices.Clone(ctx.UserRoles),
UserTraits: ctx.UserTraits.Clone(),
BeamID: ctx.BeamID,
},
BPFMetadata: apievents.BPFMetadata{
CgroupID: event.Cgroup,
@@ -578,6 +580,7 @@ func (s *Service) emit4NetworkEvent(eventBytes []byte) {
UserClusterName: ctx.UserOriginClusterName,
UserRoles: slices.Clone(ctx.UserRoles),
UserTraits: ctx.UserTraits.Clone(),
BeamID: ctx.BeamID,
},
BPFMetadata: apievents.BPFMetadata{
CgroupID: event.Cgroup,
@@ -640,6 +643,7 @@ func (s *Service) emit6NetworkEvent(eventBytes []byte) {
UserClusterName: ctx.UserOriginClusterName,
UserRoles: slices.Clone(ctx.UserRoles),
UserTraits: ctx.UserTraits.Clone(),
BeamID: ctx.BeamID,
},
BPFMetadata: apievents.BPFMetadata{
CgroupID: event.Cgroup,
+2
View File
@@ -90,6 +90,8 @@ type SessionContext struct {
UserRoles []string
// UserTraits are the traits assigned to the user.
UserTraits wrappers.Traits
// BeamID is the identifier of the Beam this session was created for.
BeamID string
// AuditSessionID is the audit session ID that should be the same
// for all processes in an SSH session. It is used to correlate
+2
View File
@@ -72,6 +72,7 @@ func SSHIdentityToSSHCA(id *decisionpb.SSHIdentity) *sshca.Identity {
AgentScope: id.GetAgentScope(),
ImmutableLabelHash: id.GetImmutableLabelHash(),
DelegationSessionID: id.GetDelegationSessionId(),
BeamID: id.GetBeamId(),
HeadlessAuthenticationID: id.GetHeadlessAuthenticationId(),
}
}
@@ -122,6 +123,7 @@ func SSHIdentityFromSSHCA(id *sshca.Identity) *decisionpb.SSHIdentity {
AgentScope: id.AgentScope,
ImmutableLabelHash: id.ImmutableLabelHash,
DelegationSessionId: id.DelegationSessionID,
BeamId: id.BeamID,
HeadlessAuthenticationId: id.HeadlessAuthenticationID,
}.Build()
}
+1
View File
@@ -120,6 +120,7 @@ func TestSSHIdentityConversion(t *testing.T) {
},
}.Build()),
DelegationSessionID: "delegation-session",
BeamID: "beam-id",
}
ignores := []string{
+2
View File
@@ -81,6 +81,7 @@ func TLSIdentityToTLSCA(id *decisionpb.TLSIdentity) *tlsca.Identity {
DeviceExtensions: deviceExtensionsFromProto(id.GetDeviceExtensions()),
UserType: types.UserType(id.GetUserType()),
DelegationSessionID: id.GetDelegationSessionId(),
BeamID: id.GetBeamId(),
}
}
@@ -133,6 +134,7 @@ func TLSIdentityFromTLSCA(id *tlsca.Identity) *decisionpb.TLSIdentity {
DeviceExtensions: deviceExtensionsToProto(&id.DeviceExtensions),
UserType: string(id.UserType),
DelegationSessionId: id.DelegationSessionID,
BeamId: id.BeamID,
}.Build()
}
+1
View File
@@ -138,6 +138,7 @@ func TestTLSIdentity_roundtrip(t *testing.T) {
}.Build(),
UserType: "user-type",
DelegationSessionId: "delegation-session",
BeamId: "beam-id",
}.Build()
tests := []struct {
+2
View File
@@ -282,6 +282,7 @@ func (h *AuthHandlers) CreateIdentityContext(sconn *ssh.ServerConn) (IdentityCon
Renewable: unmappedIdentity.Renewable,
BotName: unmappedIdentity.BotName,
BotInstanceID: unmappedIdentity.BotInstanceID,
BeamID: unmappedIdentity.BeamID,
JoinToken: unmappedIdentity.JoinToken,
PreviousIdentityExpires: unmappedIdentity.PreviousIdentityExpires,
OriginClusterName: certAuthority.GetClusterName(),
@@ -488,6 +489,7 @@ func (h *AuthHandlers) PublicKeyCallback(conn ssh.ConnMetadata, key ssh.PublicKe
Login: principal,
User: ident.Username,
TrustedDevice: ident.GetDeviceMetadata(),
BeamID: ident.BeamID,
},
ConnectionMetadata: apievents.ConnectionMetadata{
LocalAddr: conn.LocalAddr().String(),
+6
View File
@@ -310,6 +310,9 @@ type IdentityContext struct {
// deadline in cases where both require_session_mfa and disconnect_expired_cert
// are enabled. See https://github.com/gravitational/teleport/issues/18544.
PreviousIdentityExpires time.Time
// BeamID is the identifier of the Beam this session was created for.
BeamID string
}
// ServerContext holds session specific context, such as SSH auth agents, PTYs,
@@ -1161,8 +1164,10 @@ func (id *IdentityContext) GetUserMetadata() apievents.UserMetadata {
// not support trusted clusters, the scope pin should always be available
// on the unmapped identity for all scoped identities.
var scopePin *scopesv1.Pin
var beamID string
if id.UnmappedIdentity != nil {
scopePin = id.UnmappedIdentity.ScopePin
beamID = id.UnmappedIdentity.BeamID
}
return apievents.UserMetadata{
@@ -1178,6 +1183,7 @@ func (id *IdentityContext) GetUserMetadata() apievents.UserMetadata {
UserRoles: slices.Clone(id.MappedRoles),
UserTraits: id.Traits.Clone(),
ScopePin: pinning.ToEventsPin(scopePin),
BeamID: beamID,
}
}
+1
View File
@@ -352,6 +352,7 @@ func (s *ForwardServer) onRBACFailure(conn ssh.ConnMetadata, ident *sshca.Identi
Login: gitUser,
User: ident.Username,
TrustedDevice: ident.GetDeviceMetadata(),
BeamID: ident.BeamID,
},
ConnectionMetadata: apievents.ConnectionMetadata{
LocalAddr: conn.LocalAddr().String(),
+2
View File
@@ -1496,6 +1496,7 @@ func (s *session) startInteractive(ctx context.Context, scx *ServerContext, p *p
UserOriginClusterName: scx.Identity.OriginClusterName,
UserRoles: scx.Identity.MappedRoles,
UserTraits: scx.Identity.Traits,
BeamID: scx.Identity.BeamID,
Events: eventsMap,
}
@@ -1690,6 +1691,7 @@ func (s *session) startExec(ctx context.Context, channel ssh.Channel, scx *Serve
UserOriginClusterName: scx.Identity.OriginClusterName,
UserRoles: scx.Identity.MappedRoles,
UserTraits: scx.Identity.Traits,
BeamID: scx.Identity.BeamID,
Events: eventsMap,
}
+51
View File
@@ -45,6 +45,7 @@ import (
apissh "github.com/gravitational/teleport/api/ssh"
"github.com/gravitational/teleport/api/types"
apievents "github.com/gravitational/teleport/api/types/events"
"github.com/gravitational/teleport/api/types/wrappers"
"github.com/gravitational/teleport/lib/bpf"
"github.com/gravitational/teleport/lib/defaults"
"github.com/gravitational/teleport/lib/events"
@@ -53,6 +54,7 @@ import (
"github.com/gravitational/teleport/lib/modules/modulestest"
"github.com/gravitational/teleport/lib/services"
rsession "github.com/gravitational/teleport/lib/session"
"github.com/gravitational/teleport/lib/sshca"
"github.com/gravitational/teleport/lib/sshutils/sftp"
"github.com/gravitational/teleport/lib/utils/log/logtest"
"github.com/gravitational/teleport/session/reexec/reexecsftp"
@@ -1323,6 +1325,55 @@ func (c *countingBPF) LostEvents() bpf.EventCount {
return bpf.EventCount{}
}
// TestBPFSessionContext_BeamID verifies that BeamID is propagated through
// BPF SessionContext when creating enhanced recording sessions.
func TestBPFSessionContext_BeamID(t *testing.T) {
t.Parallel()
testBeamID := "test-beam-id-456"
// Create a minimal ServerContext with BeamID in UnmappedIdentity
scx := &ServerContext{
Identity: IdentityContext{
Login: "testuser",
TeleportUser: "[email protected]",
OriginClusterName: "test-cluster",
MappedRoles: []string{"test-role"},
Traits: wrappers.Traits{},
UnmappedIdentity: &sshca.Identity{
BeamID: testBeamID,
},
},
}
// Simulate creating a BPF SessionContext (similar to what happens in sess.go)
var beamID string
if scx.Identity.UnmappedIdentity != nil {
beamID = scx.Identity.UnmappedIdentity.BeamID
}
sessionCtx := &bpf.SessionContext{
Namespace: "default",
SessionID: "test-session-id",
ServerID: "test-server-id",
ServerHostname: "test-hostname",
Login: scx.Identity.Login,
User: scx.Identity.TeleportUser,
UserOriginClusterName: scx.Identity.OriginClusterName,
UserRoles: scx.Identity.MappedRoles,
UserTraits: scx.Identity.Traits,
BeamID: beamID,
Events: map[string]struct{}{},
}
// Verify BeamID is set correctly in SessionContext
require.Equal(t, testBeamID, sessionCtx.BeamID, "BeamID should be propagated to SessionContext")
// Also verify that IdentityContext.GetUserMetadata() includes BeamID
metadata := scx.Identity.GetUserMetadata()
require.Equal(t, testBeamID, metadata.BeamID, "BeamID should be included in UserMetadata")
}
func TestTrackingSession(t *testing.T) {
t.Parallel()
ctx := t.Context()
+12
View File
@@ -153,6 +153,9 @@ type Identity struct {
// DelegationSessionID is the identifier of the Delegation Session this
// certificate was created for.
DelegationSessionID string
// BeamID is the identifier of the Beam this certificate was created for,
// derived from the delegation session's types.BeamIDLabel label.
BeamID string
// HeadlessAuthenticationID is the ID of the headless authentication
// resource this certificate is being generated for.
HeadlessAuthenticationID string
@@ -328,6 +331,9 @@ func (i *Identity) Encode(certFormat string) (*ssh.Certificate, error) {
if i.DelegationSessionID != "" {
cert.Permissions.Extensions[teleport.CertExtensionDelegationSessionID] = i.DelegationSessionID
}
if i.BeamID != "" {
cert.Permissions.Extensions[teleport.CertExtensionBeamID] = i.BeamID
}
if i.GitHubUserID != "" {
cert.Permissions.Extensions[teleport.CertExtensionGitHubUserID] = i.GitHubUserID
}
@@ -357,6 +363,11 @@ func (i *Identity) Encode(certFormat string) (*ssh.Certificate, error) {
extension.Type != types.CertExtensionType_SSH {
continue
}
// Beam IDs are server-derived from delegation sessions and must not be
// spoofed or overwritten by role-supplied certificate extensions.
if extension.Name == teleport.CertExtensionBeamID {
continue
}
cert.Extensions[extension.Name] = extension.Value
}
@@ -586,6 +597,7 @@ func DecodeIdentity(cert *ssh.Certificate) (*Identity, error) {
ident.DeviceAssetTag = takeValue(teleport.CertExtensionDeviceAssetTag)
ident.DeviceCredentialID = takeValue(teleport.CertExtensionDeviceCredentialID)
ident.DelegationSessionID = takeValue(teleport.CertExtensionDelegationSessionID)
ident.BeamID = takeValue(teleport.CertExtensionBeamID)
ident.GitHubUserID = takeValue(teleport.CertExtensionGitHubUserID)
ident.GitHubUsername = takeValue(teleport.CertExtensionGitHubUsername)
ident.HeadlessAuthenticationID = takeValue(teleport.CertExtensionHeadlessAuthenticationID)
+65
View File
@@ -29,6 +29,7 @@ import (
"golang.org/x/crypto/ssh"
"google.golang.org/protobuf/testing/protocmp"
"github.com/gravitational/teleport"
"github.com/gravitational/teleport/api/constants"
joiningv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/scopes/joining/v1"
scopesv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/scopes/v1"
@@ -109,6 +110,7 @@ func TestIdentityConversion(t *testing.T) {
GitHubUsername: "ghuser",
HeadlessAuthenticationID: "headless-auth-id",
DelegationSessionID: "delegation-session-id",
BeamID: "beam-id",
AgentScope: "/foo",
ImmutableLabelHash: joining.HashImmutableLabels(joiningv1.ImmutableLabels_builder{
Ssh: map[string]string{
@@ -166,6 +168,69 @@ func TestIdentityConversion(t *testing.T) {
require.Empty(t, cmp.Diff(ident, ident2, protocmp.Transform()))
}
func TestIdentityEncodeReservesBeamIDCertExtension(t *testing.T) {
const (
customExtensionName = "[email protected]"
customExtensionValue = "custom-extension-value"
roleBeamID = "role-supplied-beam-id"
serverBeamID = "server-derived-beam-id"
)
for _, tt := range []struct {
name string
beamID string
wantBeamID string
}{
{
name: "server derived Beam ID is preserved",
beamID: serverBeamID,
wantBeamID: serverBeamID,
},
{
name: "role extension cannot create Beam attribution",
},
} {
t.Run(tt.name, func(t *testing.T) {
ident := &Identity{
CertType: ssh.UserCert,
Username: "user",
BeamID: tt.beamID,
CertificateExtensions: []*types.CertExtension{
{
Name: teleport.CertExtensionBeamID,
Value: roleBeamID,
Type: types.CertExtensionType_SSH,
Mode: types.CertExtensionMode_EXTENSION,
},
{
Name: customExtensionName,
Value: customExtensionValue,
Type: types.CertExtensionType_SSH,
Mode: types.CertExtensionMode_EXTENSION,
},
},
}
cert, err := ident.Encode(constants.CertificateFormatStandard)
require.NoError(t, err)
require.Equal(t, customExtensionValue, cert.Extensions[customExtensionName])
if tt.wantBeamID == "" {
require.NotContains(t, cert.Extensions, teleport.CertExtensionBeamID)
} else {
require.Equal(t, tt.wantBeamID, cert.Extensions[teleport.CertExtensionBeamID])
}
decoded, err := DecodeIdentity(cert)
require.NoError(t, err)
require.Equal(t, tt.wantBeamID, decoded.BeamID)
for _, extension := range decoded.CertificateExtensions {
require.NotEqual(t, teleport.CertExtensionBeamID, extension.Name)
}
})
}
}
// TestAllowedResources_SSHEncodeDecode verifies that AllowedResourceIDs and
// AllowedResourceAccessIDs are populated correctly after an SSH cert's
// encode-decode cycle across all resource mix permutations.
+21
View File
@@ -242,6 +242,10 @@ type Identity struct {
// certificate was created for.
DelegationSessionID string
// BeamID is the identifier of the Beam this certificate was created for,
// derived from the delegation session's types.BeamIDLabel label.
BeamID string
// ImmutableLabelHash is the hash of the immutable labels that have been
// applied to the identity.
ImmutableLabelHash string
@@ -667,6 +671,10 @@ var (
// encoding the agent's pinned scope and system roles.
AgentScopePinASN1ExtensionOID = asn1.ObjectIdentifier{1, 3, 9999, 2, 31}
// BeamIDASN1ExtensionOID is an extension OID that contains the identifier of
// the Beam this certificate was created for.
BeamIDASN1ExtensionOID = asn1.ObjectIdentifier{1, 3, 9999, 2, 32}
// CAClusterNameExtensionOID records the cluster name in a Teleport CA
// certificate.
//
@@ -1054,6 +1062,14 @@ func (id *Identity) Subject() (pkix.Name, error) {
})
}
if id.BeamID != "" {
subject.ExtraNames = append(subject.ExtraNames,
pkix.AttributeTypeAndValue{
Type: BeamIDASN1ExtensionOID,
Value: id.BeamID,
})
}
if id.UserType != "" {
subject.ExtraNames = append(subject.ExtraNames,
pkix.AttributeTypeAndValue{
@@ -1436,6 +1452,10 @@ func FromSubject(subject pkix.Name, expires time.Time) (*Identity, error) {
if val, ok := attr.Value.(string); ok {
id.DelegationSessionID = val
}
case attr.Type.Equal(BeamIDASN1ExtensionOID):
if val, ok := attr.Value.(string); ok {
id.BeamID = val
}
case attr.Type.Equal(AllowedResourcesASN1ExtensionOID):
allowedResourcesStr, ok := attr.Value.(string)
if ok {
@@ -1584,6 +1604,7 @@ func (id Identity) GetUserMetadata() events.UserMetadata {
UserTraits: id.Traits.Clone(),
UserClusterName: userTeleportCluster,
ScopePin: pinning.ToEventsPin(id.ScopePin),
BeamID: id.BeamID,
}
}
+37
View File
@@ -955,6 +955,43 @@ func TestDelegationSessionID(t *testing.T) {
require.Empty(t, cmp.Diff(out, &identity, cmpopts.EquateApproxTime(time.Second)))
}
func TestBeamID(t *testing.T) {
clock := clockwork.NewFakeClock()
ca, err := FromKeys([]byte(fixtures.TLSCACertPEM), []byte(fixtures.TLSCAKeyPEM))
require.NoError(t, err)
privateKey, err := cryptosuites.GenerateKeyWithAlgorithm(cryptosuites.ECDSAP256)
require.NoError(t, err)
expires := clock.Now().Add(time.Hour)
identity := Identity{
Username: "[email protected]",
Groups: []string{"admin"},
TeleportCluster: "tele-cluster",
OriginClusterName: "tele-cluster",
Expires: expires,
BeamID: "beam-id",
}
subj, err := identity.Subject()
require.NoError(t, err)
certBytes, err := ca.GenerateCertificate(CertificateRequest{
Clock: clock,
PublicKey: privateKey.Public(),
Subject: subj,
NotAfter: expires,
})
require.NoError(t, err)
cert, err := ParseCertificatePEM(certBytes)
require.NoError(t, err)
out, err := FromSubject(cert.Subject, cert.NotAfter)
require.NoError(t, err)
require.Equal(t, "beam-id", out.BeamID)
require.Empty(t, cmp.Diff(out, &identity, cmpopts.EquateApproxTime(time.Second)))
}
func TestGenerateCertificate_SubjectModifcationsPreserved(t *testing.T) {
ca, err := FromKeys([]byte(fixtures.TLSCACertPEM), []byte(fixtures.TLSCAKeyPEM))
require.NoError(t, err)