Skip to content

Commit ac5793f

Browse files
committed
feat(cli): add [api].auto_expose_new_tables to opt out of Data API default privileges
Cloud now exposes a "Default privileges for new entities" toggle that, when disabled, revokes the default GRANTs to anon/authenticated/service_role on schema public so freshly-created tables, views, sequences, and functions are not reachable through the Data API without explicit GRANTs (supabase/supabase discussion #45329). Local Supabase had no equivalent: bootstrap always installed the default GRANTs, forcing users who opted in on cloud to keep their local schema out of sync or ship a project-specific revoke migration. Add an opt-in flag under [api] with default true (preserving today's local behaviour) and have the local DB bootstrap run the same revoke SQL Studio runs at cloud project creation when the flag is false. The flag is wired through both the Go CLI (covering `supabase db reset` and legacy `supabase start`) and the TypeScript stack bootstrap (covering the new `supabase start` foreground/background flows in apps/cli/next). https://claude.ai/code/session_011pZGRjHtkxjt1iZj5LYrqq
1 parent 77080f0 commit ac5793f

17 files changed

Lines changed: 220 additions & 7 deletions

File tree

apps/cli-go/internal/db/reset/reset.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,10 @@ func initDatabase(ctx context.Context, options ...func(*pgx.ConnConfig)) error {
146146
return err
147147
}
148148
defer conn.Close(context.Background())
149-
return start.InitSchema14(ctx, conn)
149+
if err := start.InitSchema14(ctx, conn); err != nil {
150+
return err
151+
}
152+
return start.ApplyApiPrivileges(ctx, conn)
150153
}
151154

152155
// Recreate postgres database by connecting to template1

apps/cli-go/internal/db/start/start.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -384,6 +384,9 @@ func SetupDatabase(ctx context.Context, conn *pgx.Conn, host string, w io.Writer
384384
if err := initSchema(ctx, conn, host, w); err != nil {
385385
return err
386386
}
387+
if err := ApplyApiPrivileges(ctx, conn); err != nil {
388+
return err
389+
}
387390
// Create vault secrets first so roles.sql can reference them
388391
if err := vault.UpsertVaultSecrets(ctx, utils.Config.Db.Vault, conn); err != nil {
389392
return err
@@ -394,3 +397,33 @@ func SetupDatabase(ctx context.Context, conn *pgx.Conn, host string, w io.Writer
394397
}
395398
return err
396399
}
400+
401+
// RevokeDefaultDataApiPrivilegesSql matches the SQL that Studio runs at cloud project creation
402+
// when the "Default privileges for new entities" toggle is off. It removes the default GRANTs
403+
// applied by the initial schema so newly-created entities in `public` owned by `postgres` are
404+
// not exposed through the Data API roles until explicit GRANTs are issued.
405+
const RevokeDefaultDataApiPrivilegesSql = `
406+
alter default privileges for role postgres in schema public
407+
revoke select, insert, update, delete on tables from anon, authenticated, service_role;
408+
alter default privileges for role postgres in schema public
409+
revoke usage, select on sequences from anon, authenticated, service_role;
410+
alter default privileges for role postgres in schema public
411+
revoke execute on functions from anon, authenticated, service_role;
412+
`
413+
414+
// ApplyApiPrivileges adjusts the default privileges on the `public` schema to match the
415+
// `[api].auto_expose_new_tables` flag in config.toml. When the flag is true (the default), the
416+
// initial schema GRANTs are kept as-is to preserve backwards-compatible local behaviour. When the
417+
// flag is false, the GRANTs to anon/authenticated/service_role are revoked so new tables, views,
418+
// sequences, and functions created by `postgres` in `public` require explicit GRANTs to surface
419+
// through the Data API.
420+
func ApplyApiPrivileges(ctx context.Context, conn *pgx.Conn) error {
421+
if utils.Config.Api.AutoExposeNewTables {
422+
return nil
423+
}
424+
file, err := migration.NewMigrationFromReader(strings.NewReader(RevokeDefaultDataApiPrivilegesSql))
425+
if err != nil {
426+
return err
427+
}
428+
return file.ExecBatch(ctx, conn)
429+
}

apps/cli-go/internal/db/start/start_test.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,42 @@ func TestSetupDatabase(t *testing.T) {
259259
assert.Empty(t, apitest.ListUnmatchedRequests())
260260
})
261261

262+
t.Run("revokes default data api privileges when auto_expose_new_tables is false", func(t *testing.T) {
263+
utils.Config.Db.MajorVersion = 14
264+
utils.Config.Api.AutoExposeNewTables = false
265+
defer func() {
266+
utils.Config.Db.MajorVersion = 15
267+
utils.Config.Api.AutoExposeNewTables = true
268+
}()
269+
utils.Config.Db.Port = 5432
270+
utils.GlobalsSql = "create schema public"
271+
utils.InitialSchemaPg14Sql = "create schema private"
272+
// Setup in-memory fs
273+
fsys := afero.NewMemMapFs()
274+
roles := "create role postgres"
275+
require.NoError(t, afero.WriteFile(fsys, utils.CustomRolesPath, []byte(roles), 0644))
276+
// Setup mock postgres: the revoke SQL must execute between the initial schema and roles.sql
277+
conn := pgtest.NewConn()
278+
defer conn.Close(t)
279+
conn.Query(utils.GlobalsSql).
280+
Reply("CREATE SCHEMA").
281+
Query(utils.InitialSchemaPg14Sql).
282+
Reply("CREATE SCHEMA").
283+
Query("alter default privileges for role postgres in schema public\n revoke select, insert, update, delete on tables from anon, authenticated, service_role").
284+
Reply("ALTER DEFAULT PRIVILEGES").
285+
Query("alter default privileges for role postgres in schema public\n revoke usage, select on sequences from anon, authenticated, service_role").
286+
Reply("ALTER DEFAULT PRIVILEGES").
287+
Query("alter default privileges for role postgres in schema public\n revoke execute on functions from anon, authenticated, service_role").
288+
Reply("ALTER DEFAULT PRIVILEGES").
289+
Query(roles).
290+
Reply("CREATE ROLE")
291+
// Run test
292+
err := SetupLocalDatabase(context.Background(), "", fsys, io.Discard, conn.Intercept)
293+
// Check error
294+
assert.NoError(t, err)
295+
assert.Empty(t, apitest.ListUnmatchedRequests())
296+
})
297+
262298
t.Run("throws error on connect failure", func(t *testing.T) {
263299
utils.Config.Db.Port = 0
264300
// Run test

apps/cli-go/pkg/config/api.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ type (
1414
Schemas []string `toml:"schemas" json:"schemas"`
1515
ExtraSearchPath []string `toml:"extra_search_path" json:"extra_search_path"`
1616
MaxRows uint `toml:"max_rows" json:"max_rows"`
17+
// When true (default) new tables, views, sequences and functions created in the
18+
// `public` schema by `postgres` are automatically reachable through the Data API roles
19+
// `anon`, `authenticated`, and `service_role`. Set to false to match the new cloud
20+
// default and require explicit GRANTs to expose entities through the Data API.
21+
AutoExposeNewTables bool `toml:"auto_expose_new_tables" json:"auto_expose_new_tables"`
1722
// Local only config
1823
Image string `toml:"-" json:"-"`
1924
KongImage string `toml:"-" json:"-"`

apps/cli-go/pkg/config/api_test.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,3 +134,10 @@ func TestApiDiff(t *testing.T) {
134134
assertSnapshotEqual(t, diff)
135135
})
136136
}
137+
138+
func TestApiAutoExposeNewTablesDefault(t *testing.T) {
139+
t.Run("defaults to true via NewConfig", func(t *testing.T) {
140+
cfg := NewConfig()
141+
assert.True(t, cfg.Api.AutoExposeNewTables)
142+
})
143+
}

apps/cli-go/pkg/config/config.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -354,8 +354,9 @@ func NewConfig(editors ...ConfigEditor) config {
354354
initial := config{baseConfig: baseConfig{
355355
Hostname: "127.0.0.1",
356356
Api: api{
357-
Image: Images.Postgrest,
358-
KongImage: Images.Kong,
357+
Image: Images.Postgrest,
358+
KongImage: Images.Kong,
359+
AutoExposeNewTables: true,
359360
Tls: tlsKong{
360361
CertContent: kongCert,
361362
KeyContent: kongKey,

apps/cli-go/pkg/config/templates/config.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ extra_search_path = ["public", "extensions"]
1616
# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size
1717
# for accidental or malicious requests.
1818
max_rows = 1000
19+
# When true (default), new tables, views, sequences and functions created in the `public` schema by
20+
# `postgres` are automatically reachable through the Data API roles `anon`, `authenticated`, and
21+
# `service_role`. Set to false to match the new cloud default and require explicit GRANTs to expose
22+
# entities through the Data API.
23+
auto_expose_new_tables = true
1924

2025
[api.tls]
2126
# Enable HTTPS endpoints locally using a self-signed certificate.

apps/cli-go/pkg/config/testdata/TestApiDiff/detects_differences.diff

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,6 @@ diff remote[api] local[api]
99
+schemas = ["public", "private"]
1010
+extra_search_path = ["extensions", "public"]
1111
+max_rows = 1000
12+
auto_expose_new_tables = false
1213
port = 0
1314
external_url = ""
14-

apps/cli/src/next/commands/start/start.command.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import type * as CliCommand from "effect/unstable/cli/Command";
1313
import { projectLocalServiceVersionsLayer } from "../../config/project-local-service-versions.layer.ts";
1414
import { ensureProjectStateIgnored } from "../../config/project-gitignore.ts";
1515
import { CliConfig } from "../../config/cli-config.service.ts";
16+
import { ProjectContext } from "../../config/project-context.service.ts";
1617
import { ProjectHome } from "../../config/project-home.service.ts";
1718
import { projectLinkStateLayer } from "../../config/project-link-state.layer.ts";
1819
import { provideProjectCommandRuntime } from "../../config/project-runtime.layer.ts";
@@ -137,6 +138,7 @@ export const startCommand = Command.make("start", flags).pipe(
137138
const runtimeStateEffect = Effect.gen(function* () {
138139
const output = yield* Output;
139140
const cliConfig = yield* CliConfig;
141+
const projectContext = yield* ProjectContext;
140142
const projectHome = yield* ProjectHome;
141143
const runtimeInfo = yield* RuntimeInfo;
142144
const stateManager = yield* StateManager;
@@ -151,10 +153,18 @@ export const startCommand = Command.make("start", flags).pipe(
151153
onSome: (metadata) => metadata.services,
152154
}),
153155
);
154-
const stackConfig = withServiceVersions(
156+
const autoExposeNewTables = Option.match(projectContext.rawProjectConfig, {
157+
onNone: () => true,
158+
onSome: (config) => config.api.auto_expose_new_tables,
159+
});
160+
const baseStackConfig = withServiceVersions(
155161
toStartStackConfig(flags.exclude, flags.mode),
156162
serviceVersionContext.runtimeVersions,
157163
);
164+
const stackConfig = {
165+
...baseStackConfig,
166+
postgres: { ...baseStackConfig.postgres, autoExposeNewTables },
167+
};
158168
const resolvedConfig = yield* Effect.promise(() =>
159169
resolveDaemonConfig({
160170
cacheRoot: cliConfig.supabaseHome,

packages/config/src/api.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ const defaultPort = 54321;
1414
const defaultSchemas = ["public", "graphql_public"];
1515
const defaultExtraSearchPath = ["public", "extensions"];
1616
const defaultMaxRows = 1000;
17+
const defaultAutoExposeNewTables = true;
1718
const defaultTls = {};
1819
const defaultTlsEnabled = false;
1920

@@ -56,6 +57,13 @@ export const api = Schema.Struct({
5657
tags,
5758
links,
5859
}).pipe(Schema.withDecodingDefaultKey(() => defaultMaxRows)),
60+
auto_expose_new_tables: Schema.Boolean.annotate({
61+
default: defaultAutoExposeNewTables,
62+
description:
63+
"When true (default), 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`. Set to false to match the new cloud default and require explicit GRANTs to expose entities through the Data API.",
64+
tags,
65+
links,
66+
}).pipe(Schema.withDecodingDefaultKey(() => defaultAutoExposeNewTables)),
5967
tls: Schema.Struct({
6068
enabled: Schema.Boolean.annotate({
6169
default: defaultTlsEnabled,

0 commit comments

Comments
 (0)