Skip to content
Open
5 changes: 4 additions & 1 deletion apps/cli-go/internal/db/reset/reset.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,10 @@ func initDatabase(ctx context.Context, options ...func(*pgx.ConnConfig)) error {
return err
}
defer conn.Close(context.Background())
return start.InitSchema14(ctx, conn)
if err := start.InitSchema14(ctx, conn); err != nil {
return err
}
return start.ApplyApiPrivileges(ctx, conn)
}

// Recreate postgres database by connecting to template1
Expand Down
38 changes: 38 additions & 0 deletions apps/cli-go/internal/db/start/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,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
}
if err := ApplyApiPrivileges(ctx, conn); err != nil {
return err
}
// Create vault secrets first so roles.sql can reference them
if err := vault.UpsertVaultSecrets(ctx, utils.Config.Db.Vault, conn); err != nil {
return err
Expand All @@ -394,3 +397,38 @@ func SetupDatabase(ctx context.Context, conn *pgx.Conn, host string, w io.Writer
}
return err
}

// RevokeDefaultDataApiPrivilegesSql matches the SQL that Studio runs at cloud project creation
// when the "Default privileges for new entities" toggle is off. It removes the default GRANTs
// applied by the initial schema so newly-created entities in `public` owned by `postgres` are
// not exposed through the Data API roles until explicit GRANTs are issued.
const RevokeDefaultDataApiPrivilegesSql = `
alter default privileges for role postgres in schema public
revoke select, insert, update, delete on tables from anon, authenticated, service_role;
alter default privileges for role postgres in schema public
revoke usage, select on sequences from anon, authenticated, service_role;
alter default privileges for role postgres in schema public
revoke execute on functions from anon, authenticated, service_role;
`

// ApplyApiPrivileges adjusts the default privileges on the `public` schema to match the
// `[api].auto_expose_new_tables` flag in config.toml. The flag is tri-state to give users a
// safe migration window:
//
// - unset (default today): keep the bundled initial-schema GRANTs in place, so local matches
// long-standing behaviour. This implicit default flips to false on May 30, 2026, and the
// flag is removed entirely in October 2026 (always-revoked behaviour).
// - true: explicit opt-in to today's behaviour. Treated identically to unset for now; from
// May 30 the CLI will warn that the flag is being deprecated.
// - false: revoke the default Data API GRANTs so newly-created entities in `public` require
// explicit GRANTs to surface through the Data API, matching the new cloud default.
func ApplyApiPrivileges(ctx context.Context, conn *pgx.Conn) error {
if utils.Config.Api.AutoExposeNewTables == nil || *utils.Config.Api.AutoExposeNewTables {
return nil
}
file, err := migration.NewMigrationFromReader(strings.NewReader(RevokeDefaultDataApiPrivilegesSql))
if err != nil {
return err
}
return file.ExecBatch(ctx, conn)
}
37 changes: 37 additions & 0 deletions apps/cli-go/internal/db/start/start_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,43 @@ func TestSetupDatabase(t *testing.T) {
assert.Empty(t, apitest.ListUnmatchedRequests())
})

t.Run("revokes default data api privileges when auto_expose_new_tables is false", func(t *testing.T) {
utils.Config.Db.MajorVersion = 14
flag := false
utils.Config.Api.AutoExposeNewTables = &flag
defer func() {
utils.Config.Db.MajorVersion = 15
utils.Config.Api.AutoExposeNewTables = nil
}()
utils.Config.Db.Port = 5432
utils.GlobalsSql = "create schema public"
utils.InitialSchemaPg14Sql = "create schema private"
// Setup in-memory fs
fsys := afero.NewMemMapFs()
roles := "create role postgres"
require.NoError(t, afero.WriteFile(fsys, utils.CustomRolesPath, []byte(roles), 0644))
// Setup mock postgres: the revoke SQL must execute between the initial schema and roles.sql
conn := pgtest.NewConn()
defer conn.Close(t)
conn.Query(utils.GlobalsSql).
Reply("CREATE SCHEMA").
Query(utils.InitialSchemaPg14Sql).
Reply("CREATE SCHEMA").
Query("alter default privileges for role postgres in schema public\n revoke select, insert, update, delete on tables from anon, authenticated, service_role").
Reply("ALTER DEFAULT PRIVILEGES").
Query("alter default privileges for role postgres in schema public\n revoke usage, select on sequences from anon, authenticated, service_role").
Reply("ALTER DEFAULT PRIVILEGES").
Query("alter default privileges for role postgres in schema public\n revoke execute on functions from anon, authenticated, service_role").
Reply("ALTER DEFAULT PRIVILEGES").
Query(roles).
Reply("CREATE ROLE")
// Run test
err := SetupLocalDatabase(context.Background(), "", fsys, io.Discard, conn.Intercept)
// Check error
assert.NoError(t, err)
assert.Empty(t, apitest.ListUnmatchedRequests())
})

t.Run("throws error on connect failure", func(t *testing.T) {
utils.Config.Db.Port = 0
// Run test
Expand Down
9 changes: 9 additions & 0 deletions apps/cli-go/pkg/config/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,15 @@ type (
Schemas []string `toml:"schemas" json:"schemas"`
ExtraSearchPath []string `toml:"extra_search_path" json:"extra_search_path"`
MaxRows uint `toml:"max_rows" json:"max_rows"`
// When unset (default today), new tables, views, sequences and functions created in
// the `public` schema by `postgres` are automatically reachable through the Data API
// roles `anon`, `authenticated`, and `service_role`, matching long-standing local
// behaviour. Set to false to match the new cloud default and require explicit GRANTs
// to expose entities through the Data API; set to true to opt out of the upcoming
// transition once the platform default flips. Stored as a pointer so the migration
// path (unset -> default true today, default false from May 30, removed in October)
// can flip the implicit value without losing the explicit user choice.
AutoExposeNewTables *bool `toml:"auto_expose_new_tables,omitempty" json:"auto_expose_new_tables,omitempty"`
// Local only config
Image string `toml:"-" json:"-"`
KongImage string `toml:"-" json:"-"`
Expand Down
7 changes: 7 additions & 0 deletions apps/cli-go/pkg/config/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,3 +134,10 @@ func TestApiDiff(t *testing.T) {
assertSnapshotEqual(t, diff)
})
}

func TestApiAutoExposeNewTablesDefault(t *testing.T) {
t.Run("is unset on a fresh config so today's implicit-true behaviour applies", func(t *testing.T) {
cfg := NewConfig()
assert.Nil(t, cfg.Api.AutoExposeNewTables)
})
}
6 changes: 6 additions & 0 deletions apps/cli-go/pkg/config/templates/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ extra_search_path = ["public", "extensions"]
# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size
# for accidental or malicious requests.
max_rows = 1000
# Controls whether new tables, views, sequences and functions created in the `public` schema by
# `postgres` are reachable through the Data API roles (`anon`, `authenticated`, `service_role`)
# without explicit GRANTs. Leave unset today to preserve local behaviour. The implicit default
# flips to `false` on 2026-05-30 to match the new cloud default, and the field is removed in
# 2026-10-30 once the always-revoked behaviour is permanent. Set to `false` to opt in early.
# auto_expose_new_tables = false

[api.tls]
# Enable HTTPS endpoints locally using a self-signed certificate.
Expand Down
13 changes: 12 additions & 1 deletion apps/cli/src/next/commands/start/start.command.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Effect, Layer, Option, Context } from "effect";
import { loadProjectConfig } from "@supabase/config";
import {
DEFAULT_MANAGED_STACK_NAME,
StateManager,
Expand Down Expand Up @@ -150,10 +151,20 @@ export const startCommand = Command.make("start", flags).pipe(
onSome: (metadata) => metadata.services,
}),
);
const stackConfig = withServiceVersions(
// The flag is tri-state in config.toml: unset / true / false. Today, unset and true both
// preserve the long-standing local behaviour of auto-exposing new entities in `public`.
// The implicit default flips to false on 2026-05-30 to match the new cloud default, and
// the field is removed in 2026-10-30.
const loadedProjectConfig = yield* loadProjectConfig(projectHome.projectRoot);
const autoExposeNewTables = loadedProjectConfig?.config.api.auto_expose_new_tables ?? true;
const baseStackConfig = withServiceVersions(
toStartStackConfig(flags.exclude, flags.mode),
serviceVersionContext.runtimeVersions,
);
const stackConfig = {
...baseStackConfig,
postgres: { ...baseStackConfig.postgres, autoExposeNewTables },
};
const resolvedConfig = yield* Effect.promise(() =>
resolveDaemonConfig({
cacheRoot: cliConfig.supabaseHome,
Expand Down
8 changes: 8 additions & 0 deletions packages/config/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,14 @@ export const api = Schema.Struct({
tags,
links,
}).pipe(Schema.withDecodingDefaultKey(Effect.succeed(defaultMaxRows))),
auto_expose_new_tables: Schema.optionalKey(
Schema.Boolean.annotate({
description:
"Controls whether newly-created tables, views, sequences and functions in the `public` schema by `postgres` are reachable through the Data API roles (`anon`, `authenticated`, `service_role`) without explicit GRANTs. Leave unset today to keep long-standing local behaviour. The implicit default flips to `false` on 2026-05-30 to match the new cloud default, and the field is removed in 2026-10-30 once the always-revoked behaviour is permanent. Set to `false` to opt in early; set to `true` to lock in today's behaviour through the deprecation window.",
tags,
links,
}),
),
tls: Schema.Struct({
enabled: Schema.Boolean.annotate({
default: defaultTlsEnabled,
Expand Down
31 changes: 31 additions & 0 deletions packages/config/src/project.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,37 @@ describe("project discovery and lazy env resolution", () => {
}
});

test("leaves [api].auto_expose_new_tables unset by default and round-trips an explicit value", async () => {
const cwd = makeTempProject();
const projectRoot = join(cwd, "repo");

try {
await mkdir(join(projectRoot, "supabase"), { recursive: true });
await writeFile(join(projectRoot, "supabase", "config.toml"), `project_id = "ref_123"\n`);

const defaultLoaded = await runConfigEffect(loadProjectConfig(projectRoot));
// Field is intentionally optional today so the implicit default can flip on 2026-05-30
// without losing track of users who explicitly opted in either direction.
expect(defaultLoaded!.config.api.auto_expose_new_tables).toBeUndefined();

await writeFile(
join(projectRoot, "supabase", "config.toml"),
`project_id = "ref_123"\n\n[api]\nauto_expose_new_tables = false\n`,
);
const explicitFalse = await runConfigEffect(loadProjectConfig(projectRoot));
expect(explicitFalse!.config.api.auto_expose_new_tables).toBe(false);

await writeFile(
join(projectRoot, "supabase", "config.toml"),
`project_id = "ref_123"\n\n[api]\nauto_expose_new_tables = true\n`,
);
const explicitTrue = await runConfigEffect(loadProjectConfig(projectRoot));
expect(explicitTrue!.config.api.auto_expose_new_tables).toBe(true);
} finally {
await rm(cwd, { recursive: true, force: true });
}
});

test("loads raw config without resolving explicit env() references", async () => {
const cwd = makeTempProject();
const projectRoot = join(cwd, "repo");
Expand Down
1 change: 1 addition & 0 deletions packages/stack/src/Stack.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ const defaultConfig: ResolvedStackConfig = {
port: 54322,
dataDir: "/tmp/supabase/data",
version: DEFAULT_VERSIONS.postgres,
autoExposeNewTables: true,
},
postgrest: {
port: 54323,
Expand Down
10 changes: 10 additions & 0 deletions packages/stack/src/StackBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,14 @@ export interface PostgresConfig {
readonly port?: number;
readonly dataDir?: string;
readonly version?: string;
/**
* When true (default), the bundled initial schema GRANTs that expose new tables, views,
* sequences, and functions in `public` to the Data API roles (`anon`, `authenticated`,
* `service_role`) are kept in place. When false, those default privileges are revoked so the
* local stack matches the new cloud default and requires explicit GRANTs to surface entities
* through the Data API.
*/
readonly autoExposeNewTables?: boolean;
}

export interface PostgrestConfig {
Expand Down Expand Up @@ -160,6 +168,7 @@ export interface ResolvedPostgresConfig {
readonly port: number;
readonly dataDir: string;
readonly version: string;
readonly autoExposeNewTables: boolean;
}

export interface ResolvedPostgrestConfig {
Expand Down Expand Up @@ -569,6 +578,7 @@ export class StackBuilder extends Context.Service<
...makePostgresInitService({
postgresDir: postgresResolution.path,
dbPort: config.dbPort,
autoExposeNewTables: config.postgres.autoExposeNewTables,
}),
enabled: true,
});
Expand Down
1 change: 1 addition & 0 deletions packages/stack/src/StackBuilder.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ const baseConfig: ResolvedStackConfig = {
port: 5432,
dataDir: "/tmp/pg-data",
version: DEFAULT_VERSIONS.postgres,
autoExposeNewTables: true,
},
postgrest: {
port: 3001,
Expand Down
1 change: 1 addition & 0 deletions packages/stack/src/createStack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -559,6 +559,7 @@ export async function resolveConfig(
port: ports.dbPort,
dataDir: postgresDataDir,
version: postgresInput.version ?? DEFAULT_VERSIONS.postgres,
autoExposeNewTables: postgresInput.autoExposeNewTables ?? true,
},
postgrest: resolvePostgrestConfig(postgrestInput, config.postgrest, ports),
auth: resolveAuthConfig(authInput, config.auth, ports, ports.apiPort),
Expand Down
32 changes: 31 additions & 1 deletion packages/stack/src/services/postgres-init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,28 @@ import type { ServiceDef } from "@supabase/process-compose";
interface PostgresInitOptions {
readonly postgresDir: string;
readonly dbPort: number;
/**
* When false, append the SQL that Studio runs at cloud project creation to revoke the default
* Data API privileges on the `public` schema so newly-created entities require explicit GRANTs.
*/
readonly autoExposeNewTables: boolean;
}

/**
* SQL that matches what Studio runs at cloud project creation when "Default privileges for new
* entities" is off. Revokes the default GRANTs installed by the bundled initial schema so new
* tables/sequences/functions in `public` owned by `postgres` are not reachable via the Data API
* roles without explicit GRANTs.
*/
export const REVOKE_DEFAULT_DATA_API_PRIVILEGES_SQL = `
alter default privileges for role postgres in schema public
revoke select, insert, update, delete on tables from anon, authenticated, service_role;
alter default privileges for role postgres in schema public
revoke usage, select on sequences from anon, authenticated, service_role;
alter default privileges for role postgres in schema public
revoke execute on functions from anon, authenticated, service_role;
`.trim();

export const makePostgresInitService = (opts: PostgresInitOptions): ServiceDef => {
const pgBinDir = `${opts.postgresDir}/bin`;
const pgLibDir = `${opts.postgresDir}/lib`;
Expand All @@ -13,6 +33,16 @@ export const makePostgresInitService = (opts: PostgresInitOptions): ServiceDef =
const psql = `${pgBinDir}/psql -h 127.0.0.1 -p ${opts.dbPort}`;
const psqlOpts = `-v ON_ERROR_STOP=1 --no-password --no-psqlrc`;

const revokeStep = opts.autoExposeNewTables
? ""
: `
# Revoke default privileges for the Data API roles on schema public so new tables
# require explicit GRANTs. Mirrors Studio's behaviour at cloud project creation.
${psql} ${psqlOpts} -U postgres -d postgres <<'EOSQL'
${REVOKE_DEFAULT_DATA_API_PRIVILEGES_SQL}
EOSQL
`;

// Replaces calling migrate.sh (which spawns ~57 separate psql processes) with
// chained -f flags that run all SQL files in a single psql session, cutting
// postgres-init time from ~5s to ~1s.
Expand Down Expand Up @@ -61,7 +91,7 @@ EOSQL

# Reset stats (non-fatal, matches migrate.sh)
${psql} ${psqlOpts} -U supabase_admin -d postgres -c 'SELECT extensions.pg_stat_statements_reset(); SELECT pg_stat_reset();' || true
fi
${revokeStep}fi

# Backfill schemas/databases used by docker-backed auxiliary services.
${psql} ${psqlOpts} -U postgres -d postgres <<'EOSQL'
Expand Down
Loading
Loading