Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Create user in envtool for PostgreSQL #4133

Closed
wants to merge 24 commits into from
Closed
Show file tree
Hide file tree
Changes from 16 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion Taskfile.yml
Expand Up @@ -419,7 +419,7 @@ tasks:
--proxy-addr=127.0.0.1:47017
--mode=diff-normal
--handler=pg
--postgresql-url='postgres://127.0.0.1:5433/ferretdb?search_path='
--postgresql-url='postgres://username:password@127.0.0.1:5433/ferretdb?search_path='
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For run-secured to work, what other better thing can I do?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That target was supposed to be for the "old" authentication that does not need credentials in the PostgreSQL URI. Why are they needed?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's because postgres started on port 5433 is created from postgres_secured docker https://github.com/FerretDB/FerretDB/blob/main/docker-compose.yml#L28.
Unlike postgres docker which has POSTGRES_HOST_AUTH_METHOD=trust, postgres_secured doesn't have trust so username/password is required to connect to it.

--test-records-dir=tmp/records

run-proxy:
Expand Down
120 changes: 119 additions & 1 deletion cmd/envtool/envtool.go
Expand Up @@ -34,12 +34,18 @@ import (

"github.com/alecthomas/kong"
"github.com/prometheus/client_golang/prometheus"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"

"github.com/FerretDB/FerretDB/build/version"
mysqlpool "github.com/FerretDB/FerretDB/internal/backends/mysql/metadata/pool"
"github.com/FerretDB/FerretDB/internal/backends/postgresql/metadata/pool"
"github.com/FerretDB/FerretDB/internal/clientconn"
"github.com/FerretDB/FerretDB/internal/clientconn/connmetrics"
"github.com/FerretDB/FerretDB/internal/handler/registry"
"github.com/FerretDB/FerretDB/internal/util/ctxutil"
"github.com/FerretDB/FerretDB/internal/util/debug"
"github.com/FerretDB/FerretDB/internal/util/lazyerrors"
Expand Down Expand Up @@ -82,6 +88,10 @@ func waitForPort(ctx context.Context, logger *zap.SugaredLogger, port uint16) er
}

// setupAnyPostgres configures given PostgreSQL.
//
// It also creates a user in ferretdb and template1 databases so all subsequent
// databases created from template1 have the user.
// See also https://www.postgresql.org/docs/current/manage-ag-templatedbs.html.
func setupAnyPostgres(ctx context.Context, logger *zap.SugaredLogger, uri string) error {
u, err := url.Parse(uri)
if err != nil {
Expand Down Expand Up @@ -132,7 +142,14 @@ func setupAnyPostgres(ctx context.Context, logger *zap.SugaredLogger, uri string
return ctx.Err()
}

return nil
dbName := strings.Trim(u.Path, "/")

err = setupUser(ctx, logger, uint16(port), dbName)
if err != nil {
return err
}

return setupUser(ctx, logger, uint16(port), "template1")
}

// setupPostgres configures `postgres` container.
Expand Down Expand Up @@ -219,6 +236,107 @@ func setupMongodb(ctx context.Context, logger *zap.SugaredLogger) error {
return ctx.Err()
}

// setupUser creates a user in admin database with supported mechanisms.
// The user uses username/password credential which is the same as the PostgreSQL credentials.
//
// Without this, once the first user is created, the authentication fails
// as username/password does not exist in admin.system.users collection.
func setupUser(ctx context.Context, logger *zap.SugaredLogger, postgreSQLPort uint16, dbName string) error {
if err := waitForPort(ctx, logger.Named("postgreSQL"), postgreSQLPort); err != nil {
return err
}

sp, err := state.NewProvider("")
if err != nil {
return err
}

postgreSQLURL := fmt.Sprintf("postgres://username:password@localhost:%d/%s", postgreSQLPort, dbName)
listenerMetrics := connmetrics.NewListenerMetrics()
handlerOpts := &registry.NewHandlerOpts{
Logger: logger.Desugar(),
ConnMetrics: listenerMetrics.ConnMetrics,
StateProvider: sp,
PostgreSQLURL: postgreSQLURL,
TestOpts: registry.TestOpts{
CappedCleanupPercentage: 20,
EnableNewAuth: true,
},
}

h, closeBackend, err := registry.NewHandler("postgresql", handlerOpts)
if err != nil {
return err
}

defer closeBackend()

listenerOpts := clientconn.NewListenerOpts{
Mode: clientconn.NormalMode,
Metrics: listenerMetrics,
Handler: h,
Logger: logger.Desugar(),
TCP: "127.0.0.1:0",
}

l := clientconn.NewListener(&listenerOpts)

runErr := make(chan error)

ctx, cancel := context.WithCancel(ctx)
defer cancel()

go func() {
if err = l.Run(ctx); err != nil && !errors.Is(err, context.Canceled) {
runErr <- err
}
}()

defer close(runErr)

select {
case err = <-runErr:
if err != nil {
return err
}
case <-time.After(time.Millisecond):
}

port := l.TCPAddr().(*net.TCPAddr).Port
uri := fmt.Sprintf("mongodb://username:password@localhost:%d/?authMechanism=PLAIN", port)
clientOpts := options.Client().ApplyURI(uri)

client, err := mongo.Connect(ctx, clientOpts)
if err != nil {
return err
}

defer func() {
err = client.Disconnect(ctx)
}()

//nolint:forbidigo // allow usage of bson for setup dev and test environment
if err = client.Database("admin").RunCommand(ctx, bson.D{
bson.E{Key: "createUser", Value: "username"},
bson.E{Key: "roles", Value: bson.A{}},
bson.E{Key: "pwd", Value: "password"},
bson.E{Key: "mechanisms", Value: bson.A{"PLAIN", "SCRAM-SHA-1", "SCRAM-SHA-256"}},
}).Err(); err != nil {
var cmdErr mongo.CommandError
if errors.As(err, &cmdErr) && cmdErr.Code == 51003 {
return nil
}

return err
}

if ctx.Err() == context.Canceled {
return nil
}

return ctx.Err()
}

// setupMongodbSecured configures `mongodb_secured` container.
func setupMongodbSecured(ctx context.Context, logger *zap.SugaredLogger) error {
if err := waitForPort(ctx, logger.Named("mongodb_secured"), 47018); err != nil {
Expand Down
52 changes: 52 additions & 0 deletions cmd/envtool/envtool_test.go
Expand Up @@ -16,9 +16,15 @@ package main

import (
"bytes"
"context"
"fmt"
"os"
"testing"

zapadapter "github.com/jackc/pgx-zap"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/jackc/pgx/v5/tracelog"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

Expand Down Expand Up @@ -92,3 +98,49 @@ func TestPackageVersion(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, "1.0.0", output.String())
}

func TestSetupUser(t *testing.T) {
if testing.Short() {
t.Skip("skipping in -short mode")
}

t.Parallel()

ctx := testutil.Ctx(t)
baseURI := "postgres://username@127.0.0.1:5432/ferretdb"
l := testutil.Logger(t)
cfg, err := pgxpool.ParseConfig(baseURI)
require.NoError(t, err)

cfg.MinConns = 0
cfg.MaxConns = 1
cfg.ConnConfig.Tracer = &tracelog.TraceLog{
Logger: zapadapter.NewLogger(l),
LogLevel: tracelog.LogLevelTrace,
}

p, err := pgxpool.NewWithConfig(ctx, cfg)
require.NoError(t, err)

dbName := testutil.DatabaseName(t)

// use template0 because template1 may already have the user created
q := fmt.Sprintf("CREATE DATABASE %s TEMPLATE template0", pgx.Identifier{dbName}.Sanitize())
_, err = p.Exec(ctx, q)
require.NoError(t, err)

t.Cleanup(func() {
defer p.Close()

q = fmt.Sprintf("DROP DATABASE %s", pgx.Identifier{dbName}.Sanitize())
_, err = p.Exec(context.Background(), q)
require.NoError(t, err)
})

err = setupUser(ctx, l.Sugar(), 5432, dbName)
require.NoError(t, err)

// if the user already exists, it should not fail
err = setupUser(ctx, l.Sugar(), 5432, dbName)
require.NoError(t, err)
}
111 changes: 61 additions & 50 deletions integration/commands_authentication_test.go
Expand Up @@ -18,82 +18,93 @@ import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"

"github.com/FerretDB/FerretDB/integration/setup"
"github.com/FerretDB/FerretDB/internal/types"
"github.com/FerretDB/FerretDB/internal/util/must"
"github.com/FerretDB/FerretDB/internal/util/testutil"
)

func TestCommandsAuthenticationLogout(t *testing.T) {
Copy link
Contributor Author

@chilagrow chilagrow Mar 1, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Logout does not fail on authentication check, if user is not authenticated, it just returns success.
This test was updated to use newly created user, because if it uses default username/password user and logs out in this test, t.Cleanup to drop collection fails due to not authenticate.

t.Parallel()

ctx, collection := setup.Setup(t)
db := collection.Database()
s := setup.SetupWithOpts(t, nil)
ctx, db := s.Ctx, s.Collection.Database()
username, password, mechanism := "testuser", "testpass", "SCRAM-SHA-256"

err := db.RunCommand(ctx, bson.D{
{"createUser", username},
{"roles", bson.A{}},
{"pwd", password},
{"mechanisms", bson.A{mechanism}},
}).Err()
require.NoError(t, err, "cannot create user")

credential := options.Credential{
AuthMechanism: mechanism,
AuthSource: db.Name(),
Username: username,
Password: password,
}

opts := options.Client().ApplyURI(s.MongoDBURI).SetAuth(credential)

client, err := mongo.Connect(ctx, opts)
require.NoError(t, err, "cannot connect to MongoDB")

t.Cleanup(func() {
require.NoError(t, client.Disconnect(ctx))
})

db = client.Database(db.Name())

// the test user logs out
var res bson.D
err := db.RunCommand(ctx, bson.D{{"logout", 1}}).Decode(&res)
err = db.RunCommand(ctx, bson.D{{"connectionStatus", 1}}).Decode(&res)
assert.NoError(t, err)

actual := ConvertDocument(t, res)
actual.Remove("$clusterTime")
actual.Remove("operationTime")
actualAuth := must.NotFail(ConvertDocument(t, res).Get("authInfo")).(*types.Document)
actualUsers := must.NotFail(actualAuth.Get("authenticatedUsers")).(*types.Array)

expected := ConvertDocument(t, bson.D{{"ok", float64(1)}})
testutil.AssertEqual(t, expected, actual)
var hasUser bool

for i := 0; i < actualUsers.Len(); i++ {
actualUser := must.NotFail(must.NotFail(actualUsers.Get(i)).(*types.Document).Get("user"))
if actualUser == username {
hasUser = true
break
}
}

assert.True(t, hasUser, res)

// the test user logs out again, it has no effect
err = db.RunCommand(ctx, bson.D{{"logout", 1}}).Decode(&res)
assert.NoError(t, err)

actual = ConvertDocument(t, res)
actual := ConvertDocument(t, res)
actual.Remove("$clusterTime")
actual.Remove("operationTime")

expected := ConvertDocument(t, bson.D{{"ok", float64(1)}})
testutil.AssertEqual(t, expected, actual)
}

func TestCommandsAuthenticationLogoutTLS(t *testing.T) {
setup.SkipForMongoDB(t, "tls is not enabled for mongodb backend")
err = db.RunCommand(ctx, bson.D{{"connectionStatus", 1}}).Decode(&res)
assert.NoError(t, err)

t.Parallel()
actualAuth = must.NotFail(ConvertDocument(t, res).Get("authInfo")).(*types.Document)
actualUsers = must.NotFail(actualAuth.Get("authenticatedUsers")).(*types.Array)

ctx, collection := setup.Setup(t)
db := collection.Database()

// the test user is authenticated
expectedAuthenticated := bson.D{
{
"authInfo", bson.D{
{"authenticatedUsers", bson.A{bson.D{{"user", "username"}}}},
{"authenticatedUserRoles", bson.A{}},
{"authenticatedUserPrivileges", bson.A{}},
},
},
{"ok", float64(1)},
for i := 0; i < actualUsers.Len(); i++ {
actualUser := must.NotFail(must.NotFail(actualUsers.Get(i)).(*types.Document).Get("user"))
if actualUser == username {
assert.Fail(t, "user is still authenticated", res)
}
}
var res bson.D
err := db.RunCommand(ctx, bson.D{{"connectionStatus", 1}}).Decode(&res)
assert.NoError(t, err)
assert.Equal(t, expectedAuthenticated, res)

// the test user logs out
err = db.RunCommand(ctx, bson.D{{"logout", 1}}).Decode(&res)
assert.NoError(t, err)
assert.Equal(t, bson.D{{"ok", float64(1)}}, res)

// the test user is no longer authenticated
expectedUnauthenticated := bson.D{
{
"authInfo", bson.D{
{"authenticatedUsers", bson.A{}},
{"authenticatedUserRoles", bson.A{}},
{"authenticatedUserPrivileges", bson.A{}},
},
},
{"ok", float64(1)},
}
err = db.RunCommand(ctx, bson.D{{"connectionStatus", 1}}).Decode(&res)
// the test user logs out again, it has no effect
err = db.RunCommand(ctx, bson.D{{"logout", 1}}).Err()
assert.NoError(t, err)
assert.Equal(t, expectedUnauthenticated, res)
}
3 changes: 2 additions & 1 deletion integration/setup/setup.go
Expand Up @@ -334,7 +334,8 @@ func insertBenchmarkProvider(tb testtb.TB, ctx context.Context, collection *mong
func setupUser(tb testtb.TB, ctx context.Context, client *mongo.Client) {
tb.Helper()

if IsMongoDB(tb) {
if IsPostgreSQL(tb) || IsMongoDB(tb) {
// the user is created in env tool for PostgreSQL
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For SQLite we need something similar to have a database with default user. Will be handle in a separate PR.

Copy link
Contributor

@henvic henvic Mar 1, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And also for MySQL.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes that's true. MySQL hasn't been handled. let me see the status of MySQL backend

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MySQL and HANA backends are not yet supported, no need to do anything for them

return
}

Expand Down