From ac164ccaa4c0989e1794fa6e3f6bde992363b950 Mon Sep 17 00:00:00 2001 From: Tianzhou Date: Fri, 6 Mar 2026 06:35:48 -0800 Subject: [PATCH 1/3] feat: add support for privileges and default_privileges in .pgschemaignore (#339) Add [privileges] and [default_privileges] sections to .pgschemaignore that filter grants by grantee role name patterns. This allows ignoring privilege statements for roles that don't exist in the plan database (e.g., production roles not available in embedded postgres). Co-Authored-By: Claude Opus 4.6 --- cmd/ignore_integration_test.go | 175 +++++++++++++++++++++++++++++++++ cmd/util/ignoreloader.go | 40 +++++--- ir/ignore.go | 30 ++++-- ir/inspector.go | 15 +++ 4 files changed, 242 insertions(+), 18 deletions(-) diff --git a/cmd/ignore_integration_test.go b/cmd/ignore_integration_test.go index 612aad39..a96a3f05 100644 --- a/cmd/ignore_integration_test.go +++ b/cmd/ignore_integration_test.go @@ -951,6 +951,181 @@ func verifyDumpOutput(t *testing.T, output string) { } } +func TestIgnorePrivileges(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + embeddedPG := testutil.SetupPostgres(t) + defer embeddedPG.Stop() + conn, host, port, dbname, user, password := testutil.ConnectToPostgres(t, embeddedPG) + defer conn.Close() + + containerInfo := &struct { + Conn *sql.DB + Host string + Port int + DBName string + User string + Password string + }{ + Conn: conn, + Host: host, + Port: port, + DBName: dbname, + User: user, + Password: password, + } + + // Create schema with roles and privileges + setupSQL := ` +CREATE TABLE users ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + email TEXT NOT NULL +); + +CREATE TABLE orders ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id), + total_amount DECIMAL(10,2) NOT NULL +); + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'app_reader') THEN + CREATE ROLE app_reader; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'deploy_bot') THEN + CREATE ROLE deploy_bot; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'admin_role') THEN + CREATE ROLE admin_role; + END IF; +END $$; + +-- Privileges to keep +GRANT SELECT ON users TO app_reader; +GRANT SELECT ON orders TO app_reader; + +-- Privileges to ignore (deploy_bot) +GRANT ALL ON users TO deploy_bot; +GRANT ALL ON orders TO deploy_bot; + +-- Privileges to ignore (admin_role) +GRANT SELECT, INSERT, UPDATE, DELETE ON users TO admin_role; + +-- Default privileges to keep +ALTER DEFAULT PRIVILEGES GRANT SELECT ON TABLES TO app_reader; + +-- Default privileges to ignore (deploy_bot) +ALTER DEFAULT PRIVILEGES GRANT ALL ON TABLES TO deploy_bot; +` + _, err := conn.Exec(setupSQL) + if err != nil { + t.Fatalf("Failed to create test schema: %v", err) + } + + originalWd, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get current working directory: %v", err) + } + defer os.Chdir(originalWd) + + tmpDir := t.TempDir() + os.Chdir(tmpDir) + + // Create .pgschemaignore with privileges section + ignoreContent := `[privileges] +patterns = ["deploy_bot", "admin_*"] + +[default_privileges] +patterns = ["deploy_bot"] +` + err = os.WriteFile(".pgschemaignore", []byte(ignoreContent), 0644) + if err != nil { + t.Fatalf("Failed to create .pgschemaignore: %v", err) + } + + t.Run("dump", func(t *testing.T) { + output := executeIgnoreDumpCommand(t, containerInfo) + + // Privileges for app_reader should be present + if !strings.Contains(output, "app_reader") { + t.Error("Dump should include privileges for app_reader") + } + + // Privileges for deploy_bot should be ignored + if strings.Contains(output, "deploy_bot") { + t.Error("Dump should not include privileges for deploy_bot (ignored)") + } + + // Privileges for admin_role should be ignored (matches admin_*) + if strings.Contains(output, "admin_role") { + t.Error("Dump should not include privileges for admin_role (ignored by admin_* pattern)") + } + }) + + t.Run("plan", func(t *testing.T) { + // Create schema file that adds new privileges + schemaSQL := ` +CREATE TABLE users ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + email TEXT NOT NULL +); + +CREATE TABLE orders ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id), + total_amount DECIMAL(10,2) NOT NULL +); + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'app_reader') THEN + CREATE ROLE app_reader; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'deploy_bot') THEN + CREATE ROLE deploy_bot; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'admin_role') THEN + CREATE ROLE admin_role; + END IF; +END $$; + +-- Keep these privileges +GRANT SELECT ON users TO app_reader; +GRANT SELECT ON orders TO app_reader; + +-- These should be ignored in plan +GRANT ALL ON users TO deploy_bot; +GRANT ALL ON orders TO deploy_bot; +GRANT SELECT, INSERT, UPDATE, DELETE ON users TO admin_role; + +-- Default privileges +ALTER DEFAULT PRIVILEGES GRANT SELECT ON TABLES TO app_reader; +ALTER DEFAULT PRIVILEGES GRANT ALL ON TABLES TO deploy_bot; +` + schemaFile := "schema_privs.sql" + err := os.WriteFile(schemaFile, []byte(schemaSQL), 0644) + if err != nil { + t.Fatalf("Failed to create schema file: %v", err) + } + defer os.Remove(schemaFile) + + output := executeIgnorePlanCommand(t, containerInfo, schemaFile) + + // Plan should not contain any changes for ignored roles + if strings.Contains(output, "deploy_bot") { + t.Error("Plan should not include changes for deploy_bot (ignored)") + } + if strings.Contains(output, "admin_role") { + t.Error("Plan should not include changes for admin_role (ignored)") + } + }) +} + // verifyPlanOutput checks that plan output excludes ignored objects func verifyPlanOutput(t *testing.T, output string) { // Changes that should appear in plan (regular objects) diff --git a/cmd/util/ignoreloader.go b/cmd/util/ignoreloader.go index ac685192..59c133b4 100644 --- a/cmd/util/ignoreloader.go +++ b/cmd/util/ignoreloader.go @@ -28,12 +28,14 @@ func LoadIgnoreFileFromPath(filePath string) (*ir.IgnoreConfig, error) { // TomlConfig represents the TOML structure of the .pgschemaignore file // This is used for parsing more complex configurations if needed in the future type TomlConfig struct { - Tables TableIgnoreConfig `toml:"tables,omitempty"` - Views ViewIgnoreConfig `toml:"views,omitempty"` - Functions FunctionIgnoreConfig `toml:"functions,omitempty"` - Procedures ProcedureIgnoreConfig `toml:"procedures,omitempty"` - Types TypeIgnoreConfig `toml:"types,omitempty"` - Sequences SequenceIgnoreConfig `toml:"sequences,omitempty"` + Tables TableIgnoreConfig `toml:"tables,omitempty"` + Views ViewIgnoreConfig `toml:"views,omitempty"` + Functions FunctionIgnoreConfig `toml:"functions,omitempty"` + Procedures ProcedureIgnoreConfig `toml:"procedures,omitempty"` + Types TypeIgnoreConfig `toml:"types,omitempty"` + Sequences SequenceIgnoreConfig `toml:"sequences,omitempty"` + Privileges PrivilegeIgnoreConfig `toml:"privileges,omitempty"` + DefaultPrivileges DefaultPrivilegeIgnoreConfig `toml:"default_privileges,omitempty"` } // TableIgnoreConfig represents table-specific ignore configuration @@ -66,6 +68,18 @@ type SequenceIgnoreConfig struct { Patterns []string `toml:"patterns,omitempty"` } +// PrivilegeIgnoreConfig represents privilege-specific ignore configuration +// Patterns match on grantee role names +type PrivilegeIgnoreConfig struct { + Patterns []string `toml:"patterns,omitempty"` +} + +// DefaultPrivilegeIgnoreConfig represents default privilege-specific ignore configuration +// Patterns match on grantee role names +type DefaultPrivilegeIgnoreConfig struct { + Patterns []string `toml:"patterns,omitempty"` +} + // LoadIgnoreFileWithStructure loads the .pgschemaignore file using the structured TOML format // and converts it to the simple IgnoreConfig structure func LoadIgnoreFileWithStructure() (*ir.IgnoreConfig, error) { @@ -91,12 +105,14 @@ func LoadIgnoreFileWithStructureFromPath(filePath string) (*ir.IgnoreConfig, err // Convert to simple IgnoreConfig structure config := &ir.IgnoreConfig{ - Tables: tomlConfig.Tables.Patterns, - Views: tomlConfig.Views.Patterns, - Functions: tomlConfig.Functions.Patterns, - Procedures: tomlConfig.Procedures.Patterns, - Types: tomlConfig.Types.Patterns, - Sequences: tomlConfig.Sequences.Patterns, + Tables: tomlConfig.Tables.Patterns, + Views: tomlConfig.Views.Patterns, + Functions: tomlConfig.Functions.Patterns, + Procedures: tomlConfig.Procedures.Patterns, + Types: tomlConfig.Types.Patterns, + Sequences: tomlConfig.Sequences.Patterns, + Privileges: tomlConfig.Privileges.Patterns, + DefaultPrivileges: tomlConfig.DefaultPrivileges.Patterns, } return config, nil diff --git a/ir/ignore.go b/ir/ignore.go index 809c850b..aac78508 100644 --- a/ir/ignore.go +++ b/ir/ignore.go @@ -7,12 +7,14 @@ import ( // IgnoreConfig represents the configuration for ignoring database objects type IgnoreConfig struct { - Tables []string `toml:"tables,omitempty"` - Views []string `toml:"views,omitempty"` - Functions []string `toml:"functions,omitempty"` - Procedures []string `toml:"procedures,omitempty"` - Types []string `toml:"types,omitempty"` - Sequences []string `toml:"sequences,omitempty"` + Tables []string `toml:"tables,omitempty"` + Views []string `toml:"views,omitempty"` + Functions []string `toml:"functions,omitempty"` + Procedures []string `toml:"procedures,omitempty"` + Types []string `toml:"types,omitempty"` + Sequences []string `toml:"sequences,omitempty"` + Privileges []string `toml:"privileges,omitempty"` + DefaultPrivileges []string `toml:"default_privileges,omitempty"` } // ShouldIgnoreTable checks if a table should be ignored based on the patterns @@ -63,6 +65,22 @@ func (c *IgnoreConfig) ShouldIgnoreSequence(sequenceName string) bool { return c.shouldIgnore(sequenceName, c.Sequences) } +// ShouldIgnorePrivilege checks if a privilege should be ignored based on the grantee role name +func (c *IgnoreConfig) ShouldIgnorePrivilege(grantee string) bool { + if c == nil { + return false + } + return c.shouldIgnore(grantee, c.Privileges) +} + +// ShouldIgnoreDefaultPrivilege checks if a default privilege should be ignored based on the grantee role name +func (c *IgnoreConfig) ShouldIgnoreDefaultPrivilege(grantee string) bool { + if c == nil { + return false + } + return c.shouldIgnore(grantee, c.DefaultPrivileges) +} + // shouldIgnore checks if a name should be ignored based on the patterns // Patterns support wildcards (*) and negation (!) // Negation patterns (starting with !) take precedence over inclusion patterns diff --git a/ir/inspector.go b/ir/inspector.go index 2990eea5..1e7e1dbc 100644 --- a/ir/inspector.go +++ b/ir/inspector.go @@ -2030,6 +2030,11 @@ func (i *Inspector) buildPrivileges(ctx context.Context, schema *IR, targetSchem continue } + // Skip privileges for ignored grantees + if i.ignoreConfig != nil && i.ignoreConfig.ShouldIgnorePrivilege(grantee) { + continue + } + // Check for default PUBLIC grants that should be excluded if grantee == "PUBLIC" { if (objectType == "FUNCTION" || objectType == "PROCEDURE") && privilegeType == "EXECUTE" { @@ -2174,6 +2179,11 @@ func (i *Inspector) buildDefaultPrivileges(ctx context.Context, schema *IR, targ continue } + // Skip default privileges for ignored grantees + if i.ignoreConfig != nil && i.ignoreConfig.ShouldIgnoreDefaultPrivilege(p.Grantee.String) { + continue + } + key := privKey{ OwnerRole: p.OwnerRole.String, ObjectType: p.ObjectType.String, @@ -2369,6 +2379,11 @@ func (i *Inspector) buildColumnPrivileges(ctx context.Context, schema *IR, targe } } + // Skip column privileges for ignored grantees + if i.ignoreConfig != nil && i.ignoreConfig.ShouldIgnorePrivilege(grantee) { + continue + } + key := colPrivKey{ TableName: tableName, Grantee: grantee, From 0e75bbd00f86fdfb3b6e807faec71b4230fce863 Mon Sep 17 00:00:00 2001 From: Tianzhou Date: Fri, 6 Mar 2026 07:38:15 -0800 Subject: [PATCH 2/3] fix: address review feedback - check chdir error and add loader unit test - Check os.Chdir error in TestIgnorePrivileges (consistency with existing tests) - Add TestLoadIgnoreFile_PrivilegeSections unit test for TOML parsing of [privileges] and [default_privileges] sections including negation patterns Co-Authored-By: Claude Opus 4.6 --- cmd/ignore_integration_test.go | 10 ++++-- cmd/util/ignoreloader_test.go | 63 ++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 2 deletions(-) diff --git a/cmd/ignore_integration_test.go b/cmd/ignore_integration_test.go index a96a3f05..18f5cee1 100644 --- a/cmd/ignore_integration_test.go +++ b/cmd/ignore_integration_test.go @@ -1030,10 +1030,16 @@ ALTER DEFAULT PRIVILEGES GRANT ALL ON TABLES TO deploy_bot; if err != nil { t.Fatalf("Failed to get current working directory: %v", err) } - defer os.Chdir(originalWd) + defer func() { + if err := os.Chdir(originalWd); err != nil { + t.Fatalf("Failed to restore working directory: %v", err) + } + }() tmpDir := t.TempDir() - os.Chdir(tmpDir) + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("Failed to change to temp directory: %v", err) + } // Create .pgschemaignore with privileges section ignoreContent := `[privileges] diff --git a/cmd/util/ignoreloader_test.go b/cmd/util/ignoreloader_test.go index d0e449d5..36fa97f2 100644 --- a/cmd/util/ignoreloader_test.go +++ b/cmd/util/ignoreloader_test.go @@ -129,6 +129,69 @@ patterns = ["fn_test_*"] } } +func TestLoadIgnoreFile_PrivilegeSections(t *testing.T) { + tempDir := t.TempDir() + testFile := filepath.Join(tempDir, "test.pgschemaignore") + + tomlContent := `[privileges] +patterns = ["deploy_bot", "admin_*", "!admin_super"] + +[default_privileges] +patterns = ["deploy_bot"] +` + + err := os.WriteFile(testFile, []byte(tomlContent), 0644) + if err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + config, err := LoadIgnoreFileFromPath(testFile) + if err != nil { + t.Fatalf("LoadIgnoreFileFromPath() error = %v", err) + } + if config == nil { + t.Fatal("LoadIgnoreFileFromPath() returned nil config") + } + + // Test privileges section + expectedPrivileges := []string{"deploy_bot", "admin_*", "!admin_super"} + if len(config.Privileges) != len(expectedPrivileges) { + t.Errorf("Expected %d privilege patterns, got %d", len(expectedPrivileges), len(config.Privileges)) + } + for i, expected := range expectedPrivileges { + if i < len(config.Privileges) && config.Privileges[i] != expected { + t.Errorf("Expected privilege pattern %q at index %d, got %q", expected, i, config.Privileges[i]) + } + } + + // Test default_privileges section + if len(config.DefaultPrivileges) != 1 || config.DefaultPrivileges[0] != "deploy_bot" { + t.Errorf("Expected default_privileges patterns [\"deploy_bot\"], got %v", config.DefaultPrivileges) + } + + // Test ShouldIgnorePrivilege + if !config.ShouldIgnorePrivilege("deploy_bot") { + t.Error("deploy_bot should be ignored") + } + if !config.ShouldIgnorePrivilege("admin_role") { + t.Error("admin_role should be ignored (matches admin_*)") + } + if config.ShouldIgnorePrivilege("admin_super") { + t.Error("admin_super should NOT be ignored (negation pattern)") + } + if config.ShouldIgnorePrivilege("app_reader") { + t.Error("app_reader should NOT be ignored") + } + + // Test ShouldIgnoreDefaultPrivilege + if !config.ShouldIgnoreDefaultPrivilege("deploy_bot") { + t.Error("deploy_bot default privilege should be ignored") + } + if config.ShouldIgnoreDefaultPrivilege("app_reader") { + t.Error("app_reader default privilege should NOT be ignored") + } +} + func TestLoadIgnoreFile_InvalidTOML(t *testing.T) { // Create a temporary invalid TOML file tempDir := t.TempDir() From c56735c64f89c01cb0d9765c2e41e14c9470031a Mon Sep 17 00:00:00 2001 From: Tianzhou Date: Fri, 6 Mar 2026 07:46:25 -0800 Subject: [PATCH 3/3] docs: add privileges and default_privileges sections to ignore docs Co-Authored-By: Claude Opus 4.6 --- docs/cli/ignore.mdx | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/docs/cli/ignore.mdx b/docs/cli/ignore.mdx index 226689b2..d66b7814 100644 --- a/docs/cli/ignore.mdx +++ b/docs/cli/ignore.mdx @@ -12,6 +12,7 @@ The `.pgschemaignore` file allows you to exclude database objects from pgschema 2. **Temporary Objects** - Exclude temp tables, debug views, and development-only objects 3. **Legacy Objects** - Ignore deprecated objects while maintaining new schema management 4. **Environment-Specific Objects** - Skip objects that exist only in certain environments +5. **Role-Specific Privileges** - Ignore grants to roles that don't exist in the plan database ## File Format @@ -39,6 +40,12 @@ patterns = ["type_test_*"] [sequences] patterns = ["seq_temp_*", "seq_debug_*"] + +[privileges] +patterns = ["deploy_bot", "admin_*"] + +[default_privileges] +patterns = ["deploy_bot"] ``` ## Pattern Syntax @@ -79,6 +86,24 @@ patterns = [ This will ignore `test_data`, `test_results` but keep `test_core_config`, `test_core_settings`. +## Privileges + +The `[privileges]` and `[default_privileges]` sections filter GRANT statements by **grantee role name**. This is useful when running `pgschema plan` with roles that don't exist in the plan database, or managing migrations across environments with different role configurations. + +```toml +[privileges] +patterns = [ + "deploy_bot", # Ignore all grants to deploy_bot + "admin_*", # Ignore grants to any admin_* role + "!admin_super" # But keep grants to admin_super +] + +[default_privileges] +patterns = ["deploy_bot"] # Ignore ALTER DEFAULT PRIVILEGES for deploy_bot +``` + +The `[privileges]` section filters explicit grants (`GRANT ... TO role`), including column-level privileges. The `[default_privileges]` section filters `ALTER DEFAULT PRIVILEGES` statements. + ## Triggers on Ignored Tables Triggers can be defined on ignored tables. The table structure is not managed, but the trigger itself is.