From 7b13b6cd3f4d0c3ed1dc52ec57cb8ce8cbd9377b Mon Sep 17 00:00:00 2001 From: Caio Teixeira Date: Wed, 1 Nov 2023 18:10:51 -0300 Subject: [PATCH] feat: provision a new tenant through CLI --- .../2023-10-16.0.add-tenants-table.sql | 4 + internal/utils/network_type.go | 12 + stellar-multitenant/pkg/cli/add_tenants.go | 42 +++- .../pkg/cli/add_tenants_test.go | 169 ++++++++++++-- .../pkg/cli/utils/custom_set_value.go | 16 ++ stellar-multitenant/pkg/tenant/fixtures.go | 103 ++++++++ stellar-multitenant/pkg/tenant/manager.go | 104 +++++++++ .../pkg/tenant/manager_test.go | 221 ++++++++++++++++-- stellar-multitenant/pkg/tenant/tenant.go | 23 +- stellar-multitenant/pkg/tenant/tenant_test.go | 39 ++++ 10 files changed, 686 insertions(+), 47 deletions(-) create mode 100644 stellar-multitenant/pkg/tenant/fixtures.go diff --git a/db/migrations/tenant-migrations/2023-10-16.0.add-tenants-table.sql b/db/migrations/tenant-migrations/2023-10-16.0.add-tenants-table.sql index 713970565..a7f5fe21b 100644 --- a/db/migrations/tenant-migrations/2023-10-16.0.add-tenants-table.sql +++ b/db/migrations/tenant-migrations/2023-10-16.0.add-tenants-table.sql @@ -15,6 +15,7 @@ $$ language 'plpgsql'; CREATE TYPE public.email_sender_type AS ENUM ('AWS_EMAIL', 'DRY_RUN'); CREATE TYPE public.sms_sender_type AS ENUM ('TWILIO_SMS', 'AWS_SMS', 'DRY_RUN'); +CREATE TYPE public.tenant_status AS ENUM ('TENANT_CREATED', 'TENANT_PROVISIONED', 'TENANT_ACTIVATED', 'TENANT_DEACTIVATED'); CREATE TABLE public.tenants ( @@ -29,6 +30,7 @@ CREATE TABLE public.tenants cors_allowed_origins text[] NULL, base_url text NULL, sdp_ui_base_url text NULL, + status tenant_status DEFAULT 'TENANT_CREATED', created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() ); @@ -47,4 +49,6 @@ DROP TYPE public.email_sender_type; DROP TYPE public.sms_sender_type; +DROP TYPE public.tenant_status; + DROP FUNCTION update_at_refresh; diff --git a/internal/utils/network_type.go b/internal/utils/network_type.go index 7cec3f01d..c8783eafc 100644 --- a/internal/utils/network_type.go +++ b/internal/utils/network_type.go @@ -2,6 +2,7 @@ package utils import ( "fmt" + "strings" "github.com/stellar/go/network" ) @@ -23,3 +24,14 @@ func GetNetworkTypeFromNetworkPassphrase(networkPassphrase string) (NetworkType, return "", fmt.Errorf("invalid network passphrase provided") } } + +func GetNetworkTypeFromString(networkType string) (NetworkType, error) { + switch NetworkType(strings.ToLower(networkType)) { + case PubnetNetworkType: + return PubnetNetworkType, nil + case TestnetNetworkType: + return TestnetNetworkType, nil + default: + return "", fmt.Errorf("invalid network type provided") + } +} diff --git a/stellar-multitenant/pkg/cli/add_tenants.go b/stellar-multitenant/pkg/cli/add_tenants.go index 8a16edd2c..c5316cd28 100644 --- a/stellar-multitenant/pkg/cli/add_tenants.go +++ b/stellar-multitenant/pkg/cli/add_tenants.go @@ -3,34 +3,62 @@ package cli import ( "context" "fmt" + "go/types" "regexp" "github.com/spf13/cobra" + "github.com/stellar/go/support/config" "github.com/stellar/go/support/log" "github.com/stellar/stellar-disbursement-platform-backend/db" + "github.com/stellar/stellar-disbursement-platform-backend/stellar-multitenant/pkg/cli/utils" "github.com/stellar/stellar-disbursement-platform-backend/stellar-multitenant/pkg/tenant" ) var validTenantName *regexp.Regexp = regexp.MustCompile(`^[a-z-]+$`) func AddTenantsCmd() *cobra.Command { + var networkType string + configOptions := config.ConfigOptions{ + { + Name: "network-type", + Usage: "", + OptType: types.String, + CustomSetValue: utils.SetConfigOptionNetworkType, + ConfigKey: &networkType, + FlagDefault: "testnet", + Required: false, + }, + } + cmd := cobra.Command{ Use: "add-tenants", Short: "Add a new tenant.", - Example: "add-tenants [name]", + Example: "add-tenants [tenant name] [user first name] [user last name] [user email]", Long: "Add a new tenant. The tenant name should only contain lower case characters and dash (-)", Args: cobra.MatchAll( - cobra.ExactArgs(1), + cobra.ExactArgs(4), validateTenantNameArg, ), + PersistentPreRun: func(cmd *cobra.Command, args []string) { + cmd.Parent().PersistentPreRun(cmd.Parent(), args) + configOptions.Require() + err := configOptions.SetValues() + if err != nil { + log.Fatalf("Error setting values of config options: %s", err.Error()) + } + }, Run: func(cmd *cobra.Command, args []string) { ctx := cmd.Context() - if err := executeAddTenant(ctx, globalOptions.multitenantDbURL, args[0]); err != nil { + if err := executeAddTenant(ctx, globalOptions.multitenantDbURL, args[0], args[1], args[2], args[3], networkType); err != nil { log.Fatal(err) } }, } + if err := configOptions.Init(&cmd); err != nil { + log.Fatalf("initializing config options: %v", err) + } + return &cmd } @@ -41,7 +69,7 @@ func validateTenantNameArg(cmd *cobra.Command, args []string) error { return nil } -func executeAddTenant(ctx context.Context, dbURL, name string) error { +func executeAddTenant(ctx context.Context, dbURL, tenantName, userFirstName, userLastName, userEmail, networkType string) error { dbConnectionPool, err := db.OpenDBConnectionPool(dbURL) if err != nil { return fmt.Errorf("opening database connection pool: %w", err) @@ -49,12 +77,12 @@ func executeAddTenant(ctx context.Context, dbURL, name string) error { defer dbConnectionPool.Close() m := tenant.NewManager(tenant.WithDatabase(dbConnectionPool)) - t, err := m.AddTenant(ctx, name) + t, err := m.ProvisionNewTenant(ctx, tenantName, userFirstName, userLastName, userEmail, networkType) if err != nil { - return fmt.Errorf("adding tenant with name %s: %w", name, err) + return fmt.Errorf("adding tenant with name %s: %w", tenantName, err) } - log.Ctx(ctx).Infof("tenant %s added successfully", name) + log.Ctx(ctx).Infof("tenant %s added successfully", tenantName) log.Ctx(ctx).Infof("tenant ID: %s", t.ID) return nil diff --git a/stellar-multitenant/pkg/cli/add_tenants_test.go b/stellar-multitenant/pkg/cli/add_tenants_test.go index 7c6c5a923..2c8dfd9b8 100644 --- a/stellar-multitenant/pkg/cli/add_tenants_test.go +++ b/stellar-multitenant/pkg/cli/add_tenants_test.go @@ -4,9 +4,11 @@ import ( "context" "errors" "fmt" + "net/url" "strings" "testing" + "github.com/lib/pq" "github.com/spf13/cobra" "github.com/stellar/go/support/log" "github.com/stellar/stellar-disbursement-platform-backend/db" @@ -17,9 +19,20 @@ import ( ) func DeleteAllTenantsFixture(t *testing.T, ctx context.Context, dbConnectionPool db.DBConnectionPool) { - const q = "DELETE FROM tenants" + q := "DELETE FROM tenants" _, err := dbConnectionPool.ExecContext(ctx, q) require.NoError(t, err) + + var schemasToDrop []string + q = "SELECT schema_name FROM information_schema.schemata WHERE schema_name ILIKE 'sdp_%'" + err = dbConnectionPool.SelectContext(ctx, &schemasToDrop, q) + require.NoError(t, err) + + for _, schema := range schemasToDrop { + q = fmt.Sprintf("DROP SCHEMA %s CASCADE", pq.QuoteIdentifier(schema)) + _, err = dbConnectionPool.ExecContext(ctx, q) + require.NoError(t, err) + } } func Test_validateTenantNameArg(t *testing.T) { @@ -81,7 +94,7 @@ func Test_executeAddTenant(t *testing.T) { getEntries := log.DefaultLogger.StartTest(log.InfoLevel) - err := executeAddTenant(ctx, dbt.DSN, "myorg") + err := executeAddTenant(ctx, dbt.DSN, "myorg", "first", "last", "email@email.com", "testnet") assert.Nil(t, err) const q = "SELECT id FROM tenants WHERE name = $1" @@ -90,9 +103,9 @@ func Test_executeAddTenant(t *testing.T) { require.NoError(t, err) entries := getEntries() - require.Len(t, entries, 2) - assert.Equal(t, "tenant myorg added successfully", entries[0].Message) - assert.Contains(t, fmt.Sprintf("tenant ID: %s", tenantID), entries[1].Message) + require.Len(t, entries, 15) + assert.Equal(t, "tenant myorg added successfully", entries[13].Message) + assert.Contains(t, fmt.Sprintf("tenant ID: %s", tenantID), entries[14].Message) }) t.Run("duplicated tenant name", func(t *testing.T) { @@ -100,10 +113,10 @@ func Test_executeAddTenant(t *testing.T) { getEntries := log.DefaultLogger.StartTest(log.DebugLevel) - err := executeAddTenant(ctx, dbt.DSN, "myorg") + err := executeAddTenant(ctx, dbt.DSN, "myorg", "first", "last", "email@email.com", "testnet") assert.Nil(t, err) - err = executeAddTenant(ctx, dbt.DSN, "MyOrg") + err = executeAddTenant(ctx, dbt.DSN, "myorg", "first", "last", "email@email.com", "testnet") assert.ErrorIs(t, err, tenant.ErrDuplicatedTenantName) const q = "SELECT id FROM tenants WHERE name = $1" @@ -112,9 +125,9 @@ func Test_executeAddTenant(t *testing.T) { require.NoError(t, err) entries := getEntries() - require.Len(t, entries, 2) - assert.Equal(t, "tenant myorg added successfully", entries[0].Message) - assert.Contains(t, fmt.Sprintf("tenant ID: %s", tenantID), entries[1].Message) + require.Len(t, entries, 16) + assert.Equal(t, "tenant myorg added successfully", entries[13].Message) + assert.Contains(t, fmt.Sprintf("tenant ID: %s", tenantID), entries[14].Message) }) } @@ -135,17 +148,18 @@ func Test_AddTenantsCmd(t *testing.T) { mockCmd.SetErr(out) mockCmd.SetArgs([]string{"add-tenants"}) err := mockCmd.ExecuteContext(ctx) - assert.EqualError(t, err, "accepts 1 arg(s), received 0") + assert.EqualError(t, err, "accepts 4 arg(s), received 0") - expectUsageMessage := `Error: accepts 1 arg(s), received 0 + expectUsageMessage := `Error: accepts 4 arg(s), received 0 Usage: add-tenants [flags] Examples: -add-tenants [name] +add-tenants [tenant name] [user first name] [user last name] [user email] Flags: - -h, --help help for add-tenants + -h, --help help for add-tenants + --network-type string (NETWORK_TYPE) (default "testnet") ` assert.Equal(t, expectUsageMessage, out.String()) @@ -161,21 +175,27 @@ Usage: add-tenants [flags] Examples: -add-tenants [name] +add-tenants [tenant name] [user first name] [user last name] [user email] Flags: - -h, --help help for add-tenants + -h, --help help for add-tenants + --network-type string (NETWORK_TYPE) (default "testnet") ` assert.Equal(t, expectUsageMessage, out.String()) }) - t.Run("adds new tenant successfully", func(t *testing.T) { + t.Run("adds new tenant successfully testnet", func(t *testing.T) { + tenantName := "unhcr" + userFirstName := "First" + userLastName := "Last" + userEmail := "email@email.com" + out := new(strings.Builder) rootCmd := rootCmd() rootCmd.AddCommand(AddTenantsCmd()) rootCmd.SetOut(out) rootCmd.SetErr(out) - rootCmd.SetArgs([]string{"add-tenants", "unhcr", "--multitenant-db-url", dbt.DSN}) + rootCmd.SetArgs([]string{"add-tenants", tenantName, userFirstName, userLastName, userEmail, "--network-type", "testnet", "--multitenant-db-url", dbt.DSN}) getEntries := log.DefaultLogger.StartTest(log.InfoLevel) err := rootCmd.ExecuteContext(ctx) @@ -184,12 +204,117 @@ Flags: const q = "SELECT id FROM tenants WHERE name = $1" var tenantID string - err = dbConnectionPool.GetContext(ctx, &tenantID, q, "unhcr") + err = dbConnectionPool.GetContext(ctx, &tenantID, q, tenantName) require.NoError(t, err) entries := getEntries() - require.Len(t, entries, 4) - assert.Equal(t, "tenant unhcr added successfully", entries[2].Message) - assert.Contains(t, fmt.Sprintf("tenant ID: %s", tenantID), entries[3].Message) + require.Len(t, entries, 17) + assert.Equal(t, "tenant unhcr added successfully", entries[15].Message) + assert.Contains(t, fmt.Sprintf("tenant ID: %s", tenantID), entries[16].Message) + + // Connecting to the new schema + schemaName := fmt.Sprintf("sdp_%s", tenantName) + dataSourceName := dbConnectionPool.DSN() + u, err := url.Parse(dataSourceName) + require.NoError(t, err) + uq := u.Query() + uq.Set("search_path", schemaName) + u.RawQuery = uq.Encode() + + tenantSchemaConnectionPool, err := db.OpenDBConnectionPool(u.String()) + require.NoError(t, err) + defer tenantSchemaConnectionPool.Close() + + expectedTablesAfterMigrationsApplied := []string{ + "assets", + "auth_migrations", + "auth_user_mfa_codes", + "auth_user_password_reset", + "auth_users", + "channel_accounts", + "countries", + "disbursements", + "gorp_migrations", + "messages", + "organizations", + "payments", + "receiver_verifications", + "receiver_wallets", + "receivers", + "submitter_transactions", + "wallets", + "wallets_assets", + } + tenant.TenantSchemaHasTablesFixture(t, ctx, tenantSchemaConnectionPool, schemaName, expectedTablesAfterMigrationsApplied) + tenant.AssertRegisteredAssets(t, ctx, tenantSchemaConnectionPool, []string{"USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", "XLM:"}) + tenant.AssertRegisteredWallets(t, ctx, tenantSchemaConnectionPool, []string{"Demo Wallet", "Vibrant Assist"}) + tenant.AssertRegisteredUser(t, ctx, tenantSchemaConnectionPool, userFirstName, userLastName, userEmail) + }) + + t.Run("adds new tenant successfully pubnet", func(t *testing.T) { + tenantName := "irc" + userFirstName := "First" + userLastName := "Last" + userEmail := "email@email.com" + + out := new(strings.Builder) + rootCmd := rootCmd() + rootCmd.AddCommand(AddTenantsCmd()) + rootCmd.SetOut(out) + rootCmd.SetErr(out) + rootCmd.SetArgs([]string{"add-tenants", tenantName, userFirstName, userLastName, userEmail, "--network-type", "pubnet", "--multitenant-db-url", dbt.DSN}) + getEntries := log.DefaultLogger.StartTest(log.InfoLevel) + + err := rootCmd.ExecuteContext(ctx) + require.NoError(t, err) + assert.Empty(t, out.String()) + + const q = "SELECT id FROM tenants WHERE name = $1" + var tenantID string + err = dbConnectionPool.GetContext(ctx, &tenantID, q, tenantName) + require.NoError(t, err) + + entries := getEntries() + require.Len(t, entries, 17) + assert.Equal(t, "tenant irc added successfully", entries[15].Message) + assert.Contains(t, fmt.Sprintf("tenant ID: %s", tenantID), entries[16].Message) + + // Connecting to the new schema + schemaName := fmt.Sprintf("sdp_%s", tenantName) + dataSourceName := dbConnectionPool.DSN() + u, err := url.Parse(dataSourceName) + require.NoError(t, err) + uq := u.Query() + uq.Set("search_path", schemaName) + u.RawQuery = uq.Encode() + + tenantSchemaConnectionPool, err := db.OpenDBConnectionPool(u.String()) + require.NoError(t, err) + defer tenantSchemaConnectionPool.Close() + + expectedTablesAfterMigrationsApplied := []string{ + "assets", + "auth_migrations", + "auth_user_mfa_codes", + "auth_user_password_reset", + "auth_users", + "channel_accounts", + "countries", + "disbursements", + "gorp_migrations", + "messages", + "organizations", + "payments", + "receiver_verifications", + "receiver_wallets", + "receivers", + "submitter_transactions", + "wallets", + "wallets_assets", + } + tenant.TenantSchemaHasTablesFixture(t, ctx, tenantSchemaConnectionPool, schemaName, expectedTablesAfterMigrationsApplied) + tenant.AssertRegisteredAssets(t, ctx, tenantSchemaConnectionPool, []string{"USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", "XLM:"}) + tenant.AssertRegisteredWallets(t, ctx, tenantSchemaConnectionPool, []string{"Vibrant Assist RC", "Vibrant Assist"}) + tenant.AssertRegisteredUser(t, ctx, tenantSchemaConnectionPool, userFirstName, userLastName, userEmail) }) } diff --git a/stellar-multitenant/pkg/cli/utils/custom_set_value.go b/stellar-multitenant/pkg/cli/utils/custom_set_value.go index b6e89fe0a..52fc7cac1 100644 --- a/stellar-multitenant/pkg/cli/utils/custom_set_value.go +++ b/stellar-multitenant/pkg/cli/utils/custom_set_value.go @@ -10,6 +10,7 @@ import ( "github.com/stellar/go/keypair" "github.com/stellar/go/support/config" "github.com/stellar/go/support/log" + "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" "github.com/stellar/stellar-disbursement-platform-backend/stellar-multitenant/pkg/tenant" ) @@ -130,3 +131,18 @@ func SetConfigOptionOptionalBoolean(co *config.ConfigOption) error { *key = &value return nil } + +func SetConfigOptionNetworkType(co *config.ConfigOption) error { + networkType := viper.GetString(co.Name) + value, err := utils.GetNetworkTypeFromString(networkType) + if err != nil { + return fmt.Errorf("getting network type from string: %w", err) + } + + key, ok := co.ConfigKey.(*string) + if !ok { + return fmt.Errorf("the expected type for this config key is a string, but got a %T instead", co.ConfigKey) + } + *key = string(value) + return nil +} diff --git a/stellar-multitenant/pkg/tenant/fixtures.go b/stellar-multitenant/pkg/tenant/fixtures.go new file mode 100644 index 000000000..979de1dc3 --- /dev/null +++ b/stellar-multitenant/pkg/tenant/fixtures.go @@ -0,0 +1,103 @@ +package tenant + +import ( + "context" + "testing" + + "github.com/lib/pq" + "github.com/stellar/stellar-disbursement-platform-backend/db" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func ResetTenantConfigFixture(t *testing.T, ctx context.Context, dbConnectionPool db.DBConnectionPool, tenantID string) *Tenant { + t.Helper() + + const q = ` + UPDATE tenants + SET + email_sender_type = DEFAULT, sms_sender_type = DEFAULT, sep10_signing_public_key = NULL, + distribution_public_key = NULL, enable_mfa = DEFAULT, enable_recaptcha = DEFAULT, + cors_allowed_origins = NULL, base_url = NULL, sdp_ui_base_url = NULL + WHERE + id = $1 + RETURNING * + ` + + var tnt Tenant + err := dbConnectionPool.GetContext(ctx, &tnt, q, tenantID) + require.NoError(t, err) + + return &tnt +} + +func CheckSchemaExistsFixture(t *testing.T, ctx context.Context, dbConnectionPool db.DBConnectionPool, schemaName string) bool { + t.Helper() + + const q = ` + SELECT EXISTS( + SELECT schema_name FROM information_schema.schemata WHERE schema_name = $1 + ) + ` + + var exists bool + err := dbConnectionPool.GetContext(ctx, &exists, q, schemaName) + require.NoError(t, err) + + return exists +} + +// TenantSchemaHasTablesFixture asserts if the new tenant database schema has the tables passed by parameter. +func TenantSchemaHasTablesFixture(t *testing.T, ctx context.Context, dbConnectionPool db.DBConnectionPool, schemaName string, tableNames []string) { + t.Helper() + + const q = ` + SELECT table_name FROM information_schema.tables WHERE table_schema = $1 ORDER BY table_name + ` + + var schemaTables []string + err := dbConnectionPool.SelectContext(ctx, &schemaTables, q, schemaName) + require.NoError(t, err) + + assert.ElementsMatch(t, tableNames, schemaTables) +} + +func AssertRegisteredAssets(t *testing.T, ctx context.Context, dbConnectionPool db.DBConnectionPool, expectedAssets []string) { + var registeredAssets []string + queryRegisteredAssets := ` + SELECT CONCAT(code, ':', issuer) FROM assets + ` + err := dbConnectionPool.SelectContext(ctx, ®isteredAssets, queryRegisteredAssets) + require.NoError(t, err) + assert.ElementsMatch(t, expectedAssets, registeredAssets) +} + +func AssertRegisteredWallets(t *testing.T, ctx context.Context, dbConnectionPool db.DBConnectionPool, expectedWallets []string) { + var registeredWallets []string + queryRegisteredWallets := ` + SELECT name FROM wallets + ` + err := dbConnectionPool.SelectContext(ctx, ®isteredWallets, queryRegisteredWallets) + require.NoError(t, err) + assert.ElementsMatch(t, expectedWallets, registeredWallets) +} + +func AssertRegisteredUser(t *testing.T, ctx context.Context, dbConnectionPool db.DBConnectionPool, userFirstName, userLastName, userEmail string) { + var user struct { + FirstName string `db:"first_name"` + LastName string `db:"last_name"` + Email string `db:"email"` + Roles pq.StringArray `db:"roles"` + IsOwner bool `db:"is_owner"` + } + queryRegisteredUser := ` + SELECT first_name, last_name, email, roles, is_owner FROM auth_users WHERE email = $1 + ` + err := dbConnectionPool.GetContext(ctx, &user, queryRegisteredUser, userEmail) + require.NoError(t, err) + assert.Equal(t, userFirstName, user.FirstName) + assert.Equal(t, userLastName, user.LastName) + assert.Equal(t, userEmail, user.Email) + assert.Equal(t, pq.StringArray{"owner"}, user.Roles) + assert.True(t, user.IsOwner) +} diff --git a/stellar-multitenant/pkg/tenant/manager.go b/stellar-multitenant/pkg/tenant/manager.go index e75065958..3e975ed29 100644 --- a/stellar-multitenant/pkg/tenant/manager.go +++ b/stellar-multitenant/pkg/tenant/manager.go @@ -3,12 +3,21 @@ package tenant import ( "context" "database/sql" + "embed" "errors" "fmt" + "net/url" "strings" "github.com/lib/pq" + migrate "github.com/rubenv/sql-migrate" + "github.com/stellar/go/support/log" "github.com/stellar/stellar-disbursement-platform-backend/db" + authmigrations "github.com/stellar/stellar-disbursement-platform-backend/db/migrations/auth-migrations" + sdpmigrations "github.com/stellar/stellar-disbursement-platform-backend/db/migrations/sdp-migrations" + "github.com/stellar/stellar-disbursement-platform-backend/internal/services" + "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" + "github.com/stellar/stellar-disbursement-platform-backend/stellar-auth/pkg/auth" ) var ( @@ -21,6 +30,83 @@ type Manager struct { db db.DBConnectionPool } +func (m *Manager) ProvisionNewTenant(ctx context.Context, name, userFirstName, userLastName, userEmail, networkType string) (*Tenant, error) { + log.Infof("adding tenant %s", name) + t, err := m.AddTenant(ctx, name) + if err != nil { + return nil, err + } + + log.Infof("creating tenant %s database schema", t.Name) + schemaName := fmt.Sprintf("sdp_%s", t.Name) + _, err = m.db.ExecContext(ctx, fmt.Sprintf("CREATE SCHEMA %s", pq.QuoteIdentifier(schemaName))) + if err != nil { + return nil, fmt.Errorf("creating a new database schema: %w", err) + } + + dataSourceName := m.db.DSN() + u, err := url.Parse(dataSourceName) + if err != nil { + return nil, fmt.Errorf("parsing database DSN: %w", err) + } + q := u.Query() + q.Set("search_path", schemaName) + u.RawQuery = q.Encode() + + // Applying migrations + log.Infof("applying SDP migrations on the tenant %s schema", t.Name) + err = m.RunMigrationsForTenant(ctx, t, u.String(), migrate.Up, 0, sdpmigrations.FS, db.StellarSDPMigrationsTableName) + if err != nil { + return nil, fmt.Errorf("applying SDP migrations: %w", err) + } + + log.Infof("applying stellar-auth migrations on the tenant %s schema", t.Name) + err = m.RunMigrationsForTenant(ctx, t, u.String(), migrate.Up, 0, authmigrations.FS, db.StellarAuthMigrationsTableName) + if err != nil { + return nil, fmt.Errorf("applying stellar-auth migrations: %w", err) + } + + // Connecting to the tenant database schema + tenantSchemaConnectionPool, err := db.OpenDBConnectionPool(u.String()) + if err != nil { + return nil, fmt.Errorf("opening database connection on tenant schema: %w", err) + } + defer tenantSchemaConnectionPool.Close() + + err = services.SetupAssetsForProperNetwork(ctx, tenantSchemaConnectionPool, utils.NetworkType(networkType), services.DefaultAssetsNetworkMap) + if err != nil { + return nil, fmt.Errorf("running setup assets for proper network: %w", err) + } + + err = services.SetupWalletsForProperNetwork(ctx, tenantSchemaConnectionPool, utils.NetworkType(networkType), services.DefaultWalletsNetworkMap) + if err != nil { + return nil, fmt.Errorf("running setup wallets for proper network: %w", err) + } + + // TODO: send invitation email to this new user + authManager := auth.NewAuthManager( + auth.WithDefaultAuthenticatorOption(tenantSchemaConnectionPool, auth.NewDefaultPasswordEncrypter(), 0), + ) + _, err = authManager.CreateUser(ctx, &auth.User{ + FirstName: userFirstName, + LastName: userLastName, + Email: userEmail, + IsOwner: true, + Roles: []string{"owner"}, + }, "") + if err != nil { + return nil, fmt.Errorf("creating user: %w", err) + } + + tenantStatus := ProvisionedTenantStatus + t, err = m.UpdateTenantConfig(ctx, &TenantUpdate{ID: t.ID, Status: &tenantStatus}) + if err != nil { + return nil, fmt.Errorf("updating tenant %s status to %s: %w", name, ProvisionedTenantStatus, err) + } + + return t, nil +} + func (m *Manager) AddTenant(ctx context.Context, name string) (*Tenant, error) { if name == "" { return nil, ErrEmptyTenantName @@ -102,6 +188,11 @@ func (m *Manager) UpdateTenantConfig(ctx context.Context, tu *TenantUpdate) (*Te args = append(args, pq.Array(tu.CORSAllowedOrigins)) } + if tu.Status != nil { + fields = append(fields, "status = ?") + args = append(args, *tu.Status) + } + args = append(args, tu.ID) q = fmt.Sprintf(q, strings.Join(fields, ",\n")) q = m.db.Rebind(q) @@ -117,6 +208,19 @@ func (m *Manager) UpdateTenantConfig(ctx context.Context, tu *TenantUpdate) (*Te return &t, nil } +func (m *Manager) RunMigrationsForTenant( + ctx context.Context, t *Tenant, dbURL string, + dir migrate.MigrationDirection, count int, + migrationFiles embed.FS, migrationTableName db.MigrationTableName, +) error { + n, err := db.Migrate(dbURL, dir, count, migrationFiles, migrationTableName) + if err != nil { + return fmt.Errorf("applying SDP migrations: %w", err) + } + log.Infof("successful applied %d migrations", n) + return nil +} + type Option func(m *Manager) func NewManager(opts ...Option) *Manager { diff --git a/stellar-multitenant/pkg/tenant/manager_test.go b/stellar-multitenant/pkg/tenant/manager_test.go index fa480f93c..f0f796789 100644 --- a/stellar-multitenant/pkg/tenant/manager_test.go +++ b/stellar-multitenant/pkg/tenant/manager_test.go @@ -2,34 +2,134 @@ package tenant import ( "context" + "fmt" + "net/url" "testing" + migrate "github.com/rubenv/sql-migrate" "github.com/stellar/go/keypair" "github.com/stellar/stellar-disbursement-platform-backend/db" "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" + authmigrations "github.com/stellar/stellar-disbursement-platform-backend/db/migrations/auth-migrations" + sdpmigrations "github.com/stellar/stellar-disbursement-platform-backend/db/migrations/sdp-migrations" + "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func resetTenantConfigFixture(t *testing.T, ctx context.Context, dbConnectionPool db.DBConnectionPool, tenantID string) *Tenant { - t.Helper() - - const q = ` - UPDATE tenants - SET - email_sender_type = DEFAULT, sms_sender_type = DEFAULT, sep10_signing_public_key = NULL, - distribution_public_key = NULL, enable_mfa = DEFAULT, enable_recaptcha = DEFAULT, - cors_allowed_origins = NULL, base_url = NULL, sdp_ui_base_url = NULL - WHERE - id = $1 - RETURNING * - ` +func Test_Manager_ProvisionNewTenant(t *testing.T) { + dbt := dbtest.OpenWithTenantMigrationsOnly(t) + defer dbt.Close() - var tnt Tenant - err := dbConnectionPool.GetContext(ctx, &tnt, q, tenantID) + dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) require.NoError(t, err) + defer dbConnectionPool.Close() + + ctx := context.Background() + + m := NewManager(WithDatabase(dbConnectionPool)) + + t.Run("provision a new tenant for the testnet", func(t *testing.T) { + tenantName := "myorg-ukraine" + userFirstName := "First" + userLastName := "Last" + userEmail := "email@email.com" + tnt, err := m.ProvisionNewTenant(ctx, tenantName, userFirstName, userLastName, userEmail, string(utils.TestnetNetworkType)) + require.NoError(t, err) + + schemaName := fmt.Sprintf("sdp_%s", tenantName) + assert.Equal(t, tenantName, tnt.Name) + assert.Equal(t, ProvisionedTenantStatus, tnt.Status) + assert.True(t, CheckSchemaExistsFixture(t, ctx, dbConnectionPool, schemaName)) + + // Connecting to the new schema + u, err := url.Parse(dbConnectionPool.DSN()) + require.NoError(t, err) + uq := u.Query() + uq.Set("search_path", schemaName) + u.RawQuery = uq.Encode() + + tenantSchemaConnectionPool, err := db.OpenDBConnectionPool(u.String()) + require.NoError(t, err) + defer tenantSchemaConnectionPool.Close() - return &tnt + expectedTablesAfterMigrationsApplied := []string{ + "assets", + "auth_migrations", + "auth_user_mfa_codes", + "auth_user_password_reset", + "auth_users", + "channel_accounts", + "countries", + "disbursements", + "gorp_migrations", + "messages", + "organizations", + "payments", + "receiver_verifications", + "receiver_wallets", + "receivers", + "submitter_transactions", + "wallets", + "wallets_assets", + } + TenantSchemaHasTablesFixture(t, ctx, tenantSchemaConnectionPool, schemaName, expectedTablesAfterMigrationsApplied) + + AssertRegisteredAssets(t, ctx, tenantSchemaConnectionPool, []string{"USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", "XLM:"}) + AssertRegisteredWallets(t, ctx, tenantSchemaConnectionPool, []string{"Demo Wallet", "Vibrant Assist"}) + AssertRegisteredUser(t, ctx, tenantSchemaConnectionPool, userFirstName, userLastName, userEmail) + }) + + t.Run("provision a new tenant for the pubnet", func(t *testing.T) { + tenantName := "myorg-us" + userFirstName := "First" + userLastName := "Last" + userEmail := "email@email.com" + tnt, err := m.ProvisionNewTenant(ctx, tenantName, userFirstName, userLastName, userEmail, string(utils.PubnetNetworkType)) + require.NoError(t, err) + + schemaName := fmt.Sprintf("sdp_%s", tenantName) + assert.Equal(t, tenantName, tnt.Name) + assert.Equal(t, ProvisionedTenantStatus, tnt.Status) + assert.True(t, CheckSchemaExistsFixture(t, ctx, dbConnectionPool, schemaName)) + + // Connecting to the new schema + u, err := url.Parse(dbConnectionPool.DSN()) + require.NoError(t, err) + uq := u.Query() + uq.Set("search_path", schemaName) + u.RawQuery = uq.Encode() + + tenantSchemaConnectionPool, err := db.OpenDBConnectionPool(u.String()) + require.NoError(t, err) + defer tenantSchemaConnectionPool.Close() + + expectedTablesAfterMigrationsApplied := []string{ + "assets", + "auth_migrations", + "auth_user_mfa_codes", + "auth_user_password_reset", + "auth_users", + "channel_accounts", + "countries", + "disbursements", + "gorp_migrations", + "messages", + "organizations", + "payments", + "receiver_verifications", + "receiver_wallets", + "receivers", + "submitter_transactions", + "wallets", + "wallets_assets", + } + TenantSchemaHasTablesFixture(t, ctx, tenantSchemaConnectionPool, schemaName, expectedTablesAfterMigrationsApplied) + + AssertRegisteredAssets(t, ctx, tenantSchemaConnectionPool, []string{"USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", "XLM:"}) + AssertRegisteredWallets(t, ctx, tenantSchemaConnectionPool, []string{"Vibrant Assist RC", "Vibrant Assist"}) + AssertRegisteredUser(t, ctx, tenantSchemaConnectionPool, userFirstName, userLastName, userEmail) + }) } func Test_Manager_AddTenant(t *testing.T) { @@ -55,6 +155,7 @@ func Test_Manager_AddTenant(t *testing.T) { assert.NotNil(t, tnt) assert.NotEmpty(t, tnt.ID) assert.Equal(t, "myorg-ukraine", tnt.Name) + assert.Equal(t, CreatedTenantStatus, tnt.Status) }) t.Run("returns error when tenant name is duplicated", func(t *testing.T) { @@ -63,6 +164,7 @@ func Test_Manager_AddTenant(t *testing.T) { assert.NotNil(t, tnt) assert.NotEmpty(t, tnt.ID) assert.Equal(t, "myorg", tnt.Name) + assert.Equal(t, CreatedTenantStatus, tnt.Status) tnt, err = m.AddTenant(ctx, "MyOrg") assert.Equal(t, ErrDuplicatedTenantName, err) @@ -73,6 +175,7 @@ func Test_Manager_AddTenant(t *testing.T) { func Test_Manager_UpdateTenantConfig(t *testing.T) { dbt := dbtest.Open(t) defer dbt.Close() + dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) require.NoError(t, err) defer dbConnectionPool.Close() @@ -102,7 +205,7 @@ func Test_Manager_UpdateTenantConfig(t *testing.T) { }) t.Run("updates tenant config successfully", func(t *testing.T) { - tntDB = resetTenantConfigFixture(t, ctx, dbConnectionPool, tntDB.ID) + tntDB = ResetTenantConfigFixture(t, ctx, dbConnectionPool, tntDB.ID) assert.Equal(t, tntDB.EmailSenderType, DryRunEmailSenderType) assert.Equal(t, tntDB.SMSSenderType, DryRunSMSSenderType) assert.Nil(t, tntDB.SEP10SigningPublicKey) @@ -155,3 +258,87 @@ func Test_Manager_UpdateTenantConfig(t *testing.T) { assert.ElementsMatch(t, []string{"https://myorg.sdp.io", "https://myorg-dev.sdp.io"}, tnt.CORSAllowedOrigins) }) } + +func Test_Manager_RunMigrationsForTenant(t *testing.T) { + dbt := dbtest.OpenWithTenantMigrationsOnly(t) + defer dbt.Close() + + dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, err) + defer dbConnectionPool.Close() + + ctx := context.Background() + + m := NewManager(WithDatabase(dbConnectionPool)) + tnt1, err := m.AddTenant(ctx, "myorg1") + require.NoError(t, err) + tnt2, err := m.AddTenant(ctx, "myorg2") + require.NoError(t, err) + + tnt1SchemaName := fmt.Sprintf("sdp_%s", tnt1.Name) + tnt2SchemaName := fmt.Sprintf("sdp_%s", tnt2.Name) + + // Creating DB Schemas + _, err = dbConnectionPool.ExecContext(ctx, fmt.Sprintf("CREATE SCHEMA %s", tnt1SchemaName)) + require.NoError(t, err) + _, err = dbConnectionPool.ExecContext(ctx, fmt.Sprintf("CREATE SCHEMA %s", tnt2SchemaName)) + require.NoError(t, err) + + u, err := url.Parse(dbConnectionPool.DSN()) + require.NoError(t, err) + + // Tenant 1 DB connection + tnt1Q := u.Query() + tnt1Q.Set("search_path", tnt1SchemaName) + u.RawQuery = tnt1Q.Encode() + tnt1SchemaConnectionPool, err := db.OpenDBConnectionPool(u.String()) + require.NoError(t, err) + defer tnt1SchemaConnectionPool.Close() + + // Tenant 2 DB connection + tnt2Q := u.Query() + tnt2Q.Set("search_path", tnt2SchemaName) + u.RawQuery = tnt2Q.Encode() + tnt2SchemaConnectionPool, err := db.OpenDBConnectionPool(u.String()) + require.NoError(t, err) + defer tnt2SchemaConnectionPool.Close() + + // Apply migrations for Tenant 1 + err = m.RunMigrationsForTenant(ctx, tnt1, tnt1SchemaConnectionPool.DSN(), migrate.Up, 0, sdpmigrations.FS, db.StellarSDPMigrationsTableName) + require.NoError(t, err) + err = m.RunMigrationsForTenant(ctx, tnt1, tnt1SchemaConnectionPool.DSN(), migrate.Up, 0, authmigrations.FS, db.StellarAuthMigrationsTableName) + require.NoError(t, err) + + expectedTablesAfterMigrationsApplied := []string{ + "assets", + "auth_migrations", + "auth_user_mfa_codes", + "auth_user_password_reset", + "auth_users", + "channel_accounts", + "countries", + "disbursements", + "gorp_migrations", + "messages", + "organizations", + "payments", + "receiver_verifications", + "receiver_wallets", + "receivers", + "submitter_transactions", + "wallets", + "wallets_assets", + } + TenantSchemaHasTablesFixture(t, ctx, dbConnectionPool, tnt1SchemaName, expectedTablesAfterMigrationsApplied) + + // Asserting if the Tenant 2 DB Schema wasn't affected by Tenant 1 schema migrations + TenantSchemaHasTablesFixture(t, ctx, dbConnectionPool, tnt2SchemaName, []string{}) + + // Apply migrations for Tenant 2 + err = m.RunMigrationsForTenant(ctx, tnt2, tnt2SchemaConnectionPool.DSN(), migrate.Up, 0, sdpmigrations.FS, db.StellarSDPMigrationsTableName) + require.NoError(t, err) + err = m.RunMigrationsForTenant(ctx, tnt2, tnt2SchemaConnectionPool.DSN(), migrate.Up, 0, authmigrations.FS, db.StellarAuthMigrationsTableName) + require.NoError(t, err) + + TenantSchemaHasTablesFixture(t, ctx, dbConnectionPool, tnt2SchemaName, expectedTablesAfterMigrationsApplied) +} diff --git a/stellar-multitenant/pkg/tenant/tenant.go b/stellar-multitenant/pkg/tenant/tenant.go index b54c3bfc1..59d067042 100644 --- a/stellar-multitenant/pkg/tenant/tenant.go +++ b/stellar-multitenant/pkg/tenant/tenant.go @@ -55,6 +55,7 @@ type Tenant struct { CORSAllowedOrigins pq.StringArray `db:"cors_allowed_origins"` BaseURL *string `db:"base_url"` SDPUIBaseURL *string `db:"sdp_ui_base_url"` + Status TenantStatus `db:"status"` CreatedAt time.Time `db:"created_at"` UpdatedAt time.Time `db:"updated_at"` } @@ -70,6 +71,21 @@ type TenantUpdate struct { CORSAllowedOrigins []string `db:"cors_allowed_origins"` BaseURL *string `db:"base_url"` SDPUIBaseURL *string `db:"sdp_ui_base_url"` + Status *TenantStatus `db:"status"` +} + +type TenantStatus string + +const ( + CreatedTenantStatus TenantStatus = "TENANT_CREATED" + ProvisionedTenantStatus TenantStatus = "TENANT_PROVISIONED" + ActivatedTenantStatus TenantStatus = "TENANT_ACTIVATED" + DeactivatedTenantStatus TenantStatus = "TENANT_DEACTIVATED" +) + +func (s TenantStatus) IsValid() bool { + validStatuses := []TenantStatus{CreatedTenantStatus, ProvisionedTenantStatus, ActivatedTenantStatus, DeactivatedTenantStatus} + return slices.Contains(validStatuses, s) } func (tu *TenantUpdate) Validate() error { @@ -115,6 +131,10 @@ func (tu *TenantUpdate) Validate() error { } } + if tu.Status != nil && !tu.Status.IsValid() { + return fmt.Errorf("invalid tenant status: %q", *tu.Status) + } + return nil } @@ -127,7 +147,8 @@ func (tu *TenantUpdate) areAllFieldsEmpty() bool { tu.EnableReCAPTCHA == nil && tu.CORSAllowedOrigins == nil && tu.BaseURL == nil && - tu.SDPUIBaseURL == nil) + tu.SDPUIBaseURL == nil && + tu.Status == nil) } func isValidURL(u string) bool { diff --git a/stellar-multitenant/pkg/tenant/tenant_test.go b/stellar-multitenant/pkg/tenant/tenant_test.go index a8e29aa65..44e92e7d0 100644 --- a/stellar-multitenant/pkg/tenant/tenant_test.go +++ b/stellar-multitenant/pkg/tenant/tenant_test.go @@ -54,6 +54,12 @@ func Test_TenantUpdate_Validate(t *testing.T) { tu.CORSAllowedOrigins = []string{"inv@lid$"} err = tu.Validate() assert.EqualError(t, err, `invalid CORS allowed origin url: "inv@lid$"`) + + tu.CORSAllowedOrigins = nil + tenantStatus := TenantStatus("invalid") + tu.Status = &tenantStatus + err = tu.Validate() + assert.EqualError(t, err, `invalid tenant status: "invalid"`) }) t.Run("valid values", func(t *testing.T) { @@ -68,6 +74,7 @@ func Test_TenantUpdate_Validate(t *testing.T) { CORSAllowedOrigins: []string{"https://myorg.sdp.io", "https://myorg-dev.sdp.io"}, BaseURL: &[]string{"https://myorg.backend.io"}[0], SDPUIBaseURL: &[]string{"https://myorg.frontend.io"}[0], + Status: &[]TenantStatus{ProvisionedTenantStatus}[0], } err := tu.Validate() assert.NoError(t, err) @@ -108,3 +115,35 @@ func Test_ParseSMSSenderType(t *testing.T) { assert.NoError(t, err) assert.Equal(t, TwilioSMSSenderType, sst) } + +func Test_TenantStatus_IsValid(t *testing.T) { + testCases := []struct { + status TenantStatus + expect bool + }{ + { + status: CreatedTenantStatus, + expect: true, + }, + { + status: ProvisionedTenantStatus, + expect: true, + }, + { + status: ActivatedTenantStatus, + expect: true, + }, + { + status: DeactivatedTenantStatus, + expect: true, + }, + { + status: TenantStatus("invalid"), + expect: false, + }, + } + + for _, tc := range testCases { + assert.Equal(t, tc.expect, tc.status.IsValid()) + } +}