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
229 changes: 229 additions & 0 deletions internal/diff/default_privilege.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
package diff

import (
"fmt"
"sort"
"strings"

"github.com/pgschema/pgschema/ir"
)

// generateCreateDefaultPrivilegesSQL generates ALTER DEFAULT PRIVILEGES GRANT statements
func generateCreateDefaultPrivilegesSQL(privileges []*ir.DefaultPrivilege, targetSchema string, collector *diffCollector) {
for _, dp := range privileges {
sql := generateGrantDefaultPrivilegeSQL(dp, targetSchema)

context := &diffContext{
Type: DiffTypeDefaultPrivilege,
Operation: DiffOperationCreate,
Path: fmt.Sprintf("default_privileges.%s.%s", dp.ObjectType, dp.Grantee),
Source: dp,
CanRunInTransaction: true,
}

collector.collect(context, sql)
}
}

// generateDropDefaultPrivilegesSQL generates ALTER DEFAULT PRIVILEGES REVOKE statements
func generateDropDefaultPrivilegesSQL(privileges []*ir.DefaultPrivilege, targetSchema string, collector *diffCollector) {
for _, dp := range privileges {
sql := generateRevokeDefaultPrivilegeSQL(dp, targetSchema)

context := &diffContext{
Type: DiffTypeDefaultPrivilege,
Operation: DiffOperationDrop,
Path: fmt.Sprintf("default_privileges.%s.%s", dp.ObjectType, dp.Grantee),
Source: dp,
CanRunInTransaction: true,
}

collector.collect(context, sql)
}
}

// generateModifyDefaultPrivilegesSQL generates ALTER DEFAULT PRIVILEGES statements for modifications
func generateModifyDefaultPrivilegesSQL(diffs []*defaultPrivilegeDiff, targetSchema string, collector *diffCollector) {
for _, diff := range diffs {
statements := diff.generateAlterDefaultPrivilegeStatements(targetSchema)
for _, stmt := range statements {
context := &diffContext{
Type: DiffTypeDefaultPrivilege,
Operation: DiffOperationAlter,
Path: fmt.Sprintf("default_privileges.%s.%s", diff.New.ObjectType, diff.New.Grantee),
Source: diff,
CanRunInTransaction: true,
}

collector.collect(context, stmt)
}
}
}

// generateGrantDefaultPrivilegeSQL generates ALTER DEFAULT PRIVILEGES GRANT statement
func generateGrantDefaultPrivilegeSQL(dp *ir.DefaultPrivilege, targetSchema string) string {
// Sort privileges for deterministic output
sortedPrivs := make([]string, len(dp.Privileges))
copy(sortedPrivs, dp.Privileges)
sort.Strings(sortedPrivs)

privStr := strings.Join(sortedPrivs, ", ")
grantee := dp.Grantee
if grantee == "" || grantee == "PUBLIC" {
// PUBLIC is a special keyword meaning "all roles", not an identifier
grantee = "PUBLIC"
} else {
grantee = ir.QuoteIdentifier(grantee)
}

sql := fmt.Sprintf("ALTER DEFAULT PRIVILEGES IN SCHEMA %s GRANT %s ON %s TO %s",
ir.QuoteIdentifier(targetSchema), privStr, dp.ObjectType, grantee)

if dp.WithGrantOption {
sql += " WITH GRANT OPTION"
}

return sql + ";"
}

// generateRevokeDefaultPrivilegeSQL generates ALTER DEFAULT PRIVILEGES REVOKE statement
func generateRevokeDefaultPrivilegeSQL(dp *ir.DefaultPrivilege, targetSchema string) string {
// Sort privileges for deterministic output
sortedPrivs := make([]string, len(dp.Privileges))
copy(sortedPrivs, dp.Privileges)
sort.Strings(sortedPrivs)

privStr := strings.Join(sortedPrivs, ", ")
grantee := dp.Grantee
if grantee == "" || grantee == "PUBLIC" {
// PUBLIC is a special keyword meaning "all roles", not an identifier
grantee = "PUBLIC"
} else {
grantee = ir.QuoteIdentifier(grantee)
}

return fmt.Sprintf("ALTER DEFAULT PRIVILEGES IN SCHEMA %s REVOKE %s ON %s FROM %s;",
ir.QuoteIdentifier(targetSchema), privStr, dp.ObjectType, grantee)
}

// generateAlterDefaultPrivilegeStatements generates statements for privilege modifications
func (d *defaultPrivilegeDiff) generateAlterDefaultPrivilegeStatements(targetSchema string) []string {
var statements []string

// Find privileges to revoke (in old but not in new)
oldPrivSet := make(map[string]bool)
for _, p := range d.Old.Privileges {
oldPrivSet[p] = true
}
newPrivSet := make(map[string]bool)
for _, p := range d.New.Privileges {
newPrivSet[p] = true
}

var toRevoke []string
for p := range oldPrivSet {
if !newPrivSet[p] {
toRevoke = append(toRevoke, p)
}
}

var toGrant []string
for p := range newPrivSet {
if !oldPrivSet[p] {
toGrant = append(toGrant, p)
}
}

grantee := d.New.Grantee
if grantee == "" || grantee == "PUBLIC" {
// PUBLIC is a special keyword meaning "all roles", not an identifier
grantee = "PUBLIC"
} else {
grantee = ir.QuoteIdentifier(grantee)
}
quotedSchema := ir.QuoteIdentifier(targetSchema)

// Generate REVOKE for removed privileges
if len(toRevoke) > 0 {
sort.Strings(toRevoke)
statements = append(statements, fmt.Sprintf("ALTER DEFAULT PRIVILEGES IN SCHEMA %s REVOKE %s ON %s FROM %s;",
quotedSchema, strings.Join(toRevoke, ", "), d.Old.ObjectType, grantee))
}

// Generate GRANT for added privileges
if len(toGrant) > 0 {
sort.Strings(toGrant)
sql := fmt.Sprintf("ALTER DEFAULT PRIVILEGES IN SCHEMA %s GRANT %s ON %s TO %s",
quotedSchema, strings.Join(toGrant, ", "), d.New.ObjectType, grantee)
if d.New.WithGrantOption {
sql += " WITH GRANT OPTION"
}
statements = append(statements, sql+";")
}
Comment on lines 146 to 162
Copy link

Copilot AI Jan 5, 2026

Choose a reason for hiding this comment

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

There is a potential bug when both privileges and the grant option change simultaneously. Consider this scenario:

Old state: SELECT with grant option
New state: SELECT, INSERT without grant option

The current logic would:

  1. Skip revoke (toRevoke is empty since SELECT exists in both)
  2. Generate: GRANT INSERT ON ... TO ... (line 146, without grant option)

However, this leaves SELECT with the old grant option, which is incorrect. The new state should have SELECT without grant option.

When the grant option changes (d.Old.WithGrantOption != d.New.WithGrantOption) and there are privilege changes (toRevoke or toGrant not empty), you need to revoke and re-grant all privileges, not just the changed ones.

Consider checking if the grant option changed at the beginning of the function and handling that case by revoking all old privileges and granting all new privileges with the correct option.

Copilot uses AI. Check for mistakes.

// Handle WITH GRANT OPTION changes for unchanged privileges
// If grant option changed, we need to revoke and re-grant privileges that exist in both old and new
if d.Old.WithGrantOption != d.New.WithGrantOption {
// Find unchanged privileges (in both old and new)
var unchanged []string
for p := range oldPrivSet {
if newPrivSet[p] {
unchanged = append(unchanged, p)
}
}

if len(unchanged) > 0 {
sort.Strings(unchanged)
unchangedStr := strings.Join(unchanged, ", ")

// Revoke unchanged privileges first
statements = append(statements, fmt.Sprintf("ALTER DEFAULT PRIVILEGES IN SCHEMA %s REVOKE %s ON %s FROM %s;",
quotedSchema, unchangedStr, d.New.ObjectType, grantee))

// Re-grant with correct option
sql := fmt.Sprintf("ALTER DEFAULT PRIVILEGES IN SCHEMA %s GRANT %s ON %s TO %s",
quotedSchema, unchangedStr, d.New.ObjectType, grantee)
if d.New.WithGrantOption {
sql += " WITH GRANT OPTION"
}
statements = append(statements, sql+";")
}
}

return statements
}

// GetObjectName returns a unique identifier for the default privilege diff
func (d *defaultPrivilegeDiff) GetObjectName() string {
return string(d.New.ObjectType) + ":" + d.New.Grantee
}

// defaultPrivilegesEqual checks if two default privileges are structurally equal
func defaultPrivilegesEqual(old, new *ir.DefaultPrivilege) bool {
if old.ObjectType != new.ObjectType {
return false
}
if old.Grantee != new.Grantee {
return false
}
if old.WithGrantOption != new.WithGrantOption {
return false
}

// Compare privileges (order-independent)
if len(old.Privileges) != len(new.Privileges) {
return false
}

oldPrivSet := make(map[string]bool)
for _, p := range old.Privileges {
oldPrivSet[p] = true
}
for _, p := range new.Privileges {
if !oldPrivSet[p] {
return false
}
}

return true
}
Loading