diff --git a/Taskfile.yml b/Taskfile.yml index c50d557f3306..7e6c3ce49ca4 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -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=' --test-records-dir=tmp/records run-proxy: diff --git a/cmd/envtool/envtool.go b/cmd/envtool/envtool.go index 289e90b8b358..bebccf841df0 100644 --- a/cmd/envtool/envtool.go +++ b/cmd/envtool/envtool.go @@ -33,6 +33,8 @@ import ( "time" "github.com/alecthomas/kong" + "github.com/jackc/pgerrcode" + "github.com/jackc/pgx/v5/pgconn" "github.com/prometheus/client_golang/prometheus" "go.uber.org/zap" "go.uber.org/zap/zapcore" @@ -52,6 +54,12 @@ var ( //go:embed error.tmpl errorTemplateB []byte + //go:embed test_user.json + testUser string + + //go:embed test_system_users.json + testSystemUsers string + // Parsed error template. errorTemplate = template.Must(template.New("error").Option("missingkey=error").Parse(string(errorTemplateB))) ) @@ -136,14 +144,46 @@ func setupAnyPostgres(ctx context.Context, logger *zap.SugaredLogger, uri string } // setupPostgres configures `postgres` container. +// +// 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 setupPostgres(ctx context.Context, logger *zap.SugaredLogger) error { + l := logger.Named("postgres") + // user `username` must exist, but password may be any, even empty - return setupAnyPostgres(ctx, logger.Named("postgres"), "postgres://username@127.0.0.1:5432/ferretdb") + err := setupAnyPostgres(ctx, l, "postgres://username@127.0.0.1:5432/ferretdb") + if err != nil { + return err + } + + err = setupUserInPostgres(ctx, logger, "postgres://username@127.0.0.1:5432/ferretdb") + if err != nil { + return err + } + + return setupUserInPostgres(ctx, logger, "postgres://username@127.0.0.1:5432/template1") } // setupPostgresSecured configures `postgres_secured` container. +// +// 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 setupPostgresSecured(ctx context.Context, logger *zap.SugaredLogger) error { - return setupAnyPostgres(ctx, logger.Named("postgres_secured"), "postgres://username:password@127.0.0.1:5433/ferretdb") + l := logger.Named("postgres_secured") + + err := setupAnyPostgres(ctx, l, "postgres://username:password@127.0.0.1:5433/ferretdb") + if err != nil { + return err + } + + err = setupUserInPostgres(ctx, logger, "postgres://username:password@127.0.0.1:5433/ferretdb") + if err != nil { + return err + } + + return setupUserInPostgres(ctx, logger, "postgres://username:password@127.0.0.1:5433/template1") } // setupMySQL configures `mysql` container. @@ -219,6 +259,92 @@ func setupMongodb(ctx context.Context, logger *zap.SugaredLogger) error { return ctx.Err() } +// setupUserInPostgres creates a user with username/password credential in admin database +// with supported mechanisms. +// It creates admin database (PostgreSQL admin schema), if it does not exist. +// It also creates system.users collection, if it does not exist. +// +// Without this, once the first user is created, the authentication fails +// as username/password does not exist in admin.system.users collection. +func setupUserInPostgres(ctx context.Context, logger *zap.SugaredLogger, uri string) error { + sp, err := state.NewProvider("") + if err != nil { + return err + } + + u, err := url.Parse(uri) + if err != nil { + return err + } + + p, err := pool.New(uri, logger.Desugar(), sp) + if err != nil { + return err + } + + defer p.Close() + + username := u.User.Username() + password, _ := u.User.Password() + + dbPool, err := p.Get(username, password) + if err != nil { + return err + } + + defer dbPool.Close() + + var pgErr *pgconn.PgError + + q := `CREATE SCHEMA admin` + if _, err = dbPool.Exec(ctx, q); err != nil && (!errors.As(err, &pgErr) || pgErr.Code != pgerrcode.DuplicateSchema) { + return err + } + + if err == nil { + q = `CREATE TABLE admin._ferretdb_database_metadata (_jsonb jsonb)` + if _, err = dbPool.Exec(ctx, q); err != nil { + return err + } + + q = `CREATE UNIQUE INDEX _ferretdb_database_metadata_id_idx ON admin._ferretdb_database_metadata (((_jsonb->'_id')))` + if _, err = dbPool.Exec(ctx, q); err != nil { + return err + } + + q = `CREATE UNIQUE INDEX _ferretdb_database_metadata_table_idx ON admin._ferretdb_database_metadata (((_jsonb->'table')))` + if _, err = dbPool.Exec(ctx, q); err != nil { + return err + } + } + + q = `CREATE TABLE admin.system_users_aff2f7ce (_jsonb jsonb)` + if _, err = dbPool.Exec(ctx, q); err != nil && (!errors.As(err, &pgErr) || pgErr.Code != pgerrcode.DuplicateTable) { + return err + } + + if err == nil { + q = `CREATE UNIQUE INDEX "system_users_aff2f7ce__id__67399184_idx" ON admin.system_users_aff2f7ce (((_jsonb->'_id')))` + if _, err = dbPool.Exec(ctx, q); err != nil { + return err + } + + q = `INSERT INTO admin._ferretdb_database_metadata (_jsonb) VALUES ($1)` + if _, err = dbPool.Exec(ctx, q, testSystemUsers); err != nil { + return err + } + } + + q = `INSERT INTO admin.system_users_aff2f7ce (_jsonb) VALUES ($1)` + + _, err = dbPool.Exec(ctx, q, testUser) + if err != nil && (!errors.As(err, &pgErr) || pgErr.Code != pgerrcode.UniqueViolation) { + return err + } + + return nil +} + // 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 { diff --git a/cmd/envtool/envtool_test.go b/cmd/envtool/envtool_test.go index 0ebcef8c0f89..5a2d4fea168e 100644 --- a/cmd/envtool/envtool_test.go +++ b/cmd/envtool/envtool_test.go @@ -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" @@ -92,3 +98,53 @@ func TestPackageVersion(t *testing.T) { assert.NoError(t, err) assert.Equal(t, "1.0.0", output.String()) } + +func TestSetupUserInPostgres(t *testing.T) { + if testing.Short() { + t.Skip("skipping in -short mode") + } + + t.Parallel() + + baseURI := "postgres://username@127.0.0.1:5432/ferretdb?search_path=" + cfg, err := pgxpool.ParseConfig(baseURI) + require.NoError(t, err) + + l := testutil.Logger(t) + cfg.MinConns = 0 + cfg.MaxConns = 1 + cfg.ConnConfig.Tracer = &tracelog.TraceLog{ + Logger: zapadapter.NewLogger(l), + LogLevel: tracelog.LogLevelTrace, + } + + ctx := testutil.Ctx(t) + p, err := pgxpool.NewWithConfig(ctx, cfg) + require.NoError(t, err) + + dbName := testutil.DatabaseName(t) + sanitizedName := pgx.Identifier{dbName}.Sanitize() + + _, err = p.Exec(ctx, fmt.Sprintf("DROP DATABASE IF EXISTS %s", sanitizedName)) + require.NoError(t, err) + + // use template0 because template1 may already have the user created + _, err = p.Exec(ctx, fmt.Sprintf("CREATE DATABASE %s TEMPLATE template0", sanitizedName)) + require.NoError(t, err) + + t.Cleanup(func() { + defer p.Close() + + _, err = p.Exec(context.Background(), fmt.Sprintf("DROP DATABASE %s", sanitizedName)) + require.NoError(t, err) + }) + + uri := fmt.Sprintf("postgres://username@127.0.0.1:5432/%s", dbName) + + err = setupUserInPostgres(ctx, l.Sugar(), uri) + require.NoError(t, err) + + // if the user already exists, it should not fail + err = setupUserInPostgres(ctx, l.Sugar(), uri) + require.NoError(t, err) +} diff --git a/cmd/envtool/test_system_users.json b/cmd/envtool/test_system_users.json new file mode 100644 index 000000000000..616364268838 --- /dev/null +++ b/cmd/envtool/test_system_users.json @@ -0,0 +1,84 @@ +{ + "$s": { + "p": { + "_id": { + "t": "string" + }, + "uuid": { + "t": "string" + }, + "table": { + "t": "string" + }, + "indexes": { + "t": "array", + "i": [ + { + "t": "object", + "$s": { + "p": { + "pgindex": { + "t": "string" + }, + "name": { + "t": "string" + }, + "key": { + "t": "object", + "$s": { + "p": { + "_id": { + "t": "int" + } + }, + "$k": [ + "_id" + ] + } + }, + "unique": { + "t": "bool" + } + }, + "$k": [ + "pgindex", + "name", + "key", + "unique" + ] + } + } + ] + }, + "cappedSize": { + "t": "long" + }, + "cappedDocs": { + "t": "long" + } + }, + "$k": [ + "_id", + "uuid", + "table", + "indexes", + "cappedSize", + "cappedDocs" + ] + }, + "_id": "system.users", + "uuid": "b34c2094-2086-4184-8761-f5692fadb2f2", + "table": "system_users_aff2f7ce", + "indexes": [ + { + "pgindex": "system_users_aff2f7ce__id__67399184_idx", + "name": "_id_", + "key": { + "_id": 1 + }, + "unique": true + } + ], + "cappedSize": 0, + "cappedDocs": 0 +} diff --git a/cmd/envtool/test_user.json b/cmd/envtool/test_user.json new file mode 100644 index 000000000000..7156c405b700 --- /dev/null +++ b/cmd/envtool/test_user.json @@ -0,0 +1,145 @@ +{ + "$s": { + "p": { + "_id": { + "t": "string" + }, + "credentials": { + "t": "object", + "$s": { + "p": { + "SCRAM-SHA-1": { + "t": "object", + "$s": { + "p": { + "storedKey": { + "t": "string" + }, + "iterationCount": { + "t": "int" + }, + "salt": { + "t": "string" + }, + "serverKey": { + "t": "string" + } + }, + "$k": [ + "storedKey", + "iterationCount", + "salt", + "serverKey" + ] + } + }, + "SCRAM-SHA-256": { + "t": "object", + "$s": { + "p": { + "storedKey": { + "t": "string" + }, + "iterationCount": { + "t": "int" + }, + "salt": { + "t": "string" + }, + "serverKey": { + "t": "string" + } + }, + "$k": [ + "storedKey", + "iterationCount", + "salt", + "serverKey" + ] + } + }, + "PLAIN": { + "t": "object", + "$s": { + "p": { + "algo": { + "t": "string" + }, + "iterationCount": { + "t": "long" + }, + "hash": { + "t": "binData", + "s": 0 + }, + "salt": { + "t": "binData", + "s": 0 + } + }, + "$k": [ + "algo", + "iterationCount", + "hash", + "salt" + ] + } + } + }, + "$k": [ + "SCRAM-SHA-1", + "SCRAM-SHA-256", + "PLAIN" + ] + } + }, + "user": { + "t": "string" + }, + "db": { + "t": "string" + }, + "roles": { + "t": "array", + "i": [] + }, + "userId": { + "t": "binData", + "s": 4 + } + }, + "$k": [ + "_id", + "credentials", + "user", + "db", + "roles", + "userId" + ] + }, + "_id": "admin.username", + "credentials": { + "SCRAM-SHA-1": { + "storedKey": "o+viQ4miKbpHYVuv62ys3qlfcHE=", + "iterationCount": 10000, + "salt": "a8nzn5BcBhXRhScuRy39cQ==", + "serverKey": "xzgG8EYUmN+NQ9vwbQuVLgtyifQ=" + }, + "SCRAM-SHA-256": { + "storedKey": "ADDyKR9ukJMC2wXQztMbaIrTNZh7RdBSNtQ3ehnAv+U=", + "iterationCount": 15000, + "salt": "ZSTAnwxr7HEZ44F5+1qOH06asrPR/30CyhgjYw==", + "serverKey": "nu1KrJQnTXhLMFBPxv6lVqakf1lEtB8gF/NFpd3aXdc=" + }, + "PLAIN": { + "algo": "PBKDF2-HMAC-SHA256", + "iterationCount": 600000, + "hash": "1oHFV6ZG+3eBSMv/HPpfcs50dvdidhArDHmF6pPtU54=", + "salt": "sxK4yjYYSss9JmYhfBRzwA==" + } + }, + "user": "username", + "db": "admin", + "roles": [], + "userId": "Qi0njDnoRT66fs6AKwtfoA==" +} diff --git a/integration/commands_authentication_test.go b/integration/commands_authentication_test.go index e8f0c885182f..22213abf49fd 100644 --- a/integration/commands_authentication_test.go +++ b/integration/commands_authentication_test.go @@ -17,83 +17,103 @@ package integration 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) { 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) - assert.NoError(t, err) + err = db.RunCommand(ctx, bson.D{{"connectionStatus", 1}}).Decode(&res) + require.NoError(t, err) - actual := ConvertDocument(t, res) - actual.Remove("$clusterTime") - actual.Remove("operationTime") + actualAuth, _ := ConvertDocument(t, res).Get("authInfo") + require.NotNil(t, actualAuth) - expected := ConvertDocument(t, bson.D{{"ok", float64(1)}}) - testutil.AssertEqual(t, expected, actual) + actualUsersV, _ := actualAuth.(*types.Document).Get("authenticatedUsers") + require.NotNil(t, actualUsersV) + + actualUsers := actualUsersV.(*types.Array) + + 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 + } + } + + require.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) + require.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) + require.NoError(t, err) - t.Parallel() + actualAuth, _ = ConvertDocument(t, res).Get("authInfo") + require.NotNil(t, actualAuth) - 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)}, - } - var res bson.D - err := db.RunCommand(ctx, bson.D{{"connectionStatus", 1}}).Decode(&res) - assert.NoError(t, err) - assert.Equal(t, expectedAuthenticated, res) + actualUsersV, _ = actualAuth.(*types.Document).Get("authenticatedUsers") + require.NotNil(t, actualUsersV) - // 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)}, + actualUsers = actualUsersV.(*types.Array) + + for i := 0; i < actualUsers.Len(); i++ { + actualUser := must.NotFail(must.NotFail(actualUsers.Get(i)).(*types.Document).Get("user")) + if actualUser == username { + require.Fail(t, "user is still authenticated", res) + } } - err = db.RunCommand(ctx, bson.D{{"connectionStatus", 1}}).Decode(&res) - assert.NoError(t, err) - assert.Equal(t, expectedUnauthenticated, res) + + // the test user logs out again, it has no effect + err = db.RunCommand(ctx, bson.D{{"logout", 1}}).Err() + require.NoError(t, err) } diff --git a/integration/setup/setup.go b/integration/setup/setup.go index 8cbd81b06098..2ea43a694fac 100644 --- a/integration/setup/setup.go +++ b/integration/setup/setup.go @@ -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 return } diff --git a/internal/backends/backend_test.go b/internal/backends/backend_test.go index 56a0a5552bf0..9aee6ea1d1ef 100644 --- a/internal/backends/backend_test.go +++ b/internal/backends/backend_test.go @@ -112,21 +112,45 @@ func TestListDatabases(t *testing.T) { t.Parallel() dbRes, err := b.ListDatabases(ctx, nil) require.NoError(t, err) - require.Equal(t, 3, len(dbRes.Databases), "expected full list len 3") - require.Equal(t, dbNames[1], dbRes.Databases[0].Name, "expected name testDB1") - require.Equal(t, dbNames[0], dbRes.Databases[1].Name, "expected name testDB2") - require.Equal(t, dbNames[2], dbRes.Databases[2].Name, "expected name testDB3") + require.GreaterOrEqual(t, len(dbRes.Databases), 3, "expected at least 3") + + expected := []string{dbNames[1], dbNames[0], dbNames[2]} + actual := []string{dbRes.Databases[0].Name, dbRes.Databases[1].Name, dbRes.Databases[2].Name} + + if len(dbRes.Databases) > 3 { + expected = []string{"admin", dbNames[1], dbNames[0], dbNames[2]} + actual = []string{ + dbRes.Databases[0].Name, + dbRes.Databases[1].Name, + dbRes.Databases[2].Name, + dbRes.Databases[3].Name, + } + } + + require.Equalf(t, expected, actual, "expected %s databases", len(expected)) }) - t.Run("ListDatabasesWithEMptyParam", func(t *testing.T) { + t.Run("ListDatabasesWithEmptyParam", func(t *testing.T) { t.Parallel() var param backends.ListDatabasesParams dbRes, err := b.ListDatabases(ctx, ¶m) require.NoError(t, err) - require.Equal(t, 3, len(dbRes.Databases), "expected full list len 3") - require.Equal(t, dbNames[1], dbRes.Databases[0].Name, "expected name testDB1") - require.Equal(t, dbNames[0], dbRes.Databases[1].Name, "expected name testDB2") - require.Equal(t, dbNames[2], dbRes.Databases[2].Name, "expected name testDB3") + require.GreaterOrEqual(t, len(dbRes.Databases), 3, "expected at least 3") + + expected := []string{dbNames[1], dbNames[0], dbNames[2]} + actual := []string{dbRes.Databases[0].Name, dbRes.Databases[1].Name, dbRes.Databases[2].Name} + + if len(dbRes.Databases) > 3 { + expected = []string{"admin", dbNames[1], dbNames[0], dbNames[2]} + actual = []string{ + dbRes.Databases[0].Name, + dbRes.Databases[1].Name, + dbRes.Databases[2].Name, + dbRes.Databases[3].Name, + } + } + + require.Equalf(t, expected, actual, "expected %s databases", len(expected)) }) }) } diff --git a/internal/backends/postgresql/metadata/registry_test.go b/internal/backends/postgresql/metadata/registry_test.go index ac29e0003042..d06a5f0f7513 100644 --- a/internal/backends/postgresql/metadata/registry_test.go +++ b/internal/backends/postgresql/metadata/registry_test.go @@ -276,7 +276,7 @@ func TestDefaultEmptySchema(t *testing.T) { list, err := r.DatabaseList(ctx) require.NoError(t, err) - assert.Equal(t, []string{dbName}, list) + assert.Equal(t, []string{dbName, "admin"}, list) created, err := r.CollectionCreate(ctx, &CollectionCreateParams{DBName: "public", Name: testutil.CollectionName(t)}) require.NoError(t, err) @@ -284,7 +284,7 @@ func TestDefaultEmptySchema(t *testing.T) { list, err = r.DatabaseList(ctx) require.NoError(t, err) - assert.Equal(t, []string{dbName, "public"}, list) + assert.Equal(t, []string{dbName, "admin", "public"}, list) } func TestCheckDatabaseUpdated(t *testing.T) { diff --git a/internal/handler/commands.go b/internal/handler/commands.go index 03d6868697de..ded0b2b853d5 100644 --- a/internal/handler/commands.go +++ b/internal/handler/commands.go @@ -203,8 +203,9 @@ func (h *Handler) initCommands() { Help: "Returns a summary of indexes of the specified collection.", }, "logout": { - Handler: h.MsgLogout, - Help: "Logs out from the current session.", + Handler: h.MsgLogout, + anonymous: true, + Help: "Logs out from the current session.", }, "ping": { Handler: h.MsgPing,