diff --git a/lib/decision/tls_identity.go b/lib/decision/tls_identity.go
new file mode 100644
index 0000000000000..d0cf1c7905eab
--- /dev/null
+++ b/lib/decision/tls_identity.go
@@ -0,0 +1,278 @@
+// Teleport
+// Copyright (C) 2024 Gravitational, Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package decision
+
+import (
+ "time"
+
+ "google.golang.org/protobuf/types/known/timestamppb"
+
+ decisionpb "github.com/gravitational/teleport/api/gen/proto/go/teleport/decision/v1alpha1"
+ traitpb "github.com/gravitational/teleport/api/gen/proto/go/teleport/trait/v1"
+ "github.com/gravitational/teleport/api/types"
+ apitrait "github.com/gravitational/teleport/api/types/trait"
+ apitraitconvert "github.com/gravitational/teleport/api/types/trait/convert/v1"
+ "github.com/gravitational/teleport/api/types/wrappers"
+ "github.com/gravitational/teleport/api/utils/keys"
+ "github.com/gravitational/teleport/lib/tlsca"
+)
+
+// TLSIdentityToTLSCA transforms a [decisionpb.TLSIdentity] into its
+// equivalent [tlsca.Identity].
+// Note that certain types, like slices, are not deep-copied.
+func TLSIdentityToTLSCA(id *decisionpb.TLSIdentity) *tlsca.Identity {
+ if id == nil {
+ return nil
+ }
+
+ return &tlsca.Identity{
+ Username: id.Username,
+ Impersonator: id.Impersonator,
+ Groups: id.Groups,
+ SystemRoles: id.SystemRoles,
+ Usage: id.Usage,
+ Principals: id.Principals,
+ KubernetesGroups: id.KubernetesGroups,
+ KubernetesUsers: id.KubernetesUsers,
+ Expires: timestampToGoTime(id.Expires),
+ RouteToCluster: id.RouteToCluster,
+ KubernetesCluster: id.KubernetesCluster,
+ Traits: traitToWrappers(id.Traits),
+ RouteToApp: routeToAppFromProto(id.RouteToApp),
+ TeleportCluster: id.TeleportCluster,
+ RouteToDatabase: routeToDatabaseFromProto(id.RouteToDatabase),
+ DatabaseNames: id.DatabaseNames,
+ DatabaseUsers: id.DatabaseUsers,
+ MFAVerified: id.MfaVerified,
+ PreviousIdentityExpires: timestampToGoTime(id.PreviousIdentityExpires),
+ LoginIP: id.LoginIp,
+ PinnedIP: id.PinnedIp,
+ AWSRoleARNs: id.AwsRoleArns,
+ AzureIdentities: id.AzureIdentities,
+ GCPServiceAccounts: id.GcpServiceAccounts,
+ ActiveRequests: id.ActiveRequests,
+ DisallowReissue: id.DisallowReissue,
+ Renewable: id.Renewable,
+ Generation: id.Generation,
+ BotName: id.BotName,
+ BotInstanceID: id.BotInstanceId,
+ AllowedResourceIDs: resourceIDsToTypes(id.AllowedResourceIds),
+ PrivateKeyPolicy: keys.PrivateKeyPolicy(id.PrivateKeyPolicy),
+ ConnectionDiagnosticID: id.ConnectionDiagnosticId,
+ DeviceExtensions: deviceExtensionsFromProto(id.DeviceExtensions),
+ UserType: types.UserType(id.UserType),
+ }
+}
+
+// TLSIdentityFromTLSCA transforms a [tlsca.Identity] into its equivalent
+// [decisionpb.TLSIdentity].
+// Note that certain types, like slices, are not deep-copied.
+func TLSIdentityFromTLSCA(id *tlsca.Identity) *decisionpb.TLSIdentity {
+ if id == nil {
+ return nil
+ }
+
+ return &decisionpb.TLSIdentity{
+ Username: id.Username,
+ Impersonator: id.Impersonator,
+ Groups: id.Groups,
+ SystemRoles: id.SystemRoles,
+ Usage: id.Usage,
+ Principals: id.Principals,
+ KubernetesGroups: id.KubernetesGroups,
+ KubernetesUsers: id.KubernetesUsers,
+ Expires: timestampFromGoTime(id.Expires),
+ RouteToCluster: id.RouteToCluster,
+ KubernetesCluster: id.KubernetesCluster,
+ Traits: traitFromWrappers(id.Traits),
+ RouteToApp: routeToAppToProto(&id.RouteToApp),
+ TeleportCluster: id.TeleportCluster,
+ RouteToDatabase: routeToDatabaseToProto(&id.RouteToDatabase),
+ DatabaseNames: id.DatabaseNames,
+ DatabaseUsers: id.DatabaseUsers,
+ MfaVerified: id.MFAVerified,
+ PreviousIdentityExpires: timestampFromGoTime(id.PreviousIdentityExpires),
+ LoginIp: id.LoginIP,
+ PinnedIp: id.PinnedIP,
+ AwsRoleArns: id.AWSRoleARNs,
+ AzureIdentities: id.AzureIdentities,
+ GcpServiceAccounts: id.GCPServiceAccounts,
+ ActiveRequests: id.ActiveRequests,
+ DisallowReissue: id.DisallowReissue,
+ Renewable: id.Renewable,
+ Generation: id.Generation,
+ BotName: id.BotName,
+ BotInstanceId: id.BotInstanceID,
+ AllowedResourceIds: resourceIDsFromTypes(id.AllowedResourceIDs),
+ PrivateKeyPolicy: string(id.PrivateKeyPolicy),
+ ConnectionDiagnosticId: id.ConnectionDiagnosticID,
+ DeviceExtensions: deviceExtensionsToProto(&id.DeviceExtensions),
+ UserType: string(id.UserType),
+ }
+}
+
+func timestampToGoTime(t *timestamppb.Timestamp) time.Time {
+ // nil or "zero" Timestamps are mapped to Go's zero time (0-0-0 0:0.0) instead
+ // of unix epoch. The latter avoids problems with tooling (eg, Terraform) that
+ // sets structs to their defaults instead of using nil.
+ if t == nil || (t.Seconds == 0 && t.Nanos == 0) {
+ return time.Time{}
+ }
+ return t.AsTime()
+}
+
+func timestampFromGoTime(t time.Time) *timestamppb.Timestamp {
+ if t.IsZero() {
+ return nil
+ }
+ return timestamppb.New(t)
+}
+
+func traitToWrappers(traits []*traitpb.Trait) wrappers.Traits {
+ apiTraits := apitraitconvert.FromProto(traits)
+ return wrappers.Traits(apiTraits)
+}
+
+func traitFromWrappers(traits wrappers.Traits) []*traitpb.Trait {
+ if len(traits) == 0 {
+ return nil
+ }
+ apiTraits := apitrait.Traits(traits)
+ return apitraitconvert.ToProto(apiTraits)
+}
+
+func routeToAppFromProto(routeToApp *decisionpb.RouteToApp) tlsca.RouteToApp {
+ if routeToApp == nil {
+ return tlsca.RouteToApp{}
+ }
+
+ return tlsca.RouteToApp{
+ SessionID: routeToApp.SessionId,
+ PublicAddr: routeToApp.PublicAddr,
+ ClusterName: routeToApp.ClusterName,
+ Name: routeToApp.Name,
+ AWSRoleARN: routeToApp.AwsRoleArn,
+ AzureIdentity: routeToApp.AzureIdentity,
+ GCPServiceAccount: routeToApp.GcpServiceAccount,
+ URI: routeToApp.Uri,
+ TargetPort: int(routeToApp.TargetPort),
+ }
+}
+
+func routeToAppToProto(routeToApp *tlsca.RouteToApp) *decisionpb.RouteToApp {
+ if routeToApp == nil {
+ return nil
+ }
+
+ return &decisionpb.RouteToApp{
+ SessionId: routeToApp.SessionID,
+ PublicAddr: routeToApp.PublicAddr,
+ ClusterName: routeToApp.ClusterName,
+ Name: routeToApp.Name,
+ AwsRoleArn: routeToApp.AWSRoleARN,
+ AzureIdentity: routeToApp.AzureIdentity,
+ GcpServiceAccount: routeToApp.GCPServiceAccount,
+ Uri: routeToApp.URI,
+ TargetPort: int32(routeToApp.TargetPort),
+ }
+}
+
+func routeToDatabaseFromProto(routeToDatabase *decisionpb.RouteToDatabase) tlsca.RouteToDatabase {
+ if routeToDatabase == nil {
+ return tlsca.RouteToDatabase{}
+ }
+
+ return tlsca.RouteToDatabase{
+ ServiceName: routeToDatabase.ServiceName,
+ Protocol: routeToDatabase.Protocol,
+ Username: routeToDatabase.Username,
+ Database: routeToDatabase.Database,
+ Roles: routeToDatabase.Roles,
+ }
+}
+
+func routeToDatabaseToProto(routeToDatabase *tlsca.RouteToDatabase) *decisionpb.RouteToDatabase {
+ if routeToDatabase == nil {
+ return nil
+ }
+
+ return &decisionpb.RouteToDatabase{
+ ServiceName: routeToDatabase.ServiceName,
+ Protocol: routeToDatabase.Protocol,
+ Username: routeToDatabase.Username,
+ Database: routeToDatabase.Database,
+ Roles: routeToDatabase.Roles,
+ }
+}
+
+func resourceIDsToTypes(resourceIDs []*decisionpb.ResourceId) []types.ResourceID {
+ if len(resourceIDs) == 0 {
+ return nil
+ }
+
+ ret := make([]types.ResourceID, len(resourceIDs))
+ for i, r := range resourceIDs {
+ ret[i] = types.ResourceID{
+ ClusterName: r.ClusterName,
+ Kind: r.Kind,
+ Name: r.Name,
+ SubResourceName: r.SubResourceName,
+ }
+ }
+ return ret
+}
+
+func resourceIDsFromTypes(resourceIDs []types.ResourceID) []*decisionpb.ResourceId {
+ if len(resourceIDs) == 0 {
+ return nil
+ }
+
+ ret := make([]*decisionpb.ResourceId, len(resourceIDs))
+ for i, r := range resourceIDs {
+ ret[i] = &decisionpb.ResourceId{
+ ClusterName: r.ClusterName,
+ Kind: r.Kind,
+ Name: r.Name,
+ SubResourceName: r.SubResourceName,
+ }
+ }
+ return ret
+}
+
+func deviceExtensionsFromProto(exts *decisionpb.DeviceExtensions) tlsca.DeviceExtensions {
+ if exts == nil {
+ return tlsca.DeviceExtensions{}
+ }
+
+ return tlsca.DeviceExtensions{
+ DeviceID: exts.DeviceId,
+ AssetTag: exts.AssetTag,
+ CredentialID: exts.CredentialId,
+ }
+}
+
+func deviceExtensionsToProto(exts *tlsca.DeviceExtensions) *decisionpb.DeviceExtensions {
+ if exts == nil {
+ return nil
+ }
+
+ return &decisionpb.DeviceExtensions{
+ DeviceId: exts.DeviceID,
+ AssetTag: exts.AssetTag,
+ CredentialId: exts.CredentialID,
+ }
+}
diff --git a/lib/decision/tls_identity_test.go b/lib/decision/tls_identity_test.go
new file mode 100644
index 0000000000000..8ac417c3b47da
--- /dev/null
+++ b/lib/decision/tls_identity_test.go
@@ -0,0 +1,171 @@
+// Teleport
+// Copyright (C) 2024 Gravitational, Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package decision_test
+
+import (
+ "testing"
+
+ "github.com/google/go-cmp/cmp"
+ "github.com/stretchr/testify/assert"
+ "google.golang.org/protobuf/testing/protocmp"
+ "google.golang.org/protobuf/types/known/timestamppb"
+
+ decisionpb "github.com/gravitational/teleport/api/gen/proto/go/teleport/decision/v1alpha1"
+ traitpb "github.com/gravitational/teleport/api/gen/proto/go/teleport/trait/v1"
+ "github.com/gravitational/teleport/lib/decision"
+ "github.com/gravitational/teleport/lib/tlsca"
+)
+
+func TestTLSIdentity_roundtrip(t *testing.T) {
+ t.Parallel()
+
+ minimalTLSIdentity := &decisionpb.TLSIdentity{
+ // tlsca.Identity has no pointer fields, so these are always non-nil after
+ // copying.
+ RouteToApp: &decisionpb.RouteToApp{},
+ RouteToDatabase: &decisionpb.RouteToDatabase{},
+ DeviceExtensions: &decisionpb.DeviceExtensions{},
+ }
+
+ fullIdentity := &decisionpb.TLSIdentity{
+ Username: "user",
+ Impersonator: "impersonator",
+ Groups: []string{"role1", "role2"},
+ SystemRoles: []string{"system1", "system2"},
+ Usage: []string{"usage1", "usage2"},
+ Principals: []string{"login1", "login2"},
+ KubernetesGroups: []string{"kgroup1", "kgroup2"},
+ KubernetesUsers: []string{"kuser1", "kuser2"},
+ Expires: timestamppb.Now(),
+ RouteToCluster: "route-to-cluster",
+ KubernetesCluster: "k8s-cluster",
+ Traits: []*traitpb.Trait{
+ // Note: sorted by key on conversion.
+ {Key: "", Values: []string{"missingkey"}},
+ {Key: "missingvalues", Values: nil},
+ {Key: "trait1", Values: []string{"val1"}},
+ {Key: "trait2", Values: []string{"val1", "val2"}},
+ },
+ RouteToApp: &decisionpb.RouteToApp{
+ SessionId: "session-id",
+ PublicAddr: "public-addr",
+ ClusterName: "cluster-name",
+ Name: "name",
+ AwsRoleArn: "aws-role-arn",
+ AzureIdentity: "azure-id",
+ GcpServiceAccount: "gcp-service-account",
+ Uri: "uri",
+ TargetPort: 111,
+ },
+ TeleportCluster: "teleport-cluster",
+ RouteToDatabase: &decisionpb.RouteToDatabase{
+ ServiceName: "service-name",
+ Protocol: "protocol",
+ Username: "username",
+ Database: "database",
+ Roles: []string{"role1", "role2"},
+ },
+ DatabaseNames: []string{"db1", "db2"},
+ DatabaseUsers: []string{"dbuser1", "dbuser2"},
+ MfaVerified: "mfa-device-id",
+ PreviousIdentityExpires: timestamppb.Now(),
+ LoginIp: "login-ip",
+ PinnedIp: "pinned-ip",
+ AwsRoleArns: []string{"arn1", "arn2"},
+ AzureIdentities: []string{"azure-id-1", "azure-id-2"},
+ GcpServiceAccounts: []string{"gcp-account-1", "gcp-account-2"},
+ ActiveRequests: []string{"accessrequest1", "accessrequest2"},
+ DisallowReissue: true,
+ Renewable: true,
+ Generation: 112,
+ BotName: "bot-name",
+ BotInstanceId: "bot-instance-id",
+ AllowedResourceIds: []*decisionpb.ResourceId{
+ {
+ ClusterName: "cluster1",
+ Kind: "kind1",
+ Name: "name1",
+ SubResourceName: "sub-resource1",
+ },
+ {
+ ClusterName: "cluster2",
+ Kind: "kind2",
+ Name: "name2",
+ SubResourceName: "sub-resource2",
+ },
+ },
+ PrivateKeyPolicy: "private-key-policy",
+ ConnectionDiagnosticId: "connection-diag-id",
+ DeviceExtensions: &decisionpb.DeviceExtensions{
+ DeviceId: "device-id",
+ AssetTag: "asset-tag",
+ CredentialId: "credential-id",
+ },
+ UserType: "user-type",
+ }
+
+ tests := []struct {
+ name string
+ start, want *decisionpb.TLSIdentity
+ }{
+ {
+ name: "nil-to-nil",
+ start: nil,
+ want: nil,
+ },
+ {
+ name: "zero-to-zero",
+ start: &decisionpb.TLSIdentity{},
+ want: minimalTLSIdentity,
+ },
+ {
+ name: "full identity",
+ start: fullIdentity,
+ want: fullIdentity,
+ },
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ got := decision.TLSIdentityFromTLSCA(
+ decision.TLSIdentityToTLSCA(test.start),
+ )
+ if diff := cmp.Diff(test.want, got, protocmp.Transform()); diff != "" {
+ t.Errorf("TLSIdentity conversion mismatch (-want +got)\n%s", diff)
+ }
+ })
+ }
+
+ t.Run("zero tlsca.Identity", func(t *testing.T) {
+ var id tlsca.Identity
+ got := decision.TLSIdentityFromTLSCA(&id)
+ want := minimalTLSIdentity
+ if diff := cmp.Diff(want, got, protocmp.Transform()); diff != "" {
+ t.Errorf("TLSIdentity conversion mismatch (-want +got)\n%s", diff)
+ }
+ })
+}
+
+func TestTLSIdentityToTLSCA_zeroTimestamp(t *testing.T) {
+ t.Parallel()
+
+ id := decision.TLSIdentityToTLSCA(&decisionpb.TLSIdentity{
+ Expires: ×tamppb.Timestamp{},
+ PreviousIdentityExpires: ×tamppb.Timestamp{},
+ })
+ assert.Zero(t, id.Expires, "id.Expires")
+ assert.Zero(t, id.PreviousIdentityExpires, "id.PreviousIdentityExpires")
+}