Skip to content
Closed
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
2 changes: 1 addition & 1 deletion apps/cli-go/internal/db/start/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -396,7 +396,7 @@ 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;
revoke all 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;
`
Expand Down
2 changes: 1 addition & 1 deletion apps/cli-go/internal/testing/helper/privileges.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import "github.com/supabase/cli/pkg/pgtest"
func MockApiPrivilegesRevoke(conn *pgtest.MockConn) *pgtest.MockConn {
conn.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").
Query("alter default privileges for role postgres in schema public\n revoke all 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")
Expand Down
12 changes: 6 additions & 6 deletions apps/cli/src/command-internal/db-bootstrap/db-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ export const START_REVOKE_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;
revoke all 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;
`;
Expand Down Expand Up @@ -605,10 +605,10 @@ const startInitSchema = Effect.fn("DbSetup.initSchema")(function* (
});

/**
* Applies the `api.auto_expose_new_tables` tri-state: unset and `true` are both a no-op (keeps
* the bundled initial-schema grants); an explicit `false` execs
* {@link START_REVOKE_API_PRIVILEGES_SQL}. Always execs over `session` directly, regardless of PG
* major version — never part of the PG15+ one-shot Docker jobs.
* Applies the `api.auto_expose_new_tables` tri-state: unset and `false` both revoke the default
* Data API privileges by exec'ing {@link START_REVOKE_API_PRIVILEGES_SQL}; an explicit `true` is
* a no-op (keeps the bundled initial-schema grants). Always execs over `session` directly,
* regardless of PG major version — never part of the PG15+ one-shot Docker jobs.
*
* Takes `session`/`fs`/`path` directly rather than the whole {@link StartSetupLocalDatabaseInput}
* because `db reset`'s PG14 path calls it too, without any of `setupDatabase`'s other steps.
Expand All @@ -620,7 +620,7 @@ export const applyApiPrivileges = Effect.fnUntraced(function* (
tmpDir: string,
autoExposeNewTables: Option.Option<boolean>,
) {
if (Option.getOrElse(autoExposeNewTables, () => true)) return;
if (Option.getOrElse(autoExposeNewTables, () => false)) return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Severity: MEDIUM

When unset, this branch runs the revoke SQL, but its per-schema revoke does not remove PostgreSQL's global default EXECUTE grant to PUBLIC (docs). Since anon inherits PUBLIC privileges, unauthenticated Data API clients can still call newly created public-schema RPC functions, including functions with sensitive behavior.
Helpful? Add 👍 / 👎

💡 Fix Suggestion

Suggestion: Update START_REVOKE_API_PRIVILEGES_SQL (and the Go RevokeDefaultDataApiPrivilegesSql equivalent) so it also revokes the default PUBLIC execute grant on functions. Add alter default privileges in schema public revoke execute on functions from public;. Run it for each role that creates objects, for example postgres and supabase_admin, using for role. PostgreSQL grants EXECUTE on new functions to PUBLIC globally, and anon and authenticated inherit it. Revoking only from anon, authenticated, service_role therefore does not close the gap. Keep the existing explicit revokes. Add a test that creates a function after bootstrap and checks that anon cannot execute it when auto_expose_new_tables is unset or false. Be aware that revoking PUBLIC execute also affects other roles, so confirm that internal roles keep the access they need.

yield* execSqlConstant(
session,
fs,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ const SCHEMA_13_FINGERPRINT =
const SCHEMA_14_FINGERPRINT_SUFFIX = "CREATE SCHEMA IF NOT EXISTS graphql";
const REVOKE_PRIVILEGES_FINGERPRINT =
"revoke execute on functions from anon, authenticated, service_role";
const REVOKE_SEQUENCES_FINGERPRINT =
"revoke all on sequences from anon, authenticated, service_role";
const PG_NET_CREATE_FINGERPRINT = "create extension if not exists pg_net schema extensions";

function fakeSession() {
Expand Down Expand Up @@ -584,12 +586,13 @@ describe("startSetupLocalDatabase", () => {
yield* run(baseInput(workdir, session, { majorVersion: 14 }), out, docker);
const execSql = calls.filter((c) => c.kind === "exec").map((c) => c.sql);
expect(execSql.some((sql) => sql.includes(REVOKE_PRIVILEGES_FINGERPRINT))).toBe(true);
expect(execSql.some((sql) => sql.includes(REVOKE_SEQUENCES_FINGERPRINT))).toBe(true);
yield* fs.remove(workdir, { recursive: true, force: true });
}).pipe(Effect.provide(BunServices.layer));
});

it.effect(
"unset (no config.toml at all) matches the true behavior — no revoke SQL exec'd",
"unset (no config.toml at all) matches the false behavior — execs the revoke SQL",
() => {
return Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
Expand All @@ -599,7 +602,8 @@ describe("startSetupLocalDatabase", () => {
const docker = mockDockerRun();
yield* run(baseInput(workdir, session, { majorVersion: 14 }), out, docker);
const execSql = calls.filter((c) => c.kind === "exec").map((c) => c.sql);
expect(execSql.some((sql) => sql.includes(REVOKE_PRIVILEGES_FINGERPRINT))).toBe(false);
expect(execSql.some((sql) => sql.includes(REVOKE_PRIVILEGES_FINGERPRINT))).toBe(true);
expect(execSql.some((sql) => sql.includes(REVOKE_SEQUENCES_FINGERPRINT))).toBe(true);
yield* fs.remove(workdir, { recursive: true, force: true });
}).pipe(Effect.provide(BunServices.layer));
},
Expand Down
8 changes: 4 additions & 4 deletions apps/cli/src/command-internal/db-bootstrap/shadow-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,12 +184,12 @@ function canonicalJson(value: unknown): string {

/**
* The two-state behavior `applyApiPrivileges` derives from the tri-state
* `api.auto_expose_new_tables`: unset and explicit `true` both keep the grants; only `false`
* revokes them. Hashing the raw tri-state would split unset from `true` into different keys for a
* config edit that changes nothing on disk.
* `api.auto_expose_new_tables`: unset and explicit `false` both revoke the grants; only explicit
* `true` keeps them. Hashing the raw tri-state would split unset from `false` into different keys
* for a config edit that changes nothing on disk.
*/
const effectiveShadowApiGrantsKept = (value: Option.Option<boolean>): boolean =>
Option.getOrElse(value, () => true);
Option.getOrElse(value, () => false);

/**
* The cache key: a 16-hex-char (64-bit) scrypt prefix over a fixed field order. 64 bits is ample
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ describe("shadowCacheKey", () => {
{ label: "db settings", inputs: { ...base, dbSettings: { max_connections: 200 } } },
{
label: "auto expose new tables",
inputs: { ...base, autoExposeNewTables: Option.some(false) },
inputs: { ...base, autoExposeNewTables: Option.some(true) },
},
{ label: "effective webhooks / pg_net", inputs: { ...base, webhooksEnabled: false } },
{ label: "roles.sql", inputs: { ...base, rolesSql: "" } },
Expand Down Expand Up @@ -168,8 +168,8 @@ describe("shadowCacheKey", () => {
...base,
autoExposeNewTables: Option.some(false),
});
expect(explicitTrue).toBe(unset);
expect(explicitFalse).not.toBe(unset);
expect(explicitFalse).toBe(unset);
expect(explicitTrue).not.toBe(unset);
});

it("excludes a disabled service's image tag entirely", () => {
Expand Down
4 changes: 2 additions & 2 deletions apps/cli/src/command-internal/db-config.toml-read.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,8 +149,8 @@ interface BaselineTomlConfig {
readonly realtimeEnabled: boolean;
/**
* `[api] auto_expose_new_tables`, tri-state (`None` when unset). The cache
* key folds in the effective bool — unset and `true` both mean grants are
* kept.
* key folds in the effective bool — unset and `false` both mean grants are
* revoked.
*/
readonly apiAutoExposeNewTables: Option.Option<boolean>;
/** `[db.vault]` secret names (sorted), created during setup. */
Expand Down
5 changes: 3 additions & 2 deletions apps/cli/src/shared/init/project-init.templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,9 @@ extra_search_path = ["public", "extensions"]
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, matching the cloud default. Set to \`false\` to require explicit GRANTs
# instead. Left unset, a fresh project falls back to \`true\`.
# without explicit GRANTs. When unset, new entities are NOT auto-exposed, matching the new cloud
# default. Set to \`true\` to keep the legacy behaviour of auto-exposing new entities; this is
# deprecated and the field is removed on 2026-10-30 once the always-revoked behaviour is permanent.
# auto_expose_new_tables = true

[api.tls]
Expand Down
22 changes: 4 additions & 18 deletions apps/cli/src/shared/init/project-init.templates.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,26 +53,12 @@ const renderExpectedGoEject = readVendoredTemplate("config.toml").pipe(
),
);

// The Go scaffold still describes `auto_expose_new_tables` as unset-means-
// revoked and deprecated; the native template documents unset-means-exposed
// instead, since platform projects never stopped auto-exposing new entities.
const GO_AUTO_EXPOSE_COMMENT = `# without explicit GRANTs. When unset, new entities are NOT auto-exposed, matching the new cloud
# default. Set to \`true\` to keep the legacy behaviour of auto-exposing new entities; this is
# deprecated and the field is removed on 2026-10-30 once the always-revoked behaviour is permanent.
# auto_expose_new_tables = true`;

const NATIVE_AUTO_EXPOSE_COMMENT = `# without explicit GRANTs, matching the cloud default. Set to \`false\` to require explicit GRANTs
# instead. Left unset, a fresh project falls back to \`true\`.
# auto_expose_new_tables = true`;

const renderExpectedNativeEject = renderExpectedGoEject.pipe(
Effect.map((eject) =>
eject
.replace(
'# content_path = "./templates/password_changed_notification.html"',
'# content_path = "./supabase/templates/password_changed_notification.html"',
)
.replace(GO_AUTO_EXPOSE_COMMENT, NATIVE_AUTO_EXPOSE_COMMENT),
eject.replace(
'# content_path = "./templates/password_changed_notification.html"',
'# content_path = "./supabase/templates/password_changed_notification.html"',
),
),
);

Expand Down
4 changes: 2 additions & 2 deletions apps/docs/public/cli/config.schema.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion apps/docs/public/cli/project-config.schema.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/config/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export const api = Schema.Struct({
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. When unset, new entities are auto-exposed, matching the cloud default. Set to `false` to revoke the default Data API privileges so new entities require explicit GRANTs, matching a cloud project with the "Default privileges for new entities" toggle turned off.',
"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. When unset, new entities are not auto-exposed, matching the cloud default. Set to `true` to keep the legacy behaviour of auto-exposing new entities without explicit GRANTs.",
tags,
links,
}),
Expand Down
Loading