Skip to content
Merged
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
45 changes: 43 additions & 2 deletions internal/postgres/external.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,9 @@ type ExternalDatabase struct {
database string
username string
password string
tempSchema string // Temporary schema name with timestamp suffix
targetMajorVersion int // Expected major version (from target database)
tempSchema string // Temporary schema name with timestamp suffix
targetMajorVersion int // Expected major version (from target database)
stubRoles []string // Roles created for ALTER DEFAULT PRIVILEGES (issue #553)
Comment thread
tianzhou marked this conversation as resolved.
}

// ExternalDatabaseConfig holds configuration for connecting to an external database
Expand Down Expand Up @@ -170,6 +171,35 @@ func (ed *ExternalDatabase) ApplySchema(ctx context.Context, schema string, sql
// so we need to rewrite it to point to the temporary schema (issue #335)
schemaAgnosticSQL = replaceSchemaInSearchPath(schemaAgnosticSQL, schema, ed.tempSchema)

// Create stub roles referenced by ALTER DEFAULT PRIVILEGES FOR ROLE so that
// the desired SQL can apply in the plan database without "permission denied"
// errors (issue #553). The plan user must be a member of these roles for
// PostgreSQL to accept the ALTER DEFAULT PRIVILEGES statement.
// Only roles that do not already exist are created and tracked for cleanup.
candidateRoles := ExtractDefaultPrivilegeRoles(schemaAgnosticSQL)
for _, role := range candidateRoles {
var exists bool
if err := conn.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = $1)", role).Scan(&exists); err != nil {
return fmt.Errorf("failed to check existence of role %s: %w", role, err)
}
if exists {
var isMember bool
if err := conn.QueryRowContext(ctx, "SELECT pg_has_role($1, $2, 'MEMBER')", ed.username, role).Scan(&isMember); err != nil || !isMember {
return fmt.Errorf("role %q already exists in the plan database and the plan user %q is not a member of it; grant membership manually or use a plan user that is already a member of that role", role, ed.username)
}
continue
}
createRoleSQL := fmt.Sprintf("CREATE ROLE %s", quoteIdent(role))
if _, err := util.ExecContextWithLogging(ctx, conn, createRoleSQL, "create stub role for default privileges"); err != nil {
return fmt.Errorf("failed to create stub role %s: %w", role, err)
}
grantSQL := fmt.Sprintf("GRANT %s TO %s", quoteIdent(role), quoteIdent(ed.username))
if _, err := util.ExecContextWithLogging(ctx, conn, grantSQL, "grant stub role membership"); err != nil {
return fmt.Errorf("failed to grant role %s to %s: %w", role, ed.username, err)
}
ed.stubRoles = append(ed.stubRoles, role)
}

// Execute the SQL directly
// Note: Desired state SQL should never contain operations like CREATE INDEX CONCURRENTLY
// that cannot run in transactions. Those are migration details, not state declarations.
Expand All @@ -192,6 +222,13 @@ func (ed *ExternalDatabase) Stop() error {
dropSchemaSQL := fmt.Sprintf("DROP SCHEMA IF EXISTS \"%s\" CASCADE", ed.tempSchema)
// Ignore errors - this is best effort cleanup
_, _ = ed.db.ExecContext(ctx, dropSchemaSQL)

// Clean up stub roles we created for ALTER DEFAULT PRIVILEGES (issue #553).
// Only roles we actually created are tracked, so this won't touch pre-existing roles.
for _, role := range ed.stubRoles {
_, _ = ed.db.ExecContext(ctx, fmt.Sprintf("REVOKE %s FROM %s", quoteIdent(role), quoteIdent(ed.username)))
_, _ = ed.db.ExecContext(ctx, fmt.Sprintf("DROP ROLE IF EXISTS %s", quoteIdent(role)))
}
}

// Close database connection
Expand Down Expand Up @@ -260,6 +297,10 @@ func getExtensionSchemas(db *sql.DB) (map[string]string, error) {
return extensions, rows.Err()
}

func quoteIdent(name string) string {
return `"` + strings.ReplaceAll(name, `"`, `""`) + `"`
}

// detectMajorVersion queries the database to determine its PostgreSQL major version
func detectMajorVersion(db *sql.DB) (int, error) {
ctx := context.Background()
Expand Down
58 changes: 58 additions & 0 deletions internal/postgres/fk_refs.go
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,64 @@ func ExtractPartitionOfTargets(sql, defaultSchema string) []QualifiedName {
return out
}

// ExtractDefaultPrivilegeRoles returns distinct role names that appear in
// ALTER DEFAULT PRIVILEGES FOR { ROLE | USER } <role> [, <role> ...] statements.
// String literals, comments, and dollar-quoted bodies are skipped.
func ExtractDefaultPrivilegeRoles(sql string) []string {
seen := make(map[string]bool)
var out []string

walkSQLCode(sql, func(code string) {
i := 0
for i < len(code) {
idx := indexKeyword(code, i, "default")
if idx < 0 {
return
}
i = idx + len("default")
i = skipSpace(code, i)
if !hasKeywordAt(code, i, "privileges") {
continue
}
i += len("privileges")
i = skipSpace(code, i)
if !hasKeywordAt(code, i, "for") {
continue
}
i += len("for")
i = skipSpace(code, i)
if !hasKeywordAt(code, i, "role") && !hasKeywordAt(code, i, "user") {
continue
}
if hasKeywordAt(code, i, "role") {
i += len("role")
} else {
i += len("user")
}
for {
_, role, next, ok := parseQualifiedName(code, i)
if !ok {
i = next
break
}
i = next
if !seen[role] {
seen[role] = true
out = append(out, role)
}
i = skipSpace(code, i)
if i < len(code) && code[i] == ',' {
i++
} else {
break
}
}
}
})

return out
}

func walkSQLCode(sql string, fn func(code string)) {
for _, seg := range splitDollarQuotedSegments(sql) {
if seg.quoted {
Expand Down
60 changes: 60 additions & 0 deletions internal/postgres/fk_refs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -175,3 +175,63 @@ func TestExtractPartitionOfTargets(t *testing.T) {
})
}
}

func TestExtractDefaultPrivilegeRoles(t *testing.T) {
tests := []struct {
name string
sql string
want []string
}{
{
name: "single role",
sql: `ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT SELECT ON TABLES TO anon;`,
want: []string{"postgres"},
},
{
name: "quoted role",
sql: `ALTER DEFAULT PRIVILEGES FOR ROLE "supabase_admin" IN SCHEMA public GRANT ALL ON TABLES TO authenticated;`,
want: []string{"supabase_admin"},
},
{
name: "multiple distinct roles deduped",
sql: `ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT SELECT ON TABLES TO anon;
ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT INSERT ON TABLES TO anon;
ALTER DEFAULT PRIVILEGES FOR ROLE "supabase_admin" IN SCHEMA public GRANT ALL ON TABLES TO authenticated;`,
want: []string{"postgres", "supabase_admin"},
},
{
name: "no alter default privileges",
sql: `CREATE TABLE users (id int); ALTER TABLE users ADD COLUMN name text;`,
want: nil,
},
{
name: "case insensitive keywords",
sql: `alter default privileges for role myuser in schema public grant select on tables to reader;`,
want: []string{"myuser"},
},
{
name: "FOR USER synonym",
sql: `ALTER DEFAULT PRIVILEGES FOR USER myuser IN SCHEMA public GRANT SELECT ON TABLES TO reader;`,
want: []string{"myuser"},
},
{
name: "comma-separated role list",
sql: `ALTER DEFAULT PRIVILEGES FOR ROLE role1, role2, role3 IN SCHEMA public GRANT SELECT ON TABLES TO reader;`,
want: []string{"role1", "role2", "role3"},
},
{
name: "comma-separated with FOR USER",
sql: `ALTER DEFAULT PRIVILEGES FOR USER admin, "app_owner" IN SCHEMA public GRANT ALL ON TABLES TO app;`,
want: []string{"admin", "app_owner"},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ExtractDefaultPrivilegeRoles(tt.sql)
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("ExtractDefaultPrivilegeRoles() = %#v, want %#v", got, tt.want)
}
})
}
}