From b8343ec963893f06688df4e4d52c5fa7f0afdf5e Mon Sep 17 00:00:00 2001 From: Nibras Ohin Date: Fri, 6 Mar 2026 10:20:11 -0500 Subject: [PATCH] feat: added support for db roles for auto user provisioning for teleport connect (#63818) * feat: added support for db roles for auto user provisioning for teleport connect * chore: added unit tests for the auto provisioned user support changes * chore: added storybook tests * chore: reflecting on pr comments * fix: fixed loosing db roles on cert re-issue * chore: add oneOf support for db roles in proto * chore: udpated to do user transform in backend * chore: updated to group auto provisioning fields together * chore: updating to preset db user in search bar for auto user provisioning * chore: making autoUserProvisioning type required to prevent future misses of setting it * chore: updated to reflect on pr comments * chore: updating to minimize new interface declaration --- .../teleport/lib/teleterm/v1/database.pb.go | 104 +++++++++++---- .../teleport/lib/teleterm/v1/database_pb.ts | 75 ++++++++++- .../apiserver/handler/handler_databases.go | 10 +- .../handler/handler_databases_test.go | 76 +++++++++++ lib/teleterm/clusters/cluster.go | 5 + lib/teleterm/clusters/cluster_databases.go | 11 +- .../unifiedresources/unifiedresources.go | 51 +++++++- .../unififedresources_test.go | 66 +++++++++- proto/teleport/lib/teleterm/v1/database.proto | 8 ++ .../DocumentCluster/ActionButtons.story.tsx | 15 +++ .../src/ui/DocumentCluster/ActionButtons.tsx | 32 ++++- .../DocumentCluster/DocumentCluster.test.tsx | 121 ++++++++++++++++++ .../DocumentGateway/DocumentGateway.story.tsx | 72 +++++++++++ .../DocumentGateway/DocumentGateway.test.tsx | 78 +++++++++++ .../ui/DocumentGateway/DocumentGateway.tsx | 1 + .../DocumentGateway/OnlineDocumentGateway.tsx | 84 +++++++++++- .../ui/DocumentGateway/useGateway.test.tsx | 14 ++ .../src/ui/DocumentGateway/useGateway.ts | 74 +++++++++-- .../DocumentGatewayApp.story.tsx | 1 + .../DocumentGatewayApp.test.tsx | 2 + .../DocumentGatewayCliClient.tsx | 1 + .../teleterm/src/ui/Search/actions.tsx | 40 ++++-- .../teleterm/src/ui/Search/useSearch.test.tsx | 65 ++++++++++ .../src/ui/TabHost/useTabShortcuts.test.tsx | 2 + .../TopBar/Connections/Connections.story.tsx | 3 + .../connectionTrackerService.legacy.test.ts | 2 + .../connectionTrackerService.test.tsx | 88 +++++++++++++ .../connectionTrackerService.ts | 1 + .../trackedConnectionOperationsFactory.ts | 1 + .../trackedConnectionUtils.ts | 1 + .../ui/services/connectionTracker/types.ts | 3 + .../documentsService/connectToApp.ts | 1 + .../connectToDatabase.test.ts | 104 +++++++++++++++ .../documentsService/connectToDatabase.ts | 4 + .../documentsService/documentsService.test.ts | 2 + .../documentsService/documentsService.ts | 2 + .../documentsService/testHelpers.ts | 2 + .../documentsService/types.ts | 7 + 38 files changed, 1170 insertions(+), 59 deletions(-) create mode 100644 lib/teleterm/apiserver/handler/handler_databases_test.go create mode 100644 web/packages/teleterm/src/ui/services/workspacesService/documentsService/connectToDatabase.test.ts diff --git a/gen/proto/go/teleport/lib/teleterm/v1/database.pb.go b/gen/proto/go/teleport/lib/teleterm/v1/database.pb.go index 10f3a2d498a..7ea0bf4f8e0 100644 --- a/gen/proto/go/teleport/lib/teleterm/v1/database.pb.go +++ b/gen/proto/go/teleport/lib/teleterm/v1/database.pb.go @@ -60,9 +60,11 @@ type Database struct { // target_health of the "db_server" that is serving this database. TargetHealth *TargetHealth `protobuf:"bytes,9,opt,name=target_health,json=targetHealth,proto3" json:"target_health,omitempty"` // gcp_project_id is optional project ID set for GCP Project databases. - GcpProjectId string `protobuf:"bytes,10,opt,name=gcp_project_id,json=gcpProjectId,proto3" json:"gcp_project_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + GcpProjectId string `protobuf:"bytes,10,opt,name=gcp_project_id,json=gcpProjectId,proto3" json:"gcp_project_id,omitempty"` + // auto_user_provisioning contains database auto-user provisioning information. + AutoUserProvisioning *AutoUserProvisioning `protobuf:"bytes,11,opt,name=auto_user_provisioning,json=autoUserProvisioning,proto3" json:"auto_user_provisioning,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *Database) Reset() { @@ -165,6 +167,59 @@ func (x *Database) GetGcpProjectId() string { return "" } +func (x *Database) GetAutoUserProvisioning() *AutoUserProvisioning { + if x != nil { + return x.AutoUserProvisioning + } + return nil +} + +// AutoUserProvisioning contains database auto-user provisioning information. +type AutoUserProvisioning struct { + state protoimpl.MessageState `protogen:"open.v1"` + // database_roles is a list of database roles that will be assigned to the auto-provisioned database user. + DatabaseRoles []string `protobuf:"bytes,2,rep,name=database_roles,json=databaseRoles,proto3" json:"database_roles,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AutoUserProvisioning) Reset() { + *x = AutoUserProvisioning{} + mi := &file_teleport_lib_teleterm_v1_database_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AutoUserProvisioning) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AutoUserProvisioning) ProtoMessage() {} + +func (x *AutoUserProvisioning) ProtoReflect() protoreflect.Message { + mi := &file_teleport_lib_teleterm_v1_database_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AutoUserProvisioning.ProtoReflect.Descriptor instead. +func (*AutoUserProvisioning) Descriptor() ([]byte, []int) { + return file_teleport_lib_teleterm_v1_database_proto_rawDescGZIP(), []int{1} +} + +func (x *AutoUserProvisioning) GetDatabaseRoles() []string { + if x != nil { + return x.DatabaseRoles + } + return nil +} + // DatabaseServer (db_server) describes a database heartbeat signal // reported from an agent (db_service) that is proxying // the database. @@ -180,7 +235,7 @@ type DatabaseServer struct { func (x *DatabaseServer) Reset() { *x = DatabaseServer{} - mi := &file_teleport_lib_teleterm_v1_database_proto_msgTypes[1] + mi := &file_teleport_lib_teleterm_v1_database_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -192,7 +247,7 @@ func (x *DatabaseServer) String() string { func (*DatabaseServer) ProtoMessage() {} func (x *DatabaseServer) ProtoReflect() protoreflect.Message { - mi := &file_teleport_lib_teleterm_v1_database_proto_msgTypes[1] + mi := &file_teleport_lib_teleterm_v1_database_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -205,7 +260,7 @@ func (x *DatabaseServer) ProtoReflect() protoreflect.Message { // Deprecated: Use DatabaseServer.ProtoReflect.Descriptor instead. func (*DatabaseServer) Descriptor() ([]byte, []int) { - return file_teleport_lib_teleterm_v1_database_proto_rawDescGZIP(), []int{1} + return file_teleport_lib_teleterm_v1_database_proto_rawDescGZIP(), []int{2} } func (x *DatabaseServer) GetUri() string { @@ -240,7 +295,7 @@ var File_teleport_lib_teleterm_v1_database_proto protoreflect.FileDescriptor const file_teleport_lib_teleterm_v1_database_proto_rawDesc = "" + "\n" + - "'teleport/lib/teleterm/v1/database.proto\x12\x18teleport.lib.teleterm.v1\x1a$teleport/lib/teleterm/v1/label.proto\x1a,teleport/lib/teleterm/v1/target_health.proto\"\xd0\x02\n" + + "'teleport/lib/teleterm/v1/database.proto\x12\x18teleport.lib.teleterm.v1\x1a$teleport/lib/teleterm/v1/label.proto\x1a,teleport/lib/teleterm/v1/target_health.proto\"\xb6\x03\n" + "\bDatabase\x12\x10\n" + "\x03uri\x18\x01 \x01(\tR\x03uri\x12\x12\n" + "\x04name\x18\x02 \x01(\tR\x04name\x12\x12\n" + @@ -252,7 +307,10 @@ const file_teleport_lib_teleterm_v1_database_proto_rawDesc = "" + "\x06labels\x18\b \x03(\v2\x1f.teleport.lib.teleterm.v1.LabelR\x06labels\x12K\n" + "\rtarget_health\x18\t \x01(\v2&.teleport.lib.teleterm.v1.TargetHealthR\ftargetHealth\x12$\n" + "\x0egcp_project_id\x18\n" + - " \x01(\tR\fgcpProjectId\"\xa4\x01\n" + + " \x01(\tR\fgcpProjectId\x12d\n" + + "\x16auto_user_provisioning\x18\v \x01(\v2..teleport.lib.teleterm.v1.AutoUserProvisioningR\x14autoUserProvisioning\"=\n" + + "\x14AutoUserProvisioning\x12%\n" + + "\x0edatabase_roles\x18\x02 \x03(\tR\rdatabaseRoles\"\xa4\x01\n" + "\x0eDatabaseServer\x12\x10\n" + "\x03uri\x18\x01 \x01(\tR\x03uri\x12\x1a\n" + "\bhostname\x18\x02 \x01(\tR\bhostname\x12\x17\n" + @@ -271,22 +329,24 @@ func file_teleport_lib_teleterm_v1_database_proto_rawDescGZIP() []byte { return file_teleport_lib_teleterm_v1_database_proto_rawDescData } -var file_teleport_lib_teleterm_v1_database_proto_msgTypes = make([]protoimpl.MessageInfo, 2) +var file_teleport_lib_teleterm_v1_database_proto_msgTypes = make([]protoimpl.MessageInfo, 3) var file_teleport_lib_teleterm_v1_database_proto_goTypes = []any{ - (*Database)(nil), // 0: teleport.lib.teleterm.v1.Database - (*DatabaseServer)(nil), // 1: teleport.lib.teleterm.v1.DatabaseServer - (*Label)(nil), // 2: teleport.lib.teleterm.v1.Label - (*TargetHealth)(nil), // 3: teleport.lib.teleterm.v1.TargetHealth + (*Database)(nil), // 0: teleport.lib.teleterm.v1.Database + (*AutoUserProvisioning)(nil), // 1: teleport.lib.teleterm.v1.AutoUserProvisioning + (*DatabaseServer)(nil), // 2: teleport.lib.teleterm.v1.DatabaseServer + (*Label)(nil), // 3: teleport.lib.teleterm.v1.Label + (*TargetHealth)(nil), // 4: teleport.lib.teleterm.v1.TargetHealth } var file_teleport_lib_teleterm_v1_database_proto_depIdxs = []int32{ - 2, // 0: teleport.lib.teleterm.v1.Database.labels:type_name -> teleport.lib.teleterm.v1.Label - 3, // 1: teleport.lib.teleterm.v1.Database.target_health:type_name -> teleport.lib.teleterm.v1.TargetHealth - 3, // 2: teleport.lib.teleterm.v1.DatabaseServer.target_health:type_name -> teleport.lib.teleterm.v1.TargetHealth - 3, // [3:3] is the sub-list for method output_type - 3, // [3:3] is the sub-list for method input_type - 3, // [3:3] is the sub-list for extension type_name - 3, // [3:3] is the sub-list for extension extendee - 0, // [0:3] is the sub-list for field type_name + 3, // 0: teleport.lib.teleterm.v1.Database.labels:type_name -> teleport.lib.teleterm.v1.Label + 4, // 1: teleport.lib.teleterm.v1.Database.target_health:type_name -> teleport.lib.teleterm.v1.TargetHealth + 1, // 2: teleport.lib.teleterm.v1.Database.auto_user_provisioning:type_name -> teleport.lib.teleterm.v1.AutoUserProvisioning + 4, // 3: teleport.lib.teleterm.v1.DatabaseServer.target_health:type_name -> teleport.lib.teleterm.v1.TargetHealth + 4, // [4:4] is the sub-list for method output_type + 4, // [4:4] is the sub-list for method input_type + 4, // [4:4] is the sub-list for extension type_name + 4, // [4:4] is the sub-list for extension extendee + 0, // [0:4] is the sub-list for field type_name } func init() { file_teleport_lib_teleterm_v1_database_proto_init() } @@ -302,7 +362,7 @@ func file_teleport_lib_teleterm_v1_database_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_teleport_lib_teleterm_v1_database_proto_rawDesc), len(file_teleport_lib_teleterm_v1_database_proto_rawDesc)), NumEnums: 0, - NumMessages: 2, + NumMessages: 3, NumExtensions: 0, NumServices: 0, }, diff --git a/gen/proto/ts/teleport/lib/teleterm/v1/database_pb.ts b/gen/proto/ts/teleport/lib/teleterm/v1/database_pb.ts index 4ea76f4d8ce..a244606ab73 100644 --- a/gen/proto/ts/teleport/lib/teleterm/v1/database_pb.ts +++ b/gen/proto/ts/teleport/lib/teleterm/v1/database_pb.ts @@ -98,6 +98,25 @@ export interface Database { * @generated from protobuf field: string gcp_project_id = 10; */ gcpProjectId: string; + /** + * auto_user_provisioning contains database auto-user provisioning information. + * + * @generated from protobuf field: teleport.lib.teleterm.v1.AutoUserProvisioning auto_user_provisioning = 11; + */ + autoUserProvisioning?: AutoUserProvisioning; +} +/** + * AutoUserProvisioning contains database auto-user provisioning information. + * + * @generated from protobuf message teleport.lib.teleterm.v1.AutoUserProvisioning + */ +export interface AutoUserProvisioning { + /** + * database_roles is a list of database roles that will be assigned to the auto-provisioned database user. + * + * @generated from protobuf field: repeated string database_roles = 2; + */ + databaseRoles: string[]; } /** * DatabaseServer (db_server) describes a database heartbeat signal @@ -137,7 +156,8 @@ class Database$Type extends MessageType { { no: 7, name: "addr", kind: "scalar", T: 9 /*ScalarType.STRING*/ }, { no: 8, name: "labels", kind: "message", repeat: 1 /*RepeatType.PACKED*/, T: () => Label }, { no: 9, name: "target_health", kind: "message", T: () => TargetHealth }, - { no: 10, name: "gcp_project_id", kind: "scalar", T: 9 /*ScalarType.STRING*/ } + { no: 10, name: "gcp_project_id", kind: "scalar", T: 9 /*ScalarType.STRING*/ }, + { no: 11, name: "auto_user_provisioning", kind: "message", T: () => AutoUserProvisioning } ]); } create(value?: PartialMessage): Database { @@ -190,6 +210,9 @@ class Database$Type extends MessageType { case /* string gcp_project_id */ 10: message.gcpProjectId = reader.string(); break; + case /* teleport.lib.teleterm.v1.AutoUserProvisioning auto_user_provisioning */ 11: + message.autoUserProvisioning = AutoUserProvisioning.internalBinaryRead(reader, reader.uint32(), options, message.autoUserProvisioning); + break; default: let u = options.readUnknownField; if (u === "throw") @@ -232,6 +255,9 @@ class Database$Type extends MessageType { /* string gcp_project_id = 10; */ if (message.gcpProjectId !== "") writer.tag(10, WireType.LengthDelimited).string(message.gcpProjectId); + /* teleport.lib.teleterm.v1.AutoUserProvisioning auto_user_provisioning = 11; */ + if (message.autoUserProvisioning) + AutoUserProvisioning.internalBinaryWrite(message.autoUserProvisioning, writer.tag(11, WireType.LengthDelimited).fork(), options).join(); let u = options.writeUnknownFields; if (u !== false) (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); @@ -243,6 +269,53 @@ class Database$Type extends MessageType { */ export const Database = new Database$Type(); // @generated message type with reflection information, may provide speed optimized methods +class AutoUserProvisioning$Type extends MessageType { + constructor() { + super("teleport.lib.teleterm.v1.AutoUserProvisioning", [ + { no: 2, name: "database_roles", kind: "scalar", repeat: 2 /*RepeatType.UNPACKED*/, T: 9 /*ScalarType.STRING*/ } + ]); + } + create(value?: PartialMessage): AutoUserProvisioning { + const message = globalThis.Object.create((this.messagePrototype!)); + message.databaseRoles = []; + if (value !== undefined) + reflectionMergePartial(this, message, value); + return message; + } + internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: AutoUserProvisioning): AutoUserProvisioning { + let message = target ?? this.create(), end = reader.pos + length; + while (reader.pos < end) { + let [fieldNo, wireType] = reader.tag(); + switch (fieldNo) { + case /* repeated string database_roles */ 2: + message.databaseRoles.push(reader.string()); + break; + default: + let u = options.readUnknownField; + if (u === "throw") + throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`); + let d = reader.skip(wireType); + if (u !== false) + (u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d); + } + } + return message; + } + internalBinaryWrite(message: AutoUserProvisioning, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { + /* repeated string database_roles = 2; */ + for (let i = 0; i < message.databaseRoles.length; i++) + writer.tag(2, WireType.LengthDelimited).string(message.databaseRoles[i]); + let u = options.writeUnknownFields; + if (u !== false) + (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); + return writer; + } +} +/** + * @generated MessageType for protobuf message teleport.lib.teleterm.v1.AutoUserProvisioning + */ +export const AutoUserProvisioning = new AutoUserProvisioning$Type(); +// @generated message type with reflection information, may provide speed optimized methods class DatabaseServer$Type extends MessageType { constructor() { super("teleport.lib.teleterm.v1.DatabaseServer", [ diff --git a/lib/teleterm/apiserver/handler/handler_databases.go b/lib/teleterm/apiserver/handler/handler_databases.go index 30fc0e62744..c6257884189 100644 --- a/lib/teleterm/apiserver/handler/handler_databases.go +++ b/lib/teleterm/apiserver/handler/handler_databases.go @@ -77,6 +77,13 @@ func newAPIDatabase(db clusters.Database) *api.Database { // ignore potential (and unlikely) errors gcpProjectID, _ := db.GetGCPProjectID() + var autoUserProvisioning *api.AutoUserProvisioning + if db.AutoUserProvisioning != nil { + autoUserProvisioning = &api.AutoUserProvisioning{ + DatabaseRoles: db.AutoUserProvisioning.DatabaseRoles, + } + } + return &api.Database{ Uri: db.URI.String(), Name: db.GetName(), @@ -89,7 +96,8 @@ func newAPIDatabase(db clusters.Database) *api.Database { Error: db.TargetHealth.TransitionError, Message: db.TargetHealth.Message, }, - GcpProjectId: gcpProjectID, + GcpProjectId: gcpProjectID, + AutoUserProvisioning: autoUserProvisioning, } } diff --git a/lib/teleterm/apiserver/handler/handler_databases_test.go b/lib/teleterm/apiserver/handler/handler_databases_test.go new file mode 100644 index 00000000000..7e8e5447c08 --- /dev/null +++ b/lib/teleterm/apiserver/handler/handler_databases_test.go @@ -0,0 +1,76 @@ +/* + * Teleport + * Copyright (C) 2026 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 handler + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/gravitational/teleport/api/types" + api "github.com/gravitational/teleport/gen/proto/go/teleport/lib/teleterm/v1" + "github.com/gravitational/teleport/lib/teleterm/api/uri" + "github.com/gravitational/teleport/lib/teleterm/clusters" +) + +func TestNewAPIDatabase_Fields(t *testing.T) { + t.Run("populates basic database fields", func(t *testing.T) { + db, err := types.NewDatabaseV3(types.Metadata{ + Name: "test-db", + Description: "Test database", + Labels: map[string]string{ + "env": "test", + "tier": "backend", + }, + }, types.DatabaseSpecV3{ + Protocol: "postgres", + URI: "localhost:5432", + AdminUser: &types.DatabaseAdminUser{ + Name: "admin", + }, + }) + require.NoError(t, err) + + testDatabase := clusters.Database{ + URI: uri.NewClusterURI("test-cluster").AppendDB("test-db"), + Database: db, + AutoUserProvisioning: &clusters.AutoUserProvisioning{ + DatabaseRoles: []string{"reader", "writer"}, + }, + } + + apiDB := newAPIDatabase(testDatabase) + + require.Equal(t, &api.Database{ + Uri: "/clusters/test-cluster/dbs/test-db", + Name: "test-db", + Desc: "Test database", + Protocol: "postgres", + Type: "self-hosted", + Labels: []*api.Label{ + {Name: "env", Value: "test"}, + {Name: "tier", Value: "backend"}, + }, + TargetHealth: &api.TargetHealth{}, + AutoUserProvisioning: &api.AutoUserProvisioning{ + DatabaseRoles: []string{"reader", "writer"}, + }, + }, apiDB) + }) +} diff --git a/lib/teleterm/clusters/cluster.go b/lib/teleterm/clusters/cluster.go index 3085bbe3435..06b8ee6f754 100644 --- a/lib/teleterm/clusters/cluster.go +++ b/lib/teleterm/clusters/cluster.go @@ -271,6 +271,11 @@ func (c *Cluster) GetRoles(ctx context.Context) ([]*types.Role, error) { return roles, nil } +// NewAccessChecker creates a new access checker for the cluster. +func (c *Cluster) NewAccessChecker(ctx context.Context, authClient services.CurrentUserRoleGetter) (services.AccessChecker, error) { + return services.NewAccessCheckerForRemoteCluster(ctx, c.status.AccessInfo(), c.Name, authClient) +} + // GetRequestableRoles returns the requestable roles for the currently logged-in user func (c *Cluster) GetRequestableRoles(ctx context.Context, req *api.GetRequestableRolesRequest, authClient authclient.ClientI) (*types.AccessCapabilities, error) { var ( diff --git a/lib/teleterm/clusters/cluster_databases.go b/lib/teleterm/clusters/cluster_databases.go index 32b6a7bc00e..704824c7961 100644 --- a/lib/teleterm/clusters/cluster_databases.go +++ b/lib/teleterm/clusters/cluster_databases.go @@ -33,12 +33,17 @@ import ( "github.com/gravitational/teleport/lib/auth/authclient" "github.com/gravitational/teleport/lib/client" "github.com/gravitational/teleport/lib/client/db/dbcmd" - "github.com/gravitational/teleport/lib/services" dbrole "github.com/gravitational/teleport/lib/srv/db/common/role" "github.com/gravitational/teleport/lib/teleterm/api/uri" "github.com/gravitational/teleport/lib/tlsca" ) +// AutoUserProvisioning contains auto-user provisioning information. +type AutoUserProvisioning struct { + // DatabaseRoles is the list of database roles that will be assigned to the auto-provisioned user. + DatabaseRoles []string +} + // Database describes database type Database struct { // URI is the database URI @@ -47,6 +52,8 @@ type Database struct { // TargetHealth describes the health status of network connectivity // reported from an agent (db_service) that is proxying this database. TargetHealth types.TargetHealth + // AutoUserProvisioning contains auto-user provisioning information. + AutoUserProvisioning *AutoUserProvisioning } // DatabaseServer (db_server) describes a database heartbeat signal @@ -117,7 +124,7 @@ func (c *Cluster) GetAllowedDatabaseUsers(ctx context.Context, authClient authcl return nil, trace.Wrap(err) } - accessChecker, err := services.NewAccessCheckerForRemoteCluster(ctx, c.status.AccessInfo(), c.clusterClient.SiteName, authClient) + accessChecker, err := c.NewAccessChecker(ctx, authClient) if err != nil { return nil, trace.Wrap(err) } diff --git a/lib/teleterm/services/unifiedresources/unifiedresources.go b/lib/teleterm/services/unifiedresources/unifiedresources.go index 6f825869435..e78e25c2414 100644 --- a/lib/teleterm/services/unifiedresources/unifiedresources.go +++ b/lib/teleterm/services/unifiedresources/unifiedresources.go @@ -23,11 +23,13 @@ import ( "slices" "github.com/gravitational/trace" + "golang.org/x/sync/errgroup" apiclient "github.com/gravitational/teleport/api/client" "github.com/gravitational/teleport/api/client/proto" "github.com/gravitational/teleport/api/types" libclient "github.com/gravitational/teleport/lib/client" + "github.com/gravitational/teleport/lib/services" "github.com/gravitational/teleport/lib/teleterm/clusters" ) @@ -41,7 +43,12 @@ var supportedResourceKinds = []string{ types.KindMCP, } -func List(ctx context.Context, cluster *clusters.Cluster, client apiclient.ListUnifiedResourcesClient, req *proto.ListUnifiedResourcesRequest) (*ListResponse, error) { +type AuthClient interface { + services.CurrentUserRoleGetter + apiclient.ListUnifiedResourcesClient +} + +func List(ctx context.Context, cluster *clusters.Cluster, authClient AuthClient, req *proto.ListUnifiedResourcesRequest) (*ListResponse, error) { kinds := req.GetKinds() if len(kinds) == 0 { kinds = supportedResourceKinds @@ -55,11 +62,25 @@ func List(ctx context.Context, cluster *clusters.Cluster, client apiclient.ListU req.Kinds = kinds req.IncludeLogins = true - enrichedResources, nextKey, err := apiclient.GetUnifiedResourcePage(ctx, client, req) - if err != nil { + var ( + enrichedResources []*types.EnrichedResource + nextKey string + accessChecker services.AccessChecker + ) + g, ctx := errgroup.WithContext(ctx) + g.Go(func() error { + var err error + enrichedResources, nextKey, err = apiclient.GetUnifiedResourcePage(ctx, authClient, req) + return err + }) + g.Go(func() error { + var err error + accessChecker, err = cluster.NewAccessChecker(ctx, authClient) + return err + }) + if err := g.Wait(); err != nil { return nil, trace.Wrap(err) } - response := &ListResponse{ NextKey: nextKey, } @@ -82,11 +103,27 @@ func List(ctx context.Context, cluster *clusters.Cluster, client apiclient.ListU }) case types.DatabaseServer: db := r.GetDatabase() + autoUser, err := accessChecker.DatabaseAutoUserMode(db) + if err != nil { + return nil, trace.Wrap(err) + } + autoUsersEnabled := db.IsAutoUsersEnabled() && autoUser.IsEnabled() + databaseRoles, err := accessChecker.CheckDatabaseRoles(db, nil) + if err != nil { + return nil, trace.Wrap(err) + } + var autoUserProvisioning *clusters.AutoUserProvisioning + if autoUsersEnabled { + autoUserProvisioning = &clusters.AutoUserProvisioning{ + DatabaseRoles: databaseRoles, + } + } response.Resources = append(response.Resources, UnifiedResource{ Database: &clusters.Database{ - URI: cluster.URI.AppendDB(db.GetName()), - Database: db, - TargetHealth: r.GetTargetHealth(), + URI: cluster.URI.AppendDB(db.GetName()), + Database: db, + TargetHealth: r.GetTargetHealth(), + AutoUserProvisioning: autoUserProvisioning, }, RequiresRequest: requiresRequest, }) diff --git a/lib/teleterm/services/unifiedresources/unififedresources_test.go b/lib/teleterm/services/unifiedresources/unififedresources_test.go index 052448980d1..6f108085315 100644 --- a/lib/teleterm/services/unifiedresources/unififedresources_test.go +++ b/lib/teleterm/services/unifiedresources/unififedresources_test.go @@ -46,11 +46,16 @@ func TestUnifiedResourcesList(t *testing.T) { Name: "testDb", }, types.DatabaseServerSpecV3{ Hostname: "localhost", - HostID: uuid.New().String(), Database: &types.DatabaseV3{ + HostID: uuid.New().String(), + Database: &types.DatabaseV3{ Spec: types.DatabaseSpecV3{ - Protocol: defaults.ProtocolPostgres, URI: "localhost:5432", + Protocol: defaults.ProtocolPostgres, + URI: "localhost:5432", + AdminUser: &types.DatabaseAdminUser{Name: "teleport-admin"}, }, - Metadata: types.Metadata{Name: "testDb"}}}) + Metadata: types.Metadata{Name: "testDb"}, + }, + }) require.NoError(t, err) kube, err := types.NewKubernetesServerV3(types.Metadata{ @@ -114,6 +119,24 @@ func TestUnifiedResourcesList(t *testing.T) { }) require.NoError(t, err) + leafDatabase, err := types.NewDatabaseServerV3(types.Metadata{ + Name: "leafDb", + }, types.DatabaseServerSpecV3{ + Hostname: "localhost", + HostID: uuid.New().String(), + Database: &types.DatabaseV3{ + Spec: types.DatabaseSpecV3{ + Protocol: defaults.ProtocolPostgres, + URI: "localhost:5432", + AdminUser: &types.DatabaseAdminUser{Name: "teleport-admin"}, + }, + Metadata: types.Metadata{Name: "leafDb"}, + }, + }) + require.NoError(t, err) + + leafCluster := &clusters.Cluster{URI: uri.NewClusterURI("foo").AppendLeafCluster("leaf"), ProfileName: "foo", Name: "leaf"} + mockedResources := []*proto.PaginatedResource{ {Resource: &proto.PaginatedResource_Node{Node: node.(*types.ServerV2)}, Logins: []string{"ec2-user"}}, {Resource: &proto.PaginatedResource_DatabaseServer{DatabaseServer: database}}, @@ -142,6 +165,9 @@ func TestUnifiedResourcesList(t *testing.T) { require.Equal(t, UnifiedResource{Database: &clusters.Database{ URI: uri.NewClusterURI(cluster.ProfileName).AppendDB(database.GetName()), Database: database.GetDatabase(), + AutoUserProvisioning: &clusters.AutoUserProvisioning{ + DatabaseRoles: []string{}, + }, }}, response.Resources[1]) require.Equal(t, UnifiedResource{Kube: &clusters.Kube{ @@ -175,6 +201,21 @@ func TestUnifiedResourcesList(t *testing.T) { }}, response.Resources[6]) require.Equal(t, mockedNextKey, response.NextKey) + + leafResponse, err := List(ctx, leafCluster, &mockClient{ + paginatedResources: []*proto.PaginatedResource{ + {Resource: &proto.PaginatedResource_DatabaseServer{DatabaseServer: leafDatabase}}, + }, + }, &proto.ListUnifiedResourcesRequest{}) + require.NoError(t, err) + require.Len(t, leafResponse.Resources, 1) + require.Equal(t, UnifiedResource{Database: &clusters.Database{ + URI: leafCluster.URI.AppendDB(leafDatabase.GetName()), + Database: leafDatabase.GetDatabase(), + AutoUserProvisioning: &clusters.AutoUserProvisioning{ + DatabaseRoles: []string{}, + }, + }}, leafResponse.Resources[0]) } type mockClient struct { @@ -188,3 +229,22 @@ func (m *mockClient) ListUnifiedResources(ctx context.Context, req *proto.ListUn NextKey: m.nextKey, }, nil } + +func (m *mockClient) GetCurrentUserRoles(ctx context.Context) ([]types.Role, error) { + role, err := types.NewRole("auto-db-user", types.RoleSpecV6{ + Options: types.RoleOptions{ + CreateDatabaseUserMode: types.CreateDatabaseUserMode_DB_USER_MODE_KEEP, + }, + Allow: types.RoleConditions{ + DatabaseLabels: types.Labels{types.Wildcard: []string{types.Wildcard}}, + }, + }) + if err != nil { + return nil, err + } + return []types.Role{role}, nil +} + +func (m *mockClient) GetCurrentUser(ctx context.Context) (types.User, error) { + return types.NewUser("testUser") +} diff --git a/proto/teleport/lib/teleterm/v1/database.proto b/proto/teleport/lib/teleterm/v1/database.proto index f0ea758264f..f9f3347347f 100644 --- a/proto/teleport/lib/teleterm/v1/database.proto +++ b/proto/teleport/lib/teleterm/v1/database.proto @@ -47,6 +47,14 @@ message Database { TargetHealth target_health = 9; // gcp_project_id is optional project ID set for GCP Project databases. string gcp_project_id = 10; + // auto_user_provisioning contains database auto-user provisioning information. + AutoUserProvisioning auto_user_provisioning = 11; +} + +// AutoUserProvisioning contains database auto-user provisioning information. +message AutoUserProvisioning { + // database_roles is a list of database roles that will be assigned to the auto-provisioned database user. + repeated string database_roles = 2; } // DatabaseServer (db_server) describes a database heartbeat signal diff --git a/web/packages/teleterm/src/ui/DocumentCluster/ActionButtons.story.tsx b/web/packages/teleterm/src/ui/DocumentCluster/ActionButtons.story.tsx index 39dfb8b7591..31cfe2d20d3 100644 --- a/web/packages/teleterm/src/ui/DocumentCluster/ActionButtons.story.tsx +++ b/web/packages/teleterm/src/ui/DocumentCluster/ActionButtons.story.tsx @@ -145,6 +145,10 @@ function Buttons(props: StoryProps) { Database + + Database (auto-user provisioning) + + Kube @@ -328,6 +332,17 @@ function Database() { ); } +function DatabaseAutoUserProvisioning() { + return ( + + ); +} + function Kube() { return ( { + const dbUsers = await getDatabaseUsers( + appContext, + props.database.uri + ); + const autoProvisionedDbUser = dbUsers[0].login; + connect(autoProvisionedDbUser); + }} + textTransform="none" + width={buttonWidth} + > + Connect + + ); + } + return ( { + const doc = makeDocumentCluster(); + + const appContext = new MockAppContext(); + const cluster = makeRootCluster({ + uri: doc.clusterUri, + loggedInUser: makeLoggedInUser({ name: 'alice' }), + }); + appContext.addRootClusterWithDoc(cluster, doc); + + jest + .spyOn(appContext.clustersService, 'findClusterByResource') + .mockReturnValue(cluster); + + const database = makeDatabase({ + name: 'auto-user-db', + autoUserProvisioning: { databaseRoles: [] }, + }); + + const responseWithDatabase = { + resources: [ + { + kind: 'database' as const, + resource: database, + requiresRequest: false, + }, + ], + totalCount: 1, + nextKey: '', + }; + + jest + .spyOn(appContext.resourcesService, 'listUnifiedResources') + .mockResolvedValue(responseWithDatabase); + + render( + + + + + + + + + + ); + + act(mio.enterAll); + + await expect( + screen.findByRole('button', { name: 'Connect' }) + ).resolves.toBeInTheDocument(); +}); + +it('displays a user dropdown for databases without auto-user provisioning', async () => { + const user = userEvent.setup(); + const doc = makeDocumentCluster(); + + const appContext = new MockAppContext(); + const cluster = makeRootCluster({ + uri: doc.clusterUri, + loggedInUser: makeLoggedInUser({ name: 'alice' }), + }); + appContext.addRootClusterWithDoc(cluster, doc); + + jest + .spyOn(appContext.clustersService, 'findClusterByResource') + .mockReturnValue(cluster); + + const database = makeDatabase({ + name: 'regular-db', + protocol: 'postgres', + }); + + const responseWithDatabase = { + resources: [ + { + kind: 'database' as const, + resource: database, + requiresRequest: false, + }, + ], + totalCount: 1, + nextKey: '', + }; + + jest + .spyOn(appContext.resourcesService, 'listUnifiedResources') + .mockResolvedValue(responseWithDatabase); + + jest + .spyOn(appContext.resourcesService, 'getDbUsers') + .mockResolvedValue(['dbuser1', 'dbuser2']); + + render( + + + + + + + + + + ); + + act(mio.enterAll); + + const connectButton = await screen.findByRole('button', { + name: 'Connect', + }); + + await user.click(connectButton); + + await expect( + screen.findByPlaceholderText(/Enter username/i) + ).resolves.toBeInTheDocument(); +}); diff --git a/web/packages/teleterm/src/ui/DocumentGateway/DocumentGateway.story.tsx b/web/packages/teleterm/src/ui/DocumentGateway/DocumentGateway.story.tsx index 703cc51374b..eb9c5c53b53 100644 --- a/web/packages/teleterm/src/ui/DocumentGateway/DocumentGateway.story.tsx +++ b/web/packages/teleterm/src/ui/DocumentGateway/DocumentGateway.story.tsx @@ -41,6 +41,8 @@ type StoryProps = { dbNameAttempt: 'not-started' | 'processing' | 'error'; portAttempt: 'not-started' | 'processing' | 'error'; disconnectAttempt: 'not-started' | 'error'; + autoUsersEnabled: boolean; + databaseRoles: string[]; // Offline props. connectAttempt: 'not-started' | 'processing' | 'error'; }; @@ -66,6 +68,14 @@ const meta: Meta = { control: { type: 'radio' }, options: ['not-started', 'error'], }, + autoUsersEnabled: { + if: { arg: 'online' }, + control: { type: 'boolean' }, + }, + databaseRoles: { + if: { arg: 'online' }, + control: { type: 'object' }, + }, // Offline props. connectAttempt: { if: { arg: 'online', truthy: false }, @@ -80,6 +90,8 @@ const meta: Meta = { dbNameAttempt: 'not-started', portAttempt: 'not-started', disconnectAttempt: 'not-started', + autoUsersEnabled: false, + databaseRoles: [], // Offline props. connectAttempt: 'not-started', }, @@ -156,6 +168,9 @@ export function Story(props: StoryProps) { changePort: async () => [undefined, null], changePortAttempt: makeEmptyAttempt(), disconnectAttempt: makeEmptyAttempt(), + autoUserProvisioning: props.autoUsersEnabled + ? { databaseRoles: props.databaseRoles } + : undefined, }; if (props.dbNameAttempt === 'error') { @@ -191,3 +206,60 @@ export function Story(props: StoryProps) { /> ); } + +export const WithAutoUsersEnabled = { + args: { + online: true, + autoUsersEnabled: true, + databaseRoles: ['reader', 'writer'], + }, +}; + +export const WithManyDatabaseRoles = { + args: { + online: true, + autoUsersEnabled: true, + databaseRoles: [ + 'reader', + 'writer', + 'admin', + 'db_owner', + 'db_backup_operator', + 'db_datawriter', + 'db_datareader', + 'db_ddladmin', + 'db_securityadmin', + 'db_accessadmin', + 'db_backupoperator', + 'db_denydatawriter', + 'db_denydatareader', + 'analyst', + 'developer', + 'manager', + 'auditor', + 'operations', + 'support', + 'qa_engineer', + ], + }, +}; + +export const WithLongRoleNames = { + args: { + online: true, + autoUsersEnabled: true, + databaseRoles: [ + 'super-long-role-name-that-might-cause-layout-issues-in-the-ui', + 'another-very-long-database-role-name-with-many-hyphens', + 'db_administrator_with_full_permissions_read_write_execute', + ], + }, +}; + +export const WithoutAutoUsers = { + args: { + online: true, + autoUsersEnabled: false, + databaseRoles: [], + }, +}; diff --git a/web/packages/teleterm/src/ui/DocumentGateway/DocumentGateway.test.tsx b/web/packages/teleterm/src/ui/DocumentGateway/DocumentGateway.test.tsx index 2ded4c2d9b6..541f6c4f56a 100644 --- a/web/packages/teleterm/src/ui/DocumentGateway/DocumentGateway.test.tsx +++ b/web/packages/teleterm/src/ui/DocumentGateway/DocumentGateway.test.tsx @@ -50,6 +50,7 @@ test('it allows reconnecting when the gateway fails to be created', async () => origin: 'resource_table', title: '', status: '', + autoUserProvisioning: undefined, }; appContext.addRootClusterWithDoc(cluster, doc); @@ -76,3 +77,80 @@ test('it allows reconnecting when the gateway fails to be created', async () => expect(await screen.findByText('Close Connection')).toBeInTheDocument(); }); + +test('displays read-only user field when auto-user provisioning is enabled', async () => { + const appContext = new MockAppContext(); + const cluster = makeRootCluster(); + const gateway = makeDatabaseGateway({ + targetUser: 'auto-user-alice', + }); + const doc: docs.DocumentGateway = { + uri: '/docs/1', + kind: 'doc.gateway', + targetName: gateway.targetName, + targetUri: gateway.targetUri as DatabaseUri, + targetUser: gateway.targetUser, + targetSubresourceName: gateway.targetSubresourceName, + gatewayUri: gateway.uri, + origin: 'resource_table', + title: '', + status: '', + autoUserProvisioning: { databaseRoles: [] }, + }; + appContext.addRootClusterWithDoc(cluster, doc); + appContext.clustersService.setState(draftState => { + draftState.gateways.set(gateway.uri, gateway); + }); + + render( + + + + + + ); + + expect(await screen.findByLabelText('User')).toBeInTheDocument(); + expect(screen.getByLabelText('User')).toHaveValue('auto-user-alice'); + expect(screen.getByLabelText('User')).toBeDisabled(); +}); + +test('displays database roles in collapsible advanced section', async () => { + const user = userEvent.setup(); + + const appContext = new MockAppContext(); + const cluster = makeRootCluster(); + const gateway = makeDatabaseGateway(); + const doc: docs.DocumentGateway = { + uri: '/docs/1', + kind: 'doc.gateway', + targetName: gateway.targetName, + targetUri: gateway.targetUri as DatabaseUri, + targetUser: gateway.targetUser, + targetSubresourceName: gateway.targetSubresourceName, + gatewayUri: gateway.uri, + origin: 'resource_table', + title: '', + status: '', + autoUserProvisioning: { databaseRoles: ['reader', 'writer'] }, + }; + appContext.addRootClusterWithDoc(cluster, doc); + appContext.clustersService.setState(draftState => { + draftState.gateways.set(gateway.uri, gateway); + }); + + render( + + + + + + ); + + expect(await screen.findByText('Advanced')).toBeInTheDocument(); + expect(screen.queryByLabelText('Database Roles')).not.toBeInTheDocument(); + + await user.click(screen.getByText('Advanced')); + + expect(await screen.findByLabelText('Database Roles')).toBeInTheDocument(); +}); diff --git a/web/packages/teleterm/src/ui/DocumentGateway/DocumentGateway.tsx b/web/packages/teleterm/src/ui/DocumentGateway/DocumentGateway.tsx index ede152605fc..e0c169d60bf 100644 --- a/web/packages/teleterm/src/ui/DocumentGateway/DocumentGateway.tsx +++ b/web/packages/teleterm/src/ui/DocumentGateway/DocumentGateway.tsx @@ -98,6 +98,7 @@ export function DocumentGateway(props: { gateway={gateway} changePort={changePort} runCliCommand={runCliCommand} + autoUserProvisioning={doc.autoUserProvisioning} /> ); diff --git a/web/packages/teleterm/src/ui/DocumentGateway/OnlineDocumentGateway.tsx b/web/packages/teleterm/src/ui/DocumentGateway/OnlineDocumentGateway.tsx index 878e8d5a85a..83c985f0d87 100644 --- a/web/packages/teleterm/src/ui/DocumentGateway/OnlineDocumentGateway.tsx +++ b/web/packages/teleterm/src/ui/DocumentGateway/OnlineDocumentGateway.tsx @@ -16,11 +16,13 @@ * along with this program. If not, see . */ -import { useMemo, useRef } from 'react'; +import { useMemo, useRef, useState } from 'react'; +import styled from 'styled-components'; import { Alert, Box, + Button, ButtonSecondary, Flex, H1, @@ -30,7 +32,10 @@ import { Text, } from 'design'; import * as Alerts from 'design/Alert'; +import { ChevronDown, ChevronRight } from 'design/Icon'; +import { AutoUserProvisioning } from 'gen-proto-ts/teleport/lib/teleterm/v1/database_pb'; import { Gateway } from 'gen-proto-ts/teleport/lib/teleterm/v1/gateway_pb'; +import { FieldSelect } from 'shared/components/FieldSelect'; import Validation from 'shared/components/Validation'; import { Attempt, RunFuncReturnValue } from 'shared/hooks/useAsync'; import { debounce } from 'shared/utils/highbar'; @@ -47,7 +52,11 @@ export function OnlineDocumentGateway(props: { disconnectAttempt: Attempt; gateway: Gateway; runCliCommand: () => void; + autoUserProvisioning?: AutoUserProvisioning; }) { + const { gateway, autoUserProvisioning } = props; + const [isAdvancedOpen, setIsAdvancedOpen] = useState(false); + const isPortOrDbNameProcessing = props.changeDbNameAttempt.status === 'processing' || props.changePortAttempt.status === 'processing'; @@ -55,7 +64,6 @@ export function OnlineDocumentGateway(props: { props.changeDbNameAttempt.status === 'error' || props.changePortAttempt.status === 'error'; const formRef = useRef(null); - const { gateway } = props; const handleChangeDbName = useMemo(() => { return debounce((value: string) => { @@ -119,8 +127,26 @@ export function OnlineDocumentGateway(props: { ml={2} mb={0} /> + {autoUserProvisioning && ( + + )} + {autoUserProvisioning?.databaseRoles?.length > 0 && ( + + )} ); } + +const AdvancedRoles = ({ + databaseRoles, + isAdvancedOpen, + setIsAdvancedOpen, +}: { + databaseRoles: string[]; + isAdvancedOpen: boolean; + setIsAdvancedOpen: (isAdvancedOpen: boolean) => void; +}) => { + return ( + + setIsAdvancedOpen(!isAdvancedOpen)}> + {isAdvancedOpen ? ( + + ) : ( + + )} + + Advanced + + + {isAdvancedOpen && ( + + + ({ + value: role, + label: role, + }))} + readOnly + mb={0} + /> + + + )} + + ); +}; + +const ExpandToggle = styled(Button).attrs({ + fill: 'minimal', + size: 'small', +})` + padding: 0; + gap: ${props => props.theme.space[2]}px; + + &:hover { + opacity: 0.8; + } +`; diff --git a/web/packages/teleterm/src/ui/DocumentGateway/useGateway.test.tsx b/web/packages/teleterm/src/ui/DocumentGateway/useGateway.test.tsx index 503171d4735..9be1b1deaaa 100644 --- a/web/packages/teleterm/src/ui/DocumentGateway/useGateway.test.tsx +++ b/web/packages/teleterm/src/ui/DocumentGateway/useGateway.test.tsx @@ -19,6 +19,7 @@ import { act, renderHook, waitFor } from '@testing-library/react'; import { + makeDatabase, makeDatabaseGateway, makeRootCluster, } from 'teleterm/services/tshd/testHelpers'; @@ -45,6 +46,18 @@ it('creates a gateway on mount if it does not exist already', async () => { }); return gateway; }); + jest + .spyOn(appContext.resourcesService, 'listUnifiedResources') + .mockResolvedValue({ + nextKey: '', + resources: [ + { + kind: 'database', + resource: makeDatabase({ uri: doc.targetUri }), + requiresRequest: false, + }, + ], + }); const { result } = renderHook(() => useGateway(doc), { wrapper: $wrapper, @@ -125,6 +138,7 @@ const testSetup = () => { origin: 'resource_table', title: '', status: '', + autoUserProvisioning: undefined, }; appContext.addRootClusterWithDoc(cluster, doc); const workspaceContext = { diff --git a/web/packages/teleterm/src/ui/DocumentGateway/useGateway.ts b/web/packages/teleterm/src/ui/DocumentGateway/useGateway.ts index d80c8cfacaf..2e4ba65a88b 100644 --- a/web/packages/teleterm/src/ui/DocumentGateway/useGateway.ts +++ b/web/packages/teleterm/src/ui/DocumentGateway/useGateway.ts @@ -18,22 +18,25 @@ import { useCallback, useEffect } from 'react'; +import { Database } from 'gen-proto-ts/teleport/lib/teleterm/v1/database_pb'; import { Gateway } from 'gen-proto-ts/teleport/lib/teleterm/v1/gateway_pb'; import { useAsync } from 'shared/hooks/useAsync'; import { useAppContext } from 'teleterm/ui/appContextProvider'; import { useWorkspaceContext } from 'teleterm/ui/Documents'; import { useStoreSelector } from 'teleterm/ui/hooks/useStoreSelector'; +import { ResourcesService } from 'teleterm/ui/services/resources'; import { DocumentGateway, getDocumentGatewayTitle, } from 'teleterm/ui/services/workspacesService'; -import { isAppUri, isDatabaseUri } from 'teleterm/ui/uri'; +import { isAppUri, isDatabaseUri, routing } from 'teleterm/ui/uri'; +import * as uri from 'teleterm/ui/uri'; import { retryWithRelogin } from 'teleterm/ui/utils'; export function useGateway(doc: DocumentGateway) { const ctx = useAppContext(); - const { clustersService, usageService } = ctx; + const { clustersService, usageService, resourcesService } = ctx; const { documentsService } = useWorkspaceContext(); // The port to show as default in the input field in case creating a gateway fails. // This is typically the case if someone reopens the app and the port of the gateway is already @@ -54,16 +57,22 @@ export function useGateway(doc: DocumentGateway) { async (args: { localPort?: string; targetSubresourceName?: string }) => { documentsService.update(doc.uri, { status: 'connecting' }); let gw: Gateway; + let db: Database | undefined; try { - gw = await retryWithRelogin(ctx, doc.targetUri, () => - clustersService.createGateway({ - targetUri: doc.targetUri, - localPort: args.localPort, - targetUser: doc.targetUser, - targetSubresourceName: - args.targetSubresourceName || doc.targetSubresourceName, - }) + [gw, db] = await retryWithRelogin(ctx, doc.targetUri, () => + Promise.all([ + clustersService.createGateway({ + targetUri: doc.targetUri, + localPort: args.localPort, + targetUser: doc.targetUser, + targetSubresourceName: + args.targetSubresourceName || doc.targetSubresourceName, + }), + isDatabaseUri(doc.targetUri) + ? findDatabase(resourcesService, doc.targetUri) + : Promise.resolve(undefined), + ]) ); } catch (error) { documentsService.update(doc.uri, { status: 'error' }); @@ -88,6 +97,11 @@ export function useGateway(doc: DocumentGateway) { draftDoc.status = 'connected'; // The title might need to be changed if OfflineGateway changed gateway params. draftDoc.title = getDocumentGatewayTitle(draftDoc); + // Refresh autoUserProvisioning from the latest database state so the + // document stays in sync if the admin changes the config between connections. + if (db) { + draftDoc.autoUserProvisioning = db.autoUserProvisioning; + } }); if (isDatabaseUri(doc.targetUri)) { usageService.captureProtocolUse({ @@ -106,7 +120,14 @@ export function useGateway(doc: DocumentGateway) { }); } }, - [clustersService, ctx, doc, documentsService, usageService] + [ + clustersService, + ctx, + doc, + documentsService, + resourcesService, + usageService, + ] ) ); @@ -189,3 +210,34 @@ export function useGateway(doc: DocumentGateway) { changePortAttempt, }; } + +async function findDatabase( + resourcesService: ResourcesService, + targetUri: uri.DatabaseUri +): Promise { + const parsed = routing.parseDbUri(targetUri); + if (!parsed) { + return undefined; + } + const clusterUri = routing.ensureClusterUri(targetUri); + const { resources } = await resourcesService.listUnifiedResources({ + clusterUri, + kinds: ['db'], + search: parsed.params.dbId, + limit: 1, + startKey: '', + query: '', + sortBy: undefined, + searchAsRoles: false, + pinnedOnly: false, + includeRequestable: false, + }); + if (resources.length !== 1) { + return undefined; + } + const res = resources.at(0); + if (res.kind !== 'database') { + throw new Error(`Expected database resource, got ${res.kind}`); + } + return res.resource; +} diff --git a/web/packages/teleterm/src/ui/DocumentGatewayApp/DocumentGatewayApp.story.tsx b/web/packages/teleterm/src/ui/DocumentGatewayApp/DocumentGatewayApp.story.tsx index 0d1ad3c152f..ba68aa42a1c 100644 --- a/web/packages/teleterm/src/ui/DocumentGatewayApp/DocumentGatewayApp.story.tsx +++ b/web/packages/teleterm/src/ui/DocumentGatewayApp/DocumentGatewayApp.story.tsx @@ -107,6 +107,7 @@ export function Story(props: StoryProps) { status: '', targetName: 'quux', targetSubresourceName: undefined, + autoUserProvisioning: undefined, }; if (!props.online) { documentGateway.gatewayUri = undefined; diff --git a/web/packages/teleterm/src/ui/DocumentGatewayApp/DocumentGatewayApp.test.tsx b/web/packages/teleterm/src/ui/DocumentGatewayApp/DocumentGatewayApp.test.tsx index 55eb13f9505..9be1e879b70 100644 --- a/web/packages/teleterm/src/ui/DocumentGatewayApp/DocumentGatewayApp.test.tsx +++ b/web/packages/teleterm/src/ui/DocumentGatewayApp/DocumentGatewayApp.test.tsx @@ -82,6 +82,7 @@ describe('reconnecting when the gateway fails to be created', () => { origin: 'resource_table', title: '', status: '', + autoUserProvisioning: undefined, }; appContext.addRootClusterWithDoc(cluster, doc); @@ -129,6 +130,7 @@ describe('reconnecting when the gateway fails to be created', () => { origin: 'resource_table', title: '', status: '', + autoUserProvisioning: undefined, }; appContext.addRootClusterWithDoc(cluster, doc); diff --git a/web/packages/teleterm/src/ui/DocumentGatewayCliClient/DocumentGatewayCliClient.tsx b/web/packages/teleterm/src/ui/DocumentGatewayCliClient/DocumentGatewayCliClient.tsx index 0eb001bdb97..08235d053b8 100644 --- a/web/packages/teleterm/src/ui/DocumentGatewayCliClient/DocumentGatewayCliClient.tsx +++ b/web/packages/teleterm/src/ui/DocumentGatewayCliClient/DocumentGatewayCliClient.tsx @@ -111,6 +111,7 @@ const WaitingForGateway = (props: { dbUser: doc.targetUser, // We don't pass gcpProjectId as target so the target user will not be adjusted, // but it doesn't matter because it was already adjusted when this doc was created. + autoUserProvisioning: undefined, }, { origin: 'reopened_session' } ); diff --git a/web/packages/teleterm/src/ui/Search/actions.tsx b/web/packages/teleterm/src/ui/Search/actions.tsx index 6fec4a4da73..492d4dea3cf 100644 --- a/web/packages/teleterm/src/ui/Search/actions.tsx +++ b/web/packages/teleterm/src/ui/Search/actions.tsx @@ -216,6 +216,33 @@ export function mapToAction( }; } + const { uri, name, protocol, gcpProjectId, autoUserProvisioning } = + result.resource; + + if (autoUserProvisioning) { + return { + type: 'simple-action', + searchResult: result, + perform: async () => { + const dbUsers = await retryWithRelogin(ctx, uri, () => + ctx.resourcesService.getDbUsers(uri) + ); + return connectToDatabase( + ctx, + { + uri, + name, + protocol, + gcpProjectId, + dbUser: dbUsers[0], + autoUserProvisioning, + }, + { origin: 'search_bar' } + ); + }, + }; + } + return { type: 'parametrized-action', searchResult: result, @@ -232,9 +259,8 @@ export function mapToAction( }), placeholder: 'Provide db username', }, - perform: dbUser => { - const { uri, name, protocol, gcpProjectId } = result.resource; - return connectToDatabase( + perform: dbUser => + connectToDatabase( ctx, { uri, @@ -242,12 +268,10 @@ export function mapToAction( protocol, gcpProjectId, dbUser: dbUser.value, + autoUserProvisioning: undefined, }, - { - origin: 'search_bar', - } - ); - }, + { origin: 'search_bar' } + ), }; } case 'windows_desktop': { diff --git a/web/packages/teleterm/src/ui/Search/useSearch.test.tsx b/web/packages/teleterm/src/ui/Search/useSearch.test.tsx index 5a10b03f439..19ff3240a8f 100644 --- a/web/packages/teleterm/src/ui/Search/useSearch.test.tsx +++ b/web/packages/teleterm/src/ui/Search/useSearch.test.tsx @@ -23,6 +23,7 @@ import { ShowResources } from 'gen-proto-ts/teleport/lib/teleterm/v1/cluster_pb' import { getAppAddrWithProtocol } from 'teleterm/services/tshd/app'; import { makeApp, + makeDatabase, makeKube, makeLabelsList, makeLeafCluster, @@ -35,6 +36,7 @@ import { SearchResult, SearchResultApp } from 'teleterm/ui/services/resources'; import { routing, ServerUri, Params as UriParams } from 'teleterm/ui/uri'; import { MockAppContextProvider } from '../fixtures/MockAppContextProvider'; +import { mapToAction } from './actions'; import { makeResourceResult } from './testHelpers'; import { rankResults, useFilterSearch, useResourceSearch } from './useSearch'; @@ -531,6 +533,69 @@ describe('useFiltersSearch', () => { }); }); +describe('mapToAction for database results', () => { + const stubSearchContext = { + inputValue: '', + filters: [], + setFilter: () => {}, + removeFilter: () => {}, + isOpen: false, + open: () => {}, + close: () => {}, + closeWithoutRestoringFocus: () => {}, + resetInput: () => {}, + changeActivePicker: () => {}, + setInputValue: () => {}, + activePicker: undefined, + inputRef: undefined, + pauseUserInteraction: async (cb: () => Promise) => { + await cb(); + }, + addWindowEventListener: () => ({ cleanup: () => {} }), + makeEventListener: (cb: T) => cb, + advancedSearchEnabled: false, + toggleAdvancedSearch: () => {}, + }; + + it('returns parametrized-action when autoUserProvisioning is not set, prompting for a db username', () => { + const appContext = new MockAppContext(); + const result = makeResourceResult({ + kind: 'database', + resource: makeDatabase(), + }); + + const action = mapToAction( + appContext, + undefined, + stubSearchContext, + result + ); + + expect(action.type).toBe('parametrized-action'); + }); + + it('returns simple-action when autoUserProvisioning is set, skipping db username prompt', () => { + const appContext = new MockAppContext(); + const result = makeResourceResult({ + kind: 'database', + resource: makeDatabase({ + autoUserProvisioning: { + databaseRoles: ['reader'], + }, + }), + }); + + const action = mapToAction( + appContext, + undefined, + stubSearchContext, + result + ); + + expect(action.type).toBe('simple-action'); + }); +}); + /** * Constructs an app search result. Extracts root and leaf cluster ID from the URI and uses it to * assemble publicAddr of the app in the form of .. diff --git a/web/packages/teleterm/src/ui/TabHost/useTabShortcuts.test.tsx b/web/packages/teleterm/src/ui/TabHost/useTabShortcuts.test.tsx index e459582fd54..7251007b405 100644 --- a/web/packages/teleterm/src/ui/TabHost/useTabShortcuts.test.tsx +++ b/web/packages/teleterm/src/ui/TabHost/useTabShortcuts.test.tsx @@ -59,6 +59,7 @@ function getMockDocuments(): Document[] { targetSubresourceName: undefined, origin: 'resource_table', status: '', + autoUserProvisioning: undefined, }, { kind: 'doc.gateway', @@ -71,6 +72,7 @@ function getMockDocuments(): Document[] { targetSubresourceName: undefined, origin: 'resource_table', status: '', + autoUserProvisioning: undefined, }, makeDocumentCluster({ uri: '/docs/test_uri_6', diff --git a/web/packages/teleterm/src/ui/TopBar/Connections/Connections.story.tsx b/web/packages/teleterm/src/ui/TopBar/Connections/Connections.story.tsx index 8a34aec7bca..bb17376a592 100644 --- a/web/packages/teleterm/src/ui/TopBar/Connections/Connections.story.tsx +++ b/web/packages/teleterm/src/ui/TopBar/Connections/Connections.story.tsx @@ -271,6 +271,7 @@ const makeConnections = (index = 0) => { port: '22', gatewayUri: '/gateways/empty', clusterName: 'teleport.example.sh', + autoUserProvisioning: undefined, }, { connected: false, @@ -292,6 +293,7 @@ const makeConnections = (index = 0) => { gatewayUri: '/gateways/some-web-app', clusterName: 'teleport.example.sh', targetProtocol: 'HTTP', + autoUserProvisioning: undefined, }, { connected: true, @@ -304,6 +306,7 @@ const makeConnections = (index = 0) => { gatewayUri: '/gateways/some-mcp-server', clusterName: 'teleport.example.sh', targetProtocol: 'MCP', + autoUserProvisioning: undefined, }, ]; }; diff --git a/web/packages/teleterm/src/ui/services/connectionTracker/connectionTrackerService.legacy.test.ts b/web/packages/teleterm/src/ui/services/connectionTracker/connectionTrackerService.legacy.test.ts index a152b769fb8..e395364d843 100644 --- a/web/packages/teleterm/src/ui/services/connectionTracker/connectionTrackerService.legacy.test.ts +++ b/web/packages/teleterm/src/ui/services/connectionTracker/connectionTrackerService.legacy.test.ts @@ -76,6 +76,7 @@ it('removeItemsBelongingToRootCluster removes connections', () => { targetUser: 'alice', targetName: 'test', targetSubresourceName: 'pg', + autoUserProvisioning: undefined, }, { kind: 'connection.kube', @@ -115,6 +116,7 @@ it('updates the port of a gateway connection when the underlying doc gets update port: '12345', origin: 'resource_table', status: '', + autoUserProvisioning: undefined, }; const { connectionTrackerService, workspacesService } = diff --git a/web/packages/teleterm/src/ui/services/connectionTracker/connectionTrackerService.test.tsx b/web/packages/teleterm/src/ui/services/connectionTracker/connectionTrackerService.test.tsx index 53d6fe94266..1eba87359e3 100644 --- a/web/packages/teleterm/src/ui/services/connectionTracker/connectionTrackerService.test.tsx +++ b/web/packages/teleterm/src/ui/services/connectionTracker/connectionTrackerService.test.tsx @@ -30,6 +30,8 @@ import { MockedUnaryCall } from 'teleterm/services/tshd/cloneableClient'; import { makeApp, makeAppGateway, + makeDatabase, + makeDatabaseGateway, makeRootCluster, } from 'teleterm/services/tshd/testHelpers'; import { ResourcesContextProvider } from 'teleterm/ui/DocumentCluster/resourcesContext'; @@ -56,6 +58,7 @@ test('updating target port creates new connection', async () => { targetUser: undefined, targetSubresourceName: '1337', origin: 'resource_table', + autoUserProvisioning: undefined, }); // Add without opening. It's not necessary and it'll be easier to verify activating connections // later if we don't open the doc at this point. @@ -114,6 +117,7 @@ test('updating target port to match connection params of gateway created by othe targetUri: app.uri, targetUser: undefined, origin: 'resource_table' as const, + autoUserProvisioning: undefined, }; const doc1 = docsService.createGatewayDocument({ ...baseDocumentGatewayFields, @@ -189,6 +193,90 @@ test('updating target port to match connection params of gateway created by othe expect(docsService.getLocation()).toEqual(doc1.uri); }); +test('connection tracker syncs autoUserProvisioning when gateway document is updated', async () => { + const ctx = new MockAppContext(); + const rootCluster = makeRootCluster(); + ctx.addRootCluster(rootCluster); + ctx.workspacesService.setState(draft => { + draft.rootClusterUri = rootCluster.uri; + }); + + const docsService = ctx.workspacesService.getWorkspaceDocumentService( + rootCluster.uri + ); + + const database = makeDatabase({ + protocol: 'postgres', + autoUserProvisioning: { databaseRoles: [] }, + }); + + jest.spyOn(ctx.tshd, 'createGateway').mockImplementation(async () => { + const gateway = makeDatabaseGateway({ + targetUri: database.uri, + targetName: database.name, + targetUser: 'alice', + uri: `/gateways/${unique()}`, + localPort: '5432', + }); + + return new MockedUnaryCall(gateway); + }); + + const doc = docsService.createGatewayDocument({ + targetName: database.name, + targetUri: database.uri, + targetUser: 'alice', + origin: 'resource_table', + autoUserProvisioning: { + databaseRoles: ['reader', 'writer'], + }, + }); + + docsService.add(doc); + + const topBarConnectMyComputerRef = createRef(); + const topBarAccessRequestRef = createRef(); + const Component = () => ( + + + + + + ); + + render(); + + expect(await screen.findByText('Close Connection')).toBeInTheDocument(); + + let connections = ctx.connectionTracker.getConnections(); + expect(connections).toHaveLength(1); + let connection = connections[0] as TrackedGatewayConnection; + expect(connection.autoUserProvisioning?.databaseRoles).toEqual([ + 'reader', + 'writer', + ]); + + act(() => { + ctx.workspacesService.setState(draftState => { + const workspace = draftState.workspaces[rootCluster.uri]; + const document = workspace.documents.find(d => d.uri === doc.uri); + if (document && document.kind === 'doc.gateway') { + document.autoUserProvisioning = { + databaseRoles: ['admin'], + }; + } + }); + }); + + connections = ctx.connectionTracker.getConnections(); + connection = connections[0] as TrackedGatewayConnection; + expect(connection.autoUserProvisioning?.databaseRoles).toEqual(['admin']); +}); + function setupTests(): { ctx: IAppContext; docsService: DocumentsService; diff --git a/web/packages/teleterm/src/ui/services/connectionTracker/connectionTrackerService.ts b/web/packages/teleterm/src/ui/services/connectionTracker/connectionTrackerService.ts index c61d5d54ded..7fb898b2d44 100644 --- a/web/packages/teleterm/src/ui/services/connectionTracker/connectionTrackerService.ts +++ b/web/packages/teleterm/src/ui/services/connectionTracker/connectionTrackerService.ts @@ -285,6 +285,7 @@ export class ConnectionTrackerService extends ImmutableStore. */ +import { AutoUserProvisioning } from 'gen-proto-ts/teleport/lib/teleterm/v1/database_pb'; + import { AppUri, DatabaseUri, @@ -45,6 +47,7 @@ export interface TrackedGatewayConnection extends TrackedConnectionBase { port?: string; targetSubresourceName?: string; targetProtocol?: string; + autoUserProvisioning: AutoUserProvisioning | undefined; } export interface TrackedKubeConnection extends TrackedConnectionBase { diff --git a/web/packages/teleterm/src/ui/services/workspacesService/documentsService/connectToApp.ts b/web/packages/teleterm/src/ui/services/workspacesService/documentsService/connectToApp.ts index b03faf18642..6ff67350749 100644 --- a/web/packages/teleterm/src/ui/services/workspacesService/documentsService/connectToApp.ts +++ b/web/packages/teleterm/src/ui/services/workspacesService/documentsService/connectToApp.ts @@ -172,6 +172,7 @@ export async function setUpAppGateway( targetUser: '', targetSubresourceName: options.targetPort?.toString(), targetProtocol: options.targetProtocol, + autoUserProvisioning: undefined, }); const connectionToReuse = ctx.connectionTracker.findConnectionByDocument(doc); diff --git a/web/packages/teleterm/src/ui/services/workspacesService/documentsService/connectToDatabase.test.ts b/web/packages/teleterm/src/ui/services/workspacesService/documentsService/connectToDatabase.test.ts new file mode 100644 index 00000000000..2d6db02913c --- /dev/null +++ b/web/packages/teleterm/src/ui/services/workspacesService/documentsService/connectToDatabase.test.ts @@ -0,0 +1,104 @@ +/** + * Teleport + * Copyright (C) 2026 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 . + */ + +import { + makeDatabase, + makeRootCluster, +} from 'teleterm/services/tshd/testHelpers'; +import { MockAppContext } from 'teleterm/ui/fixtures/mocks'; +import { IAppContext } from 'teleterm/ui/types'; + +import { connectToDatabase } from './connectToDatabase'; + +describe('connectToDatabase', () => { + test('creates gateway document with autoUserProvisioning when enabled', async () => { + const appContext = new MockAppContext(); + setTestCluster(appContext); + const database = makeDatabase(); + const autoUserProvisioning = { + databaseRoles: ['reader'], + }; + + await connectToDatabase( + appContext, + { + uri: database.uri, + name: database.name, + protocol: database.protocol, + dbUser: 'alice', + autoUserProvisioning, + }, + { origin: 'resource_table' } + ); + + const documents = appContext.workspacesService + .getActiveWorkspaceDocumentService() + .getGatewayDocuments(); + expect(documents).toHaveLength(1); + expect(documents[0]).toMatchObject({ + kind: 'doc.gateway', + targetUri: database.uri, + targetUser: 'alice', + autoUserProvisioning, + }); + }); + + test('passes auto-provisioned username through unchanged for leaf cluster databases', async () => { + const appContext = new MockAppContext(); + const rootCluster = makeRootCluster({ + uri: '/clusters/root' as const, + name: 'root', + }); + setTestCluster(appContext, rootCluster); + + const leafDatabase = makeDatabase({ + uri: '/clusters/root/leaves/leaf/dbs/postgres' as const, + }); + + await connectToDatabase( + appContext, + { + uri: leafDatabase.uri, + name: leafDatabase.name, + protocol: 'postgres', + dbUser: 'remote-alice-root', + autoUserProvisioning: { + databaseRoles: [], + }, + }, + { origin: 'resource_table' } + ); + + const documents = appContext.workspacesService + .getActiveWorkspaceDocumentService() + .getGatewayDocuments(); + expect(documents[0].targetUser).toBe('remote-alice-root'); + }); +}); + +function setTestCluster( + appContext: IAppContext, + cluster = makeRootCluster() +): void { + appContext.workspacesService.setState(d => { + d.rootClusterUri = cluster.uri; + }); + appContext.clustersService.setState(d => { + d.clusters.set(cluster.uri, cluster); + }); +} diff --git a/web/packages/teleterm/src/ui/services/workspacesService/documentsService/connectToDatabase.ts b/web/packages/teleterm/src/ui/services/workspacesService/documentsService/connectToDatabase.ts index 177c3a28fbf..95b6bc853da 100644 --- a/web/packages/teleterm/src/ui/services/workspacesService/documentsService/connectToDatabase.ts +++ b/web/packages/teleterm/src/ui/services/workspacesService/documentsService/connectToDatabase.ts @@ -16,6 +16,8 @@ * along with this program. If not, see . */ +import { AutoUserProvisioning } from 'gen-proto-ts/teleport/lib/teleterm/v1/database_pb'; + import { GatewayProtocol } from 'teleterm/services/tshd/gateway'; import { IAppContext } from 'teleterm/ui/types'; import { DatabaseUri, routing } from 'teleterm/ui/uri'; @@ -30,6 +32,7 @@ export async function connectToDatabase( protocol: string; dbUser: string; gcpProjectId?: string; + autoUserProvisioning: AutoUserProvisioning | undefined; }, telemetry: { origin: DocumentOrigin; @@ -50,6 +53,7 @@ export async function connectToDatabase( target.gcpProjectId ), origin: telemetry.origin, + autoUserProvisioning: target.autoUserProvisioning, }); const connectionToReuse = ctx.connectionTracker.findConnectionByDocument(doc); diff --git a/web/packages/teleterm/src/ui/services/workspacesService/documentsService/documentsService.test.ts b/web/packages/teleterm/src/ui/services/workspacesService/documentsService/documentsService.test.ts index 4a290efa787..630ee49e24d 100644 --- a/web/packages/teleterm/src/ui/services/workspacesService/documentsService/documentsService.test.ts +++ b/web/packages/teleterm/src/ui/services/workspacesService/documentsService/documentsService.test.ts @@ -84,6 +84,7 @@ describe('document should be added', () => { targetSubresourceName: undefined, origin: 'resource_table', status: '', + autoUserProvisioning: undefined, }; test('at the specific position', () => { @@ -161,6 +162,7 @@ test('only gateway documents should be returned', () => { targetSubresourceName: undefined, origin: 'resource_table', status: '', + autoUserProvisioning: undefined, }; service.add(gatewayDocument); diff --git a/web/packages/teleterm/src/ui/services/workspacesService/documentsService/documentsService.ts b/web/packages/teleterm/src/ui/services/workspacesService/documentsService/documentsService.ts index 54c2d3da69f..f3ceedd87eb 100644 --- a/web/packages/teleterm/src/ui/services/workspacesService/documentsService/documentsService.ts +++ b/web/packages/teleterm/src/ui/services/workspacesService/documentsService/documentsService.ts @@ -143,6 +143,7 @@ export class DocumentsService { gatewayUri, origin, targetProtocol, + autoUserProvisioning, } = opts; const uri = routing.getDocUri({ docId: unique() }); @@ -159,6 +160,7 @@ export class DocumentsService { origin, status: '', targetProtocol, + autoUserProvisioning, }; doc.title = getDocumentGatewayTitle(doc); return doc; diff --git a/web/packages/teleterm/src/ui/services/workspacesService/documentsService/testHelpers.ts b/web/packages/teleterm/src/ui/services/workspacesService/documentsService/testHelpers.ts index a0ae1bc3412..f4810844275 100644 --- a/web/packages/teleterm/src/ui/services/workspacesService/documentsService/testHelpers.ts +++ b/web/packages/teleterm/src/ui/services/workspacesService/documentsService/testHelpers.ts @@ -66,6 +66,7 @@ export function makeDocumentGatewayDatabase( status: '', targetSubresourceName: gw.targetSubresourceName, origin: 'connection_list', + autoUserProvisioning: undefined, ...props, }; } @@ -86,6 +87,7 @@ export function makeDocumentGatewayApp( status: '', targetSubresourceName: gw.targetSubresourceName, origin: 'connection_list', + autoUserProvisioning: undefined, ...props, }; } diff --git a/web/packages/teleterm/src/ui/services/workspacesService/documentsService/types.ts b/web/packages/teleterm/src/ui/services/workspacesService/documentsService/types.ts index 279dce8aaf7..610dc46dc4e 100644 --- a/web/packages/teleterm/src/ui/services/workspacesService/documentsService/types.ts +++ b/web/packages/teleterm/src/ui/services/workspacesService/documentsService/types.ts @@ -16,6 +16,7 @@ * along with this program. If not, see . */ +import { AutoUserProvisioning } from 'gen-proto-ts/teleport/lib/teleterm/v1/database_pb'; import { Report } from 'gen-proto-ts/teleport/lib/vnet/diag/v1/diag_pb'; import { ResourceHealthStatus, @@ -111,6 +112,11 @@ export interface DocumentGateway extends DocumentBase { * targetProtocol is the protocol of the resource proxied by the gateway. */ targetProtocol?: string; + /** + * autoUserProvisioning contains auto-user provisioning information for this database gateway. + * Its presence indicates that auto-user provisioning is enabled. + */ + autoUserProvisioning: AutoUserProvisioning | undefined; } /** @@ -331,6 +337,7 @@ export type CreateGatewayDocumentOpts = { port?: string; origin: DocumentOrigin; targetProtocol?: string; + autoUserProvisioning: AutoUserProvisioning | undefined; }; export type CreateAccessRequestDocumentOpts = {