mirror of
https://github.com/gravitational/teleport.git
synced 2026-10-12 02:15:01 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -120,6 +120,7 @@ func TestSSHIdentityConversion(t *testing.T) {
|
||||
},
|
||||
}.Build()),
|
||||
DelegationSessionID: "delegation-session",
|
||||
BeamID: "beam-id",
|
||||
}
|
||||
|
||||
ignores := []string{
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -138,6 +138,7 @@ func TestTLSIdentity_roundtrip(t *testing.T) {
|
||||
}.Build(),
|
||||
UserType: "user-type",
|
||||
DelegationSessionId: "delegation-session",
|
||||
BeamId: "beam-id",
|
||||
}.Build()
|
||||
|
||||
tests := []struct {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user