From 860a9d4c3346d89376a2f6e675ede593d43595f0 Mon Sep 17 00:00:00 2001 From: Alan Parra Date: Thu, 19 Dec 2024 15:38:35 -0300 Subject: [PATCH] Add conversions to/from decisionpb.TLSIdentity (#50308) * Add conversions to/from decisionpb.TLSIdentity * Map timestamppb.Timestamp{} to 0-0-0 0:0.0 (instead of unix epoch) * Document that slices are not deep-copied --- lib/decision/tls_identity.go | 278 ++++++++++++++++++++++++++++++ lib/decision/tls_identity_test.go | 171 ++++++++++++++++++ 2 files changed, 449 insertions(+) create mode 100644 lib/decision/tls_identity.go create mode 100644 lib/decision/tls_identity_test.go 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") +}