fix: stub roles for ALTER DEFAULT PRIVILEGES in external plan DB - #556
Conversation
When using an external plan database, ALTER DEFAULT PRIVILEGES FOR ROLE statements fail with "permission denied" (SQLSTATE 42501) because the plan user is not a member of the referenced role. Fix by parsing role names from the desired SQL, creating stub roles in the plan DB, and granting membership to the plan user before applying the schema. Stub roles are cleaned up in Stop(). Fixes #553 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Greptile SummaryThe PR extracts owners from
Confidence Score: 2/5The PR is not safe to merge until stub-role ownership and cleanup are corrected and all supported default-privilege role forms are extracted. The changed cleanup can delete or retain privileges for persistent cluster roles, while valid Files Needing Attention: internal/postgres/external.go, internal/postgres/fk_refs.go
|
| Filename | Overview |
|---|---|
| internal/postgres/external.go | Adds external-plan role setup and cleanup, but cleanup does not track ownership or partial progress and can mutate persistent cluster authorization state. |
| internal/postgres/fk_refs.go | Adds role extraction but omits PostgreSQL-supported FOR USER and comma-separated role-list forms. |
| internal/postgres/fk_refs_test.go | Covers basic single-role extraction and deduplication but lacks valid alternate-keyword and multi-role grammar cases. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart TD
SQL[Desired schema SQL] --> Parse[Extract default-privilege roles]
Parse --> Create[Create role or accept duplicate]
Create --> Grant[Grant role to plan user]
Grant --> Apply[Apply desired schema]
Apply --> Stop[Stop external provider]
Stop --> Drop[Drop every recorded role]
Create -. pre-existing role .-> Drop
Grant -. failed drop leaves membership .-> Persist[Persistent authorization change]
Create -. later setup failure before recording .-> Leak[Role or membership leak]
Reviews (1): Last reviewed commit: "fix: stub roles for ALTER DEFAULT PRIVIL..." | Re-trigger Greptile
| } | ||
| } | ||
| } | ||
| ed.stubRoles = stubRoles |
There was a problem hiding this comment.
Cleanup loses role ownership state
If a referenced role already exists in the persistent plan database, this code records it for cleanup and Stop attempts to delete a cluster role that this run did not create; when deletion fails because the role has dependencies, the newly granted membership remains because cleanup never revokes it. A setup error on a later role also returns before ed.stubRoles is assigned, so roles and memberships created earlier in the loop persist after Stop.
How this was verified: The duplicate-role path is suppressed, cleanup drops every recorded name, and no membership revocation or incremental creation tracking exists.
Knowledge Base Used: Postgres Embedded/External Validation (internal/postgres)
| if !hasKeywordAt(code, i, "role") { | ||
| continue | ||
| } | ||
| i += len("role") | ||
| _, role, next, ok := parseQualifiedName(code, i) | ||
| if !ok { |
There was a problem hiding this comment.
Valid role forms remain unstubbed
When desired SQL uses PostgreSQL's valid FOR USER role form or a comma-separated FOR ROLE role1, role2 list, the extractor either skips the clause or captures only its first role. The external plan database therefore executes the original statement without creating and granting every referenced role, causing planning to fail with a missing-role or permission error.
Knowledge Base Used: Postgres Embedded/External Validation (internal/postgres)
Check pg_roles before creating each stub role. Skip roles that already exist in the plan DB so Stop() never drops a pre-existing role. Also revoke grant before dropping to ensure clean removal. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When a referenced role already exists in the plan DB, check whether the plan user is already a member. If not, return an actionable error instead of granting membership on a pre-existing cluster-level object. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR addresses failures when generating plans against an external plan database where ALTER DEFAULT PRIVILEGES FOR ROLE <role> can be rejected with SQLSTATE 42501 if the plan user is not a member of the referenced role (issue #553). It introduces role extraction from desired SQL and attempts to provision role stubs/memberships so the desired schema can be applied in the plan DB.
Changes:
- Added
ExtractDefaultPrivilegeRolesSQL scanner to find roles referenced byALTER DEFAULT PRIVILEGES ... FOR ROLE .... - Updated external plan DB schema-apply flow to create stub roles and grant membership to the plan user, then clean up in
Stop(). - Added unit tests for role extraction.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| internal/postgres/fk_refs.go | Adds ExtractDefaultPrivilegeRoles SQL-walking parser to extract roles from desired SQL. |
| internal/postgres/fk_refs_test.go | Adds unit tests covering basic role extraction cases. |
| internal/postgres/external.go | Creates/grants stub roles before applying desired SQL to external plan DB; attempts cleanup on shutdown. |
Suppressed comments (2)
internal/postgres/external.go:182
- ApplySchema() currently records all referenced roles in ed.stubRoles and Stop() drops them later, which can delete real roles. It also relies on matching the error text "already a member" and will fail when the referenced role equals the plan username (GRANT role TO role errors). Track (1) roles that were created as stubs and (2) memberships that were newly granted, so Stop() can safely revert only what this run changed.
// 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 {
internal/postgres/external.go:220
- Stop() drops roles but does not revoke any memberships it granted, and if the roles existed beforehand this can either (a) incorrectly delete real roles or (b) leave the plan user with extra memberships after the command completes. Cleanup should revoke only the memberships added by ApplySchema and drop only roles that were created as stubs.
// Errors during cleanup are logged but don't cause failures.
func (ed *ExternalDatabase) Stop() error {
// Drop the temporary schema (best effort - don't fail if this errors)
if ed.db != nil && ed.tempSchema != "" {
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
ExtractDefaultPrivilegeRoles now handles: - FOR USER as a synonym for FOR ROLE (valid PostgreSQL syntax) - Comma-separated role lists: FOR ROLE r1, r2, r3 Added 3 test cases covering these forms. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Addressed all review findings across 3 commits since the initial submission: Greptile/Copilot reviewed the first commit (dacf6a9) — the following commits fix the issues they flagged:
Test coverage: 8 cases for |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.
Suppressed comments (4)
internal/postgres/fk_refs.go:229
RoleSpecinALTER DEFAULT PRIVILEGES FOR ROLE/USERcan beCURRENT_USER,SESSION_USER, orCURRENT_ROLE(seeinternal/gram.y), but this extractor will treat those keywords as literal role names (e.g. "current_user") and attempt to create/grant/drop stub roles unnecessarily (and potentially fail if the plan user lacks CREATEROLE). Consider explicitly ignoring these keyword role specs, and also rejecting dotted/qualified names since role specs aren’t schema-qualified.
_, role, next, ok := parseQualifiedName(code, i)
if !ok {
i = next
break
internal/postgres/fk_refs_test.go:216
- The tests don’t cover
FOR ROLE CURRENT_USER/SESSION_USER/CURRENT_ROLE, which are validRoleSpecvalues and should not result in stub role creation. Adding a test case for these keywords will prevent regressions.
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"},
},
internal/postgres/external.go:191
- The membership check conflates query errors with a non-member result (
err != nil || !isMember), which can hide the real failure mode (e.g. permission issues runningpg_has_role). Handle the query error separately and keep the actionable "not a member" message only for the!isMembercase.
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)
}
internal/postgres/external.go:230
- Cleanup currently uses
DROP ROLE IF EXISTS ...withoutCASCADE. If the desired SQL includesALTER DEFAULT PRIVILEGES FOR ROLE <stub>withoutIN SCHEMA, PostgreSQL can create default ACL entries owned by that role, andDROP ROLEmay fail under RESTRICT. UsingCASCADEimproves best-effort cleanup and avoids accumulating stub roles in the plan DB.
_, _ = ed.db.ExecContext(ctx, fmt.Sprintf("DROP ROLE IF EXISTS %s", quoteIdent(role)))
Summary
ALTER DEFAULT PRIVILEGES FOR ROLE <role>with "permission denied" (SQLSTATE 42501) because the plan user is not a member of the referenced roleExtractDefaultPrivilegeRoles, create stub roles + grant membership to plan user before applying schema, clean up inStop()ExtractDefaultPrivilegeRolesparser with 5 test cases (single, quoted, multiple/deduped, no match, case insensitive)Fixes #553
Test plan
ExtractDefaultPrivilegeRoles(5 cases, all pass)go build ./...passesgo vet ./...passes🤖 Generated with Claude Code