mirror of
https://github.com/gravitational/teleport.git
synced 2026-09-24 16:17:11 +08:00
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:
@@ -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
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
+2
@@ -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 } =
|
||||
|
||||
+88
@@ -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
|
||||
);
|
||||
|
||||
+1
@@ -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 {
|
||||
|
||||
+1
@@ -172,6 +172,7 @@ export async function setUpAppGateway(
|
||||
targetUser: '',
|
||||
targetSubresourceName: options.targetPort?.toString(),
|
||||
targetProtocol: options.targetProtocol,
|
||||
autoUserProvisioning: undefined,
|
||||
});
|
||||
|
||||
const connectionToReuse = ctx.connectionTracker.findConnectionByDocument(doc);
|
||||
|
||||
+104
@@ -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);
|
||||
});
|
||||
}
|
||||
+4
@@ -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);
|
||||
|
||||
+2
@@ -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);
|
||||
|
||||
+2
@@ -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;
|
||||
|
||||
+2
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user