Skip to content

Comments

fix: revoke default privilege grants on new tables#257

Merged
tianzhou merged 7 commits intomainfrom
default_privilege
Jan 26, 2026
Merged

fix: revoke default privilege grants on new tables#257
tianzhou merged 7 commits intomainfrom
default_privilege

Conversation

@tianzhou
Copy link
Contributor

When default privileges grant permissions to a role on new tables, and a new table is created with explicit REVOKE for that role, the REVOKE statement was missing from the initial migration plan. This caused the schema to require two apply cycles to converge.

The fix detects when privileges would be auto-granted by default privileges on new objects, but the user didn't include those explicit privileges in the new state (meaning they intentionally revoked them). These REVOKE statements are now generated after the table is created.

Fix #253

Copilot AI review requested due to automatic review settings January 26, 2026 14:42
When default privileges grant permissions to a role on new tables, and
a new table is created with explicit REVOKE for that role, the REVOKE
statement was missing from the initial migration plan. This caused the
schema to require two apply cycles to converge.

The fix detects when privileges would be auto-granted by default
privileges on new objects, but the user didn't include those explicit
privileges in the new state (meaning they intentionally revoked them).
These REVOKE statements are now generated after the table is created.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Fixes migration planning so that when PostgreSQL default privileges would auto-grant privileges on a newly created table, but the desired end state does not include those privileges (because they were explicitly revoked in the desired schema), the plan includes the needed REVOKE in the same apply cycle (Issue #253).

Changes:

  • Add diff logic to synthesize “revoke default-granted privileges on newly created tables” steps after table creation.
  • Wire the new revoke steps into the create-phase SQL generation ordering (after tables, before explicit GRANTs).
  • Add a new file-based diff test case covering the one-cycle convergence scenario.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
internal/diff/privilege.go Adds computeRevokedDefaultGrants to generate synthetic privilege revokes for default-granted privileges on new tables.
internal/diff/diff.go Stores and emits the new revoke steps during create-phase generation.
testdata/diff/privilege/revoke_default_privilege/old.sql Test setup: default privileges granting table access to a role.
testdata/diff/privilege/revoke_default_privilege/new.sql Desired state: create table + explicit revoke for the default-granted role + grant to admin role.
testdata/diff/privilege/revoke_default_privilege/diff.sql Expected generated SQL now includes the missing REVOKE.
testdata/diff/privilege/revoke_default_privilege/plan.txt Human-readable plan output updated to include the REVOKE step.
testdata/diff/privilege/revoke_default_privilege/plan.sql Raw plan SQL output updated to include the REVOKE step.
testdata/diff/privilege/revoke_default_privilege/plan.json JSON plan output updated to include the REVOKE step.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 366 to 384
// For each new table, check which default privileges would auto-grant
for _, table := range addedTables {
for _, dp := range defaultPrivileges {
// Only process default privileges for TABLES
if dp.ObjectType != ir.DefaultPrivilegeObjectTypeTables {
continue
}

// Check if the new state has an explicit privilege for this table+grantee
// If not, the user intentionally revoked the default grant
privilegeKey := string(ir.PrivilegeObjectTypeTable) + ":" + table.Name + ":" + dp.Grantee
hasExplicitGrant := false
for key := range newPrivs {
// Match by object key prefix (without grant option suffix)
if len(key) >= len(privilegeKey) && key[:len(privilegeKey)] == privilegeKey {
hasExplicitGrant = true
break
}
}
Copy link

Copilot AI Jan 26, 2026

Choose a reason for hiding this comment

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

computeRevokedDefaultGrants currently scans all keys in newPrivs for every (new table × default privilege) pair. For schemas with many privileges this becomes unnecessarily expensive and makes the logic harder to follow. Consider precomputing an index like map[objectKey][]*ir.Privilege (or at least a map[objectKey]bool) once, and doing O(1) lookups inside this loop.

Copilot uses AI. Check for mistakes.
tianzhou and others added 2 commits January 26, 2026 07:12
- Filter out redundant GRANTs on new tables when covered by default privileges
- Move default privileges creation before table creation so auto-grants apply
- Enhanced add_table_privilege test case with a new table to verify behavior

When creating a new table along with default privileges, the system now:
1. Creates default privileges first
2. Creates tables (so PostgreSQL auto-grants based on default privileges)
3. Skips explicit GRANTs that are covered by default privileges

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1. Fix prefix matching bug: Use exact object key lookup instead of
   prefix matching to avoid false positives (e.g., app_role matching
   app_role2)

2. Handle partial revokes: Compute set difference between default
   privileges and actual privileges, generating REVOKE only for the
   missing privileges instead of all-or-nothing

3. Ensure deterministic ordering: Sort revokedPrivs by GetObjectKey()
   for consistent output across runs

4. Improve performance: Build an index of privileges by object key
   for O(1) lookups instead of O(n²) scanning

Added test case: partial_revoke_default_privilege to verify partial
revoke behavior works correctly.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 19 out of 19 changed files in this pull request and generated 3 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +1454 to +1456
// Create default privileges BEFORE tables so auto-grants apply to new tables
generateCreateDefaultPrivilegesSQL(d.addedDefaultPrivileges, targetSchema, collector)

Copy link

Copilot AI Jan 26, 2026

Choose a reason for hiding this comment

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

generateCreateSQL creates added default privileges before tables, but default privilege modifications still run later in generateModifySQL (after CREATE TABLE). Since ALTER DEFAULT PRIVILEGES is not retroactive, any tables created in the same migration will inherit the old defaults if a default privilege is modified, which can leave newly created tables with broader or narrower access than the desired state until a follow-up apply (security window). Consider ensuring the effective default privileges (including modifications relevant to TABLES) are applied before creating new tables, or avoiding relying on default privileges for new-table privilege convergence when defaults are being modified in the same migration.

Copilot uses AI. Check for mistakes.
- Fix IR object mutation bug: use local map[string]map[string]bool
  instead of mutating shared *ir.Privilege objects when merging
  privileges from multiple entries

- Fix modified default privilege timing: use "active" defaults
  (old + added) instead of all new defaults when filtering GRANTs
  and computing REVOKEs. Modified defaults run AFTER table creation,
  so their old version is what's active when the table is created.

See: #257 (review)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 19 out of 19 changed files in this pull request and generated 2 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +366 to +375
// Build an index of privileges by (ObjectType:ObjectName:Grantee) for O(1) lookups
// This avoids O(n²) complexity when scanning newPrivs for each (table, default privilege) pair
// Use a separate map to track merged privilege sets to avoid mutating shared IR objects
privSetByObjectKey := make(map[string]map[string]bool)
for _, p := range newPrivs {
key := p.GetObjectKey()
if existing, ok := privSetByObjectKey[key]; ok {
// Merge privileges from both entries
for _, priv := range p.Privileges {
existing[priv] = true
Copy link

Copilot AI Jan 26, 2026

Choose a reason for hiding this comment

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

computeRevokedDefaultGrants merges desired privileges by GetObjectKey() (which intentionally ignores WithGrantOption). If a default privilege would auto-grant e.g. SELECT WITH GRANT OPTION on a new table, but the desired state only has SELECT (no grant option), this function will treat SELECT as “present” and won’t generate the required REVOKE GRANT OPTION FOR SELECT ... after table creation. That leaves the new table with an unintended grant option and likely reintroduces the “two apply cycles to converge” behavior for grant-option changes. Consider tracking grantable vs non-grantable privileges separately (e.g., keyed by GetFullKey() or splitting sets by WithGrantOption) and emitting a post-create REVOKE GRANT OPTION FOR when the only mismatch is the grant option.

Copilot uses AI. Check for mistakes.
tianzhou and others added 2 commits January 26, 2026 08:28
Merge partial_revoke_default_privilege test case into revoke_default_privilege
to test both scenarios in a single comprehensive test:

1. Full revoke: secrets table where reader role has no privileges
   (revokes auto-granted SELECT from default privileges)

2. Partial revoke: readonly_data table where app_user has only SELECT
   (revokes INSERT, UPDATE, DELETE but keeps SELECT from default privileges)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
When computing activeDefaultPrivileges for GRANT/REVOKE filtering on
new tables, exclude default privileges that are scheduled to be dropped.
Since drops run before creates in the migration order, dropped defaults
won't be in effect when new tables are created.

This fixes a case where:
1. Old state has default privilege granting SELECT to role
2. New state drops that default privilege and creates a new table
3. Previously: would skip explicit GRANT (thinking default covers it)
4. Now: correctly generates explicit GRANT since default is dropped

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated 2 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +398 to +422
// Compute which default privileges need to be revoked
// (privileges in dp.Privileges but not in existingPrivSet)
var privsToRevoke []string
if existingPrivSet == nil {
// No explicit privilege exists - revoke all default privileges
privsToRevoke = dp.Privileges
} else {
// Compute set difference: dp.Privileges - existingPrivSet
for _, p := range dp.Privileges {
if !existingPrivSet[p] {
privsToRevoke = append(privsToRevoke, p)
}
}
}

if len(privsToRevoke) > 0 {
// Create a synthetic privilege to revoke the missing default grants
revokedPrivs = append(revokedPrivs, &ir.Privilege{
ObjectType: ir.PrivilegeObjectTypeTable,
ObjectName: table.Name,
Grantee: dp.Grantee,
Privileges: privsToRevoke,
WithGrantOption: dp.WithGrantOption,
})
}
Copy link

Copilot AI Jan 26, 2026

Choose a reason for hiding this comment

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

computeRevokedDefaultGrants merges desired privileges across grant-option variants (keyed by GetObjectKey), and only computes missing privilege types. This means a new table that inherits default privileges WITH GRANT OPTION but whose desired state removes only the grant option (via REVOKE GRANT OPTION FOR ...) will not get a corresponding revoke step, and may still require a second apply (and leave the grant option enabled). Consider extending this logic to detect grant-option-only differences for auto-granted privileges on new tables and emit a REVOKE GRANT OPTION FOR statement (e.g., by tracking desired privileges separately by WithGrantOption and generating an alter-style privilege diff).

Suggested change
// Compute which default privileges need to be revoked
// (privileges in dp.Privileges but not in existingPrivSet)
var privsToRevoke []string
if existingPrivSet == nil {
// No explicit privilege exists - revoke all default privileges
privsToRevoke = dp.Privileges
} else {
// Compute set difference: dp.Privileges - existingPrivSet
for _, p := range dp.Privileges {
if !existingPrivSet[p] {
privsToRevoke = append(privsToRevoke, p)
}
}
}
if len(privsToRevoke) > 0 {
// Create a synthetic privilege to revoke the missing default grants
revokedPrivs = append(revokedPrivs, &ir.Privilege{
ObjectType: ir.PrivilegeObjectTypeTable,
ObjectName: table.Name,
Grantee: dp.Grantee,
Privileges: privsToRevoke,
WithGrantOption: dp.WithGrantOption,
})
}
// Compute which default privileges need to be revoked.
// We distinguish between:
// - full revokes (privilege type should not exist at all), and
// - grant-option-only revokes (privilege type should exist, but
// the auto-granted WITH GRANT OPTION from default privileges
// should be removed).
var fullRevokes []string
var grantOptionOnlyRevokes []string
if existingPrivSet == nil {
// No explicit privilege exists - revoke all default privileges fully.
fullRevokes = dp.Privileges
} else {
for _, p := range dp.Privileges {
if !existingPrivSet[p] {
// Privilege type is not desired at all: full revoke.
fullRevokes = append(fullRevokes, p)
} else if dp.WithGrantOption {
// Privilege type is desired, but the default privilege
// would auto-grant WITH GRANT OPTION. Since the desired
// state only guarantees the presence of the privilege type
// (tracked via existingPrivSet) and not the grant option,
// emit a separate revoke of the grant option.
grantOptionOnlyRevokes = append(grantOptionOnlyRevokes, p)
}
}
}
if len(fullRevokes) > 0 {
// Create a synthetic privilege to fully revoke the missing default grants.
revokedPrivs = append(revokedPrivs, &ir.Privilege{
ObjectType: ir.PrivilegeObjectTypeTable,
ObjectName: table.Name,
Grantee: dp.Grantee,
Privileges: fullRevokes,
WithGrantOption: false,
})
}
if len(grantOptionOnlyRevokes) > 0 {
// Create a synthetic privilege to revoke only the grant option
// for default privileges that would otherwise auto-grant it.
revokedPrivs = append(revokedPrivs, &ir.Privilege{
ObjectType: ir.PrivilegeObjectTypeTable,
ObjectName: table.Name,
Grantee: dp.Grantee,
Privileges: grantOptionOnlyRevokes,
WithGrantOption: true,
})
}

Copilot uses AI. Check for mistakes.
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
@tianzhou tianzhou merged commit dd83bd3 into main Jan 26, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Default privileges + selective REVOKE requires two apply cycles to converge

1 participant