diff --git a/lib/cloud/gcp/sql.go b/lib/cloud/gcp/sql.go index 5717d0f4954fa..c00e1b4fbd1e0 100644 --- a/lib/cloud/gcp/sql.go +++ b/lib/cloud/gcp/sql.go @@ -36,6 +36,8 @@ import ( // SQLAdminClient defines an interface providing access to the GCP Cloud SQL API. type SQLAdminClient interface { + // GetUser retrieves a resource containing information about a user. + GetUser(ctx context.Context, db types.Database, dbUser string) (*sqladmin.User, error) // UpdateUser updates an existing user for the project/instance configured in a session. UpdateUser(ctx context.Context, db types.Database, dbUser string, user *sqladmin.User) error // GetDatabaseInstance returns database instance details for the project/instance @@ -61,6 +63,16 @@ type gcpSQLAdminClient struct { service *sqladmin.Service } +// GetUser retrieves a resource containing information about a user. +func (g *gcpSQLAdminClient) GetUser(ctx context.Context, db types.Database, dbUser string) (*sqladmin.User, error) { + user, err := g.service.Users.Get( + db.GetGCP().ProjectID, + db.GetGCP().InstanceID, + dbUser, + ).Host("%").Context(ctx).Do() + return user, trace.Wrap(convertAPIError(err)) +} + // UpdateUser updates an existing user in a Cloud SQL for the project/instance // configured in a session. func (g *gcpSQLAdminClient) UpdateUser(ctx context.Context, db types.Database, dbUser string, user *sqladmin.User) error { @@ -69,7 +81,7 @@ func (g *gcpSQLAdminClient) UpdateUser(ctx context.Context, db types.Database, d db.GetGCP().InstanceID, user).Name(dbUser).Host("%").Context(ctx).Do() if err != nil { - return trace.Wrap(err) + return trace.Wrap(convertAPIError(err)) } return nil } @@ -80,7 +92,7 @@ func (g *gcpSQLAdminClient) GetDatabaseInstance(ctx context.Context, db types.Da gcp := db.GetGCP() dbi, err := g.service.Instances.Get(gcp.ProjectID, gcp.InstanceID).Context(ctx).Do() if err != nil { - return nil, trace.Wrap(err) + return nil, trace.Wrap(convertAPIError(err)) } return dbi, nil } @@ -110,7 +122,7 @@ func (g *gcpSQLAdminClient) GenerateEphemeralCert(ctx context.Context, db types. }) resp, err := req.Context(ctx).Do() if err != nil { - return nil, trace.Wrap(err) + return nil, trace.Wrap(convertAPIError(err)) } // Create TLS certificate from returned ephemeral certificate and private key. diff --git a/lib/cloud/mocks/gcp.go b/lib/cloud/mocks/gcp.go index 3dc076aca4fa6..7a4e9209c4de4 100644 --- a/lib/cloud/mocks/gcp.go +++ b/lib/cloud/mocks/gcp.go @@ -39,6 +39,15 @@ type GCPSQLAdminClientMock struct { DatabaseInstance *sqladmin.DatabaseInstance // EphemeralCert is returned from GenerateEphemeralCert. EphemeralCert *tls.Certificate + // DatabaseUser is returned from GetUser. + DatabaseUser *sqladmin.User +} + +func (g *GCPSQLAdminClientMock) GetUser(ctx context.Context, db types.Database, dbUser string) (*sqladmin.User, error) { + if g.DatabaseUser == nil { + return nil, trace.AccessDenied("unauthorized") + } + return g.DatabaseUser, nil } func (g *GCPSQLAdminClientMock) UpdateUser(ctx context.Context, db types.Database, dbUser string, user *sqladmin.User) error { diff --git a/lib/srv/db/common/auth.go b/lib/srv/db/common/auth.go index aca79693b73af..062e6286ddbe3 100644 --- a/lib/srv/db/common/auth.go +++ b/lib/srv/db/common/auth.go @@ -317,6 +317,11 @@ func (a *dbAuth) GetCloudSQLAuthToken(ctx context.Context, sessionCtx *Session) return "", trace.Wrap(err) } a.cfg.Log.Debugf("Generating GCP auth token for %s.", sessionCtx) + + serviceAccountName := sessionCtx.DatabaseUser + if !strings.HasSuffix(serviceAccountName, ".gserviceaccount.com") { + serviceAccountName = serviceAccountName + ".gserviceaccount.com" + } resp, err := gcpIAM.GenerateAccessToken(ctx, &gcpcredentialspb.GenerateAccessTokenRequest{ // From GenerateAccessToken docs: @@ -324,7 +329,7 @@ func (a *dbAuth) GetCloudSQLAuthToken(ctx context.Context, sessionCtx *Session) // The resource name of the service account for which the credentials // are requested, in the following format: // projects/-/serviceAccounts/{ACCOUNT_EMAIL_OR_UNIQUEID} - Name: fmt.Sprintf("projects/-/serviceAccounts/%v.gserviceaccount.com", sessionCtx.DatabaseUser), + Name: fmt.Sprintf("projects/-/serviceAccounts/%v", serviceAccountName), // From GenerateAccessToken docs: // // Code to identify the scopes to be included in the OAuth 2.0 access @@ -390,12 +395,19 @@ func (a *dbAuth) GetCloudSQLPassword(ctx context.Context, sessionCtx *Session) ( func (a *dbAuth) updateCloudSQLUser(ctx context.Context, sessionCtx *Session, gcpCloudSQL gcp.SQLAdminClient, user *sqladmin.User) error { err := gcpCloudSQL.UpdateUser(ctx, sessionCtx.Database, sessionCtx.DatabaseUser, user) if err != nil { + // Note that mysql client has a 1024 char limit for displaying errors + // so we need to keep the message short when possible. This message + // does get cut off when sessionCtx.DatabaseUser or err is long. return trace.AccessDenied(`Could not update Cloud SQL user %q password: %v -Make sure Teleport db service has "Cloud SQL Admin" GCP IAM role, or -"cloudsql.users.update" IAM permission. +If the db user uses IAM authentication, please use the full service account email +ID as "--db-user", or grant the Teleport Database Service the +"cloudsql.users.get" IAM permission so it can discover the user type. + +If the db user uses passwords, make sure Teleport Database Service has "Cloud +SQL Admin" GCP IAM role, or "cloudsql.users.update" IAM permission. `, sessionCtx.DatabaseUser, err) } return nil diff --git a/lib/srv/db/mysql/engine.go b/lib/srv/db/mysql/engine.go index d7ec31a5e8a81..8d53746642778 100644 --- a/lib/srv/db/mysql/engine.go +++ b/lib/srv/db/mysql/engine.go @@ -33,7 +33,6 @@ import ( "github.com/gravitational/teleport/api/types" "github.com/gravitational/teleport/api/utils/retryutils" - "github.com/gravitational/teleport/lib/defaults" "github.com/gravitational/teleport/lib/services" "github.com/gravitational/teleport/lib/srv/db/cloud" "github.com/gravitational/teleport/lib/srv/db/common" @@ -220,34 +219,17 @@ func (e *Engine) connect(ctx context.Context, sessionCtx *common.Session) (*clie return nil, trace.Wrap(err) } case sessionCtx.Database.IsCloudSQL(): - // For Cloud SQL MySQL there is no IAM auth, so we use one-time passwords - // by resetting the database user password for each connection. Thus, - // acquire a lock to make sure all connection attempts to the same - // database and user are serialized. - retryCtx, cancel := context.WithTimeout(ctx, defaults.DatabaseConnectTimeout) - defer cancel() - lease, err := services.AcquireSemaphoreWithRetry(retryCtx, e.makeAcquireSemaphoreConfig(sessionCtx)) - if err != nil { - return nil, trace.Wrap(err) - } - // Only release the semaphore after the connection has been established - // below. If the semaphore fails to release for some reason, it will - // expire in a minute on its own. - defer func() { - err := e.AuthClient.CancelSemaphoreLease(ctx, *lease) - if err != nil { - e.Log.WithError(err).Errorf("Failed to cancel lease: %v.", lease) - } - }() - password, err = e.Auth.GetCloudSQLPassword(ctx, sessionCtx) + // Get the client once for subsequent calls (it acquires a read lock). + gcpClient, err := e.CloudClients.GetGCPSQLAdminClient(ctx) if err != nil { return nil, trace.Wrap(err) } - // Get the client once for subsequent calls (it acquires a read lock). - gcpClient, err := e.CloudClients.GetGCPSQLAdminClient(ctx) + + user, password, err = e.getGCPUserAndPassword(ctx, sessionCtx, gcpClient) if err != nil { return nil, trace.Wrap(err) } + // Detect whether the instance is set to require SSL. // Fallback to not requiring SSL for access denied errors. requireSSL, err := cloud.GetGCPRequireSSL(ctx, sessionCtx, gcpClient) diff --git a/lib/srv/db/mysql/gcp.go b/lib/srv/db/mysql/gcp.go new file mode 100644 index 0000000000000..4b424f062f00e --- /dev/null +++ b/lib/srv/db/mysql/gcp.go @@ -0,0 +1,175 @@ +/* + * Teleport + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package mysql + +import ( + "context" + "fmt" + "strings" + + "github.com/gravitational/trace" + + "github.com/gravitational/teleport/lib/cloud/gcp" + "github.com/gravitational/teleport/lib/defaults" + "github.com/gravitational/teleport/lib/services" + "github.com/gravitational/teleport/lib/srv/db/common" +) + +func isDBUserFullGCPServerAccountID(dbUser string) bool { + // Example: mysql-iam-user@my-project-id.iam.gserviceaccount.com + return strings.Contains(dbUser, "@") && + strings.HasSuffix(dbUser, ".iam.gserviceaccount.com") +} + +func isDBUserShortGCPServiceAccountID(dbUser string) bool { + // Example: mysql-iam-user@my-project-id.iam + return strings.Contains(dbUser, "@") && + strings.HasSuffix(dbUser, ".iam") +} + +func gcpServiceAccountToDatabaseUser(serviceAccountName string) string { + user, _, _ := strings.Cut(serviceAccountName, "@") + return user +} + +func databaseUserToGCPServiceAccount(sessionCtx *common.Session) string { + return fmt.Sprintf("%s@%s.iam.gserviceaccount.com", sessionCtx.DatabaseUser, sessionCtx.Database.GetGCP().ProjectID) +} + +func (e *Engine) getGCPUserAndPassword(ctx context.Context, sessionCtx *common.Session, gcpClient gcp.SQLAdminClient) (string, string, error) { + // If `--db-user` is the full service account email ID, use IAM Auth. + if isDBUserFullGCPServerAccountID(sessionCtx.DatabaseUser) { + user := gcpServiceAccountToDatabaseUser(sessionCtx.DatabaseUser) + password, err := e.getGCPIAMAuthToken(ctx, sessionCtx) + if err != nil { + return "", "", trace.Wrap(err) + } + return user, password, nil + } + + // Note that GCP Postgres' format "user@my-project-id.iam" is not accepted + // for GCP MySQL. For GCP Postgres, "user@my-project-id.iam" is the actual + // mapped in-database username. However, the mapped in-database username + // for GCP MySQL does not have the "@my-project-id.iam" part. + if isDBUserShortGCPServiceAccountID(sessionCtx.DatabaseUser) { + return "", "", trace.BadParameter("username %q is not accepted for GCP MySQL. Please use the in-database username or the full service account Email ID.", sessionCtx.DatabaseUser) + } + + // Get user info to decide how to authenticate. + user := sessionCtx.DatabaseUser + dbUserInfo, err := gcpClient.GetUser(ctx, sessionCtx.Database, sessionCtx.DatabaseUser) + switch { + // GetUser permission is new for IAM auth. If no permission, assume legacy password user. + case trace.IsAccessDenied(err): + e.Log.WithField("user", sessionCtx.DatabaseUser).Debug("Access denied to get GCP MySQL database user info. Continue with password auth.") + password, err := e.getGCPOneTimePassword(ctx, sessionCtx) + if err != nil { + return "", "", trace.Wrap(err) + } + return user, password, nil + + // Make the original error message "object not found" more readable. Note + // that catching not found here also prevents Google creating a new + // database user during OTP generation. + case trace.IsNotFound(err): + return "", "", trace.NotFound("database user %q does not exist in database %q", sessionCtx.DatabaseUser, sessionCtx.Database.GetName()) + + // Report any other error. + case err != nil: + return "", "", trace.Wrap(err) + } + + // The user type constants are documented in their SDK. However, in + // practice, type can also be empty for built-in user. + switch dbUserInfo.Type { + case "", + gcpMySQLDBUserTypeBuiltIn: + password, err := e.getGCPOneTimePassword(ctx, sessionCtx) + if err != nil { + return "", "", trace.Wrap(err) + } + return user, password, nil + + case gcpMySQLDBUserTypeServiceAccount, + gcpMySQLDBUserTypeGroupServiceAccount: + serviceAccountName := databaseUserToGCPServiceAccount(sessionCtx) + password, err := e.getGCPIAMAuthToken(ctx, sessionCtx.WithUser(serviceAccountName)) + if err != nil { + return "", "", trace.Wrap(err) + } + return user, password, nil + + case gcpMySQLDBUserTypeUser, + gcpMySQLDBUserTypeGroupUser: + return "", "", trace.BadParameter("GCP MySQL user type %q not supported", dbUserInfo.Type) + + default: + return "", "", trace.BadParameter("unknown GCP MySQL user type %q", dbUserInfo.Type) + } +} + +func (e *Engine) getGCPIAMAuthToken(ctx context.Context, sessionCtx *common.Session) (string, error) { + e.Log.WithField("session", sessionCtx).Debug("Authenticating GCP MySQL with IAM auth.") + + // Note that sessionCtx.DatabaseUser is the service account. + password, err := e.Auth.GetCloudSQLAuthToken(ctx, sessionCtx) + return password, trace.Wrap(err) +} + +func (e *Engine) getGCPOneTimePassword(ctx context.Context, sessionCtx *common.Session) (string, error) { + e.Log.WithField("session", sessionCtx).Debug("Authenticating GCP MySQL with password auth.") + + // For Cloud SQL MySQL legacy auth, we use one-time passwords by resetting + // the database user password for each connection. Thus, acquire a lock to + // make sure all connection attempts to the same database and user are + // serialized. + retryCtx, cancel := context.WithTimeout(ctx, defaults.DatabaseConnectTimeout) + defer cancel() + lease, err := services.AcquireSemaphoreWithRetry(retryCtx, e.makeAcquireSemaphoreConfig(sessionCtx)) + if err != nil { + return "", trace.Wrap(err) + } + // Only release the semaphore after the connection has been established + // below. If the semaphore fails to release for some reason, it will + // expire in a minute on its own. + defer func() { + err := e.AuthClient.CancelSemaphoreLease(ctx, *lease) + if err != nil { + e.Log.WithError(err).Errorf("Failed to cancel lease: %v.", lease) + } + }() + password, err := e.Auth.GetCloudSQLPassword(ctx, sessionCtx) + if err != nil { + return "", trace.Wrap(err) + } + return password, nil +} + +const ( + // gcpMySQLDBUserTypeBuiltIn indicates the database's built-in user type. + gcpMySQLDBUserTypeBuiltIn = "BUILT_IN" + // gcpMySQLDBUserTypeServiceAccount indicates a Cloud IAM service account. + gcpMySQLDBUserTypeServiceAccount = "CLOUD_IAM_SERVICE_ACCOUNT" + // gcpMySQLDBUserTypeGroupServiceAccount indicates a Cloud IAM group service account. + gcpMySQLDBUserTypeGroupServiceAccount = "CLOUD_IAM_GROUP_SERVICE_ACCOUNT" + // gcpMySQLDBUserTypeUser indicates a Cloud IAM user. + gcpMySQLDBUserTypeUser = "CLOUD_IAM_USER" + // gcpMySQLDBUserTypeGroupUser indicates a Cloud IAM group login user. + gcpMySQLDBUserTypeGroupUser = "CLOUD_IAM_GROUP_USER" +) diff --git a/lib/srv/db/mysql/gcp_test.go b/lib/srv/db/mysql/gcp_test.go new file mode 100644 index 0000000000000..04a6f6384a78f --- /dev/null +++ b/lib/srv/db/mysql/gcp_test.go @@ -0,0 +1,219 @@ +/* + * Teleport + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package mysql + +import ( + "context" + "testing" + + "github.com/gravitational/trace" + "github.com/jonboulle/clockwork" + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/require" + sqladmin "google.golang.org/api/sqladmin/v1beta4" + + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/lib/auth" + "github.com/gravitational/teleport/lib/cloud/gcp" + "github.com/gravitational/teleport/lib/cloud/mocks" + "github.com/gravitational/teleport/lib/defaults" + "github.com/gravitational/teleport/lib/srv/db/common" +) + +type fakeAuth struct { + common.Auth +} + +func (a fakeAuth) GetCloudSQLAuthToken(ctx context.Context, sessionCtx *common.Session) (string, error) { + if !isDBUserFullGCPServerAccountID(sessionCtx.DatabaseUser) { + return "", trace.BadParameter("database user must be a service account") + } + return "iam-auth-token", nil +} + +func (a fakeAuth) GetCloudSQLPassword(ctx context.Context, sessionCtx *common.Session) (string, error) { + if isDBUserFullGCPServerAccountID(sessionCtx.DatabaseUser) { + return "", trace.BadParameter("database user must not be a service account") + } + return "one-time-password", nil +} + +func Test_getGCPUserAndPassowrd(t *testing.T) { + ctx := context.Background() + authClient := makeAuthClient(t) + db := makeGCPMySQLDatabase(t) + dbAuth := &fakeAuth{} + + tests := []struct { + name string + inputDatabaseUser string + mockDBAuth common.Auth + mockGCPClient gcp.SQLAdminClient + wantDatabaseUser string + wantPassword string + wantError bool + }{ + { + name: "iam auth with full service account", + inputDatabaseUser: "iam-auth-user@project-id.iam.gserviceaccount.com", + mockDBAuth: dbAuth, + wantDatabaseUser: "iam-auth-user", + wantPassword: "iam-auth-token", + }, + { + name: "iam auth with short service account", + inputDatabaseUser: "iam-auth-user@project-id.iam", + mockDBAuth: dbAuth, + wantError: true, + }, + { + name: "iam auth with CLOUD_IAM_SERVICE_ACCOUNT user", + inputDatabaseUser: "iam-auth-user", + mockDBAuth: dbAuth, + mockGCPClient: &mocks.GCPSQLAdminClientMock{ + DatabaseUser: makeGCPDatabaseUser("iam-auth-user", "CLOUD_IAM_SERVICE_ACCOUNT"), + }, + wantDatabaseUser: "iam-auth-user", + wantPassword: "iam-auth-token", + }, + { + name: "iam auth with CLOUD_IAM_GROUP_SERVICE_ACCOUNT user", + inputDatabaseUser: "iam-auth-user", + mockDBAuth: dbAuth, + mockGCPClient: &mocks.GCPSQLAdminClientMock{ + DatabaseUser: makeGCPDatabaseUser("iam-auth-user", "CLOUD_IAM_GROUP_SERVICE_ACCOUNT"), + }, + wantDatabaseUser: "iam-auth-user", + wantPassword: "iam-auth-token", + }, + { + name: "password auth without GetUser permission", + inputDatabaseUser: "some-user", + mockDBAuth: dbAuth, + mockGCPClient: &mocks.GCPSQLAdminClientMock{ + // Default no permission to GetUser, + }, + wantDatabaseUser: "some-user", + wantPassword: "one-time-password", + }, + { + name: "password auth with BUILT_IN user", + inputDatabaseUser: "password-user", + mockDBAuth: dbAuth, + mockGCPClient: &mocks.GCPSQLAdminClientMock{ + DatabaseUser: makeGCPDatabaseUser("password-user", "BUILT_IN"), + }, + wantDatabaseUser: "password-user", + wantPassword: "one-time-password", + }, + { + name: "password auth with empty user type", + inputDatabaseUser: "password-user", + mockDBAuth: dbAuth, + mockGCPClient: &mocks.GCPSQLAdminClientMock{ + DatabaseUser: makeGCPDatabaseUser("password-user", ""), + }, + wantDatabaseUser: "password-user", + wantPassword: "one-time-password", + }, + { + name: "unsupported user type", + inputDatabaseUser: "some-user", + mockDBAuth: dbAuth, + mockGCPClient: &mocks.GCPSQLAdminClientMock{ + DatabaseUser: makeGCPDatabaseUser("some-user", "CLOUD_IAM_USER"), + }, + wantError: true, + }, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + engine := NewEngine(common.EngineConfig{ + Auth: test.mockDBAuth, + AuthClient: authClient, + Context: ctx, + Clock: clockwork.NewRealClock(), + Log: logrus.StandardLogger(), + }).(*Engine) + + sessionCtx := &common.Session{ + Database: db, + DatabaseUser: test.inputDatabaseUser, + } + + databaseUser, password, err := engine.getGCPUserAndPassword(ctx, sessionCtx, test.mockGCPClient) + if test.wantError { + require.Error(t, err) + } else { + require.NoError(t, err) + require.Equal(t, test.wantDatabaseUser, databaseUser) + require.Equal(t, test.wantPassword, password) + } + }) + } +} + +func makeAuthClient(t *testing.T) *auth.Client { + t.Helper() + + authServer, err := auth.NewTestAuthServer(auth.TestAuthServerConfig{ + ClusterName: "mysql-test", + Dir: t.TempDir(), + }) + require.NoError(t, err) + t.Cleanup(func() { authServer.Close() }) + + tlsServer, err := authServer.NewTestTLSServer() + require.NoError(t, err) + t.Cleanup(func() { tlsServer.Close() }) + + authClient, err := tlsServer.NewClient(auth.TestServerID(types.RoleDatabase, "mysql-test")) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, authClient.Close()) }) + + return authClient +} + +func makeGCPMySQLDatabase(t *testing.T) types.Database { + t.Helper() + + database, err := types.NewDatabaseV3(types.Metadata{ + Name: "gcp-mysql", + }, types.DatabaseSpecV3{ + Protocol: defaults.ProtocolMySQL, + URI: "localhost:3306", + GCP: types.GCPCloudSQL{ + ProjectID: "project-1", + InstanceID: "instance-1", + }, + }) + require.NoError(t, err) + return database +} + +func makeGCPDatabaseUser(name, userType string) *sqladmin.User { + return &sqladmin.User{ + Name: name, + Host: "%", + Type: userType, + Instance: "instance-1", + } +}