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
This commit is contained in:
Nibras Ohin
2026-03-06 15:20:11 +00:00
committed by GitHub
parent cfcd24a2f8
commit b8343ec963
38 changed files with 1170 additions and 59 deletions
@@ -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,
},
+74 -1
View File
@@ -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<Database> {
{ 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>): Database {
@@ -190,6 +210,9 @@ class Database$Type extends MessageType<Database> {
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<Database> {
/* 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<Database> {
*/
export const Database = new Database$Type();
// @generated message type with reflection information, may provide speed optimized methods
class AutoUserProvisioning$Type extends MessageType<AutoUserProvisioning> {
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>): AutoUserProvisioning {
const message = globalThis.Object.create((this.messagePrototype!));
message.databaseRoles = [];
if (value !== undefined)
reflectionMergePartial<AutoUserProvisioning>(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<DatabaseServer> {
constructor() {
super("teleport.lib.teleterm.v1.DatabaseServer", [
@@ -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,
}
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
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)
})
}
+5
View File
@@ -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 (
+9 -2
View File
@@ -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)
}
@@ -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,
})
@@ -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")
}
@@ -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
@@ -145,6 +145,10 @@ function Buttons(props: StoryProps) {
<Text>Database</Text>
<Database />
</Box>
<Box>
<Text>Database (auto-user provisioning)</Text>
<DatabaseAutoUserProvisioning />
</Box>
<Box>
<Text>Kube</Text>
<Kube />
@@ -328,6 +332,17 @@ function Database() {
);
}
function DatabaseAutoUserProvisioning() {
return (
<ConnectDatabaseActionButton
database={makeDatabase({
uri: `${testCluster.uri}/dbs/bar`,
autoUserProvisioning: { databaseRoles: ['reader', 'writer'] },
})}
/>
);
}
function Kube() {
return (
<ConnectKubeActionButton
@@ -216,15 +216,43 @@ export function ConnectDatabaseActionButton(props: {
const appContext = useAppContext();
function connect(dbUser: string): void {
const { uri, name, protocol, gcpProjectId } = props.database;
const { uri, name, protocol, gcpProjectId, autoUserProvisioning } =
props.database;
connectToDatabase(
appContext,
{ uri, name, protocol, dbUser, gcpProjectId },
{
uri,
name,
protocol,
dbUser,
gcpProjectId,
autoUserProvisioning,
},
{ origin: 'resource_table' }
);
}
if (props.database.autoUserProvisioning) {
return (
<ButtonBorder
size="small"
onClick={async () => {
const dbUsers = await getDatabaseUsers(
appContext,
props.database.uri
);
const autoProvisionedDbUser = dbUsers[0].login;
connect(autoProvisionedDbUser);
}}
textTransform="none"
width={buttonWidth}
>
Connect
</ButtonBorder>
);
}
return (
<MenuLogin
{...getDatabaseMenuLoginOptions(
@@ -17,12 +17,14 @@
*/
import { act } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { mockIntersectionObserver } from 'jsdom-testing-mocks';
import { render, screen } from 'design/utils/testing';
import {
makeAcl,
makeDatabase,
makeLoggedInUser,
makeRootCluster,
} from 'teleterm/services/tshd/testHelpers';
@@ -150,3 +152,122 @@ it('does not display a button for Connect My Computer in the empty state if the
screen.queryByRole('button', { name: 'Connect My Computer' })
).not.toBeInTheDocument();
});
it('displays a simple Connect button for databases with auto-user provisioning enabled', async () => {
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(
<MockAppContextProvider appContext={appContext}>
<MockWorkspaceContextProvider>
<ResourcesContextProvider>
<ConnectMyComputerContextProvider rootClusterUri={doc.clusterUri}>
<DocumentCluster doc={doc} visible={true} />
</ConnectMyComputerContextProvider>
</ResourcesContextProvider>
</MockWorkspaceContextProvider>
</MockAppContextProvider>
);
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(
<MockAppContextProvider appContext={appContext}>
<MockWorkspaceContextProvider>
<ResourcesContextProvider>
<ConnectMyComputerContextProvider rootClusterUri={doc.clusterUri}>
<DocumentCluster doc={doc} visible={true} />
</ConnectMyComputerContextProvider>
</ResourcesContextProvider>
</MockWorkspaceContextProvider>
</MockAppContextProvider>
);
act(mio.enterAll);
const connectButton = await screen.findByRole('button', {
name: 'Connect',
});
await user.click(connectButton);
await expect(
screen.findByPlaceholderText(/Enter username/i)
).resolves.toBeInTheDocument();
});
@@ -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<StoryProps> = {
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<StoryProps> = {
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: [],
},
};
@@ -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(
<MockAppContextProvider appContext={appContext}>
<MockWorkspaceContextProvider>
<DocumentGateway visible doc={doc} />
</MockWorkspaceContextProvider>
</MockAppContextProvider>
);
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(
<MockAppContextProvider appContext={appContext}>
<MockWorkspaceContextProvider>
<DocumentGateway visible doc={doc} />
</MockWorkspaceContextProvider>
</MockAppContextProvider>
);
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();
});
@@ -98,6 +98,7 @@ export function DocumentGateway(props: {
gateway={gateway}
changePort={changePort}
runCliCommand={runCliCommand}
autoUserProvisioning={doc.autoUserProvisioning}
/>
</Document>
);
@@ -16,11 +16,13 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
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<void>;
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<HTMLFormElement>(null);
const { gateway } = props;
const handleChangeDbName = useMemo(() => {
return debounce((value: string) => {
@@ -119,8 +127,26 @@ export function OnlineDocumentGateway(props: {
ml={2}
mb={0}
/>
{autoUserProvisioning && (
<ConfigFieldInput
label="User"
value={gateway.targetUser}
toolTipContent="Using auto provisioned user, you cannot change the database user."
readonly
disabled
ml={2}
mb={0}
/>
)}
</Validation>
</Flex>
{autoUserProvisioning?.databaseRoles?.length > 0 && (
<AdvancedRoles
databaseRoles={autoUserProvisioning.databaseRoles}
isAdvancedOpen={isAdvancedOpen}
setIsAdvancedOpen={setIsAdvancedOpen}
/>
)}
<CliCommand
cliCommand={props.gateway.gatewayCliCommand.preview}
isLoading={isPortOrDbNameProcessing}
@@ -157,3 +183,57 @@ export function OnlineDocumentGateway(props: {
</Box>
);
}
const AdvancedRoles = ({
databaseRoles,
isAdvancedOpen,
setIsAdvancedOpen,
}: {
databaseRoles: string[];
isAdvancedOpen: boolean;
setIsAdvancedOpen: (isAdvancedOpen: boolean) => void;
}) => {
return (
<Box mt={2} mb={2}>
<ExpandToggle onClick={() => setIsAdvancedOpen(!isAdvancedOpen)}>
{isAdvancedOpen ? (
<ChevronDown size="small" />
) : (
<ChevronRight size="small" />
)}
<Text fontSize={2} color="text.main">
Advanced
</Text>
</ExpandToggle>
{isAdvancedOpen && (
<Box mt={2}>
<Validation>
<FieldSelect
isMulti
label="Database Roles"
toolTipContent="These database roles are assigned by your Teleport administrator and are read only."
value={databaseRoles.map(role => ({
value: role,
label: role,
}))}
readOnly
mb={0}
/>
</Validation>
</Box>
)}
</Box>
);
};
const ExpandToggle = styled(Button).attrs({
fill: 'minimal',
size: 'small',
})`
padding: 0;
gap: ${props => props.theme.space[2]}px;
&:hover {
opacity: 0.8;
}
`;
@@ -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 = {
@@ -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<Database | undefined> {
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;
}
@@ -107,6 +107,7 @@ export function Story(props: StoryProps) {
status: '',
targetName: 'quux',
targetSubresourceName: undefined,
autoUserProvisioning: undefined,
};
if (!props.online) {
documentGateway.gatewayUri = undefined;
@@ -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);
@@ -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' }
);
@@ -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': {
@@ -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<any>) => {
await cb();
},
addWindowEventListener: () => ({ cleanup: () => {} }),
makeEventListener: <T,>(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 <appId>.<leafClusterId || rootClusterId>.
@@ -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',
@@ -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,
},
];
};
@@ -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 } =
@@ -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<HTMLDivElement>();
const topBarAccessRequestRef = createRef<HTMLDivElement>();
const Component = () => (
<MockAppContextProvider appContext={ctx}>
<ResourcesContextProvider>
<TabHost
ctx={ctx}
topBarConnectMyComputerRef={topBarConnectMyComputerRef}
topBarAccessRequestRef={topBarAccessRequestRef}
/>
</ResourcesContextProvider>
</MockAppContextProvider>
);
render(<Component />);
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;
@@ -285,6 +285,7 @@ export class ConnectionTrackerService extends ImmutableStore<ConnectionTrackerSt
gwConn.title = doc.title;
gwConn.targetSubresourceName = doc.targetSubresourceName;
gwConn.port = doc.port;
gwConn.autoUserProvisioning = doc.autoUserProvisioning;
gwConn.connected = !!this._clusterService.findGateway(
doc.gatewayUri
);
@@ -146,6 +146,7 @@ export class TrackedConnectionOperationsFactory {
gatewayUri: gw?.uri,
port: connection.port,
origin: params.origin,
autoUserProvisioning: connection.autoUserProvisioning,
});
documentsService.add(gwDoc);
@@ -184,6 +184,7 @@ export function createGatewayConnection(
targetName: document.targetName,
targetSubresourceName: document.targetSubresourceName,
targetProtocol: document.targetProtocol,
autoUserProvisioning: document.autoUserProvisioning,
};
}
@@ -16,6 +16,8 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
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 {
@@ -172,6 +172,7 @@ export async function setUpAppGateway(
targetUser: '',
targetSubresourceName: options.targetPort?.toString(),
targetProtocol: options.targetProtocol,
autoUserProvisioning: undefined,
});
const connectionToReuse = ctx.connectionTracker.findConnectionByDocument(doc);
@@ -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 <http://www.gnu.org/licenses/>.
*/
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);
});
}
@@ -16,6 +16,8 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
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);
@@ -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);
@@ -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;
@@ -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,
};
}
@@ -16,6 +16,7 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
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 = {