Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
16 changes: 15 additions & 1 deletion apps/cli/src/next/commands/start/start.command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type * as CliCommand from "effect/unstable/cli/Command";
import { projectLocalServiceVersionsLayer } from "../../config/project-local-service-versions.layer.ts";
import { ensureProjectStateIgnored } from "../../config/project-gitignore.ts";
import { CliConfig } from "../../config/cli-config.service.ts";
import { ProjectContext } from "../../config/project-context.service.ts";
import { ProjectHome } from "../../config/project-home.service.ts";
import { projectLinkStateLayer } from "../../config/project-link-state.layer.ts";
import { provideProjectCommandRuntime } from "../../config/project-runtime.layer.ts";
Expand Down Expand Up @@ -137,6 +138,7 @@ export const startCommand = Command.make("start", flags).pipe(
const runtimeStateEffect = Effect.gen(function* () {
const output = yield* Output;
const cliConfig = yield* CliConfig;
const projectContext = yield* ProjectContext;
const projectHome = yield* ProjectHome;
const runtimeInfo = yield* RuntimeInfo;
const stateManager = yield* StateManager;
Expand All @@ -151,10 +153,22 @@ export const startCommand = Command.make("start", flags).pipe(
onSome: (metadata) => metadata.services,
}),
);
const stackConfig = withServiceVersions(
const autoExposeNewTables = Option.match(projectContext.rawProjectConfig, {
onNone: () => true,
// 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.
onSome: (config) => 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(() => 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 ServiceMap.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