diff --git a/internal/db/diff/diff_test.go b/internal/db/diff/diff_test.go index 3c21a83cc..0f71d2f29 100644 --- a/internal/db/diff/diff_test.go +++ b/internal/db/diff/diff_test.go @@ -42,6 +42,9 @@ func TestRun(t *testing.T) { // Setup in-memory fs fsys := afero.NewMemMapFs() require.NoError(t, flags.LoadConfig(fsys)) + origServiceRoleKey := utils.Config.Auth.ServiceRoleKey.Value + utils.Config.Auth.ServiceRoleKey.Value = "" + t.Cleanup(func() { utils.Config.Auth.ServiceRoleKey.Value = origServiceRoleKey }) // Setup mock docker require.NoError(t, apitest.MockDocker(utils.Docker)) defer gock.OffAll() @@ -69,9 +72,10 @@ func TestRun(t *testing.T) { require.NoError(t, apitest.MockDockerLogs(utils.Docker, "test-migra", diff)) // Setup mock postgres conn := pgtest.NewConn() - conn.Query(CREATE_TEMPLATE). - Reply("CREATE DATABASE") defer conn.Close(t) + helper.MockVaultSetup(conn, ""). + Query(CREATE_TEMPLATE). + Reply("CREATE DATABASE") // Run test err := Run(context.Background(), []string{"public"}, "file", dbConfig, DiffSchemaMigra, fsys, func(cc *pgx.ConnConfig) { if cc.Host == dbConfig.Host { @@ -121,6 +125,12 @@ func TestMigrateShadow(t *testing.T) { utils.Config.Db.ShadowPort = 54320 utils.GlobalsSql = "create schema public" utils.InitialSchemaPg14Sql = "create schema private" + origWebhookSchema := start.WebhookSchema + start.WebhookSchema = "create schema supabase_functions" + t.Cleanup(func() { start.WebhookSchema = origWebhookSchema }) + origServiceRoleKey := utils.Config.Auth.ServiceRoleKey.Value + utils.Config.Auth.ServiceRoleKey.Value = "" + t.Cleanup(func() { utils.Config.Auth.ServiceRoleKey.Value = origServiceRoleKey }) // Setup in-memory fs fsys := afero.NewMemMapFs() path := filepath.Join(utils.MigrationsDir, "0_test.sql") @@ -133,6 +143,9 @@ func TestMigrateShadow(t *testing.T) { Reply("CREATE SCHEMA"). Query(utils.InitialSchemaPg14Sql). Reply("CREATE SCHEMA"). + Query(start.WebhookSchema). + Reply("CREATE SCHEMA") + helper.MockVaultSetup(conn, ""). Query(CREATE_TEMPLATE). Reply("CREATE DATABASE") helper.MockMigrationHistory(conn). @@ -277,6 +290,12 @@ create schema public`) }) t.Run("throws error on failure to diff target", func(t *testing.T) { + origWebhookSchema := start.WebhookSchema + start.WebhookSchema = "create schema supabase_functions" + t.Cleanup(func() { start.WebhookSchema = origWebhookSchema }) + origServiceRoleKey := utils.Config.Auth.ServiceRoleKey.Value + utils.Config.Auth.ServiceRoleKey.Value = "" + t.Cleanup(func() { utils.Config.Auth.ServiceRoleKey.Value = origServiceRoleKey }) // Setup in-memory fs fsys := afero.NewMemMapFs() path := filepath.Join(utils.MigrationsDir, "0_test.sql") @@ -312,6 +331,9 @@ create schema public`) Reply("CREATE SCHEMA"). Query(utils.InitialSchemaPg14Sql). Reply("CREATE SCHEMA"). + Query(start.WebhookSchema). + Reply("CREATE SCHEMA") + helper.MockVaultSetup(conn, ""). Query(CREATE_TEMPLATE). Reply("CREATE DATABASE") helper.MockMigrationHistory(conn). diff --git a/internal/db/push/push.go b/internal/db/push/push.go index 6960702d1..f190b3a1f 100644 --- a/internal/db/push/push.go +++ b/internal/db/push/push.go @@ -50,6 +50,11 @@ func Run(ctx context.Context, dryRun, ignoreVersionMismatch bool, includeRoles, } } if len(pending) == 0 && len(seeds) == 0 && len(globals) == 0 { + if !dryRun { + if err := syncVaultSecrets(ctx, config, conn); err != nil { + return err + } + } fmt.Println("Remote database is up to date.") return nil } @@ -67,6 +72,7 @@ func Run(ctx context.Context, dryRun, ignoreVersionMismatch bool, includeRoles, fmt.Fprint(os.Stderr, confirmSeedAll(seeds)) } } else { + var vaultSynced bool if len(globals) > 0 { msg := "Do you want to create custom roles in the database cluster?" if shouldPush, err := utils.NewConsole().PromptYesNo(ctx, msg, true); err != nil { @@ -74,6 +80,10 @@ func Run(ctx context.Context, dryRun, ignoreVersionMismatch bool, includeRoles, } else if !shouldPush { return errors.New(context.Canceled) } + if err := syncVaultSecrets(ctx, config, conn); err != nil { + return err + } + vaultSynced = true if err := migration.SeedGlobals(ctx, globals, conn, afero.NewIOFS(fsys)); err != nil { return err } @@ -85,8 +95,11 @@ func Run(ctx context.Context, dryRun, ignoreVersionMismatch bool, includeRoles, } else if !shouldPush { return errors.New(context.Canceled) } - if err := vault.UpsertVaultSecrets(ctx, utils.Config.Db.Vault, conn); err != nil { - return err + if !vaultSynced { + if err := syncVaultSecrets(ctx, config, conn); err != nil { + return err + } + vaultSynced = true } if err := migration.ApplyMigrations(ctx, pending, conn, afero.NewIOFS(fsys)); err != nil { return err @@ -101,6 +114,11 @@ func Run(ctx context.Context, dryRun, ignoreVersionMismatch bool, includeRoles, } else if !shouldPush { return errors.New(context.Canceled) } + if !vaultSynced { + if err := syncVaultSecrets(ctx, config, conn); err != nil { + return err + } + } if err := migration.SeedData(ctx, seeds, conn, afero.NewIOFS(fsys)); err != nil { return err } @@ -112,6 +130,18 @@ func Run(ctx context.Context, dryRun, ignoreVersionMismatch bool, includeRoles, return nil } +func syncVaultSecrets(ctx context.Context, config pgconn.Config, conn *pgx.Conn) error { + secrets := utils.Config.Db.Vault + if utils.IsLocalDatabase(config) || len(flags.ProjectRef) > 0 { + var projectRef string + if !utils.IsLocalDatabase(config) { + projectRef = flags.ProjectRef + } + secrets = vault.WithEdgeFunctionSecrets(secrets, projectRef, utils.Config.Auth.ServiceRoleKey.Value) + } + return vault.UpsertVaultSecrets(ctx, secrets, conn) +} + func confirmPushAll(pending []string) (msg string) { for _, path := range pending { filename := filepath.Base(path) diff --git a/internal/db/push/push_test.go b/internal/db/push/push_test.go index b5d0d2c7f..064250b9b 100644 --- a/internal/db/push/push_test.go +++ b/internal/db/push/push_test.go @@ -13,9 +13,11 @@ import ( "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/supabase/cli/internal/testing/apitest" "github.com/supabase/cli/internal/testing/fstest" "github.com/supabase/cli/internal/testing/helper" "github.com/supabase/cli/internal/utils" + "github.com/supabase/cli/internal/utils/flags" "github.com/supabase/cli/pkg/migration" "github.com/supabase/cli/pkg/pgtest" ) @@ -29,11 +31,13 @@ var dbConfig = pgconn.Config{ } func TestMigrationPush(t *testing.T) { + flags.ProjectRef = apitest.RandomProjectRef() + t.Run("dry run", func(t *testing.T) { // Setup in-memory fs fsys := afero.NewMemMapFs() path := filepath.Join(utils.MigrationsDir, "0_test.sql") - require.NoError(t, afero.WriteFile(fsys, path, []byte(""), 0644)) + require.NoError(t, afero.WriteFile(fsys, path, []byte(""), 0o644)) // Setup mock postgres conn := pgtest.NewConn() defer conn.Close(t) @@ -46,6 +50,9 @@ func TestMigrationPush(t *testing.T) { }) t.Run("ignores up to date", func(t *testing.T) { + origServiceRoleKey := utils.Config.Auth.ServiceRoleKey.Value + utils.Config.Auth.ServiceRoleKey.Value = "" + t.Cleanup(func() { utils.Config.Auth.ServiceRoleKey.Value = origServiceRoleKey }) // Setup in-memory fs fsys := afero.NewMemMapFs() // Setup mock postgres @@ -53,6 +60,7 @@ func TestMigrationPush(t *testing.T) { defer conn.Close(t) conn.Query(migration.LIST_MIGRATION_VERSION). Reply("SELECT 0") + helper.MockVaultSetup(conn, flags.ProjectRef) // Run test err := Run(context.Background(), false, false, false, false, dbConfig, fsys, conn.Intercept) // Check error @@ -89,15 +97,19 @@ func TestMigrationPush(t *testing.T) { }) t.Run("throws error on push failure", func(t *testing.T) { + origServiceRoleKey := utils.Config.Auth.ServiceRoleKey.Value + utils.Config.Auth.ServiceRoleKey.Value = "" + t.Cleanup(func() { utils.Config.Auth.ServiceRoleKey.Value = origServiceRoleKey }) // Setup in-memory fs fsys := afero.NewMemMapFs() path := filepath.Join(utils.MigrationsDir, "0_test.sql") - require.NoError(t, afero.WriteFile(fsys, path, []byte(""), 0644)) + require.NoError(t, afero.WriteFile(fsys, path, []byte(""), 0o644)) // Setup mock postgres conn := pgtest.NewConn() defer conn.Close(t) conn.Query(migration.LIST_MIGRATION_VERSION). Reply("SELECT 0") + helper.MockVaultSetup(conn, flags.ProjectRef) helper.MockMigrationHistory(conn). Query("RESET ALL"). Reply("RESET"). @@ -112,16 +124,22 @@ func TestMigrationPush(t *testing.T) { } func TestPushAll(t *testing.T) { + flags.ProjectRef = apitest.RandomProjectRef() + t.Run("ignores missing roles and seed", func(t *testing.T) { + origServiceRoleKey := utils.Config.Auth.ServiceRoleKey.Value + utils.Config.Auth.ServiceRoleKey.Value = "" + t.Cleanup(func() { utils.Config.Auth.ServiceRoleKey.Value = origServiceRoleKey }) // Setup in-memory fs fsys := afero.NewMemMapFs() path := filepath.Join(utils.MigrationsDir, "0_test.sql") - require.NoError(t, afero.WriteFile(fsys, path, []byte{}, 0644)) + require.NoError(t, afero.WriteFile(fsys, path, []byte{}, 0o644)) // Setup mock postgres conn := pgtest.NewConn() defer conn.Close(t) conn.Query(migration.LIST_MIGRATION_VERSION). Reply("SELECT 0") + helper.MockVaultSetup(conn, flags.ProjectRef) helper.MockMigrationHistory(conn). Query("RESET ALL"). Reply("RESET"). @@ -138,7 +156,7 @@ func TestPushAll(t *testing.T) { // Setup in-memory fs fsys := afero.NewMemMapFs() path := filepath.Join(utils.MigrationsDir, "0_test.sql") - require.NoError(t, afero.WriteFile(fsys, path, []byte{}, 0644)) + require.NoError(t, afero.WriteFile(fsys, path, []byte{}, 0o644)) // Setup mock postgres conn := pgtest.NewConn() defer conn.Close(t) @@ -154,7 +172,7 @@ func TestPushAll(t *testing.T) { // Setup in-memory fs fsys := &fstest.StatErrorFs{DenyPath: utils.CustomRolesPath} path := filepath.Join(utils.MigrationsDir, "0_test.sql") - require.NoError(t, afero.WriteFile(fsys, path, []byte{}, 0644)) + require.NoError(t, afero.WriteFile(fsys, path, []byte{}, 0o644)) // Setup mock postgres conn := pgtest.NewConn() defer conn.Close(t) @@ -167,14 +185,17 @@ func TestPushAll(t *testing.T) { }) t.Run("throws error on seed failure", func(t *testing.T) { + origServiceRoleKey := utils.Config.Auth.ServiceRoleKey.Value + utils.Config.Auth.ServiceRoleKey.Value = "" + t.Cleanup(func() { utils.Config.Auth.ServiceRoleKey.Value = origServiceRoleKey }) digest := hex.EncodeToString(sha256.New().Sum(nil)) seedPath := filepath.Join(utils.SupabaseDirPath, "seed.sql") utils.Config.Db.Seed.SqlPaths = []string{seedPath} // Setup in-memory fs fsys := afero.NewMemMapFs() - require.NoError(t, afero.WriteFile(fsys, seedPath, []byte{}, 0644)) + require.NoError(t, afero.WriteFile(fsys, seedPath, []byte{}, 0o644)) path := filepath.Join(utils.MigrationsDir, "0_test.sql") - require.NoError(t, afero.WriteFile(fsys, path, []byte{}, 0644)) + require.NoError(t, afero.WriteFile(fsys, path, []byte{}, 0o644)) // Setup mock postgres conn := pgtest.NewConn() defer conn.Close(t) @@ -182,6 +203,7 @@ func TestPushAll(t *testing.T) { Reply("SELECT 0"). Query(migration.SELECT_SEED_TABLE). Reply("SELECT 0") + helper.MockVaultSetup(conn, flags.ProjectRef) helper.MockMigrationHistory(conn). Query("RESET ALL"). Reply("RESET"). diff --git a/internal/db/reset/reset.go b/internal/db/reset/reset.go index 40aee23f5..6b59e1737 100644 --- a/internal/db/reset/reset.go +++ b/internal/db/reset/reset.go @@ -28,6 +28,7 @@ import ( "github.com/supabase/cli/internal/migration/repair" "github.com/supabase/cli/internal/seed/buckets" "github.com/supabase/cli/internal/utils" + "github.com/supabase/cli/internal/utils/flags" "github.com/supabase/cli/pkg/migration" ) @@ -251,7 +252,7 @@ func resetRemote(ctx context.Context, version string, config pgconn.Config, fsys return err } defer conn.Close(context.Background()) - return down.ResetAll(ctx, version, conn, fsys) + return down.ResetAll(ctx, version, flags.ProjectRef, false, conn, fsys) } func LikeEscapeSchema(schemas []string) (result []string) { diff --git a/internal/db/reset/reset_test.go b/internal/db/reset/reset_test.go index 839ccdc8b..73f745166 100644 --- a/internal/db/reset/reset_test.go +++ b/internal/db/reset/reset_test.go @@ -19,6 +19,7 @@ import ( "github.com/supabase/cli/internal/db/start" "github.com/supabase/cli/internal/testing/apitest" "github.com/supabase/cli/internal/testing/fstest" + "github.com/supabase/cli/internal/testing/helper" "github.com/supabase/cli/internal/utils" "github.com/supabase/cli/pkg/pgtest" "github.com/supabase/cli/pkg/storage" @@ -28,7 +29,7 @@ func TestResetCommand(t *testing.T) { utils.Config.Hostname = "127.0.0.1" utils.Config.Db.Port = 5432 - var dbConfig = pgconn.Config{ + dbConfig := pgconn.Config{ Host: utils.Config.Hostname, Port: utils.Config.Db.Port, User: "admin", @@ -39,6 +40,9 @@ func TestResetCommand(t *testing.T) { t.Run("seeds storage after reset", func(t *testing.T) { utils.DbId = "test-reset" utils.Config.Db.MajorVersion = 15 + origServiceRoleKey := utils.Config.Auth.ServiceRoleKey.Value + utils.Config.Auth.ServiceRoleKey.Value = "" + t.Cleanup(func() { utils.Config.Auth.ServiceRoleKey.Value = origServiceRoleKey }) // Setup in-memory fs fsys := afero.NewMemMapFs() // Setup mock docker @@ -67,6 +71,7 @@ func TestResetCommand(t *testing.T) { // Setup mock postgres conn := pgtest.NewConn() defer conn.Close(t) + helper.MockVaultSetup(conn, "") // Restarts services utils.StorageId = "test-storage" utils.GotrueId = "test-auth" diff --git a/internal/db/start/start.go b/internal/db/start/start.go index 92c8fe5eb..691741b5a 100644 --- a/internal/db/start/start.go +++ b/internal/db/start/start.go @@ -34,7 +34,7 @@ var ( //go:embed templates/schema.sql initialSchema string //go:embed templates/webhook.sql - webhookSchema string + WebhookSchema string //go:embed templates/_supabase.sql _supabaseSchema string //go:embed templates/restore.sh @@ -94,7 +94,7 @@ cat <<'EOF' > /etc/postgresql-custom/pgsodium_root.key && \ cat <<'EOF' >> /etc/postgresql/postgresql.conf && \ docker-entrypoint.sh postgres -D /etc/postgresql ` + strings.Join(args, " ") + ` ` + initialSchema + ` -` + webhookSchema + ` +` + WebhookSchema + ` ` + _supabaseSchema + ` EOF ` + utils.Config.Db.RootKey.Value + ` @@ -248,7 +248,15 @@ func initSchema(ctx context.Context, conn *pgx.Conn, host string, w io.Writer) e } else if err := file.ExecBatch(ctx, conn); err != nil { return err } - return InitSchema14(ctx, conn) + if err := InitSchema14(ctx, conn); err != nil { + return err + } + if file, err := migration.NewMigrationFromReader(strings.NewReader(WebhookSchema)); err != nil { + return err + } else if err := file.ExecBatch(ctx, conn); err != nil { + return err + } + return nil } return initSchema15(ctx, host) } @@ -372,8 +380,9 @@ func SetupDatabase(ctx context.Context, conn *pgx.Conn, host string, w io.Writer if err := initSchema(ctx, conn, host, w); err != nil { return err } + secrets := vault.WithEdgeFunctionSecrets(utils.Config.Db.Vault, "", utils.Config.Auth.ServiceRoleKey.Value) // Create vault secrets first so roles.sql can reference them - if err := vault.UpsertVaultSecrets(ctx, utils.Config.Db.Vault, conn); err != nil { + if err := vault.UpsertVaultSecrets(ctx, secrets, conn); err != nil { return err } err := migration.SeedGlobals(ctx, []string{utils.CustomRolesPath}, conn, afero.NewIOFS(fsys)) diff --git a/internal/db/start/start_test.go b/internal/db/start/start_test.go index 39f23d8ba..cc0066e43 100644 --- a/internal/db/start/start_test.go +++ b/internal/db/start/start_test.go @@ -17,6 +17,7 @@ import ( "github.com/stretchr/testify/require" "github.com/supabase/cli/internal/testing/apitest" "github.com/supabase/cli/internal/testing/fstest" + "github.com/supabase/cli/internal/testing/helper" "github.com/supabase/cli/internal/utils" "github.com/supabase/cli/pkg/cast" "github.com/supabase/cli/pkg/pgtest" @@ -56,10 +57,13 @@ func TestStartDatabase(t *testing.T) { utils.Config.Db.MajorVersion = 15 utils.DbId = "supabase_db_test" utils.Config.Db.Port = 5432 + origServiceRoleKey := utils.Config.Auth.ServiceRoleKey.Value + utils.Config.Auth.ServiceRoleKey.Value = "" + t.Cleanup(func() { utils.Config.Auth.ServiceRoleKey.Value = origServiceRoleKey }) // Setup in-memory fs fsys := afero.NewMemMapFs() roles := "create role test" - require.NoError(t, afero.WriteFile(fsys, utils.CustomRolesPath, []byte(roles), 0644)) + require.NoError(t, afero.WriteFile(fsys, utils.CustomRolesPath, []byte(roles), 0o644)) // Setup mock docker require.NoError(t, apitest.MockDocker(utils.Docker)) defer gock.OffAll() @@ -86,7 +90,8 @@ func TestStartDatabase(t *testing.T) { // Setup mock postgres conn := pgtest.NewConn() defer conn.Close(t) - conn.Query(roles). + helper.MockVaultSetup(conn, ""). + Query(roles). Reply("CREATE ROLE") // Run test err := StartDatabase(context.Background(), "", fsys, io.Discard, conn.Intercept) @@ -159,7 +164,7 @@ func TestStartCommand(t *testing.T) { t.Run("throws error on malformed config", func(t *testing.T) { // Setup in-memory fs fsys := afero.NewMemMapFs() - require.NoError(t, afero.WriteFile(fsys, utils.ConfigPath, []byte("malformed"), 0644)) + require.NoError(t, afero.WriteFile(fsys, utils.ConfigPath, []byte("malformed"), 0o644)) // Run test err := Run(context.Background(), "", fsys) // Check error @@ -237,12 +242,18 @@ func TestSetupDatabase(t *testing.T) { utils.Config.Db.MajorVersion = 15 }() utils.Config.Db.Port = 5432 + origServiceRoleKey := utils.Config.Auth.ServiceRoleKey.Value + utils.Config.Auth.ServiceRoleKey.Value = "" + t.Cleanup(func() { utils.Config.Auth.ServiceRoleKey.Value = origServiceRoleKey }) utils.GlobalsSql = "create schema public" utils.InitialSchemaPg14Sql = "create schema private" + origWebhookSchema := WebhookSchema + WebhookSchema = "create schema supabase_functions" + t.Cleanup(func() { WebhookSchema = origWebhookSchema }) // Setup in-memory fs fsys := afero.NewMemMapFs() roles := "create role postgres" - require.NoError(t, afero.WriteFile(fsys, utils.CustomRolesPath, []byte(roles), 0644)) + require.NoError(t, afero.WriteFile(fsys, utils.CustomRolesPath, []byte(roles), 0o644)) // Setup mock postgres conn := pgtest.NewConn() defer conn.Close(t) @@ -250,6 +261,9 @@ func TestSetupDatabase(t *testing.T) { Reply("CREATE SCHEMA"). Query(utils.InitialSchemaPg14Sql). Reply("CREATE SCHEMA"). + Query(WebhookSchema). + Reply("CREATE SCHEMA") + helper.MockVaultSetup(conn, ""). Query(roles). Reply("CREATE ROLE") // Run test @@ -288,6 +302,9 @@ func TestSetupDatabase(t *testing.T) { t.Run("throws error on read failure", func(t *testing.T) { utils.Config.Db.Port = 5432 + origServiceRoleKey := utils.Config.Auth.ServiceRoleKey.Value + utils.Config.Auth.ServiceRoleKey.Value = "" + t.Cleanup(func() { utils.Config.Auth.ServiceRoleKey.Value = origServiceRoleKey }) // Setup in-memory fs fsys := &fstest.OpenErrorFs{DenyPath: utils.CustomRolesPath} // Setup mock docker @@ -302,6 +319,7 @@ func TestSetupDatabase(t *testing.T) { // Setup mock postgres conn := pgtest.NewConn() defer conn.Close(t) + helper.MockVaultSetup(conn, "") // Run test err := SetupLocalDatabase(context.Background(), "", fsys, io.Discard, conn.Intercept) // Check error @@ -309,12 +327,16 @@ func TestSetupDatabase(t *testing.T) { assert.Empty(t, apitest.ListUnmatchedRequests()) }) } + func TestStartDatabaseWithCustomSettings(t *testing.T) { t.Run("starts database with custom MaxConnections", func(t *testing.T) { // Setup utils.Config.Db.MajorVersion = 15 utils.DbId = "supabase_db_test" utils.Config.Db.Port = 5432 + origServiceRoleKey := utils.Config.Auth.ServiceRoleKey.Value + utils.Config.Auth.ServiceRoleKey.Value = "" + t.Cleanup(func() { utils.Config.Auth.ServiceRoleKey.Value = origServiceRoleKey }) utils.Config.Db.Settings.MaxConnections = cast.Ptr(uint(50)) // Setup in-memory fs @@ -347,6 +369,7 @@ func TestStartDatabaseWithCustomSettings(t *testing.T) { // Setup mock postgres conn := pgtest.NewConn() defer conn.Close(t) + helper.MockVaultSetup(conn, "") // Run test err := StartDatabase(context.Background(), "", fsys, io.Discard, conn.Intercept) diff --git a/internal/db/start/templates/webhook.sql b/internal/db/start/templates/webhook.sql index 52cd09747..8c7839f56 100644 --- a/internal/db/start/templates/webhook.sql +++ b/internal/db/start/templates/webhook.sql @@ -44,6 +44,8 @@ CREATE FUNCTION supabase_functions.http_request() headers jsonb DEFAULT '{}'::jsonb; params jsonb DEFAULT '{}'::jsonb; timeout_ms integer DEFAULT 1000; + base_url text; + service_key text; BEGIN IF url IS NULL OR url = 'null' THEN RAISE EXCEPTION 'url argument is missing'; @@ -53,12 +55,38 @@ CREATE FUNCTION supabase_functions.http_request() RAISE EXCEPTION 'method argument is missing'; END IF; + -- Auto-detect: if not a URL, treat as edge function name + IF url NOT ILIKE 'http://%' AND url NOT ILIKE 'https://%' THEN + IF NOT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = 'vault') THEN + RAISE EXCEPTION 'Edge function webhooks require vault extension. Install vault or use full URL.'; + END IF; + + SELECT decrypted_secret INTO base_url + FROM vault.decrypted_secrets WHERE name = 'supabase_functions_url'; + IF base_url IS NULL THEN + RAISE EXCEPTION 'Vault secret "supabase_functions_url" not found'; + END IF; + + SELECT decrypted_secret INTO service_key + FROM vault.decrypted_secrets WHERE name = 'supabase_service_role_key'; + IF service_key IS NULL THEN + RAISE EXCEPTION 'Vault secret "supabase_service_role_key" not found'; + END IF; + + url := rtrim(base_url, '/') || '/' || ltrim(url, '/'); + END IF; + IF TG_ARGV[2] IS NULL OR TG_ARGV[2] = 'null' THEN headers = '{"Content-Type": "application/json"}'::jsonb; ELSE headers = TG_ARGV[2]::jsonb; END IF; + -- Add auth header for edge functions + IF service_key IS NOT NULL THEN + headers = headers || jsonb_build_object('Authorization', 'Bearer ' || service_key); + END IF; + IF TG_ARGV[3] IS NULL OR TG_ARGV[3] = 'null' THEN params = '{}'::jsonb; ELSE @@ -229,4 +257,23 @@ ALTER function supabase_functions.http_request() SET search_path = supabase_func REVOKE ALL ON FUNCTION supabase_functions.http_request() FROM PUBLIC; GRANT EXECUTE ON FUNCTION supabase_functions.http_request() TO postgres, anon, authenticated, service_role; +INSERT INTO supabase_functions.migrations (version) VALUES ('20241214000000_http_request_edge_support'); + +DO +$$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_namespace + WHERE nspname = 'vault' + ) + THEN + GRANT USAGE ON SCHEMA vault TO supabase_functions_admin; + GRANT SELECT ON vault.secrets TO supabase_functions_admin; + GRANT SELECT ON vault.decrypted_secrets TO supabase_functions_admin; + GRANT EXECUTE ON FUNCTION vault._crypto_aead_det_decrypt(bytea, bytea, bigint, bytea, bytea) TO supabase_functions_admin; + END IF; +END +$$; + COMMIT; diff --git a/internal/migration/down/down.go b/internal/migration/down/down.go index 35a9fa7e8..fe33fb3d4 100644 --- a/internal/migration/down/down.go +++ b/internal/migration/down/down.go @@ -11,6 +11,7 @@ import ( "github.com/spf13/afero" "github.com/supabase/cli/internal/migration/apply" "github.com/supabase/cli/internal/utils" + "github.com/supabase/cli/internal/utils/flags" "github.com/supabase/cli/pkg/migration" "github.com/supabase/cli/pkg/vault" ) @@ -41,14 +42,23 @@ func Run(ctx context.Context, last uint, config pgconn.Config, fsys afero.Fs, op } version := remoteMigrations[total-last-1] fmt.Fprintln(os.Stderr, "Resetting database to version:", version) - return ResetAll(ctx, version, conn, fsys) + isLocal := utils.IsLocalDatabase(config) + var projectRef string + if !isLocal { + projectRef = flags.ProjectRef + } + return ResetAll(ctx, version, projectRef, isLocal, conn, fsys) } -func ResetAll(ctx context.Context, version string, conn *pgx.Conn, fsys afero.Fs) error { +func ResetAll(ctx context.Context, version string, projectRef string, isLocal bool, conn *pgx.Conn, fsys afero.Fs) error { if err := migration.DropUserSchemas(ctx, conn); err != nil { return err } - if err := vault.UpsertVaultSecrets(ctx, utils.Config.Db.Vault, conn); err != nil { + secrets := utils.Config.Db.Vault + if isLocal || len(projectRef) > 0 { + secrets = vault.WithEdgeFunctionSecrets(secrets, projectRef, utils.Config.Auth.ServiceRoleKey.Value) + } + if err := vault.UpsertVaultSecrets(ctx, secrets, conn); err != nil { return err } return apply.MigrateAndSeed(ctx, version, conn, fsys) diff --git a/internal/migration/down/down_test.go b/internal/migration/down/down_test.go index 89d9ff66d..21b81aed6 100644 --- a/internal/migration/down/down_test.go +++ b/internal/migration/down/down_test.go @@ -10,6 +10,7 @@ import ( "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/supabase/cli/internal/testing/apitest" "github.com/supabase/cli/internal/testing/helper" "github.com/supabase/cli/internal/utils" "github.com/supabase/cli/pkg/migration" @@ -78,6 +79,10 @@ func TestMigrationsDown(t *testing.T) { func TestResetRemote(t *testing.T) { t.Run("resets remote database", func(t *testing.T) { + projectRef := apitest.RandomProjectRef() + origServiceRoleKey := utils.Config.Auth.ServiceRoleKey.Value + utils.Config.Auth.ServiceRoleKey.Value = "" + t.Cleanup(func() { utils.Config.Auth.ServiceRoleKey.Value = origServiceRoleKey }) // Setup in-memory fs fsys := afero.NewMemMapFs() path := filepath.Join(utils.MigrationsDir, "0_schema.sql") @@ -87,18 +92,23 @@ func TestResetRemote(t *testing.T) { defer conn.Close(t) conn.Query(migration.DropObjects). Reply("INSERT 0") + helper.MockVaultSetup(conn, projectRef) helper.MockMigrationHistory(conn). Query("RESET ALL"). Reply("RESET"). Query(migration.INSERT_MIGRATION_VERSION, "0", "schema", nil). Reply("INSERT 0 1") // Run test - err := ResetAll(context.Background(), "", conn.MockClient(t), fsys) + err := ResetAll(context.Background(), "", projectRef, false, conn.MockClient(t), fsys) // Check error assert.NoError(t, err) }) t.Run("resets remote database with seed config disabled", func(t *testing.T) { + projectRef := apitest.RandomProjectRef() + origServiceRoleKey := utils.Config.Auth.ServiceRoleKey.Value + utils.Config.Auth.ServiceRoleKey.Value = "" + t.Cleanup(func() { utils.Config.Auth.ServiceRoleKey.Value = origServiceRoleKey }) // Setup in-memory fs fsys := afero.NewMemMapFs() path := filepath.Join(utils.MigrationsDir, "0_schema.sql") @@ -111,6 +121,7 @@ func TestResetRemote(t *testing.T) { defer conn.Close(t) conn.Query(migration.DropObjects). Reply("INSERT 0") + helper.MockVaultSetup(conn, projectRef) helper.MockMigrationHistory(conn). Query("RESET ALL"). Reply("RESET"). @@ -118,7 +129,7 @@ func TestResetRemote(t *testing.T) { Reply("INSERT 0 1") utils.Config.Db.Seed.Enabled = false // Run test - err := ResetAll(context.Background(), "", conn.MockClient(t), fsys) + err := ResetAll(context.Background(), "", projectRef, false, conn.MockClient(t), fsys) // No error should be raised since we're skipping the seed assert.NoError(t, err) }) @@ -132,7 +143,7 @@ func TestResetRemote(t *testing.T) { conn.Query(migration.DropObjects). ReplyError(pgerrcode.InsufficientPrivilege, "permission denied for relation supabase_migrations") // Run test - err := ResetAll(context.Background(), "", conn.MockClient(t), fsys) + err := ResetAll(context.Background(), "", "", false, conn.MockClient(t), fsys) // Check error assert.ErrorContains(t, err, "ERROR: permission denied for relation supabase_migrations (SQLSTATE 42501)") }) diff --git a/internal/migration/squash/squash_test.go b/internal/migration/squash/squash_test.go index 15eb52c9f..3dfe91844 100644 --- a/internal/migration/squash/squash_test.go +++ b/internal/migration/squash/squash_test.go @@ -46,6 +46,9 @@ func TestSquashCommand(t *testing.T) { // Setup in-memory fs fsys := afero.NewMemMapFs() require.NoError(t, flags.LoadConfig(fsys)) + origServiceRoleKey := utils.Config.Auth.ServiceRoleKey.Value + utils.Config.Auth.ServiceRoleKey.Value = "" + t.Cleanup(func() { utils.Config.Auth.ServiceRoleKey.Value = origServiceRoleKey }) paths := []string{ filepath.Join(utils.MigrationsDir, "0_init.sql"), filepath.Join(utils.MigrationsDir, "1_target.sql"), @@ -84,6 +87,7 @@ func TestSquashCommand(t *testing.T) { // Setup mock postgres conn := pgtest.NewConn() defer conn.Close(t) + helper.MockVaultSetup(conn, "") helper.MockMigrationHistory(conn). Query("RESET ALL"). Reply("RESET"). @@ -282,6 +286,9 @@ func TestSquashMigrations(t *testing.T) { path := filepath.Join(utils.MigrationsDir, "0_init.sql") sql := "create schema test" require.NoError(t, afero.WriteFile(fsys, path, []byte(sql), 0644)) + origServiceRoleKey := utils.Config.Auth.ServiceRoleKey.Value + utils.Config.Auth.ServiceRoleKey.Value = "" + t.Cleanup(func() { utils.Config.Auth.ServiceRoleKey.Value = origServiceRoleKey }) // Setup mock docker require.NoError(t, apitest.MockDocker(utils.Docker)) defer gock.OffAll() @@ -311,6 +318,7 @@ func TestSquashMigrations(t *testing.T) { // Setup mock postgres conn := pgtest.NewConn() defer conn.Close(t) + helper.MockVaultSetup(conn, "") helper.MockMigrationHistory(conn). Query("RESET ALL"). Reply("RESET"). diff --git a/internal/migration/up/up.go b/internal/migration/up/up.go index 331abce66..506e82cf9 100644 --- a/internal/migration/up/up.go +++ b/internal/migration/up/up.go @@ -10,6 +10,7 @@ import ( "github.com/jackc/pgx/v4" "github.com/spf13/afero" "github.com/supabase/cli/internal/utils" + "github.com/supabase/cli/internal/utils/flags" "github.com/supabase/cli/pkg/migration" "github.com/supabase/cli/pkg/vault" ) @@ -24,7 +25,15 @@ func Run(ctx context.Context, includeAll bool, config pgconn.Config, fsys afero. if err != nil { return err } - if err := vault.UpsertVaultSecrets(ctx, utils.Config.Db.Vault, conn); err != nil { + secrets := utils.Config.Db.Vault + if utils.IsLocalDatabase(config) || len(flags.ProjectRef) > 0 { + var projectRef string + if !utils.IsLocalDatabase(config) { + projectRef = flags.ProjectRef + } + secrets = vault.WithEdgeFunctionSecrets(secrets, projectRef, utils.Config.Auth.ServiceRoleKey.Value) + } + if err := vault.UpsertVaultSecrets(ctx, secrets, conn); err != nil { return err } return migration.ApplyMigrations(ctx, pending, conn, afero.NewIOFS(fsys)) diff --git a/internal/start/start_test.go b/internal/start/start_test.go index a90d7a719..4f70a3709 100644 --- a/internal/start/start_test.go +++ b/internal/start/start_test.go @@ -19,6 +19,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/supabase/cli/internal/testing/apitest" + "github.com/supabase/cli/internal/testing/helper" "github.com/supabase/cli/internal/utils" "github.com/supabase/cli/pkg/config" "github.com/supabase/cli/pkg/pgtest" @@ -93,6 +94,9 @@ func TestStartCommand(t *testing.T) { func TestDatabaseStart(t *testing.T) { t.Run("starts database locally", func(t *testing.T) { + origServiceRoleKey := utils.Config.Auth.ServiceRoleKey.Value + utils.Config.Auth.ServiceRoleKey.Value = "" + t.Cleanup(func() { utils.Config.Auth.ServiceRoleKey.Value = origServiceRoleKey }) // Setup in-memory fs fsys := afero.NewMemMapFs() // Setup mock docker @@ -164,6 +168,7 @@ func TestDatabaseStart(t *testing.T) { // Setup mock postgres conn := pgtest.NewConn() defer conn.Close(t) + helper.MockVaultSetup(conn, "") // Setup health probes started := []string{ utils.DbId, utils.KongId, utils.GotrueId, utils.InbucketId, utils.RealtimeId, diff --git a/internal/testing/helper/vault.go b/internal/testing/helper/vault.go new file mode 100644 index 000000000..402b95b3a --- /dev/null +++ b/internal/testing/helper/vault.go @@ -0,0 +1,25 @@ +package helper + +import ( + "fmt" + + "github.com/supabase/cli/pkg/pgtest" + "github.com/supabase/cli/pkg/vault" +) + +func MockVaultSetup(conn *pgtest.MockConn, projectRef string) *pgtest.MockConn { + var url string + if len(projectRef) == 0 { + url = "http://kong:8000/functions/v1" + } else { + url = fmt.Sprintf("https://%s.supabase.co/functions/v1", projectRef) + } + // Mock vault existence check + conn.Query(vault.CHECK_VAULT). + Reply("SELECT 1", []interface{}{1}). + Query(vault.READ_VAULT_KV, []string{vault.SecretFunctionsUrl}). + Reply("SELECT 0"). + Query(vault.CREATE_VAULT_KV, url, vault.SecretFunctionsUrl). + Reply("SELECT 1") + return conn +} diff --git a/pkg/vault/batch.go b/pkg/vault/batch.go index 8f44bb797..d584abe0b 100644 --- a/pkg/vault/batch.go +++ b/pkg/vault/batch.go @@ -15,6 +15,10 @@ const ( CREATE_VAULT_KV = "SELECT vault.create_secret($1, $2)" READ_VAULT_KV = "SELECT id, name FROM vault.secrets WHERE name = ANY($1)" UPDATE_VAULT_KV = "SELECT vault.update_secret($1, $2)" + CHECK_VAULT = "SELECT 1 FROM pg_namespace WHERE nspname = 'vault'" + + SecretFunctionsUrl = "supabase_functions_url" + SecretServiceRoleKey = "supabase_service_role_key" ) type VaultTable struct { @@ -34,6 +38,13 @@ func UpsertVaultSecrets(ctx context.Context, secrets map[string]config.Secret, c if len(keys) == 0 { return nil } + var exists int + if err := conn.QueryRow(ctx, CHECK_VAULT).Scan(&exists); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil + } + return errors.Errorf("failed to check vault schema: %w", err) + } fmt.Fprintln(os.Stderr, "Updating vault secrets...") rows, err := conn.Query(ctx, READ_VAULT_KV, keys) if err != nil { @@ -58,3 +69,29 @@ func UpsertVaultSecrets(ctx context.Context, secrets map[string]config.Secret, c } return nil } + +func WithEdgeFunctionSecrets(secrets map[string]config.Secret, projectRef, serviceRoleKey string) map[string]config.Secret { + result := make(map[string]config.Secret, len(secrets)+2) + for k, v := range secrets { + result[k] = v + } + if _, exists := result[SecretFunctionsUrl]; !exists { + var url string + if len(projectRef) == 0 { + url = "http://kong:8000/functions/v1" + } else { + url = fmt.Sprintf("https://%s.supabase.co/functions/v1", projectRef) + } + result[SecretFunctionsUrl] = config.Secret{ + Value: url, + SHA256: "default", + } + } + if _, exists := result[SecretServiceRoleKey]; !exists && len(projectRef) == 0 && len(serviceRoleKey) > 0 { + result[SecretServiceRoleKey] = config.Secret{ + Value: serviceRoleKey, + SHA256: "default", + } + } + return result +} diff --git a/pkg/vault/batch_test.go b/pkg/vault/batch_test.go new file mode 100644 index 000000000..91da160c6 --- /dev/null +++ b/pkg/vault/batch_test.go @@ -0,0 +1,123 @@ +package vault + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/supabase/cli/pkg/config" +) + +func TestWithEdgeFunctionSecrets(t *testing.T) { + t.Run("adds local defaults when projectRef is empty", func(t *testing.T) { + secrets := map[string]config.Secret{} + serviceRoleKey := "test-service-role-key" + + result := WithEdgeFunctionSecrets(secrets, "", serviceRoleKey) + + assert.Len(t, result, 2) + assert.Equal(t, "http://kong:8000/functions/v1", result[SecretFunctionsUrl].Value) + assert.Equal(t, serviceRoleKey, result[SecretServiceRoleKey].Value) + }) + + t.Run("adds production URL when projectRef is provided", func(t *testing.T) { + secrets := map[string]config.Secret{} + serviceRoleKey := "test-service-role-key" + projectRef := "abcdefghijklmnop" + + result := WithEdgeFunctionSecrets(secrets, projectRef, serviceRoleKey) + + assert.Len(t, result, 1) + assert.Equal(t, "https://abcdefghijklmnop.supabase.co/functions/v1", result[SecretFunctionsUrl].Value) + assert.NotContains(t, result, SecretServiceRoleKey) + }) + + t.Run("preserves user-defined secrets", func(t *testing.T) { + userUrl := "https://custom.example.com/functions/v1" + userKey := "custom-service-key" + secrets := map[string]config.Secret{ + SecretFunctionsUrl: {Value: userUrl, SHA256: "custom-hash"}, + SecretServiceRoleKey: {Value: userKey, SHA256: "custom-hash"}, + } + + result := WithEdgeFunctionSecrets(secrets, "some-project", "ignored-key") + + assert.Len(t, result, 2) + assert.Equal(t, userUrl, result[SecretFunctionsUrl].Value) + assert.Equal(t, "custom-hash", result[SecretFunctionsUrl].SHA256) + assert.Equal(t, userKey, result[SecretServiceRoleKey].Value) + assert.Equal(t, "custom-hash", result[SecretServiceRoleKey].SHA256) + }) + + t.Run("skips service role key when empty", func(t *testing.T) { + secrets := map[string]config.Secret{} + + result := WithEdgeFunctionSecrets(secrets, "", "") + + assert.Len(t, result, 1) + assert.Contains(t, result, SecretFunctionsUrl) + assert.NotContains(t, result, SecretServiceRoleKey) + }) + + t.Run("preserves existing secrets in input", func(t *testing.T) { + existingSecret := config.Secret{Value: "existing-value", SHA256: "existing-hash"} + secrets := map[string]config.Secret{ + "custom_secret": existingSecret, + } + + result := WithEdgeFunctionSecrets(secrets, "", "service-key") + + assert.Len(t, result, 3) + assert.Equal(t, existingSecret, result["custom_secret"]) + assert.Contains(t, result, SecretFunctionsUrl) + assert.Contains(t, result, SecretServiceRoleKey) + }) + + t.Run("does not modify original map", func(t *testing.T) { + secrets := map[string]config.Secret{ + "existing": {Value: "value", SHA256: "hash"}, + } + + result := WithEdgeFunctionSecrets(secrets, "", "service-key") + + assert.Len(t, secrets, 1) + assert.Len(t, result, 3) + }) + + t.Run("handles nil input map", func(t *testing.T) { + result := WithEdgeFunctionSecrets(nil, "", "service-key") + + assert.Len(t, result, 2) + assert.Contains(t, result, SecretFunctionsUrl) + assert.Contains(t, result, SecretServiceRoleKey) + }) + + t.Run("partial override - only URL defined by user for remote", func(t *testing.T) { + secrets := map[string]config.Secret{ + SecretFunctionsUrl: {Value: "https://custom.example.com/functions/v1", SHA256: "custom"}, + } + result := WithEdgeFunctionSecrets(secrets, "project-ref", "auto-service-key") + assert.Len(t, result, 1) + assert.Equal(t, "https://custom.example.com/functions/v1", result[SecretFunctionsUrl].Value) + assert.NotContains(t, result, SecretServiceRoleKey) + }) + + t.Run("partial override - only URL defined by user for local", func(t *testing.T) { + secrets := map[string]config.Secret{ + SecretFunctionsUrl: {Value: "http://custom:8000/functions/v1", SHA256: "custom"}, + } + result := WithEdgeFunctionSecrets(secrets, "", "auto-service-key") + assert.Len(t, result, 2) + assert.Equal(t, "http://custom:8000/functions/v1", result[SecretFunctionsUrl].Value) + assert.Equal(t, "auto-service-key", result[SecretServiceRoleKey].Value) + }) + + t.Run("partial override - only service key defined by user", func(t *testing.T) { + secrets := map[string]config.Secret{ + SecretServiceRoleKey: {Value: "user-defined-key", SHA256: "custom"}, + } + result := WithEdgeFunctionSecrets(secrets, "my-project", "ignored-key") + assert.Len(t, result, 2) + assert.Equal(t, "https://my-project.supabase.co/functions/v1", result[SecretFunctionsUrl].Value) + assert.Equal(t, "user-defined-key", result[SecretServiceRoleKey].Value) + }) +}