From cf35a5f9ba03dfa80e9d59188196a8823e7e545d Mon Sep 17 00:00:00 2001 From: Tiago Silva Date: Tue, 23 Jun 2026 14:36:36 +0100 Subject: [PATCH] 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 --- constants.go | 4 + lib/auth/auth.go | 2 + lib/auth/auth_with_roles.go | 22 +++- .../delegation/delegationv1/generate_certs.go | 7 + .../delegationv1/generate_certs_test.go | 122 ++++++++++++++++++ .../delegationv1/session_service_test.go | 9 ++ lib/auth/internal/cert/request.go | 3 + lib/auth/internal/session/request.go | 3 + lib/auth/sessions.go | 14 +- lib/authz/permissions.go | 1 + lib/authz/permissions_test.go | 67 ++++++++++ lib/bpf/bpf.go | 4 + lib/bpf/common.go | 2 + lib/decision/ssh_identity.go | 2 + lib/decision/ssh_identity_test.go | 1 + lib/decision/tls_identity.go | 2 + lib/decision/tls_identity_test.go | 1 + lib/srv/authhandlers.go | 2 + lib/srv/ctx.go | 6 + lib/srv/git/forward.go | 1 + lib/srv/sess.go | 2 + lib/srv/sess_test.go | 51 ++++++++ lib/sshca/identity.go | 12 ++ lib/sshca/identity_test.go | 65 ++++++++++ lib/tlsca/ca.go | 21 +++ lib/tlsca/ca_test.go | 37 ++++++ 26 files changed, 461 insertions(+), 2 deletions(-) diff --git a/constants.go b/constants.go index ec4d19a90a7..0a1abaa19d7 100644 --- a/constants.go +++ b/constants.go @@ -547,6 +547,10 @@ const ( // CertExtensionDelegationSessionID contains the identifier of the // Delegation Session this certificate was created for. CertExtensionDelegationSessionID = "delegation-session-id@goteleport.com" + // CertExtensionBeamID contains the identifier of the Beam this certificate + // was created for, derived from the delegation session's + // types.BeamIDLabel label. + CertExtensionBeamID = "beam-id@goteleport.com" // CertCriticalOptionSourceAddress is a critical option that defines IP addresses (in CIDR notation) // from which this certificate is accepted for authentication. diff --git a/lib/auth/auth.go b/lib/auth/auth.go index b281e2b23af..5c3ae32f31f 100644 --- a/lib/auth/auth.go +++ b/lib/auth/auth.go @@ -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, diff --git a/lib/auth/auth_with_roles.go b/lib/auth/auth_with_roles.go index 4f5955e9121..2c69aebf233 100644 --- a/lib/auth/auth_with_roles.go +++ b/lib/auth/auth_with_roles.go @@ -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) } diff --git a/lib/auth/delegation/delegationv1/generate_certs.go b/lib/auth/delegation/delegationv1/generate_certs.go index c7f9b2f1196..1bdb48e71a7 100644 --- a/lib/auth/delegation/delegationv1/generate_certs.go +++ b/lib/auth/delegation/delegationv1/generate_certs.go @@ -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, diff --git a/lib/auth/delegation/delegationv1/generate_certs_test.go b/lib/auth/delegation/delegationv1/generate_certs_test.go index c5d6ebd5045..4976bad73e2 100644 --- a/lib/auth/delegation/delegationv1/generate_certs_test.go +++ b/lib/auth/delegation/delegationv1/generate_certs_test.go @@ -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) diff --git a/lib/auth/delegation/delegationv1/session_service_test.go b/lib/auth/delegation/delegationv1/session_service_test.go index 7b2a8d5a87f..e63ee995a5d 100644 --- a/lib/auth/delegation/delegationv1/session_service_test.go +++ b/lib/auth/delegation/delegationv1/session_service_test.go @@ -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()) diff --git a/lib/auth/internal/cert/request.go b/lib/auth/internal/cert/request.go index 3d409152f88..96bd1b8e988 100644 --- a/lib/auth/internal/cert/request.go +++ b/lib/auth/internal/cert/request.go @@ -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 diff --git a/lib/auth/internal/session/request.go b/lib/auth/internal/session/request.go index 7b17006c778..dadb967d1ae 100644 --- a/lib/auth/internal/session/request.go +++ b/lib/auth/internal/session/request.go @@ -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. diff --git a/lib/auth/sessions.go b/lib/auth/sessions.go index 02cf3134816..c91e34fcad3 100644 --- a/lib/auth/sessions.go +++ b/lib/auth/sessions.go @@ -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) diff --git a/lib/authz/permissions.go b/lib/authz/permissions.go index 1a8e2486faa..127b87ae52a 100644 --- a/lib/authz/permissions.go +++ b/lib/authz/permissions.go @@ -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) diff --git a/lib/authz/permissions_test.go b/lib/authz/permissions_test.go index 55beb70d3f6..d4bece60a83 100644 --- a/lib/authz/permissions_test.go +++ b/lib/authz/permissions_test.go @@ -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) diff --git a/lib/bpf/bpf.go b/lib/bpf/bpf.go index 1b9030e22c2..01296a86c97 100644 --- a/lib/bpf/bpf.go +++ b/lib/bpf/bpf.go @@ -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, diff --git a/lib/bpf/common.go b/lib/bpf/common.go index 176b5f5d9e6..ad864a40bdb 100644 --- a/lib/bpf/common.go +++ b/lib/bpf/common.go @@ -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 diff --git a/lib/decision/ssh_identity.go b/lib/decision/ssh_identity.go index d0e428e8de3..7f1b62a2ead 100644 --- a/lib/decision/ssh_identity.go +++ b/lib/decision/ssh_identity.go @@ -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() } diff --git a/lib/decision/ssh_identity_test.go b/lib/decision/ssh_identity_test.go index 13decef5479..2438e5fa795 100644 --- a/lib/decision/ssh_identity_test.go +++ b/lib/decision/ssh_identity_test.go @@ -120,6 +120,7 @@ func TestSSHIdentityConversion(t *testing.T) { }, }.Build()), DelegationSessionID: "delegation-session", + BeamID: "beam-id", } ignores := []string{ diff --git a/lib/decision/tls_identity.go b/lib/decision/tls_identity.go index f10a0012691..7717d60f411 100644 --- a/lib/decision/tls_identity.go +++ b/lib/decision/tls_identity.go @@ -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() } diff --git a/lib/decision/tls_identity_test.go b/lib/decision/tls_identity_test.go index 951952d9380..f9f3dc2cac3 100644 --- a/lib/decision/tls_identity_test.go +++ b/lib/decision/tls_identity_test.go @@ -138,6 +138,7 @@ func TestTLSIdentity_roundtrip(t *testing.T) { }.Build(), UserType: "user-type", DelegationSessionId: "delegation-session", + BeamId: "beam-id", }.Build() tests := []struct { diff --git a/lib/srv/authhandlers.go b/lib/srv/authhandlers.go index 53e379d2229..8d8cc6887ad 100644 --- a/lib/srv/authhandlers.go +++ b/lib/srv/authhandlers.go @@ -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(), diff --git a/lib/srv/ctx.go b/lib/srv/ctx.go index c49b4b7372c..88dbd18bf36 100644 --- a/lib/srv/ctx.go +++ b/lib/srv/ctx.go @@ -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, } } diff --git a/lib/srv/git/forward.go b/lib/srv/git/forward.go index e4b10f9d520..af0b87c385a 100644 --- a/lib/srv/git/forward.go +++ b/lib/srv/git/forward.go @@ -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(), diff --git a/lib/srv/sess.go b/lib/srv/sess.go index 1dc7c477817..fcc87fe7b54 100644 --- a/lib/srv/sess.go +++ b/lib/srv/sess.go @@ -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, } diff --git a/lib/srv/sess_test.go b/lib/srv/sess_test.go index 742d6a07e48..db757f1abbf 100644 --- a/lib/srv/sess_test.go +++ b/lib/srv/sess_test.go @@ -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: "testuser@example.com", + 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() diff --git a/lib/sshca/identity.go b/lib/sshca/identity.go index b25f85c95d0..745466e6b95 100644 --- a/lib/sshca/identity.go +++ b/lib/sshca/identity.go @@ -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) diff --git a/lib/sshca/identity_test.go b/lib/sshca/identity_test.go index 83886050bec..bc1d33d8e79 100644 --- a/lib/sshca/identity_test.go +++ b/lib/sshca/identity_test.go @@ -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 = "login@example.com" + 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. diff --git a/lib/tlsca/ca.go b/lib/tlsca/ca.go index 91e6da94cc8..a0388f53ee9 100644 --- a/lib/tlsca/ca.go +++ b/lib/tlsca/ca.go @@ -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, } } diff --git a/lib/tlsca/ca_test.go b/lib/tlsca/ca_test.go index 01c887bdf4c..24c4cd8fb81 100644 --- a/lib/tlsca/ca_test.go +++ b/lib/tlsca/ca_test.go @@ -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: "alice@example.com", + 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)