diff --git a/cmd/ignore_integration_test.go b/cmd/ignore_integration_test.go index 612aad39..18f5cee1 100644 --- a/cmd/ignore_integration_test.go +++ b/cmd/ignore_integration_test.go @@ -951,6 +951,187 @@ 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 func() { + if err := os.Chdir(originalWd); err != nil { + t.Fatalf("Failed to restore working directory: %v", err) + } + }() + + tmpDir := t.TempDir() + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("Failed to change to temp directory: %v", err) + } + + // 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/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() 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. 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,